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..c13139719 --- /dev/null +++ b/src/cm/extensions/aiAutocomplete.ts @@ -0,0 +1,123 @@ +import { ViewPlugin, Decoration, WidgetType, EditorView } from "@codemirror/view"; +import { StateField, StateEffect } from "@codemirror/state"; + +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) { + 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); + + 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 + } + }).catch(() => {}); + } + } + + 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..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[]; @@ -54,6 +55,9 @@ export function createMainEditorExtensions( pushExtension(extensions, options.searchExtension); pushExtension(extensions, options.readOnlyExtension); + // Add AI Autocomplete + extensions.push(aiAutocomplete()); + if (options.optionExtensions?.length) { extensions.push(...options.optionExtensions); } diff --git a/src/lib/settings.js b/src/lib/settings.js index 7421f3d2c..82d152622 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -214,6 +214,12 @@ class Settings { workspaces: {}, }, }, + ai: { + provider: "openai", + model: "gpt-4o-mini", + openaiKey: "", + openRouterKey: "", + }, 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..c2e65e213 --- /dev/null +++ b/src/pages/aiAgent/aiAgent.js @@ -0,0 +1,328 @@ +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"; + +// ── Simple markdown renderer ────────────────────────────────────────────────── +function renderMarkdown(text) { + if (!text) return ""; + 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`; + }); + text = text + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\n/g, "
"); + 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); + 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.\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(); + 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; + 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); + } + + 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 }); + + toggleAgentMode = () => { + this.setState((s) => ({ agentMode: !s.agentMode })); + }; + + // ── 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 response = await aiService.chat(newMessages, (token, full) => { + streamBuf = full; + this.setState({ streamingText: full }); + }); + + 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}` }]; + 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.handleSend(); + } + }; + + clearHistory = () => { + const initial = [{ role: "assistant", content: "Chat cleared! How can I help?" }]; + this.setState({ messages: initial, streamingText: "", agentSteps: [] }); + aiService.clearHistory(this._fileUri); + }; + + render() { + const { messages, input, isLoading, streamingText, agentMode, agentSteps } = this.state; + const fileLabel = editorManager?.activeFile?.filename; + + return html` +
+ +
+ ${fileLabel ? html` + ${fileLabel} + ` : html`AI Assistant`} +
+ + +
+
+ + +
(this._historyRef = el)}> + ${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} +
+ + +
+ ${agentMode ? html`
Agent will read & modify files autonomously
` : null} +
+ + +
+
+
+ `; + } +} + +export default function AiAgentInclude() { + if (document.querySelector(".ai-agent-page")) return; + + const $page = Page("AI Agent"); + $page.classList.add("ai-agent-page"); + + 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); + actionStack.remove("aiAgent"); + }; + + app.append($page); + $page.show(); +} diff --git a/src/pages/aiAgent/aiAgent.scss b/src/pages/aiAgent/aiAgent.scss new file mode 100644 index 000000000..3a14d81d7 --- /dev/null +++ b/src/pages/aiAgent/aiAgent.scss @@ -0,0 +1,431 @@ +// 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%; + overflow: hidden; + background: var(--secondary-color); + color: var(--primary-text-color); + font-size: 14px; +} + +// ── Header ──────────────────────────────────────────────────────────────────── +.ai-agent-header { + 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); } + } +} + +// ── Message list ────────────────────────────────────────────────────────────── +.ai-chat-history { + 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: 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; + 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 { + max-width: min(80%, 480px); + padding: 9px 13px; +} + +// ── Message content ─────────────────────────────────────────────────────────── +.ai-message-content { + 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 / streaming ────────────────────────────────────────────────────── +.ai-thinking { + 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.88; } + +// ── Input bar ───────────────────────────────────────────────────────────────── +.ai-chat-input { + 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; + 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 log ──────────────────────────────────────────────────────────── +.agent-steps-log { + 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: 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%); + } +} 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..7f0cccf01 --- /dev/null +++ b/src/settings/aiSettings.js @@ -0,0 +1,56 @@ +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: "provider", + text: strings["ai provider"] || "AI Provider", + value: values.provider, + select: [ + ["openai", "OpenAI"], + ["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", + text: "OpenAI API Key", + value: values.openaiKey, + prompt: "OpenAI API Key", + promptType: "text", + info: "Your direct OpenAI API key", + }, + { + key: "openRouterKey", + text: "OpenRouter API Key", + value: values.openRouterKey, + prompt: "OpenRouter API Key", + promptType: "text", + info: "Your OpenRouter API key", + }, + ]; + + return settingsPage(title, items, callback, undefined, { + preserveOrder: true, + pageClassName: "detail-settings-page", + listClassName: "detail-settings-list", + infoAsDescription: true, + valueInTail: true, + }); + + function callback(key, value) { + values[key] = value; + appSettings.update(); + } +} diff --git a/src/settings/mainSettings.js b/src/settings/mainSettings.js index 2946f57c7..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(); @@ -107,6 +108,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 +240,7 @@ export default function mainSettings() { case "preview-settings": case "terminal-settings": case "lsp-settings": + case "ai-settings": appSettings.uiSettings[key].show(); break; @@ -370,6 +380,7 @@ export default function mainSettings() { "preview-settings": previewSettings, "terminal-settings": terminalSettings, "lsp-settings": lspSettings, + "ai-settings": aiSettings, }; const instantiated = {}; diff --git a/src/sidebarApps/index.js b/src/sidebarApps/index.js index 4172f7991..054eb3fba 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..b036347d2 --- /dev/null +++ b/src/utils/ai/AIService.js @@ -0,0 +1,175 @@ +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 + */ + async getActiveAdapter() { + const settings = appSettings.value.ai; + + 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, settings.model); + case "openrouter": + 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.`, + ); + } + } + + /** + * Prompt the user to configure API key in settings + */ + async promptForConfig(message) { + alert(message || "Please configure your AI provider in Settings > AI Settings."); + appSettings.uiSettings["ai-settings"]?.show(); + } + + /** + * 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(); + 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 ""; + return await adapter.completeCode(prefix, suffix); + } catch (error) { + console.error("AI Completion error", error); + return ""; + } + } + + /** + * 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 ───────────────────────────────────────────────── + + /** + * 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/AcodeProAdapter.js b/src/utils/ai/AcodeProAdapter.js new file mode 100644 index 000000000..1403e842b --- /dev/null +++ b/src/utils/ai/AcodeProAdapter.js @@ -0,0 +1,79 @@ +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; + } + + 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 ?? ""; + } +} 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..39eae28ee --- /dev/null +++ b/src/utils/ai/OpenAiAdapter.js @@ -0,0 +1,125 @@ +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"; + } + + /** + * 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: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages, + stream, + }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error?.message || "OpenAI API Error"); + } + + 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; + } + + /** + * 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 = [ + { + 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 new file mode 100644 index 000000000..7f0e2eab9 --- /dev/null +++ b/src/utils/ai/OpenRouterAdapter.js @@ -0,0 +1,125 @@ +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"; + } + + /** + * 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: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + "HTTP-Referer": "https://acode.foxdebug.com", + "X-Title": "Acode", + }, + body: JSON.stringify({ + model: this.model, + messages, + stream, + }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error?.message || "OpenRouter API Error"); + } + + 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; + } + + /** + * 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 = [ + { + 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/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 (_) {} +} 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + +