From 6bbfb5bb43c97ee86f1bb3b662bf3225625eb9f4 Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 15:55:09 +0530 Subject: [PATCH 01/10] feat: Add initial AI Agent integration --- package-lock.json | 191 +++++++++++---------- package.json | 2 + src/cm/extensions/aiAutocomplete.ts | 119 +++++++++++++ src/cm/mainEditorExtensions.ts | 5 + src/lib/settings.js | 8 + src/pages/aiAgent/aiAgent.js | 116 +++++++++++++ src/pages/aiAgent/aiAgent.scss | 80 +++++++++ src/pages/aiAgent/index.js | 5 + src/settings/aiSettings.js | 66 ++++++++ src/settings/mainSettings.js | 10 ++ src/sidebarApps/index.js | 12 ++ src/utils/ai/AIService.js | 72 ++++++++ src/utils/ai/AcodeProAdapter.js | 37 ++++ src/utils/ai/BaseProvider.js | 38 +++++ src/utils/ai/OpenAiAdapter.js | 54 ++++++ src/utils/ai/OpenRouterAdapter.js | 32 ++++ www/logo.svg | 252 +++------------------------- 17 files changed, 775 insertions(+), 324 deletions(-) create mode 100644 src/cm/extensions/aiAutocomplete.ts create mode 100644 src/pages/aiAgent/aiAgent.js create mode 100644 src/pages/aiAgent/aiAgent.scss create mode 100644 src/pages/aiAgent/index.js create mode 100644 src/settings/aiSettings.js create mode 100644 src/utils/ai/AIService.js create mode 100644 src/utils/ai/AcodeProAdapter.js create mode 100644 src/utils/ai/BaseProvider.js create mode 100644 src/utils/ai/OpenAiAdapter.js create mode 100644 src/utils/ai/OpenRouterAdapter.js diff --git a/package-lock.json b/package-lock.json index 2facaba1c..0b413d4f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "escape-string-regexp": "^5.0.0", "esprima": "^4.0.1", "filesize": "^11.0.17", + "htm": "^3.1.1", "html-tag-js": "^2.4.16", "jszip": "^3.10.1", "katex": "^0.16.45", @@ -80,6 +81,7 @@ "motion": "^12.42.0", "mustache": "^4.2.0", "picomatch": "^4.0.4", + "preact": "^10.29.8", "url-parse": "^1.5.10", "vanilla-picker": "^2.12.3", "vscode-css-languageservice": "6.3.10", @@ -192,6 +194,7 @@ "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", @@ -3600,6 +3603,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -3612,6 +3616,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", @@ -3648,6 +3653,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -3674,6 +3680,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -3701,6 +3708,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", @@ -3904,6 +3912,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -3996,6 +4005,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -4017,6 +4027,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.4.tgz", "integrity": "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", @@ -4082,33 +4093,10 @@ "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==", "license": "MIT" }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -4194,7 +4182,6 @@ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -5344,29 +5331,6 @@ "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", @@ -5560,6 +5524,40 @@ "@napi-rs/wasm-runtime": "1.1.4" } }, + "node_modules/@rspack/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rspack/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rspack/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rspack/binding-win32-arm64-msvc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.0.3.tgz", @@ -5627,6 +5625,7 @@ "integrity": "sha512-2ufO/8FHIA/lX6UOgSsKPhpDvHr0sh9lYq/n/LsIZsTwu3973BGbu2fg1Akvuu3rEnskPqXjsqH2EPBzEA42uA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rspack/binding": "2.0.3" }, @@ -6132,8 +6131,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/markdown-it": { "version": "14.1.2", @@ -6150,8 +6148,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/node": { "version": "26.1.2", @@ -6352,7 +6349,6 @@ "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -6363,24 +6359,21 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", @@ -6388,7 +6381,6 @@ "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -6400,8 +6392,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", @@ -6409,7 +6400,6 @@ "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -6423,7 +6413,6 @@ "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -6434,7 +6423,6 @@ "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -6444,8 +6432,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", @@ -6453,7 +6440,6 @@ "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -6471,7 +6457,6 @@ "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -6486,7 +6471,6 @@ "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -6500,7 +6484,6 @@ "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -6516,7 +6499,6 @@ "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -6587,16 +6569,14 @@ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/abbrev": { "version": "4.0.0", @@ -6708,6 +6688,7 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7474,6 +7455,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7493,8 +7475,7 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/buffer-image-size": { "version": "0.6.4", @@ -7760,7 +7741,6 @@ "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -8851,6 +8831,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -9260,6 +9241,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -9993,7 +9975,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -10021,7 +10002,6 @@ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -10035,7 +10015,6 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -10046,7 +10025,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -10087,7 +10065,6 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -11124,6 +11101,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/htm": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", + "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==", + "license": "Apache-2.0" + }, "node_modules/html-tag-js": { "version": "2.4.16", "resolved": "https://registry.npmjs.org/html-tag-js/-/html-tag-js-2.4.16.tgz", @@ -12014,7 +11997,6 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -12030,7 +12012,6 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -12834,6 +12815,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -13153,7 +13135,6 @@ "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -14758,6 +14739,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -14895,6 +14877,24 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prepend-http": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", @@ -14911,6 +14911,7 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -15211,6 +15212,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15941,6 +15943,7 @@ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -16333,7 +16336,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -16345,7 +16347,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16694,7 +16695,6 @@ "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -16713,8 +16713,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/through": { "version": "2.3.8", @@ -16994,6 +16993,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17363,6 +17363,7 @@ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -17620,7 +17621,6 @@ "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2" }, @@ -17686,7 +17686,6 @@ "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } diff --git a/package.json b/package.json index 30297fa42..aca43e0c3 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,7 @@ "escape-string-regexp": "^5.0.0", "esprima": "^4.0.1", "filesize": "^11.0.17", + "htm": "^3.1.1", "html-tag-js": "^2.4.16", "jszip": "^3.10.1", "katex": "^0.16.45", @@ -193,6 +194,7 @@ "motion": "^12.42.0", "mustache": "^4.2.0", "picomatch": "^4.0.4", + "preact": "^10.29.8", "url-parse": "^1.5.10", "vanilla-picker": "^2.12.3", "vscode-css-languageservice": "6.3.10", diff --git a/src/cm/extensions/aiAutocomplete.ts b/src/cm/extensions/aiAutocomplete.ts new file mode 100644 index 000000000..c836e5d1b --- /dev/null +++ b/src/cm/extensions/aiAutocomplete.ts @@ -0,0 +1,119 @@ +import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view"; +import { StateField, StateEffect } from "@codemirror/state"; +import aiService from "utils/ai/AIService"; +import appSettings from "lib/settings"; + +class GhostTextWidget extends WidgetType { + constructor(public text: string) { + super(); + } + + eq(other: GhostTextWidget) { + return this.text === other.text; + } + + toDOM() { + const span = document.createElement("span"); + span.className = "cm-ai-ghost-text"; + span.textContent = this.text; + span.style.opacity = "0.5"; + span.style.fontStyle = "italic"; + return span; + } +} + +const setGhostText = StateEffect.define<{ pos: number; text: string } | null>(); + +export const ghostTextState = StateField.define({ + create() { + return Decoration.none; + }, + update(decorations, tr) { + decorations = decorations.map(tr.changes); + for (let e of tr.effects) { + if (e.is(setGhostText)) { + if (e.value === null) { + return Decoration.none; + } + return Decoration.set([ + Decoration.widget({ + widget: new GhostTextWidget(e.value.text), + side: 1, + }).range(e.value.pos), + ]); + } + } + // If document changed, clear ghost text + if (tr.docChanged) { + return Decoration.none; + } + return decorations; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +export const aiAutocompletePlugin = ViewPlugin.fromClass( + class { + timeout: any; + constructor(public view: any) {} + + update(update: any) { + if (update.docChanged && appSettings.value.ai?.provider) { + clearTimeout(this.timeout); + this.view.dispatch({ effects: setGhostText.of(null) }); + + this.timeout = setTimeout(async () => { + try { + const pos = this.view.state.selection.main.head; + const doc = this.view.state.doc.toString(); + const prefix = doc.slice(0, pos); + const suffix = doc.slice(pos); + + const completion = await aiService.completeCode(prefix, suffix); + if (completion) { + this.view.dispatch({ + effects: setGhostText.of({ pos, text: completion }), + }); + } + } catch (e) { + // Ignore errors or log them + } + }, 1000); // 1s debounce + } + } + + destroy() { + clearTimeout(this.timeout); + } + } +); + +// We also need a keymap to accept the suggestion +import { keymap } from "@codemirror/view"; +export const acceptAiSuggestionKeymap = keymap.of([ + { + key: "Tab", + run: (view) => { + const ghost = view.state.field(ghostTextState, false); + if (ghost && ghost.size > 0) { + let textToInsert = ""; + ghost.between(0, view.state.doc.length, (from, to, deco) => { + textToInsert = (deco.spec.widget as GhostTextWidget).text; + }); + if (textToInsert) { + const pos = view.state.selection.main.head; + view.dispatch({ + changes: { from: pos, insert: textToInsert }, + effects: setGhostText.of(null), + }); + return true; + } + } + return false; + }, + }, +]); + +export function aiAutocomplete() { + return [ghostTextState, aiAutocompletePlugin, acceptAiSuggestionKeymap]; +} diff --git a/src/cm/mainEditorExtensions.ts b/src/cm/mainEditorExtensions.ts index e445bf9b4..d32fa09c2 100644 --- a/src/cm/mainEditorExtensions.ts +++ b/src/cm/mainEditorExtensions.ts @@ -54,6 +54,11 @@ export function createMainEditorExtensions( pushExtension(extensions, options.searchExtension); pushExtension(extensions, options.readOnlyExtension); + // Add AI Autocomplete + import("./extensions/aiAutocomplete").then(m => { + extensions.push(m.aiAutocomplete()); + }).catch(() => {}); + if (options.optionExtensions?.length) { extensions.push(...options.optionExtensions); } diff --git a/src/lib/settings.js b/src/lib/settings.js index 7421f3d2c..af149ed0e 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -214,6 +214,14 @@ class Settings { workspaces: {}, }, }, + ai: { + provider: "openai", + openaiKey: "", + geminiKey: "", + anthropicKey: "", + openRouterKey: "", + useAcodePro: true, + }, developerMode: false, shiftClickSelection: true, showShareButton: true, diff --git a/src/pages/aiAgent/aiAgent.js b/src/pages/aiAgent/aiAgent.js new file mode 100644 index 000000000..67d8c07c1 --- /dev/null +++ b/src/pages/aiAgent/aiAgent.js @@ -0,0 +1,116 @@ +import "./aiAgent.scss"; +import Page from "components/page"; +import actionStack from "lib/actionStack"; +import { render, Component } from "preact"; +import { html } from "htm/preact"; +import aiService from "utils/ai/AIService"; + +class AiAgentApp extends Component { + constructor(props) { + super(props); + this.state = { + messages: [ + { role: "assistant", content: "Hello! I am your AI agent. How can I help you today?" } + ], + input: "", + isLoading: false + }; + } + + handleInput = (e) => { + this.setState({ input: e.target.value }); + } + + sendMessage = async () => { + const { input, messages } = this.state; + if (!input.trim() || this.state.isLoading) return; + + const userMsg = { role: "user", content: input }; + const newMessages = [...messages, userMsg]; + + this.setState({ + messages: newMessages, + input: "", + isLoading: true + }); + + try { + // This represents the Think -> Act -> Observe loop at a basic level + // We can expand this logic in aiService to actually perform multi-step reasoning + const response = await aiService.chat(newMessages); + this.setState({ + messages: [...newMessages, { role: "assistant", content: response }], + isLoading: false + }); + } catch (error) { + this.setState({ + messages: [...newMessages, { role: "assistant", content: `**Error:** ${error.message}` }], + isLoading: false + }); + } + } + + handleKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + } + + render() { + const { messages, input, isLoading } = this.state; + return html` +
+
+ ${messages.map(msg => html` +
+
+ ${msg.content} +
+
+ `)} + ${isLoading ? html`
Thinking...
` : null} +
+
+ + +
+
+ `; + } +} + +export default function AiAgentInclude() { + const $page = Page("AI Agent"); + $page.classList.add("ai-agent-page"); + + // We mount the Preact component into the $page.body + // HTML Tag JS (which Acode uses) requires standard DOM elements + // The Page component already sets $page.body = tag('div', { className: 'main' }); + // Wait, actually Page() returns a DOM element. We can just render into a new div. + + const rootDiv = document.createElement("div"); + rootDiv.className = "main"; + $page.body = rootDiv; + + render(html`<${AiAgentApp} />`, rootDiv); + + actionStack.push({ + id: "aiAgent", + action: $page.hide, + }); + + $page.onhide = function () { + render(null, rootDiv); // Unmount + actionStack.remove("aiAgent"); + }; + + app.append($page); +} diff --git a/src/pages/aiAgent/aiAgent.scss b/src/pages/aiAgent/aiAgent.scss new file mode 100644 index 000000000..d6a338dd1 --- /dev/null +++ b/src/pages/aiAgent/aiAgent.scss @@ -0,0 +1,80 @@ +.ai-agent-page { + .ai-agent-container { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--primary-color); + + .ai-chat-history { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + + .ai-message { + display: flex; + + &.user { + justify-content: flex-end; + .ai-message-bubble { + background-color: var(--secondary-color); + color: var(--text-color); + border-radius: 12px 12px 0 12px; + } + } + + &.assistant { + justify-content: flex-start; + .ai-message-bubble { + background-color: var(--popup-background-color); + color: var(--text-color); + border-radius: 12px 12px 12px 0; + } + } + + .ai-message-bubble { + max-width: 85%; + padding: 10px 14px; + font-size: 14px; + line-height: 1.4; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); + } + } + } + + .ai-chat-input { + display: flex; + padding: 10px; + background-color: var(--popup-background-color); + border-top: 1px solid var(--border-color); + + textarea { + flex: 1; + resize: none; + border: none; + background: transparent; + color: var(--text-color); + outline: none; + padding: 8px; + min-height: 40px; + max-height: 120px; + font-family: inherit; + } + + button { + background: none; + border: none; + color: var(--link-color); + padding: 10px; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + } +} diff --git a/src/pages/aiAgent/index.js b/src/pages/aiAgent/index.js new file mode 100644 index 000000000..aec42590a --- /dev/null +++ b/src/pages/aiAgent/index.js @@ -0,0 +1,5 @@ +export default function AiAgentPage() { + import(/* webpackChunkName: "aiAgent" */ "./aiAgent").then((res) => { + res.default(); + }); +} diff --git a/src/settings/aiSettings.js b/src/settings/aiSettings.js new file mode 100644 index 000000000..4bdb05957 --- /dev/null +++ b/src/settings/aiSettings.js @@ -0,0 +1,66 @@ +import settingsPage from "../components/settingsPage"; +import appSettings from "../lib/settings"; + +export default function aiSettings() { + const title = strings["ai settings"] || "AI Settings"; + const values = appSettings.value.ai; + const items = [ + { + key: "useAcodePro", + text: strings["use acode pro ai"] || "Use Acode Pro AI", + info: "Prioritize using existing Claude/Codex subscription if active", + checkbox: values.useAcodePro, + }, + { + key: "provider", + text: strings["ai provider"] || "AI Provider", + value: values.provider, + select: [ + ["openai", "OpenAI"], + ["gemini", "Google Gemini"], + ["anthropic", "Anthropic Claude"], + ["openrouter", "OpenRouter"], + ], + }, + { + key: "openaiKey", + text: "OpenAI API Key", + value: values.openaiKey, + prompt: "OpenAI API Key", + promptType: "text", + }, + { + key: "geminiKey", + text: "Gemini API Key", + value: values.geminiKey, + prompt: "Gemini API Key", + promptType: "text", + }, + { + key: "anthropicKey", + text: "Anthropic API Key", + value: values.anthropicKey, + prompt: "Anthropic API Key", + promptType: "text", + }, + { + key: "openRouterKey", + text: "OpenRouter API Key", + value: values.openRouterKey, + prompt: "OpenRouter API Key", + promptType: "text", + }, + ]; + + return settingsPage(title, items, callback, undefined, { + preserveOrder: true, + pageClassName: "detail-settings-page", + listClassName: "detail-settings-list", + groupByDefault: true, + }); + + function callback(key, value) { + values[key] = value; + appSettings.update(); + } +} diff --git a/src/settings/mainSettings.js b/src/settings/mainSettings.js index 2946f57c7..ca1614ad9 100644 --- a/src/settings/mainSettings.js +++ b/src/settings/mainSettings.js @@ -107,6 +107,14 @@ export default function mainSettings() { category: categories.customizationTools, chevron: true, }, + { + key: "ai-settings", + text: strings["ai settings"] || "AI Settings", + icon: "psychiatry", // Use an appropriate material icon like 'psychiatry' or 'smart_toy' + info: "Configure AI providers and features", + category: categories.customizationTools, + chevron: true, + }, { key: "backup-restore", text: `${strings.backup.capitalize()} & ${strings.restore.capitalize()}`, @@ -231,6 +239,7 @@ export default function mainSettings() { case "preview-settings": case "terminal-settings": case "lsp-settings": + case "ai-settings": appSettings.uiSettings[key].show(); break; @@ -370,6 +379,7 @@ export default function mainSettings() { "preview-settings": previewSettings, "terminal-settings": terminalSettings, "lsp-settings": lspSettings, + "ai-settings": () => import("./aiSettings").then(m => m.default), }; const instantiated = {}; diff --git a/src/sidebarApps/index.js b/src/sidebarApps/index.js index 4172f7991..187eb0525 100644 --- a/src/sidebarApps/index.js +++ b/src/sidebarApps/index.js @@ -91,6 +91,18 @@ async function loadApps() { add(...(await import("./extensions")).default); add(...(await import("./notification")).default); setSponsorSidebarAppVisibility(appSettings.value.showSponsorSidebarApp); + + // Add AI Agent icon + const $aiIcon = ( + { + import("../pages/aiAgent").then((res) => res.default()); + }} + /> + ); + $apps.append($aiIcon); } /** diff --git a/src/utils/ai/AIService.js b/src/utils/ai/AIService.js new file mode 100644 index 000000000..ca5de68d2 --- /dev/null +++ b/src/utils/ai/AIService.js @@ -0,0 +1,72 @@ +import appSettings from "lib/settings"; +import OpenAiAdapter from "./OpenAiAdapter"; +import OpenRouterAdapter from "./OpenRouterAdapter"; +import AcodeProAdapter from "./AcodeProAdapter"; + +class AIService { + /** + * Get the active provider adapter based on settings and fallback logic + */ + async getActiveAdapter() { + const settings = appSettings.value.ai; + + // 1. Check Acode Pro first if enabled + if (settings.useAcodePro) { + // In a real implementation, we would check if the user is logged in + // and has an active acode_pro subscription from `auth.getLoggedInUser()` + // For now, we will return the adapter and let it throw QUOTA_EXHAUSTED + // if they don't have access. + return new AcodeProAdapter(); + } + + // 2. Fallback to direct keys based on provider selection + switch (settings.provider) { + case "openai": + if (!settings.openaiKey) throw new Error("OpenAI Key missing. Please update AI Settings."); + return new OpenAiAdapter(settings.openaiKey); + case "openrouter": + if (!settings.openRouterKey) throw new Error("OpenRouter Key missing. Please update AI Settings."); + return new OpenRouterAdapter(settings.openRouterKey); + // Implement gemini and anthropic later + default: + throw new Error("Selected AI provider is not supported yet."); + } + } + + /** + * Prompt the user for an API key if quota is exhausted + */ + async promptForKey() { + // In a real implementation, we can trigger a dialog or redirect to settings + alert("Acode Pro AI quota exhausted. Please enter your own API key in Settings > AI Settings."); + appSettings.uiSettings["ai-settings"].show(); + } + + async chat(messages) { + try { + const adapter = await this.getActiveAdapter(); + return await adapter.chat(messages); + } catch (error) { + if (error.code === "QUOTA_EXHAUSTED") { + await this.promptForKey(); + throw new Error("Quota exhausted. Switched to fallback."); + } + throw error; + } + } + + async completeCode(prefix, suffix) { + try { + const adapter = await this.getActiveAdapter(); + if (!adapter.completeCode) { + return ""; // not supported by this adapter + } + return await adapter.completeCode(prefix, suffix); + } catch (error) { + console.error("AI Completion error", error); + return ""; + } + } +} + +export default new AIService(); diff --git a/src/utils/ai/AcodeProAdapter.js b/src/utils/ai/AcodeProAdapter.js new file mode 100644 index 000000000..c9f60f921 --- /dev/null +++ b/src/utils/ai/AcodeProAdapter.js @@ -0,0 +1,37 @@ +import BaseProvider from "./BaseProvider"; + +export default class AcodeProAdapter extends BaseProvider { + constructor(apiKey = "acode_pro_token", model = "claude-3-haiku") { + super(apiKey, model); + // Replace with the actual Acode Pro AI endpoint + this.baseUrl = "https://api.foxdebug.com/v1/ai"; + } + + async chat(messages) { + const res = await fetch(`${this.baseUrl}/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages: messages, + }), + }); + + if (res.status === 402 || res.status === 429) { + const error = new Error("Acode Pro subscription exhausted."); + error.code = "QUOTA_EXHAUSTED"; + throw error; + } + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error?.message || "Acode Pro API Error"); + } + + const data = await res.json(); + return data.choices[0].message.content; + } +} diff --git a/src/utils/ai/BaseProvider.js b/src/utils/ai/BaseProvider.js new file mode 100644 index 000000000..261ab471b --- /dev/null +++ b/src/utils/ai/BaseProvider.js @@ -0,0 +1,38 @@ +export default class BaseProvider { + constructor(apiKey, model) { + this.apiKey = apiKey; + this.model = model; + } + + /** + * Send a chat message to the provider. + * @param {Array<{role: string, content: string}>} messages + * @returns {Promise} The response string + */ + async chat(messages) { + throw new Error("chat() not implemented for " + this.constructor.name); + } + + /** + * Complete code based on prefix and suffix + * @param {string} prefix + * @param {string} suffix + * @returns {Promise} The completion text + */ + async completeCode(prefix, suffix) { + throw new Error( + "completeCode() not implemented for " + this.constructor.name, + ); + } + + /** + * Execute an agent loop for a task + * @param {string} task + * @param {Function} onStep - Callback for each step (Thought, Action, Observation) + */ + async executeAgentLoop(task, onStep) { + throw new Error( + "executeAgentLoop() not implemented for " + this.constructor.name, + ); + } +} diff --git a/src/utils/ai/OpenAiAdapter.js b/src/utils/ai/OpenAiAdapter.js new file mode 100644 index 000000000..11df17351 --- /dev/null +++ b/src/utils/ai/OpenAiAdapter.js @@ -0,0 +1,54 @@ +import BaseProvider from "./BaseProvider"; + +export default class OpenAiAdapter extends BaseProvider { + constructor(apiKey, model = "gpt-4o-mini") { + super(apiKey, model); + this.baseUrl = "https://api.openai.com/v1"; + } + + async chat(messages) { + const res = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages: messages, + }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error?.message || "OpenAI API Error"); + } + + const data = await res.json(); + return data.choices[0].message.content; + } + + async completeCode(prefix, suffix) { + // Example using FIM (Fill-in-the-middle) if supported, or generic prompt + const prompt = `<|fim_prefix|>${prefix}<|fim_suffix|>${suffix}<|fim_middle|>`; + const res = await fetch(`${this.baseUrl}/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: "gpt-3.5-turbo-instruct", + prompt: prompt, + max_tokens: 64, + temperature: 0.2, + }), + }); + + if (!res.ok) { + throw new Error("OpenAI Code Completion Error"); + } + const data = await res.json(); + return data.choices[0].text; + } +} diff --git a/src/utils/ai/OpenRouterAdapter.js b/src/utils/ai/OpenRouterAdapter.js new file mode 100644 index 000000000..b3cb9d765 --- /dev/null +++ b/src/utils/ai/OpenRouterAdapter.js @@ -0,0 +1,32 @@ +import BaseProvider from "./BaseProvider"; + +export default class OpenRouterAdapter extends BaseProvider { + constructor(apiKey, model = "openai/gpt-4o-mini") { + super(apiKey, model); + this.baseUrl = "https://openrouter.ai/api/v1"; + } + + async chat(messages) { + const res = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + "HTTP-Referer": "https://acode.foxdebug.com", + "X-Title": "Acode", + }, + body: JSON.stringify({ + model: this.model, + messages: messages, + }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error?.message || "OpenRouter API Error"); + } + + const data = await res.json(); + return data.choices[0].message.content; + } +} diff --git a/www/logo.svg b/www/logo.svg index 382df236d..90dc12f65 100644 --- a/www/logo.svg +++ b/www/logo.svg @@ -1,229 +1,25 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + From 7a4143b6cb1b7b2f6a752af40109dde3d71a8527 Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 16:12:00 +0530 Subject: [PATCH 02/10] fix: AI Settings not opening due to async import --- src/settings/mainSettings.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/settings/mainSettings.js b/src/settings/mainSettings.js index ca1614ad9..67d71d33c 100644 --- a/src/settings/mainSettings.js +++ b/src/settings/mainSettings.js @@ -29,6 +29,7 @@ import previewSettings from "./previewSettings"; import scrollSettings from "./scrollSettings"; import searchSettings from "./searchSettings"; import terminalSettings from "./terminalSettings"; +import aiSettings from "./aiSettings"; export default function mainSettings() { const title = strings.settings.capitalize(); @@ -379,7 +380,7 @@ export default function mainSettings() { "preview-settings": previewSettings, "terminal-settings": terminalSettings, "lsp-settings": lspSettings, - "ai-settings": () => import("./aiSettings").then(m => m.default), + "ai-settings": aiSettings, }; const instantiated = {}; From c8a59e79bea1f085f7b8a9f697d9abe20203d3cd Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 16:28:11 +0530 Subject: [PATCH 03/10] fix: Synchronous load for aiAutocomplete and fix AI sidebar icon --- src/cm/mainEditorExtensions.ts | 5 ++--- src/sidebarApps/index.js | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/cm/mainEditorExtensions.ts b/src/cm/mainEditorExtensions.ts index d32fa09c2..922bb121e 100644 --- a/src/cm/mainEditorExtensions.ts +++ b/src/cm/mainEditorExtensions.ts @@ -1,5 +1,6 @@ import type { Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; +import { aiAutocomplete } from "./extensions/aiAutocomplete"; interface MainEditorExtensionOptions { emmetExtensions?: Extension[]; @@ -55,9 +56,7 @@ export function createMainEditorExtensions( pushExtension(extensions, options.readOnlyExtension); // Add AI Autocomplete - import("./extensions/aiAutocomplete").then(m => { - extensions.push(m.aiAutocomplete()); - }).catch(() => {}); + extensions.push(aiAutocomplete()); if (options.optionExtensions?.length) { extensions.push(...options.optionExtensions); diff --git a/src/sidebarApps/index.js b/src/sidebarApps/index.js index 187eb0525..054eb3fba 100644 --- a/src/sidebarApps/index.js +++ b/src/sidebarApps/index.js @@ -95,7 +95,7 @@ async function loadApps() { // Add AI Agent icon const $aiIcon = ( { import("../pages/aiAgent").then((res) => res.default()); From fb430ec8ea3de6bd64826abe98abc80685ec86e8 Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 16:34:37 +0530 Subject: [PATCH 04/10] fix: App crash due to circular dependencies on launch --- src/cm/extensions/aiAutocomplete.ts | 44 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/cm/extensions/aiAutocomplete.ts b/src/cm/extensions/aiAutocomplete.ts index c836e5d1b..df73c1190 100644 --- a/src/cm/extensions/aiAutocomplete.ts +++ b/src/cm/extensions/aiAutocomplete.ts @@ -1,7 +1,5 @@ import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view"; import { StateField, StateEffect } from "@codemirror/state"; -import aiService from "utils/ai/AIService"; -import appSettings from "lib/settings"; class GhostTextWidget extends WidgetType { constructor(public text: string) { @@ -58,27 +56,33 @@ export const aiAutocompletePlugin = ViewPlugin.fromClass( constructor(public view: any) {} update(update: any) { - if (update.docChanged && appSettings.value.ai?.provider) { - clearTimeout(this.timeout); - this.view.dispatch({ effects: setGhostText.of(null) }); + if (update.docChanged) { + import("lib/settings").then((mSettings) => { + const appSettings = mSettings.default; + if (appSettings.value.ai?.provider) { + clearTimeout(this.timeout); + this.view.dispatch({ effects: setGhostText.of(null) }); - this.timeout = setTimeout(async () => { - try { - const pos = this.view.state.selection.main.head; - const doc = this.view.state.doc.toString(); - const prefix = doc.slice(0, pos); - const suffix = doc.slice(pos); + this.timeout = setTimeout(async () => { + try { + const pos = this.view.state.selection.main.head; + const doc = this.view.state.doc.toString(); + const prefix = doc.slice(0, pos); + const suffix = doc.slice(pos); - const completion = await aiService.completeCode(prefix, suffix); - if (completion) { - this.view.dispatch({ - effects: setGhostText.of({ pos, text: completion }), - }); - } - } catch (e) { - // Ignore errors or log them + const { default: aiService } = await import("utils/ai/AIService"); + const completion = await aiService.completeCode(prefix, suffix); + if (completion) { + this.view.dispatch({ + effects: setGhostText.of({ pos, text: completion }), + }); + } + } catch (e) { + // Ignore errors or log them + } + }, 1000); // 1s debounce } - }, 1000); // 1s debounce + }).catch(() => {}); } } From d19cebea68893cf75cd3bfb267e394be869ef9b0 Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 16:47:35 +0530 Subject: [PATCH 05/10] refactor: Remove AcodeProAdapter, simplify AIService to use direct provider keys --- src/cm/extensions/aiAutocomplete.ts | 2 +- src/settings/aiSettings.js | 6 ---- src/utils/ai/AIService.js | 35 +++++++---------------- src/utils/ai/AcodeProAdapter.js | 44 ++++++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 33 deletions(-) diff --git a/src/cm/extensions/aiAutocomplete.ts b/src/cm/extensions/aiAutocomplete.ts index df73c1190..c13139719 100644 --- a/src/cm/extensions/aiAutocomplete.ts +++ b/src/cm/extensions/aiAutocomplete.ts @@ -1,4 +1,4 @@ -import { ViewPlugin, Decoration, WidgetType } from "@codemirror/view"; +import { ViewPlugin, Decoration, WidgetType, EditorView } from "@codemirror/view"; import { StateField, StateEffect } from "@codemirror/state"; class GhostTextWidget extends WidgetType { diff --git a/src/settings/aiSettings.js b/src/settings/aiSettings.js index 4bdb05957..07626fdae 100644 --- a/src/settings/aiSettings.js +++ b/src/settings/aiSettings.js @@ -5,12 +5,6 @@ export default function aiSettings() { const title = strings["ai settings"] || "AI Settings"; const values = appSettings.value.ai; const items = [ - { - key: "useAcodePro", - text: strings["use acode pro ai"] || "Use Acode Pro AI", - info: "Prioritize using existing Claude/Codex subscription if active", - checkbox: values.useAcodePro, - }, { key: "provider", text: strings["ai provider"] || "AI Provider", diff --git a/src/utils/ai/AIService.js b/src/utils/ai/AIService.js index ca5de68d2..48712b8cf 100644 --- a/src/utils/ai/AIService.js +++ b/src/utils/ai/AIService.js @@ -1,45 +1,33 @@ import appSettings from "lib/settings"; import OpenAiAdapter from "./OpenAiAdapter"; import OpenRouterAdapter from "./OpenRouterAdapter"; -import AcodeProAdapter from "./AcodeProAdapter"; class AIService { /** - * Get the active provider adapter based on settings and fallback logic + * Get the active provider adapter based on settings */ async getActiveAdapter() { const settings = appSettings.value.ai; - // 1. Check Acode Pro first if enabled - if (settings.useAcodePro) { - // In a real implementation, we would check if the user is logged in - // and has an active acode_pro subscription from `auth.getLoggedInUser()` - // For now, we will return the adapter and let it throw QUOTA_EXHAUSTED - // if they don't have access. - return new AcodeProAdapter(); - } - - // 2. Fallback to direct keys based on provider selection switch (settings.provider) { case "openai": - if (!settings.openaiKey) throw new Error("OpenAI Key missing. Please update AI Settings."); + if (!settings.openaiKey) throw new Error("OpenAI Key missing. Please go to Settings > AI Settings."); return new OpenAiAdapter(settings.openaiKey); case "openrouter": - if (!settings.openRouterKey) throw new Error("OpenRouter Key missing. Please update AI Settings."); + if (!settings.openRouterKey) throw new Error("OpenRouter Key missing. Please go to Settings > AI Settings."); return new OpenRouterAdapter(settings.openRouterKey); - // Implement gemini and anthropic later + // Gemini and Anthropic adapters to be added later default: - throw new Error("Selected AI provider is not supported yet."); + throw new Error(`Provider "${settings.provider || 'none'}" is not configured. Please go to Settings > AI Settings.`); } } /** - * Prompt the user for an API key if quota is exhausted + * Prompt the user to configure API key in settings */ - async promptForKey() { - // In a real implementation, we can trigger a dialog or redirect to settings - alert("Acode Pro AI quota exhausted. Please enter your own API key in Settings > AI Settings."); - appSettings.uiSettings["ai-settings"].show(); + async promptForConfig(message) { + alert(message || "Please configure your AI provider in Settings > AI Settings."); + appSettings.uiSettings["ai-settings"]?.show(); } async chat(messages) { @@ -47,10 +35,7 @@ class AIService { const adapter = await this.getActiveAdapter(); return await adapter.chat(messages); } catch (error) { - if (error.code === "QUOTA_EXHAUSTED") { - await this.promptForKey(); - throw new Error("Quota exhausted. Switched to fallback."); - } + await this.promptForConfig(error.message); throw error; } } diff --git a/src/utils/ai/AcodeProAdapter.js b/src/utils/ai/AcodeProAdapter.js index c9f60f921..1403e842b 100644 --- a/src/utils/ai/AcodeProAdapter.js +++ b/src/utils/ai/AcodeProAdapter.js @@ -4,7 +4,7 @@ export default class AcodeProAdapter extends BaseProvider { constructor(apiKey = "acode_pro_token", model = "claude-3-haiku") { super(apiKey, model); // Replace with the actual Acode Pro AI endpoint - this.baseUrl = "https://api.foxdebug.com/v1/ai"; + this.baseUrl = "https://api.foxdebug.com/v1/ai"; } async chat(messages) { @@ -34,4 +34,46 @@ export default class AcodeProAdapter extends BaseProvider { const data = await res.json(); return data.choices[0].message.content; } + + async completeCode(prefix, suffix) { + // Use the chat endpoint since /complete doesn't exist on api.foxdebug.com + const messages = [ + { + role: "system", + content: + "You are a code completion assistant. Given a code prefix and suffix, output ONLY the code that fills in the middle. No explanations, no markdown, no extra text.", + }, + { + role: "user", + content: `Complete the code between the prefix and suffix.\n\nPREFIX:\n${prefix}\n\nSUFFIX:\n${suffix}\n\nOutput only the fill-in-the-middle code:`, + }, + ]; + + const res = await fetch(`${this.baseUrl}/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages, + max_tokens: 64, + temperature: 0.2, + }), + }); + + if (res.status === 402 || res.status === 429) { + const error = new Error("Acode Pro subscription exhausted."); + error.code = "QUOTA_EXHAUSTED"; + throw error; + } + + if (!res.ok) { + throw new Error("Acode Pro Code Completion Error"); + } + + const data = await res.json(); + return data.choices[0].message.content ?? ""; + } } From dc6d47e65b6d5e2049a2e978f16fc9b371bba354 Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 16:51:48 +0530 Subject: [PATCH 06/10] feat: Streaming responses, context-aware AI, per-file history, Apply-to-Editor --- src/pages/aiAgent/aiAgent.js | 206 ++++++++++++++---- src/pages/aiAgent/aiAgent.scss | 332 ++++++++++++++++++++++++------ src/utils/ai/AIService.js | 122 ++++++++++- src/utils/ai/OpenAiAdapter.js | 83 +++++--- src/utils/ai/OpenRouterAdapter.js | 66 +++++- 5 files changed, 664 insertions(+), 145 deletions(-) diff --git a/src/pages/aiAgent/aiAgent.js b/src/pages/aiAgent/aiAgent.js index 67d8c07c1..6ffcd4b2e 100644 --- a/src/pages/aiAgent/aiAgent.js +++ b/src/pages/aiAgent/aiAgent.js @@ -5,21 +5,96 @@ import { render, Component } from "preact"; import { html } from "htm/preact"; import aiService from "utils/ai/AIService"; +// ── Simple markdown-ish renderer (bold, inline code, code blocks, newlines) ── +function renderMarkdown(text) { + if (!text) return ""; + // Extract code blocks first + const blocks = []; + text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { + const idx = blocks.length; + blocks.push({ lang, code: code.trim() }); + return `\x00BLOCK${idx}\x00`; + }); + + // Inline transforms + text = text + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\n/g, "
"); + + // Restore code blocks + text = text.replace(/\x00BLOCK(\d+)\x00/g, (_, i) => { + const { lang, code } = blocks[i]; + const escaped = code.replace(//g, ">"); + return `
${lang || "code"}
${escaped}
`; + }); + + return text; +} + class AiAgentApp extends Component { constructor(props) { super(props); + // Load history keyed to the active file + this._fileUri = editorManager?.activeFile?.uri || null; + const saved = aiService.loadHistory(this._fileUri); this.state = { - messages: [ - { role: "assistant", content: "Hello! I am your AI agent. How can I help you today?" } - ], + messages: saved.length + ? saved + : [{ role: "assistant", content: "Hello! I'm your AI coding assistant. Ask me anything about your code." }], input: "", - isLoading: false + isLoading: false, + streamingText: "", + }; + this._historyRef = null; + } + + componentDidMount() { + this._scrollToBottom(); + + // Handle "Apply" button clicks inside rendered markdown + this._handleApply = (e) => { + const btn = e.target.closest(".apply-btn"); + if (!btn) return; + const code = decodeURIComponent(btn.dataset.code || ""); + if (!code) return; + try { + const editor = editorManager?.editor; + const state = editor?.state; + if (!editor || !state) return; + const { from, to } = state.selection.main; + editor.dispatch({ + changes: { from, to, insert: code }, + }); + toast("Code applied to editor!"); + } catch (_) {} }; + + this._historyRef?.addEventListener("click", this._handleApply); + } + + componentWillUnmount() { + this._historyRef?.removeEventListener("click", this._handleApply); + } + + componentDidUpdate() { + this._scrollToBottom(); + } + + _scrollToBottom() { + if (this._historyRef) { + this._historyRef.scrollTop = this._historyRef.scrollHeight; + } + } + + _saveHistory(messages) { + aiService.saveHistory(this._fileUri, messages); } handleInput = (e) => { this.setState({ input: e.target.value }); - } + }; sendMessage = async () => { const { input, messages } = this.state; @@ -28,57 +103,109 @@ class AiAgentApp extends Component { const userMsg = { role: "user", content: input }; const newMessages = [...messages, userMsg]; - this.setState({ - messages: newMessages, - input: "", - isLoading: true - }); + this.setState({ messages: newMessages, input: "", isLoading: true, streamingText: "" }); try { - // This represents the Think -> Act -> Observe loop at a basic level - // We can expand this logic in aiService to actually perform multi-step reasoning - const response = await aiService.chat(newMessages); - this.setState({ - messages: [...newMessages, { role: "assistant", content: response }], - isLoading: false - }); + let streamBuf = ""; + const onChunk = (token, full) => { + streamBuf = full; + this.setState({ streamingText: full }); + }; + + const response = await aiService.chat(newMessages, onChunk); + const final = response || streamBuf; + + const finalMessages = [...newMessages, { role: "assistant", content: final }]; + this.setState({ messages: finalMessages, isLoading: false, streamingText: "" }); + this._saveHistory(finalMessages); } catch (error) { - this.setState({ - messages: [...newMessages, { role: "assistant", content: `**Error:** ${error.message}` }], - isLoading: false - }); + const errMessages = [ + ...newMessages, + { role: "assistant", content: `**Error:** ${error.message}` }, + ]; + this.setState({ messages: errMessages, isLoading: false, streamingText: "" }); } - } + }; handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } + }; + + clearHistory = () => { + const initial = [{ role: "assistant", content: "Chat cleared! How can I help?" }]; + this.setState({ messages: initial, streamingText: "" }); + aiService.clearHistory(this._fileUri); + }; + + _getFileLabel() { + const file = editorManager?.activeFile; + if (!file) return null; + return file.filename || "untitled"; } render() { - const { messages, input, isLoading } = this.state; + const { messages, input, isLoading, streamingText } = this.state; + const fileLabel = this._getFileLabel(); + return html`
-
- ${messages.map(msg => html` -
-
- ${msg.content} +
+ ${fileLabel ? html` + ${fileLabel} + ` : null} + +
+ +
(this._historyRef = el)}> + ${messages.map( + (msg) => html` +
+
+ ${msg.role === "assistant" + ? html`` + : html``} +
+
+
+
-
- `)} - ${isLoading ? html`
Thinking...
` : null} + `, + )} + + ${isLoading + ? html`
+
+
+
`, + }} + >
+
+
` + : null}
+
- -
@@ -88,14 +215,12 @@ class AiAgentApp extends Component { } export default function AiAgentInclude() { + // Don't re-open if already open + if (document.querySelector(".ai-agent-page")) return; + const $page = Page("AI Agent"); $page.classList.add("ai-agent-page"); - // We mount the Preact component into the $page.body - // HTML Tag JS (which Acode uses) requires standard DOM elements - // The Page component already sets $page.body = tag('div', { className: 'main' }); - // Wait, actually Page() returns a DOM element. We can just render into a new div. - const rootDiv = document.createElement("div"); rootDiv.className = "main"; $page.body = rootDiv; @@ -108,9 +233,10 @@ export default function AiAgentInclude() { }); $page.onhide = function () { - render(null, rootDiv); // Unmount + render(null, rootDiv); actionStack.remove("aiAgent"); }; app.append($page); + $page.show(); } diff --git a/src/pages/aiAgent/aiAgent.scss b/src/pages/aiAgent/aiAgent.scss index d6a338dd1..a51a4d947 100644 --- a/src/pages/aiAgent/aiAgent.scss +++ b/src/pages/aiAgent/aiAgent.scss @@ -1,80 +1,280 @@ +@use "sass:color"; + .ai-agent-page { - .ai-agent-container { + display: flex; + flex-direction: column; + height: 100%; + background: var(--popup-background-color, #1e1e1e); + + > .body { + flex: 1; + overflow: hidden; display: flex; - flex-direction: column; - height: 100%; - background-color: var(--primary-color); - - .ai-chat-history { - flex: 1; - overflow-y: auto; - padding: 10px; - display: flex; - flex-direction: column; - gap: 10px; - - .ai-message { - display: flex; - - &.user { - justify-content: flex-end; - .ai-message-bubble { - background-color: var(--secondary-color); - color: var(--text-color); - border-radius: 12px 12px 0 12px; - } - } - - &.assistant { - justify-content: flex-start; - .ai-message-bubble { - background-color: var(--popup-background-color); - color: var(--text-color); - border-radius: 12px 12px 12px 0; - } - } + } +} - .ai-message-bubble { - max-width: 85%; - padding: 10px 14px; - font-size: 14px; - line-height: 1.4; - box-shadow: 0 1px 2px rgba(0,0,0,0.1); - } - } +.ai-agent-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + font-family: var(--font-family, system-ui, sans-serif); +} + +// ── Header ──────────────────────────────────────────────────────────────────── +.ai-agent-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.2); + flex-shrink: 0; + + .ai-file-context { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--secondary-text-color, #aaa); + max-width: calc(100% - 44px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .icon { + font-size: 14px; + opacity: 0.6; + } + } + + .ai-clear-btn { + background: none; + border: none; + color: var(--secondary-text-color, #aaa); + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; + transition: background 0.15s; + + &:hover, + &:active { + background: rgba(255, 255, 255, 0.1); + } + + .icon { font-size: 18px; } + } +} + +// ── Messages ────────────────────────────────────────────────────────────────── +.ai-chat-history { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + scroll-behavior: smooth; + + // Scrollbar + &::-webkit-scrollbar { width: 3px; } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 3px; + } +} + +.ai-message { + display: flex; + gap: 8px; + align-items: flex-start; + animation: msgIn 0.18s ease-out; + + @keyframes msgIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } + } + + &.user { + flex-direction: row-reverse; + + .ai-message-bubble { + background: var(--accent-color, #4f8ef7); + color: #fff; + border-radius: 16px 4px 16px 16px; + align-items: flex-end; } + } + + &.assistant .ai-message-bubble { + background: rgba(255, 255, 255, 0.07); + color: var(--text-color, #e0e0e0); + border-radius: 4px 16px 16px 16px; + } +} - .ai-chat-input { +.ai-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + .icon { font-size: 16px; } +} + +.ai-message-bubble { + padding: 9px 13px; + max-width: calc(100% - 44px); + word-break: break-word; +} + +.ai-message-content { + font-size: 14px; + line-height: 1.55; + + code { + font-family: var(--code-font-family, monospace); + font-size: 12.5px; + background: rgba(0, 0, 0, 0.3); + padding: 1px 5px; + border-radius: 4px; + } + + pre { + margin: 8px 0 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + + .code-header { display: flex; - padding: 10px; - background-color: var(--popup-background-color); - border-top: 1px solid var(--border-color); - - textarea { - flex: 1; - resize: none; - border: none; - background: transparent; - color: var(--text-color); - outline: none; - padding: 8px; - min-height: 40px; - max-height: 120px; - font-family: inherit; + align-items: center; + justify-content: space-between; + padding: 5px 10px; + background: rgba(0, 0, 0, 0.35); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + + .code-lang { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.04em; } - button { - background: none; - border: none; - color: var(--link-color); - padding: 10px; + .apply-btn { + font-size: 11px; + font-weight: 600; + color: var(--accent-color, #4f8ef7); + background: rgba(79, 142, 247, 0.15); + border: 1px solid rgba(79, 142, 247, 0.3); + border-radius: 5px; + padding: 2px 8px; cursor: pointer; - - &:disabled { - opacity: 0.5; - cursor: not-allowed; + transition: background 0.15s; + + &:hover, + &:active { + background: rgba(79, 142, 247, 0.3); } } } + + code { + display: block; + background: transparent; + padding: 10px; + font-size: 12.5px; + overflow-x: auto; + white-space: pre; + border-radius: 0; + } + } + + strong { font-weight: 600; } + em { font-style: italic; } +} + +// ── Thinking dots ───────────────────────────────────────────────────────────── +.ai-thinking { + display: inline-flex; + gap: 4px; + align-items: center; + padding: 2px 0; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--secondary-text-color, #aaa); + animation: dotBounce 1.2s ease-in-out infinite; + + &:nth-child(2) { animation-delay: 0.2s; } + &:nth-child(3) { animation-delay: 0.4s; } + } + + @keyframes dotBounce { + 0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; } + 40% { transform: scale(1); opacity: 1; } + } +} + +.streaming { + opacity: 0.9; +} + +// ── Input ───────────────────────────────────────────────────────────────────── +.ai-chat-input { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.2); + flex-shrink: 0; + + textarea { + flex: 1; + background: rgba(255, 255, 255, 0.07); + color: var(--text-color, #e0e0e0); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + padding: 9px 13px; + font-size: 14px; + line-height: 1.4; + resize: none; + outline: none; + max-height: 120px; + overflow-y: auto; + font-family: inherit; + transition: border-color 0.15s; + + &::placeholder { color: rgba(255, 255, 255, 0.3); } + &:focus { border-color: var(--accent-color, #4f8ef7); } + } + + .ai-send-btn { + width: 38px; + height: 38px; + border-radius: 50%; + background: var(--accent-color, #4f8ef7); + border: none; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: opacity 0.15s, transform 0.1s; + + .icon { font-size: 18px; } + + &:hover { opacity: 0.85; } + &:active { transform: scale(0.92); } + &:disabled { opacity: 0.4; cursor: default; transform: none; } } } diff --git a/src/utils/ai/AIService.js b/src/utils/ai/AIService.js index 48712b8cf..127b214d0 100644 --- a/src/utils/ai/AIService.js +++ b/src/utils/ai/AIService.js @@ -2,6 +2,9 @@ import appSettings from "lib/settings"; import OpenAiAdapter from "./OpenAiAdapter"; import OpenRouterAdapter from "./OpenRouterAdapter"; +const HISTORY_PREFIX = "acode_ai_history_"; +const MAX_HISTORY_ENTRIES = 20; + class AIService { /** * Get the active provider adapter based on settings @@ -11,14 +14,21 @@ class AIService { switch (settings.provider) { case "openai": - if (!settings.openaiKey) throw new Error("OpenAI Key missing. Please go to Settings > AI Settings."); - return new OpenAiAdapter(settings.openaiKey); + if (!settings.openaiKey) + throw new Error( + "OpenAI Key missing. Please go to Settings > AI Settings.", + ); + return new OpenAiAdapter(settings.openaiKey, settings.model); case "openrouter": - if (!settings.openRouterKey) throw new Error("OpenRouter Key missing. Please go to Settings > AI Settings."); - return new OpenRouterAdapter(settings.openRouterKey); - // Gemini and Anthropic adapters to be added later + if (!settings.openRouterKey) + throw new Error( + "OpenRouter Key missing. Please go to Settings > AI Settings.", + ); + return new OpenRouterAdapter(settings.openRouterKey, settings.model); default: - throw new Error(`Provider "${settings.provider || 'none'}" is not configured. Please go to Settings > AI Settings.`); + throw new Error( + `Provider "${settings.provider || "none"}" is not configured. Please go to Settings > AI Settings.`, + ); } } @@ -30,28 +40,118 @@ class AIService { appSettings.uiSettings["ai-settings"]?.show(); } - async chat(messages) { + /** + * Build a context-injected system message for the current file + */ + getFileContext() { + try { + const file = editorManager?.activeFile; + if (!file) return null; + const editor = editorManager?.editor; + const selection = editor?.state?.sliceDoc?.( + editor.state.selection.main.from, + editor.state.selection.main.to, + ); + const lang = file.session?.customData?.mode || file.filename?.split(".").pop() || "unknown"; + const fileName = file.filename || "untitled"; + const fullCode = editor?.state?.doc?.toString?.() || ""; + + let ctx = `You are an AI coding assistant inside Acode, a mobile code editor.\nActive file: ${fileName} (${lang})`; + if (selection && selection.trim().length > 0) { + ctx += `\n\nCurrently selected code:\n\`\`\`${lang}\n${selection}\n\`\`\``; + } else if (fullCode && fullCode.length < 8000) { + ctx += `\n\nFile contents:\n\`\`\`${lang}\n${fullCode}\n\`\`\``; + } + return ctx; + } catch (_) { + return "You are an AI coding assistant inside Acode, a mobile code editor."; + } + } + + /** + * Build full messages array with system context prepended + */ + buildMessages(userMessages) { + const sysContent = this.getFileContext(); + const system = sysContent + ? [{ role: "system", content: sysContent }] + : []; + return [...system, ...userMessages]; + } + + /** + * Streaming chat — calls onChunk(token, fullText) live + * @param {Array} messages plain user/assistant messages (no system) + * @param {function|null} onChunk + */ + async chat(messages, onChunk = null) { try { const adapter = await this.getActiveAdapter(); - return await adapter.chat(messages); + const fullMessages = this.buildMessages(messages); + return await adapter.chat(fullMessages, onChunk); } catch (error) { await this.promptForConfig(error.message); throw error; } } + /** + * Code completion (no context injection — just prefix/suffix) + */ async completeCode(prefix, suffix) { try { const adapter = await this.getActiveAdapter(); - if (!adapter.completeCode) { - return ""; // not supported by this adapter - } + if (!adapter.completeCode) return ""; return await adapter.completeCode(prefix, suffix); } catch (error) { console.error("AI Completion error", error); return ""; } } + + // ─── Conversation History ───────────────────────────────────────────────── + + /** + * Returns storage key for the given file URI (or global if null) + */ + _historyKey(fileUri) { + const safe = (fileUri || "global").replace(/[^a-zA-Z0-9]/g, "_"); + return `${HISTORY_PREFIX}${safe}`; + } + + /** + * Load conversation history for a file URI + * @param {string|null} fileUri + * @returns {Array} + */ + loadHistory(fileUri) { + try { + const raw = localStorage.getItem(this._historyKey(fileUri)); + return raw ? JSON.parse(raw) : []; + } catch (_) { + return []; + } + } + + /** + * Save conversation history for a file URI + * @param {string|null} fileUri + * @param {Array} messages + */ + saveHistory(fileUri, messages) { + try { + const trimmed = messages.slice(-MAX_HISTORY_ENTRIES); + localStorage.setItem(this._historyKey(fileUri), JSON.stringify(trimmed)); + } catch (_) {} + } + + /** + * Clear history for a file URI + * @param {string|null} fileUri + */ + clearHistory(fileUri) { + localStorage.removeItem(this._historyKey(fileUri)); + } } export default new AIService(); diff --git a/src/utils/ai/OpenAiAdapter.js b/src/utils/ai/OpenAiAdapter.js index 11df17351..12937d9a2 100644 --- a/src/utils/ai/OpenAiAdapter.js +++ b/src/utils/ai/OpenAiAdapter.js @@ -6,7 +6,13 @@ export default class OpenAiAdapter extends BaseProvider { this.baseUrl = "https://api.openai.com/v1"; } - async chat(messages) { + /** + * Streaming chat — calls onChunk(text) for each SSE token, returns full text + * @param {Array} messages + * @param {function} onChunk + */ + async chat(messages, onChunk = null) { + const stream = !!onChunk; const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", headers: { @@ -15,7 +21,8 @@ export default class OpenAiAdapter extends BaseProvider { }, body: JSON.stringify({ model: this.model, - messages: messages, + messages, + stream, }), }); @@ -24,31 +31,59 @@ export default class OpenAiAdapter extends BaseProvider { throw new Error(error.error?.message || "OpenAI API Error"); } - const data = await res.json(); - return data.choices[0].message.content; + if (!stream) { + const data = await res.json(); + return data.choices[0].message.content; + } + + // SSE streaming + return this._readStream(res, onChunk); } - async completeCode(prefix, suffix) { - // Example using FIM (Fill-in-the-middle) if supported, or generic prompt - const prompt = `<|fim_prefix|>${prefix}<|fim_suffix|>${suffix}<|fim_middle|>`; - const res = await fetch(`${this.baseUrl}/completions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify({ - model: "gpt-3.5-turbo-instruct", - prompt: prompt, - max_tokens: 64, - temperature: 0.2, - }), - }); + async _readStream(res, onChunk) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; - if (!res.ok) { - throw new Error("OpenAI Code Completion Error"); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") continue; + + try { + const json = JSON.parse(data); + const token = json.choices?.[0]?.delta?.content; + if (token) { + fullText += token; + onChunk(token, fullText); + } + } catch (_) {} + } } - const data = await res.json(); - return data.choices[0].text; + + return fullText; + } + + async completeCode(prefix, suffix) { + // Use chat-based FIM since /completions is legacy + const messages = [ + { + role: "system", + content: + "You are a code completion engine. Output ONLY the code that fills the middle. No explanations, no markdown fences.", + }, + { + role: "user", + content: `PREFIX:\n${prefix}\n\nSUFFIX:\n${suffix}\n\nFill in the middle:`, + }, + ]; + return this.chat(messages); } } diff --git a/src/utils/ai/OpenRouterAdapter.js b/src/utils/ai/OpenRouterAdapter.js index b3cb9d765..3d85342e9 100644 --- a/src/utils/ai/OpenRouterAdapter.js +++ b/src/utils/ai/OpenRouterAdapter.js @@ -6,7 +6,13 @@ export default class OpenRouterAdapter extends BaseProvider { this.baseUrl = "https://openrouter.ai/api/v1"; } - async chat(messages) { + /** + * Streaming chat — calls onChunk(text) for each SSE token, returns full text + * @param {Array} messages + * @param {function|null} onChunk + */ + async chat(messages, onChunk = null) { + const stream = !!onChunk; const res = await fetch(`${this.baseUrl}/chat/completions`, { method: "POST", headers: { @@ -17,7 +23,8 @@ export default class OpenRouterAdapter extends BaseProvider { }, body: JSON.stringify({ model: this.model, - messages: messages, + messages, + stream, }), }); @@ -26,7 +33,58 @@ export default class OpenRouterAdapter extends BaseProvider { throw new Error(error.error?.message || "OpenRouter API Error"); } - const data = await res.json(); - return data.choices[0].message.content; + if (!stream) { + const data = await res.json(); + return data.choices[0].message.content; + } + + // SSE streaming + return this._readStream(res, onChunk); + } + + async _readStream(res, onChunk) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n"); + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (data === "[DONE]") continue; + + try { + const json = JSON.parse(data); + const token = json.choices?.[0]?.delta?.content; + if (token) { + fullText += token; + onChunk(token, fullText); + } + } catch (_) {} + } + } + + return fullText; + } + + async completeCode(prefix, suffix) { + const messages = [ + { + role: "system", + content: + "You are a code completion engine. Output ONLY the code that fills the middle. No explanations, no markdown fences.", + }, + { + role: "user", + content: `PREFIX:\n${prefix}\n\nSUFFIX:\n${suffix}\n\nFill in the middle:`, + }, + ]; + return this.chat(messages); } } From 104bc17f1df7b027ce4ac2ed6db14ef967c7424c Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 17:15:12 +0530 Subject: [PATCH 07/10] feat: Agentic AI with Think->Act->Observe loop, tool calling, and live step UI --- src/pages/aiAgent/aiAgent.js | 270 +++++++++++++++++---------- src/pages/aiAgent/aiAgent.scss | 119 +++++++++++- src/utils/ai/AIService.js | 18 ++ src/utils/ai/OpenAiAdapter.js | 36 ++++ src/utils/ai/OpenRouterAdapter.js | 35 ++++ src/utils/ai/agentLoop.js | 115 ++++++++++++ src/utils/ai/agentTools.js | 293 ++++++++++++++++++++++++++++++ 7 files changed, 784 insertions(+), 102 deletions(-) create mode 100644 src/utils/ai/agentLoop.js create mode 100644 src/utils/ai/agentTools.js diff --git a/src/pages/aiAgent/aiAgent.js b/src/pages/aiAgent/aiAgent.js index 6ffcd4b2e..5a8a94303 100644 --- a/src/pages/aiAgent/aiAgent.js +++ b/src/pages/aiAgent/aiAgent.js @@ -5,55 +5,70 @@ import { render, Component } from "preact"; import { html } from "htm/preact"; import aiService from "utils/ai/AIService"; -// ── Simple markdown-ish renderer (bold, inline code, code blocks, newlines) ── +// ── Simple markdown renderer ────────────────────────────────────────────────── function renderMarkdown(text) { if (!text) return ""; - // Extract code blocks first const blocks = []; text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { const idx = blocks.length; blocks.push({ lang, code: code.trim() }); return `\x00BLOCK${idx}\x00`; }); - - // Inline transforms text = text .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/\n/g, "
"); - - // Restore code blocks text = text.replace(/\x00BLOCK(\d+)\x00/g, (_, i) => { const { lang, code } = blocks[i]; const escaped = code.replace(//g, ">"); return `
${lang || "code"}
${escaped}
`; }); - return text; } +// ── Tool step card ───────────────────────────────────────────────────────────── +function ToolStep({ step }) { + const icon = { + tool_start: "⏳", + tool_done: "✅", + tool_error: "❌", + }[step.type] || "🔧"; + + return html` +
+ ${icon} +
+ ${step.label} + ${step.argSummary ? html`${step.argSummary}` : null} + ${step.type === "tool_error" + ? html`${step.error}` + : null} +
+
+ `; +} + class AiAgentApp extends Component { constructor(props) { super(props); - // Load history keyed to the active file this._fileUri = editorManager?.activeFile?.uri || null; const saved = aiService.loadHistory(this._fileUri); this.state = { messages: saved.length ? saved - : [{ role: "assistant", content: "Hello! I'm your AI coding assistant. Ask me anything about your code." }], + : [{ role: "assistant", content: "Hello! I'm your AI coding assistant.\n\nUse **Chat** mode for questions, or switch to **Agent** mode and I'll autonomously read, edit, and create files in your project." }], input: "", isLoading: false, streamingText: "", + agentMode: false, + agentSteps: [], // current run steps }; this._historyRef = null; } componentDidMount() { this._scrollToBottom(); - - // Handle "Apply" button clicks inside rendered markdown this._handleApply = (e) => { const btn = e.target.closest(".apply-btn"); if (!btn) return; @@ -61,16 +76,12 @@ class AiAgentApp extends Component { if (!code) return; try { const editor = editorManager?.editor; - const state = editor?.state; - if (!editor || !state) return; - const { from, to } = state.selection.main; - editor.dispatch({ - changes: { from, to, insert: code }, - }); - toast("Code applied to editor!"); + if (!editor) return; + const { from, to } = editor.state.selection.main; + editor.dispatch({ changes: { from, to, insert: code } }); + window.toast?.("Code applied!"); } catch (_) {} }; - this._historyRef?.addEventListener("click", this._handleApply); } @@ -92,122 +103,202 @@ class AiAgentApp extends Component { aiService.saveHistory(this._fileUri, messages); } - handleInput = (e) => { - this.setState({ input: e.target.value }); + handleInput = (e) => this.setState({ input: e.target.value }); + + toggleAgentMode = () => { + this.setState((s) => ({ agentMode: !s.agentMode })); }; - sendMessage = async () => { + // ── Chat mode ───────────────────────────────────────────────────────────── + sendChat = async () => { const { input, messages } = this.state; if (!input.trim() || this.state.isLoading) return; const userMsg = { role: "user", content: input }; const newMessages = [...messages, userMsg]; - this.setState({ messages: newMessages, input: "", isLoading: true, streamingText: "" }); try { let streamBuf = ""; - const onChunk = (token, full) => { + const response = await aiService.chat(newMessages, (token, full) => { streamBuf = full; this.setState({ streamingText: full }); - }; + }); - const response = await aiService.chat(newMessages, onChunk); const final = response || streamBuf; - const finalMessages = [...newMessages, { role: "assistant", content: final }]; this.setState({ messages: finalMessages, isLoading: false, streamingText: "" }); this._saveHistory(finalMessages); } catch (error) { - const errMessages = [ - ...newMessages, - { role: "assistant", content: `**Error:** ${error.message}` }, - ]; + const errMessages = [...newMessages, { role: "assistant", content: `**Error:** ${error.message}` }]; this.setState({ messages: errMessages, isLoading: false, streamingText: "" }); } }; + // ── Agent mode ───────────────────────────────────────────────────────────── + sendAgent = async () => { + const { input, messages } = this.state; + if (!input.trim() || this.state.isLoading) return; + + const userMsg = { role: "user", content: input }; + const newMessages = [...messages, userMsg]; + this.setState({ + messages: newMessages, + input: "", + isLoading: true, + agentSteps: [], + streamingText: "", + }); + + const steps = []; + + try { + const final = await aiService.runAgent(newMessages, (event) => { + if (event.type === "tool_start") { + steps.push({ type: "tool_start", label: event.label, argSummary: event.argSummary }); + this.setState({ agentSteps: [...steps] }); + } else if (event.type === "tool_done") { + // Replace last step with done + const last = steps[steps.length - 1]; + if (last) last.type = "tool_done"; + this.setState({ agentSteps: [...steps] }); + } else if (event.type === "tool_error") { + const last = steps[steps.length - 1]; + if (last) { last.type = "tool_error"; last.error = event.error; } + this.setState({ agentSteps: [...steps] }); + } + }); + + // Build a combined message with steps + final response + const agentMessage = { + role: "assistant", + content: final, + steps: [...steps], + }; + const finalMessages = [...newMessages, agentMessage]; + this.setState({ messages: finalMessages, isLoading: false, agentSteps: [] }); + this._saveHistory(finalMessages); + } catch (error) { + const errMessages = [...newMessages, { role: "assistant", content: `**Agent Error:** ${error.message}` }]; + this.setState({ messages: errMessages, isLoading: false, agentSteps: [] }); + } + }; + + handleSend = () => { + if (this.state.agentMode) this.sendAgent(); + else this.sendChat(); + }; + handleKeyDown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - this.sendMessage(); + this.handleSend(); } }; clearHistory = () => { const initial = [{ role: "assistant", content: "Chat cleared! How can I help?" }]; - this.setState({ messages: initial, streamingText: "" }); + this.setState({ messages: initial, streamingText: "", agentSteps: [] }); aiService.clearHistory(this._fileUri); }; - _getFileLabel() { - const file = editorManager?.activeFile; - if (!file) return null; - return file.filename || "untitled"; - } - render() { - const { messages, input, isLoading, streamingText } = this.state; - const fileLabel = this._getFileLabel(); + const { messages, input, isLoading, streamingText, agentMode, agentSteps } = this.state; + const fileLabel = editorManager?.activeFile?.filename; return html`
+
${fileLabel ? html` ${fileLabel} - ` : null} - + ` : html`AI Assistant`} +
+ + +
+
(this._historyRef = el)}> - ${messages.map( - (msg) => html` -
-
- ${msg.role === "assistant" - ? html`` - : html``} + ${messages.map((msg) => html` +
+
+ ${msg.role === "assistant" + ? html`` + : html``} +
+
+ + ${msg.steps?.length ? html` +
+ ${msg.steps.map((s) => html`<${ToolStep} step=${s} />`)} +
+ ` : null} +
+
+
+ `)} + + + ${isLoading && agentMode && agentSteps.length ? html` +
+
+
+
+ ${agentSteps.map((s) => html`<${ToolStep} step=${s} />`)}
-
-
+
+
- `, - )} - - ${isLoading - ? html`
-
-
-
`, - }} - >
-
-
` - : null} +
+ ` : isLoading ? html` +
+
+
+
`, + }} + >
+
+
+ ` : null}
-
- - + +
+ ${agentMode ? html`
Agent will read & modify files autonomously
` : null} +
+ + +
`; @@ -215,7 +306,6 @@ class AiAgentApp extends Component { } export default function AiAgentInclude() { - // Don't re-open if already open if (document.querySelector(".ai-agent-page")) return; const $page = Page("AI Agent"); @@ -227,11 +317,7 @@ export default function AiAgentInclude() { render(html`<${AiAgentApp} />`, rootDiv); - actionStack.push({ - id: "aiAgent", - action: $page.hide, - }); - + actionStack.push({ id: "aiAgent", action: $page.hide }); $page.onhide = function () { render(null, rootDiv); actionStack.remove("aiAgent"); diff --git a/src/pages/aiAgent/aiAgent.scss b/src/pages/aiAgent/aiAgent.scss index a51a4d947..89761496a 100644 --- a/src/pages/aiAgent/aiAgent.scss +++ b/src/pages/aiAgent/aiAgent.scss @@ -37,14 +37,39 @@ gap: 6px; font-size: 12px; color: var(--secondary-text-color, #aaa); - max-width: calc(100% - 44px); + max-width: calc(100% - 120px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + .icon { font-size: 14px; opacity: 0.6; } + } + + .ai-header-actions { + display: flex; + align-items: center; + gap: 6px; + } + + .ai-mode-toggle { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--secondary-text-color, #aaa); + border-radius: 8px; + padding: 3px 8px; + cursor: pointer; + transition: all 0.15s; - .icon { - font-size: 14px; - opacity: 0.6; + .icon { font-size: 14px; } + + &.active { + background: rgba(255, 160, 60, 0.15); + border-color: rgba(255, 160, 60, 0.4); + color: #ffa03c; } } @@ -56,16 +81,12 @@ padding: 4px 6px; border-radius: 6px; transition: background 0.15s; - - &:hover, - &:active { - background: rgba(255, 255, 255, 0.1); - } - + &:hover, &:active { background: rgba(255, 255, 255, 0.1); } .icon { font-size: 18px; } } } + // ── Messages ────────────────────────────────────────────────────────────────── .ai-chat-history { flex: 1; @@ -278,3 +299,81 @@ &:disabled { opacity: 0.4; cursor: default; transform: none; } } } + +// ── Agent mode input ────────────────────────────────────────────────────────── +.ai-chat-input.agent-mode { + border-top-color: rgba(255, 160, 60, 0.25); + background: rgba(255, 160, 60, 0.04); + + .ai-input-row { display: flex; gap: 8px; align-items: flex-end; } + + .agent-hint { + font-size: 11px; + color: #ffa03c; + opacity: 0.8; + padding: 0 2px 5px; + } + + .ai-send-btn { + background: #ffa03c; + } +} + +.ai-input-row { + display: flex; + gap: 8px; + align-items: flex-end; +} + +// ── Agent step cards ────────────────────────────────────────────────────────── +.agent-steps-log { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; + padding: 8px 10px; + background: rgba(0, 0, 0, 0.25); + border-radius: 8px; + border-left: 2px solid rgba(255, 255, 255, 0.1); + + &.live { + border-left-color: #ffa03c; + } +} + +.agent-step { + display: flex; + align-items: flex-start; + gap: 7px; + font-size: 12.5px; + line-height: 1.4; + animation: msgIn 0.12s ease-out; + + .step-icon { flex-shrink: 0; font-size: 13px; margin-top: 1px; } + + .step-body { + display: flex; + flex-direction: column; + gap: 1px; + } + + .step-label { + color: var(--text-color, #e0e0e0); + font-weight: 500; + } + + .step-arg { + font-family: var(--code-font-family, monospace); + font-size: 11.5px; + color: var(--secondary-text-color, #aaa); + } + + .step-error { + font-size: 11.5px; + color: #f87272; + } + + &.tool_start .step-label { opacity: 0.7; } + &.tool_done .step-label { color: #6ee7a0; } + &.tool_error .step-label { color: #f87272; } +} diff --git a/src/utils/ai/AIService.js b/src/utils/ai/AIService.js index 127b214d0..b036347d2 100644 --- a/src/utils/ai/AIService.js +++ b/src/utils/ai/AIService.js @@ -109,6 +109,24 @@ class AIService { } } + /** + * Run the Agentic Think → Act → Observe loop. + * @param {Array} messages - plain user/assistant messages (no system) + * @param {function} onEvent - called on every agent step event + * @returns {Promise} - the final assistant text + */ + async runAgent(messages, onEvent) { + try { + const adapter = await this.getActiveAdapter(); + const fullMessages = this.buildMessages(messages); + const { runAgentLoop } = await import("./agentLoop"); + return await runAgentLoop(adapter, fullMessages, onEvent); + } catch (error) { + await this.promptForConfig(error.message); + throw error; + } + } + // ─── Conversation History ───────────────────────────────────────────────── /** diff --git a/src/utils/ai/OpenAiAdapter.js b/src/utils/ai/OpenAiAdapter.js index 12937d9a2..39eae28ee 100644 --- a/src/utils/ai/OpenAiAdapter.js +++ b/src/utils/ai/OpenAiAdapter.js @@ -71,6 +71,42 @@ export default class OpenAiAdapter extends BaseProvider { return fullText; } + /** + * Non-streaming chat with tool/function calling support. + * @param {Array} messages + * @param {Array} tools + * @returns {Promise<{content, tool_calls, finish_reason, message}>} + */ + async chatWithTools(messages, tools) { + const res = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages, + tools, + tool_choice: "auto", + }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error?.message || "OpenAI API Error"); + } + + const data = await res.json(); + const choice = data.choices[0]; + return { + content: choice.message.content || null, + tool_calls: choice.message.tool_calls || null, + finish_reason: choice.finish_reason, + message: choice.message, + }; + } + async completeCode(prefix, suffix) { // Use chat-based FIM since /completions is legacy const messages = [ diff --git a/src/utils/ai/OpenRouterAdapter.js b/src/utils/ai/OpenRouterAdapter.js index 3d85342e9..7f0e2eab9 100644 --- a/src/utils/ai/OpenRouterAdapter.js +++ b/src/utils/ai/OpenRouterAdapter.js @@ -73,6 +73,41 @@ export default class OpenRouterAdapter extends BaseProvider { return fullText; } + /** + * Non-streaming chat with tool/function calling support. + */ + async chatWithTools(messages, tools) { + const res = await fetch(`${this.baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + "HTTP-Referer": "https://acode.foxdebug.com", + "X-Title": "Acode", + }, + body: JSON.stringify({ + model: this.model, + messages, + tools, + tool_choice: "auto", + }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error?.message || "OpenRouter API Error"); + } + + const data = await res.json(); + const choice = data.choices[0]; + return { + content: choice.message.content || null, + tool_calls: choice.message.tool_calls || null, + finish_reason: choice.finish_reason, + message: choice.message, + }; + } + async completeCode(prefix, suffix) { const messages = [ { diff --git a/src/utils/ai/agentLoop.js b/src/utils/ai/agentLoop.js new file mode 100644 index 000000000..6fc8c1cd2 --- /dev/null +++ b/src/utils/ai/agentLoop.js @@ -0,0 +1,115 @@ +import { TOOL_DEFINITIONS, TOOL_LABELS, executeTool } from "./agentTools"; + +const MAX_ITERATIONS = 15; + +const AGENT_SYSTEM_PROMPT = `You are an expert AI coding agent inside Acode, a mobile code editor on Android. + +You have access to tools that let you read, write, create, and edit files in the user's project. +Use them proactively to complete tasks autonomously. + +Guidelines: +- Always read a file before editing it to understand its current state. +- Use edit_file for small targeted changes, write_file only when rewriting completely. +- After completing changes, provide a clear summary of what you did. +- If a task is ambiguous, make a reasonable assumption and proceed. +- When you have completed the task, respond with a final natural language summary — do NOT call more tools.`; + +/** + * Run the Think → Act → Observe agent loop. + * + * @param {object} adapter - An OpenAiAdapter or OpenRouterAdapter instance + * @param {Array} messages - The full message history (with system context prepended) + * @param {function} onEvent - Callback: { type, name?, args?, result?, error?, content? } + * @returns {Promise} - The final text response + */ +export async function runAgentLoop(adapter, messages, onEvent) { + if (!adapter.chatWithTools) { + throw new Error("This provider does not support tool/function calling."); + } + + const workingMessages = [...messages]; + + // Inject agent system guidance + if (workingMessages[0]?.role === "system") { + workingMessages[0].content += `\n\n${AGENT_SYSTEM_PROMPT}`; + } else { + workingMessages.unshift({ role: "system", content: AGENT_SYSTEM_PROMPT }); + } + + for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) { + // ── Think: call the LLM ────────────────────────────────────────────── + let response; + try { + response = await adapter.chatWithTools(workingMessages, TOOL_DEFINITIONS); + } catch (err) { + onEvent({ type: "error", error: err.message }); + throw err; + } + + // Add assistant message to history + workingMessages.push(response.message); + + // ── No tool calls → final answer ───────────────────────────────────── + if (!response.tool_calls || response.tool_calls.length === 0) { + const content = response.content || "(No response)"; + onEvent({ type: "done", content }); + return content; + } + + // ── Act + Observe: execute each tool call ──────────────────────────── + for (const toolCall of response.tool_calls) { + const name = toolCall.function.name; + let args; + try { + args = JSON.parse(toolCall.function.arguments || "{}"); + } catch { + args = {}; + } + + const label = TOOL_LABELS[name] || name; + const argSummary = _summarizeArgs(name, args); + + // Notify UI: tool starting + onEvent({ type: "tool_start", name, label, argSummary }); + + let result; + try { + result = await executeTool(name, args); + onEvent({ type: "tool_done", name, label, result }); + } catch (err) { + result = `Error: ${err.message}`; + onEvent({ type: "tool_error", name, label, error: err.message }); + } + + // Feed result back to LLM as tool message + workingMessages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: typeof result === "string" ? result : JSON.stringify(result), + }); + } + } + + const timeout = "Agent reached the maximum number of steps. Task may be incomplete."; + onEvent({ type: "done", content: timeout }); + return timeout; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _summarizeArgs(name, args) { + switch (name) { + case "read_file": + case "list_dir": + case "search_in_file": + case "open_file": + return args.path || ""; + case "write_file": + case "edit_file": + return args.path || ""; + case "apply_to_editor": + return args.code ? args.code.slice(0, 40) + (args.code.length > 40 ? "…" : "") : ""; + default: + return ""; + } +} diff --git a/src/utils/ai/agentTools.js b/src/utils/ai/agentTools.js new file mode 100644 index 000000000..d801e571f --- /dev/null +++ b/src/utils/ai/agentTools.js @@ -0,0 +1,293 @@ +import fsOperation from "fileSystem"; +import Url from "utils/Url"; + +// ─── Path resolution ────────────────────────────────────────────────────────── + +/** + * Resolve a relative or absolute path to an Acode URI. + * Tries each open folder as a root. + */ +function resolvePath(path) { + if (!path) return null; + + // Already a full URI + if (/^(file|content|ftp|sftp|https?):\/\//.test(path)) return path; + + // Normalize slashes + path = path.replace(/\\/g, "/").replace(/^\/+/, ""); + + // Try open folders + const folders = window.addedFolder || []; + for (const folder of folders) { + const base = folder.url?.replace(/\/$/, "") || folder; + if (base) return `${base}/${path}`; + } + + // Fallback: try DATA_STORAGE or just return as-is + return path; +} + +// ─── Tool Definitions (OpenAI function-calling format) ──────────────────────── + +export const TOOL_DEFINITIONS = [ + { + type: "function", + function: { + name: "read_file", + description: "Read the full contents of a file. Use relative paths like 'src/index.js'.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file" }, + }, + required: ["path"], + }, + }, + }, + { + type: "function", + function: { + name: "write_file", + description: "Create or completely overwrite a file with new content.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file" }, + content: { type: "string", description: "Full content to write" }, + }, + required: ["path", "content"], + }, + }, + }, + { + type: "function", + function: { + name: "edit_file", + description: + "Replace an exact string in a file with new content. Prefer this over write_file for small edits.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file" }, + old_string: { type: "string", description: "Exact string to find and replace" }, + new_string: { type: "string", description: "Replacement string" }, + }, + required: ["path", "old_string", "new_string"], + }, + }, + }, + { + type: "function", + function: { + name: "list_dir", + description: "List all files and subdirectories at a given path.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Directory path. Use '.' or '' for project root.", + }, + }, + required: ["path"], + }, + }, + }, + { + type: "function", + function: { + name: "search_in_file", + description: "Search for a text pattern inside a file and return matching lines.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to search" }, + query: { type: "string", description: "Text or regex pattern to search" }, + }, + required: ["path", "query"], + }, + }, + }, + { + type: "function", + function: { + name: "get_active_file", + description: "Get the filename, path, and full content of the currently active editor file.", + parameters: { type: "object", properties: {} }, + }, + }, + { + type: "function", + function: { + name: "apply_to_editor", + description: + "Insert or replace code in the active editor at the current cursor selection.", + parameters: { + type: "object", + properties: { + code: { type: "string", description: "Code to insert/replace at cursor" }, + }, + required: ["code"], + }, + }, + }, + { + type: "function", + function: { + name: "open_file", + description: "Open a file in the Acode editor as a new tab.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Path to the file to open" }, + }, + required: ["path"], + }, + }, + }, +]; + +// ─── Tool names (human-readable labels) ────────────────────────────────────── + +export const TOOL_LABELS = { + read_file: "Reading file", + write_file: "Writing file", + edit_file: "Editing file", + list_dir: "Listing directory", + search_in_file: "Searching in file", + get_active_file: "Getting active file", + apply_to_editor: "Applying code to editor", + open_file: "Opening file", +}; + +// ─── Tool Executor ──────────────────────────────────────────────────────────── + +/** + * Execute a tool call and return a string result for the LLM. + * @param {string} name + * @param {object} args + * @returns {Promise} + */ +export async function executeTool(name, args) { + switch (name) { + case "read_file": { + const uri = resolvePath(args.path); + if (!uri) throw new Error(`Cannot resolve path: ${args.path}`); + const fs = fsOperation(uri); + const data = await fs.readFile("utf-8"); + if (!data) throw new Error(`File is empty or cannot be read: ${args.path}`); + return typeof data === "string" ? data : new TextDecoder().decode(data); + } + + case "write_file": { + const uri = resolvePath(args.path); + if (!uri) throw new Error(`Cannot resolve path: ${args.path}`); + const exists = await fsOperation(uri).exists().catch(() => false); + if (exists) { + await fsOperation(uri).writeFile(args.content); + } else { + const parentUri = Url.dirname(uri); + const filename = Url.basename(uri); + await fsOperation(parentUri).createFile(filename, args.content); + } + // Reload if open in editor + _reloadFileIfOpen(uri); + return `File written: ${args.path}`; + } + + case "edit_file": { + const uri = resolvePath(args.path); + if (!uri) throw new Error(`Cannot resolve path: ${args.path}`); + const fs = fsOperation(uri); + const raw = await fs.readFile("utf-8"); + const content = typeof raw === "string" ? raw : new TextDecoder().decode(raw); + if (!content.includes(args.old_string)) { + throw new Error( + `String not found in ${args.path}. The exact string must match including whitespace.`, + ); + } + const updated = content.replace(args.old_string, args.new_string); + await fs.writeFile(updated); + _reloadFileIfOpen(uri); + return `File edited: ${args.path}`; + } + + case "list_dir": { + const path = args.path === "." || !args.path ? null : args.path; + let uri; + if (!path) { + const folders = window.addedFolder || []; + if (!folders.length) return "No project folder is open."; + uri = folders[0].url || folders[0]; + } else { + uri = resolvePath(path); + } + const entries = await fsOperation(uri).lsDir(); + if (!entries || !entries.length) return "Directory is empty."; + const lines = entries.map((e) => { + const name = e.name || Url.basename(e.url || ""); + const type = e.isDirectory || e.type === "dir" ? "📁" : "📄"; + return `${type} ${name}`; + }); + return lines.join("\n"); + } + + case "search_in_file": { + const uri = resolvePath(args.path); + const raw = await fsOperation(uri).readFile("utf-8"); + const content = typeof raw === "string" ? raw : new TextDecoder().decode(raw); + const lines = content.split("\n"); + let re; + try { + re = new RegExp(args.query, "i"); + } catch { + re = new RegExp(args.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"); + } + const matches = []; + lines.forEach((line, i) => { + if (re.test(line)) matches.push(`L${i + 1}: ${line.trim()}`); + }); + return matches.length ? matches.join("\n") : "No matches found."; + } + + case "get_active_file": { + const file = editorManager?.activeFile; + if (!file) return "No file is currently open."; + const content = editorManager?.editor?.state?.doc?.toString?.() || ""; + return JSON.stringify({ + filename: file.filename, + uri: file.uri, + content, + }); + } + + case "apply_to_editor": { + const editor = editorManager?.editor; + if (!editor) throw new Error("No editor is open."); + const { from, to } = editor.state.selection.main; + editor.dispatch({ changes: { from, to, insert: args.code } }); + return `Code applied to editor at cursor position.`; + } + + case "open_file": { + const uri = resolvePath(args.path); + const { default: openFile } = await import("lib/openFile"); + await openFile(uri, { render: true }); + return `Opened: ${args.path}`; + } + + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _reloadFileIfOpen(uri) { + try { + const openFile = editorManager?.getFile?.(uri, "uri"); + if (openFile && typeof openFile.reloadContent === "function") { + openFile.reloadContent(); + } + } catch (_) {} +} From f9128e79d7324d454ada2471a2aebf297613ad13 Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 18:09:04 +0530 Subject: [PATCH 08/10] fix: Rewrite AI Agent SCSS to follow Acode CSS variable conventions --- src/pages/aiAgent/aiAgent.scss | 732 ++++++++++++++++++--------------- 1 file changed, 392 insertions(+), 340 deletions(-) diff --git a/src/pages/aiAgent/aiAgent.scss b/src/pages/aiAgent/aiAgent.scss index 89761496a..3a14d81d7 100644 --- a/src/pages/aiAgent/aiAgent.scss +++ b/src/pages/aiAgent/aiAgent.scss @@ -1,379 +1,431 @@ -@use "sass:color"; - -.ai-agent-page { - display: flex; - flex-direction: column; - height: 100%; - background: var(--popup-background-color, #1e1e1e); - - > .body { - flex: 1; - overflow: hidden; - display: flex; - } +// AI Agent — follows Acode's existing CSS variable conventions exactly. +// Ref: settingsPage.scss, page.scss, welcome.scss + +// ── Fix wc-page overflow so the chat area can scroll internally ─────────────── +wc-page.ai-agent-page { + overflow: hidden !important; + display: flex !important; + flex-direction: column !important; + + > .main { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } } +// ── Root container ──────────────────────────────────────────────────────────── .ai-agent-container { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - font-family: var(--font-family, system-ui, sans-serif); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background: var(--secondary-color); + color: var(--primary-text-color); + font-size: 14px; } // ── Header ──────────────────────────────────────────────────────────────────── .ai-agent-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(0, 0, 0, 0.2); - flex-shrink: 0; - - .ai-file-context { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--secondary-text-color, #aaa); - max-width: calc(100% - 120px); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - .icon { font-size: 14px; opacity: 0.6; } - } - - .ai-header-actions { - display: flex; - align-items: center; - gap: 6px; - } - - .ai-mode-toggle { - display: flex; - align-items: center; - gap: 4px; - font-size: 12px; - font-weight: 600; - background: rgba(255, 255, 255, 0.07); - border: 1px solid rgba(255, 255, 255, 0.12); - color: var(--secondary-text-color, #aaa); - border-radius: 8px; - padding: 3px 8px; - cursor: pointer; - transition: all 0.15s; - - .icon { font-size: 14px; } - - &.active { - background: rgba(255, 160, 60, 0.15); - border-color: rgba(255, 160, 60, 0.4); - color: #ffa03c; - } - } - - .ai-clear-btn { - background: none; - border: none; - color: var(--secondary-text-color, #aaa); - cursor: pointer; - padding: 4px 6px; - border-radius: 6px; - transition: background 0.15s; - &:hover, &:active { background: rgba(255, 255, 255, 0.1); } - .icon { font-size: 18px; } - } + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + height: 42px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-color); + background: var(--secondary-color); + + .ai-file-context { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 500; + color: var(--secondary-text-color); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + .icon { + font-size: 14px; + flex-shrink: 0; + } + } + + .ai-header-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + + // Mode toggle — same pill pattern as Acode's filter chips + .ai-mode-toggle { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 20px; + border: 1px solid var(--border-color); + background: transparent; + color: var(--secondary-text-color); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + + .icon { font-size: 13px; } + + &.active { + background: color-mix(in srgb, var(--active-color), transparent 82%); + border-color: color-mix(in srgb, var(--active-color), transparent 40%); + color: var(--active-color); + } + + &:not(.active):hover { + background: var(--primary-color); + } + } + + .ai-clear-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--secondary-text-color); + cursor: pointer; + transition: background 0.15s; + + .icon { font-size: 18px; } + + &:hover { background: var(--primary-color); } + } } - -// ── Messages ────────────────────────────────────────────────────────────────── +// ── Message list ────────────────────────────────────────────────────────────── .ai-chat-history { - flex: 1; - overflow-y: auto; - padding: 12px; - display: flex; - flex-direction: column; - gap: 12px; - scroll-behavior: smooth; - - // Scrollbar - &::-webkit-scrollbar { width: 3px; } - &::-webkit-scrollbar-track { background: transparent; } - &::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); - border-radius: 3px; - } + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 14px 12px; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--secondary-color); + + &::-webkit-scrollbar { width: 3px; } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; + } +} + +// ── Message row ─────────────────────────────────────────────────────────────── +@keyframes ai-msg-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } } .ai-message { - display: flex; - gap: 8px; - align-items: flex-start; - animation: msgIn 0.18s ease-out; - - @keyframes msgIn { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } - } - - &.user { - flex-direction: row-reverse; - - .ai-message-bubble { - background: var(--accent-color, #4f8ef7); - color: #fff; - border-radius: 16px 4px 16px 16px; - align-items: flex-end; - } - } - - &.assistant .ai-message-bubble { - background: rgba(255, 255, 255, 0.07); - color: var(--text-color, #e0e0e0); - border-radius: 4px 16px 16px 16px; - } + display: flex; + gap: 8px; + align-items: flex-start; + animation: ai-msg-in 0.16s ease-out both; + + &.user { + flex-direction: row-reverse; + + .ai-message-bubble { + background: var(--active-color); + border-radius: 18px 4px 18px 18px; + + .ai-message-content { + color: #fff; + + code { + background: rgba(0, 0, 0, 0.2); + } + } + } + } + + &.assistant .ai-message-bubble { + background: var(--primary-color); + border-radius: 4px 18px 18px 18px; + } } .ai-avatar { - width: 28px; - height: 28px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.1); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - .icon { font-size: 16px; } + width: 28px; + height: 28px; + flex-shrink: 0; + border-radius: 50%; + background: var(--primary-color); + border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + + .icon { + font-size: 15px; + color: var(--secondary-text-color); + } } .ai-message-bubble { - padding: 9px 13px; - max-width: calc(100% - 44px); - word-break: break-word; + max-width: min(80%, 480px); + padding: 9px 13px; } +// ── Message content ─────────────────────────────────────────────────────────── .ai-message-content { - font-size: 14px; - line-height: 1.55; - - code { - font-family: var(--code-font-family, monospace); - font-size: 12.5px; - background: rgba(0, 0, 0, 0.3); - padding: 1px 5px; - border-radius: 4px; - } - - pre { - margin: 8px 0 0; - border-radius: 8px; - overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.1); - - .code-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 5px 10px; - background: rgba(0, 0, 0, 0.35); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - - .code-lang { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - text-transform: uppercase; - letter-spacing: 0.04em; - } - - .apply-btn { - font-size: 11px; - font-weight: 600; - color: var(--accent-color, #4f8ef7); - background: rgba(79, 142, 247, 0.15); - border: 1px solid rgba(79, 142, 247, 0.3); - border-radius: 5px; - padding: 2px 8px; - cursor: pointer; - transition: background 0.15s; - - &:hover, - &:active { - background: rgba(79, 142, 247, 0.3); - } - } - } - - code { - display: block; - background: transparent; - padding: 10px; - font-size: 12.5px; - overflow-x: auto; - white-space: pre; - border-radius: 0; - } - } - - strong { font-weight: 600; } - em { font-style: italic; } + color: var(--primary-text-color); + font-size: 14px; + line-height: 1.6; + word-break: break-word; + + strong { font-weight: 600; } + em { font-style: italic; } + + code { + font-family: monospace; + font-size: 12px; + background: color-mix(in srgb, var(--border-color), transparent 30%); + padding: 1px 5px; + border-radius: 3px; + } + + pre { + margin: 8px 0 2px; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border-color); + background: var(--secondary-color); + + .code-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 10px; + background: color-mix(in srgb, var(--border-color), transparent 50%); + border-bottom: 1px solid var(--border-color); + + .code-lang { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--secondary-text-color); + } + + .apply-btn { + font-size: 11px; + font-weight: 600; + background: transparent; + border: 1px solid var(--active-color); + color: var(--active-color); + border-radius: 4px; + padding: 1px 7px; + cursor: pointer; + transition: background 0.12s; + + &:hover { + background: color-mix(in srgb, var(--active-color), transparent 80%); + } + } + } + + code { + display: block; + background: transparent; + padding: 10px 12px; + font-size: 12px; + overflow-x: auto; + white-space: pre; + border-radius: 0; + color: var(--primary-text-color); + } + } } -// ── Thinking dots ───────────────────────────────────────────────────────────── +// ── Thinking / streaming ────────────────────────────────────────────────────── .ai-thinking { - display: inline-flex; - gap: 4px; - align-items: center; - padding: 2px 0; - - span { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--secondary-text-color, #aaa); - animation: dotBounce 1.2s ease-in-out infinite; - - &:nth-child(2) { animation-delay: 0.2s; } - &:nth-child(3) { animation-delay: 0.4s; } - } - - @keyframes dotBounce { - 0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; } - 40% { transform: scale(1); opacity: 1; } - } + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 0; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--secondary-text-color); + animation: ai-dot 1.1s ease-in-out infinite; + + &:nth-child(2) { animation-delay: 0.2s; } + &:nth-child(3) { animation-delay: 0.4s; } + } + + @keyframes ai-dot { + 0%, 80%, 100% { transform: scale(0.65); opacity: 0.35; } + 40% { transform: scale(1); opacity: 0.9; } + } } -.streaming { - opacity: 0.9; -} +.streaming { opacity: 0.88; } -// ── Input ───────────────────────────────────────────────────────────────────── +// ── Input bar ───────────────────────────────────────────────────────────────── .ai-chat-input { - display: flex; - align-items: flex-end; - gap: 8px; - padding: 10px 12px; - border-top: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(0, 0, 0, 0.2); - flex-shrink: 0; - - textarea { - flex: 1; - background: rgba(255, 255, 255, 0.07); - color: var(--text-color, #e0e0e0); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 12px; - padding: 9px 13px; - font-size: 14px; - line-height: 1.4; - resize: none; - outline: none; - max-height: 120px; - overflow-y: auto; - font-family: inherit; - transition: border-color 0.15s; - - &::placeholder { color: rgba(255, 255, 255, 0.3); } - &:focus { border-color: var(--accent-color, #4f8ef7); } - } - - .ai-send-btn { - width: 38px; - height: 38px; - border-radius: 50%; - background: var(--accent-color, #4f8ef7); - border: none; - color: #fff; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex-shrink: 0; - transition: opacity 0.15s, transform 0.1s; - - .icon { font-size: 18px; } - - &:hover { opacity: 0.85; } - &:active { transform: scale(0.92); } - &:disabled { opacity: 0.4; cursor: default; transform: none; } - } -} - -// ── Agent mode input ────────────────────────────────────────────────────────── -.ai-chat-input.agent-mode { - border-top-color: rgba(255, 160, 60, 0.25); - background: rgba(255, 160, 60, 0.04); - - .ai-input-row { display: flex; gap: 8px; align-items: flex-end; } - - .agent-hint { - font-size: 11px; - color: #ffa03c; - opacity: 0.8; - padding: 0 2px 5px; - } - - .ai-send-btn { - background: #ffa03c; - } + flex-shrink: 0; + padding: 8px 12px; + border-top: 1px solid var(--border-color); + background: var(--secondary-color); + display: flex; + flex-direction: column; + gap: 5px; + + .agent-hint { + font-size: 11px; + font-weight: 500; + // Use active-color tint — same pattern as .settings-section-label + color: color-mix(in srgb, var(--active-color), transparent 18%); + } + + &.agent-mode { + border-top-color: color-mix(in srgb, var(--active-color), transparent 60%); + + .ai-send-btn { + // Keep same active-color but add slight brightness signal + opacity: 1; + } + } } .ai-input-row { - display: flex; - gap: 8px; - align-items: flex-end; + display: flex; + align-items: flex-end; + gap: 8px; + + textarea { + flex: 1; + background: var(--primary-color); + color: var(--primary-text-color); + border: 1px solid var(--border-color); + border-radius: 18px; + padding: 8px 13px; + font-size: 13.5px; + line-height: 1.45; + font-family: inherit; + resize: none; + outline: none; + max-height: 100px; + overflow-y: auto; + transition: border-color 0.15s; + + &::placeholder { + color: var(--secondary-text-color); + opacity: 0.7; + } + + &:focus { + border-color: var(--active-color); + } + } + + .ai-send-btn { + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 50%; + border: none; + background: var(--active-color); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s; + + .icon { font-size: 18px; } + + &:hover { opacity: 0.85; } + &:active { transform: scale(0.9); } + &:disabled { + opacity: 0.3; + transform: none; + cursor: default; + } + } } -// ── Agent step cards ────────────────────────────────────────────────────────── +// ── Agent step log ──────────────────────────────────────────────────────────── .agent-steps-log { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 10px; - padding: 8px 10px; - background: rgba(0, 0, 0, 0.25); - border-radius: 8px; - border-left: 2px solid rgba(255, 255, 255, 0.1); - - &.live { - border-left-color: #ffa03c; - } + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 6px; + padding: 7px 10px; + border-radius: 6px; + background: color-mix(in srgb, var(--border-color), transparent 50%); + border-left: 2px solid var(--border-color); + + &.live { + border-left-color: var(--active-color); + } } .agent-step { - display: flex; - align-items: flex-start; - gap: 7px; - font-size: 12.5px; - line-height: 1.4; - animation: msgIn 0.12s ease-out; - - .step-icon { flex-shrink: 0; font-size: 13px; margin-top: 1px; } - - .step-body { - display: flex; - flex-direction: column; - gap: 1px; - } - - .step-label { - color: var(--text-color, #e0e0e0); - font-weight: 500; - } - - .step-arg { - font-family: var(--code-font-family, monospace); - font-size: 11.5px; - color: var(--secondary-text-color, #aaa); - } - - .step-error { - font-size: 11.5px; - color: #f87272; - } - - &.tool_start .step-label { opacity: 0.7; } - &.tool_done .step-label { color: #6ee7a0; } - &.tool_error .step-label { color: #f87272; } + display: flex; + align-items: flex-start; + gap: 6px; + animation: ai-msg-in 0.12s ease-out both; + + .step-icon { + font-size: 12px; + flex-shrink: 0; + margin-top: 2px; + } + + .step-body { + display: flex; + flex-direction: column; + gap: 1px; + } + + .step-label { + font-size: 12.5px; + font-weight: 500; + color: var(--primary-text-color); + } + + .step-arg { + font-family: monospace; + font-size: 11px; + color: var(--secondary-text-color); + } + + .step-error { + font-size: 11px; + color: color-mix(in srgb, red, var(--primary-text-color) 30%); + } + + &.tool_start .step-label { opacity: 0.55; } + + // Use active-color for success — consistent with Acode's accent usage + &.tool_done .step-label { + color: color-mix(in srgb, var(--active-color), transparent 10%); + } + + &.tool_error .step-label { + color: color-mix(in srgb, red, var(--primary-text-color) 30%); + } } From 33809cd1146a232631c5db8d01e5ba752f3409e2 Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 18:11:33 +0530 Subject: [PATCH 09/10] fix: AI settings page styling, add model config, remove unused providers --- src/lib/settings.js | 4 +--- src/settings/aiSettings.js | 30 +++++++++++++----------------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/lib/settings.js b/src/lib/settings.js index af149ed0e..82d152622 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -216,11 +216,9 @@ class Settings { }, ai: { provider: "openai", + model: "gpt-4o-mini", openaiKey: "", - geminiKey: "", - anthropicKey: "", openRouterKey: "", - useAcodePro: true, }, developerMode: false, shiftClickSelection: true, diff --git a/src/settings/aiSettings.js b/src/settings/aiSettings.js index 07626fdae..7f0cccf01 100644 --- a/src/settings/aiSettings.js +++ b/src/settings/aiSettings.js @@ -11,10 +11,17 @@ export default function aiSettings() { value: values.provider, select: [ ["openai", "OpenAI"], - ["gemini", "Google Gemini"], - ["anthropic", "Anthropic Claude"], ["openrouter", "OpenRouter"], ], + info: "Select which AI API provider to use", + }, + { + key: "model", + text: "AI Model", + value: values.model || "gpt-4o-mini", + prompt: "AI Model (e.g. gpt-4o-mini, anthropic/claude-3-haiku)", + promptType: "text", + info: "The exact model string to request", }, { key: "openaiKey", @@ -22,20 +29,7 @@ export default function aiSettings() { value: values.openaiKey, prompt: "OpenAI API Key", promptType: "text", - }, - { - key: "geminiKey", - text: "Gemini API Key", - value: values.geminiKey, - prompt: "Gemini API Key", - promptType: "text", - }, - { - key: "anthropicKey", - text: "Anthropic API Key", - value: values.anthropicKey, - prompt: "Anthropic API Key", - promptType: "text", + info: "Your direct OpenAI API key", }, { key: "openRouterKey", @@ -43,6 +37,7 @@ export default function aiSettings() { value: values.openRouterKey, prompt: "OpenRouter API Key", promptType: "text", + info: "Your OpenRouter API key", }, ]; @@ -50,7 +45,8 @@ export default function aiSettings() { preserveOrder: true, pageClassName: "detail-settings-page", listClassName: "detail-settings-list", - groupByDefault: true, + infoAsDescription: true, + valueInTail: true, }); function callback(key, value) { From 4a0495c4c73b2a740b67fe88eb2935d5e3be57e4 Mon Sep 17 00:00:00 2001 From: Priyanshu Jain Date: Tue, 18 Aug 2026 18:14:00 +0530 Subject: [PATCH 10/10] fix: Use valid icons for Agent mode toggle and send button --- src/pages/aiAgent/aiAgent.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/aiAgent/aiAgent.js b/src/pages/aiAgent/aiAgent.js index 5a8a94303..c2e65e213 100644 --- a/src/pages/aiAgent/aiAgent.js +++ b/src/pages/aiAgent/aiAgent.js @@ -219,7 +219,7 @@ class AiAgentApp extends Component { onclick=${this.toggleAgentMode} title="${agentMode ? "Switch to Chat mode" : "Switch to Agent mode"}" > - + ${agentMode ? "Agent" : "Chat"}