diff --git a/.env b/.env index 1265eede2f1349f95f3db3ada7bdae68ea0aa133..c14a1ae41b952cbcb807957619ca51a2c54e52c4 100644 --- a/.env +++ b/.env @@ -112,6 +112,7 @@ PARQUET_EXPORT_SECRET= RATE_LIMIT= # requests per minute MESSAGES_BEFORE_LOGIN=# how many messages a user can send in a conversation before having to login. set to 0 to force login right away +APP_BASE="" # base path of the app, e.g. /chat, left blank as default PUBLIC_APP_NAME=ChatUI # name used as title throughout the app PUBLIC_APP_ASSETS=chatui # used to find logos & favicons in static/$PUBLIC_APP_ASSETS PUBLIC_APP_COLOR=blue # can be any of tailwind colors: https://tailwindcss.com/docs/customizing-colors#default-color-palette @@ -126,4 +127,6 @@ EXPOSE_API=true # PUBLIC_APP_COLOR=yellow # PUBLIC_APP_DESCRIPTION="Making the community's best AI chat models available to everyone." # PUBLIC_APP_DATA_SHARING=1 -# PUBLIC_APP_DISCLAIMER=1 \ No newline at end of file +# PUBLIC_APP_DISCLAIMER=1 + +ENABLE_ASSISTANTS=false #set to true to enable assistants feature \ No newline at end of file diff --git a/.env.template b/.env.template index d8573a499c7af9e782dc1e23d43536d3a08dfcd6..8fe36ccac7129d69d95742a4f3567b83d6beb7e6 100644 --- a/.env.template +++ b/.env.template @@ -254,4 +254,5 @@ PUBLIC_GOOGLE_ANALYTICS_ID=G-8Q63TH4CSL # ADDRESS_HEADER=X-Forwarded-For # XFF_DEPTH=2 -EXPOSE_API=false \ No newline at end of file +ENABLE_ASSISTANTS=true +EXPOSE_API=false diff --git a/.vscode/settings.json b/.vscode/settings.json index c32c1bbc3ef092e6702bb6ef83bf3fd87308d20b..0d24922796c50aa3b9c2007e1f04d02ad42174bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { - "source.fixAll": true + "source.fixAll": "explicit" }, "eslint.validate": ["javascript", "svelte"] } diff --git a/package-lock.json b/package-lock.json index 23ca6bbbe3432f630b111e3242a365633b634ced..91688155b655aa66844b20097168b7f543446110 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@huggingface/hub": "^0.5.1", "@huggingface/inference": "^2.6.3", "@iconify-json/bi": "^1.1.21", + "@resvg/resvg-js": "^2.6.0", "@xenova/transformers": "^2.6.0", "autoprefixer": "^10.4.14", "browser-image-resizer": "^2.4.1", @@ -28,6 +29,8 @@ "parquetjs": "^0.11.2", "postcss": "^8.4.31", "saslprep": "^1.0.3", + "satori": "^0.10.11", + "satori-html": "^0.3.2", "serpapi": "^1.1.1", "tailwind-scrollbar": "^3.0.0", "tailwindcss": "^3.4.0", @@ -790,6 +793,208 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.0.tgz", + "integrity": "sha512-Tf3YpbBKcQn991KKcw/vg7vZf98v01seSv6CVxZBbRkL/xyjnoYB6KgrFL6zskT1A4dWC/vg77KyNOW+ePaNlA==", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.0", + "@resvg/resvg-js-android-arm64": "2.6.0", + "@resvg/resvg-js-darwin-arm64": "2.6.0", + "@resvg/resvg-js-darwin-x64": "2.6.0", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.0", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.0", + "@resvg/resvg-js-linux-arm64-musl": "2.6.0", + "@resvg/resvg-js-linux-x64-gnu": "2.6.0", + "@resvg/resvg-js-linux-x64-musl": "2.6.0", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.0", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.0", + "@resvg/resvg-js-win32-x64-msvc": "2.6.0" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.0.tgz", + "integrity": "sha512-lJnZ/2P5aMocrFMW7HWhVne5gH82I8xH6zsfH75MYr4+/JOaVcGCTEQ06XFohGMdYRP3v05SSPLPvTM/RHjxfA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.0.tgz", + "integrity": "sha512-N527f529bjMwYWShZYfBD60dXA4Fux+D695QsHQ93BDYZSHUoOh1CUGUyICevnTxs7VgEl98XpArmUWBZQVMfQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-MabUKLVayEwlPo0mIqAmMt+qESN8LltCvv5+GLgVga1avpUrkxj/fkU1TKm8kQegutUjbP/B0QuMuUr0uhF8ew==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.0.tgz", + "integrity": "sha512-zrFetdnSw/suXjmyxSjfDV7i61hahv6DDG6kM7BYN2yJ3Es5+BZtqYZTcIWogPJedYKmzN1YTMWGd/3f0ubFiA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.0.tgz", + "integrity": "sha512-sH4gxXt7v7dGwjGyzLwn7SFGvwZG6DQqLaZ11MmzbCwd9Zosy1TnmrMJfn6TJ7RHezmQMgBPi18bl55FZ1AT4A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.0.tgz", + "integrity": "sha512-fCyMncqCJtrlANADIduYF4IfnWQ295UKib7DAxFXQhBsM9PLDTpizr0qemZcCNadcwSVHnAIzL4tliZhCM8P6A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-ouLjTgBQHQyxLht4FdMPTvuY8xzJigM9EM2Tlu0llWkN1mKyTQrvYWi6TA6XnKdzDJHy7ZLpWpjZi7F5+Pg+Vg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.0.tgz", + "integrity": "sha512-n3zC8DWsvxC1AwxpKFclIPapDFibs5XdIRoV/mcIlxlh0vseW1F49b97F33BtJQRmlntsqqN6GMMqx8byB7B+Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-n4tasK1HOlAxdTEROgYA1aCfsEKk0UOFDNd/AQTTZlTmCbHKXPq+O8npaaKlwXquxlVK8vrkcWbksbiGqbCAcw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.0.tgz", + "integrity": "sha512-X2+EoBJFwDI5LDVb51Sk7ldnVLitMGr9WwU/i21i3fAeAXZb3hM16k67DeTy16OYkT2dk/RfU1tP1wG+rWbz2Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.0.tgz", + "integrity": "sha512-L7oevWjQoUgK5W1fCKn0euSVemhDXVhrjtwqpc7MwBKKimYeiOshO1Li1pa8bBt5PESahenhWgdB6lav9O0fEg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.0.tgz", + "integrity": "sha512-8lJlghb+Unki5AyKgsnFbRJwkEj9r1NpwyuBG8yEJiG1W9eEGl03R3I7bsVa3haof/3J1NlWf0rzSa1G++A2iw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "25.0.7", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.7.tgz", @@ -922,6 +1127,21 @@ } } }, + "node_modules/@shuding/opentype.js": { + "version": "1.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz", + "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==", + "dependencies": { + "fflate": "^0.7.3", + "string.prototype.codepointat": "^0.2.1" + }, + "bin": { + "ot": "bin/ot" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/@sveltejs/adapter-node": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-1.3.1.tgz", @@ -1931,6 +2151,14 @@ "node": ">= 6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001542", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001542.tgz", @@ -2190,6 +2418,34 @@ "node": "*" } }, + "node_modules/css-background-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz", + "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==" + }, + "node_modules/css-box-shadow": { + "version": "1.0.0-3", + "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz", + "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==" + }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "engines": { + "node": ">=4" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css-tree": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", @@ -2472,6 +2728,11 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.359.tgz", "integrity": "sha512-OoVcngKCIuNXtZnsYoqlCvr0Cf3NIPzDIgwUfI9bdTFjXCrr79lI0kwQstLPZ7WhCezLlGksZk/BFAzoXC7GDw==" }, + "node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -2542,6 +2803,11 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2874,6 +3140,11 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3184,6 +3455,17 @@ "node": ">= 0.4" } }, + "node_modules/hex-rgb": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz", + "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/highlight.js": { "version": "11.7.0", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.7.0.tgz", @@ -3682,6 +3964,23 @@ "node": ">=10" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4417,6 +4716,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4457,6 +4761,15 @@ "node": ">=0.6.19" } }, + "node_modules/parse-css-color": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz", + "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==", + "dependencies": { + "color-name": "^1.1.4", + "hex-rgb": "^4.1.0" + } + }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", @@ -5290,6 +5603,34 @@ "node": ">=6" } }, + "node_modules/satori": { + "version": "0.10.11", + "resolved": "https://registry.npmjs.org/satori/-/satori-0.10.11.tgz", + "integrity": "sha512-yLm1xPRPZUaKcBZJ6nmezoJjHB4MqV8x7Mu0PyZUJodRWRDD27UbeMwzuY9LEGG57WYLO4CQsGPlbHWV1Ex9TQ==", + "dependencies": { + "@shuding/opentype.js": "1.4.0-beta.0", + "css-background-parser": "^0.1.0", + "css-box-shadow": "1.0.0-3", + "css-to-react-native": "^3.0.0", + "emoji-regex": "^10.2.1", + "escape-html": "^1.0.3", + "linebreak": "^1.1.0", + "parse-css-color": "^0.2.1", + "postcss-value-parser": "^4.2.0", + "yoga-wasm-web": "^0.3.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/satori-html": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/satori-html/-/satori-html-0.3.2.tgz", + "integrity": "sha512-wjTh14iqADFKDK80e51/98MplTGfxz2RmIzh0GqShlf4a67+BooLywF17TvJPD6phO0Hxm7Mf1N5LtRYvdkYRA==", + "dependencies": { + "ultrahtml": "^1.2.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -5553,6 +5894,11 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string.prototype.codepointat": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz", + "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==" + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6054,6 +6400,11 @@ "globrex": "^0.1.2" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, "node_modules/tinybench": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.0.tgz", @@ -6270,6 +6621,11 @@ "node": ">=0.8.0" } }, + "node_modules/ultrahtml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.5.2.tgz", + "integrity": "sha512-qh4mBffhlkiXwDAOxvSGxhL0QEQsTbnP9BozOK3OYPEGvPvdWzvAUaXNtUSMdNsKDtuyjEbyVUPFZ52SSLhLqw==" + }, "node_modules/undici": { "version": "5.26.4", "resolved": "https://registry.npmjs.org/undici/-/undici-5.26.4.tgz", @@ -6281,6 +6637,15 @@ "node": ">=14.0" } }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -6769,6 +7134,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoga-wasm-web": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz", + "integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==" + }, "node_modules/zod": { "version": "3.22.3", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", diff --git a/package.json b/package.json index 8d56863dfcddd0c0c73fd570264cf824f9bf3a87..5f2c4e86d1612d7c019d450815c2565299146e1c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@huggingface/hub": "^0.5.1", "@huggingface/inference": "^2.6.3", "@iconify-json/bi": "^1.1.21", + "@resvg/resvg-js": "^2.6.0", "@xenova/transformers": "^2.6.0", "autoprefixer": "^10.4.14", "browser-image-resizer": "^2.4.1", @@ -64,6 +65,8 @@ "parquetjs": "^0.11.2", "postcss": "^8.4.31", "saslprep": "^1.0.3", + "satori": "^0.10.11", + "satori-html": "^0.3.2", "serpapi": "^1.1.1", "tailwind-scrollbar": "^3.0.0", "tailwindcss": "^3.4.0", diff --git a/src/lib/components/AssistantSettings.svelte b/src/lib/components/AssistantSettings.svelte new file mode 100644 index 0000000000000000000000000000000000000000..85394e8d6cf7d3550bbe6f540997d62da91fa244 --- /dev/null +++ b/src/lib/components/AssistantSettings.svelte @@ -0,0 +1,277 @@ +<script lang="ts"> + import type { readAndCompressImage } from "browser-image-resizer"; + import type { Model } from "$lib/types/Model"; + import type { Assistant } from "$lib/types/Assistant"; + + import { onMount } from "svelte"; + import { applyAction, enhance } from "$app/forms"; + import { base } from "$app/paths"; + import CarbonPen from "~icons/carbon/pen"; + import CarbonUpload from "~icons/carbon/upload"; + import { useSettingsStore } from "$lib/stores/settings"; + import IconLoading from "./icons/IconLoading.svelte"; + + type ActionData = { + error: boolean; + errors: { + field: string | number; + message: string; + }[]; + } | null; + + type AssistantFront = Omit<Assistant, "_id" | "createdById"> & { _id: string }; + + export let form: ActionData; + export let assistant: AssistantFront | undefined = undefined; + export let models: Model[] = []; + + let files: FileList | null = null; + + const settings = useSettingsStore(); + + let compress: typeof readAndCompressImage | null = null; + + onMount(async () => { + const module = await import("browser-image-resizer"); + compress = module.readAndCompressImage; + }); + + let inputMessage1 = assistant?.exampleInputs[0] ?? ""; + let inputMessage2 = assistant?.exampleInputs[1] ?? ""; + let inputMessage3 = assistant?.exampleInputs[2] ?? ""; + let inputMessage4 = assistant?.exampleInputs[3] ?? ""; + + function resetErrors() { + if (form) { + form.errors = []; + form.error = false; + } + } + + function onFilesChange(e: Event) { + const inputEl = e.target as HTMLInputElement; + if (inputEl.files?.length) { + files = inputEl.files; + resetErrors(); + deleteExistingAvatar = false; + } + } + + function getError(field: string, returnForm: ActionData) { + return returnForm?.errors.find((error) => error.field === field)?.message ?? ""; + } + + let deleteExistingAvatar = false; + + let loading = false; +</script> + +<form + method="POST" + class="flex h-full flex-col" + enctype="multipart/form-data" + use:enhance={async ({ formData }) => { + loading = true; + if (files?.[0] && files[0].size > 0 && compress) { + await compress(files[0], { + maxWidth: 500, + maxHeight: 500, + quality: 1, + }).then((resizedImage) => { + formData.set("avatar", resizedImage); + }); + } + + if (deleteExistingAvatar === true) { + if (assistant?.avatar) { + // if there is an avatar we explicitly removei t + formData.set("avatar", "null"); + } else { + // else we just remove it from the input + formData.delete("avatar"); + } + } + + return async ({ result }) => { + loading = false; + await applyAction(result); + }; + }} +> + {#if assistant} + <h2 class="text-xl font-semibold">Edit assistant ({assistant?.name ?? ""})</h2> + <p class="mb-6 text-sm text-gray-500"> + Modifying an existing assistant will propagate those changes to all users. + </p> + {:else} + <h2 class="text-xl font-semibold">Create new assistant</h2> + <p class="mb-6 text-sm text-gray-500"> + Assistants are public, and can be accessed by anyone with the link. + </p> + {/if} + + <div class="mx-1 grid flex-1 grid-cols-2 gap-4 max-sm:grid-cols-1"> + <div class="flex flex-col gap-4"> + <div> + <span class="mb-1 block pb-2 text-sm font-semibold">Avatar</span> + <input + type="file" + accept="image/*" + name="avatar" + id="avatar" + class="hidden" + on:change={onFilesChange} + /> + + {#if (files && files[0]) || (assistant?.avatar && !deleteExistingAvatar)} + <div class="group relative mx-auto h-12 w-12"> + {#if files && files[0]} + <img + src={URL.createObjectURL(files[0])} + alt="avatar" + class="crop mx-auto h-12 w-12 cursor-pointer rounded-full object-cover" + /> + {:else if assistant?.avatar} + <img + src="{base}/settings/assistants/{assistant._id}/avatar?hash={assistant.avatar}" + alt="avatar" + class="crop mx-auto h-12 w-12 cursor-pointer rounded-full object-cover" + /> + {/if} + + <label + for="avatar" + class="invisible absolute bottom-0 h-12 w-12 rounded-full bg-black bg-opacity-50 p-1 group-hover:visible hover:visible" + > + <CarbonPen class="mx-auto my-auto h-full cursor-pointer text-center text-white" /> + </label> + </div> + <div class="mx-auto w-max pt-1"> + <button + type="button" + on:click|stopPropagation|preventDefault={() => { + files = null; + deleteExistingAvatar = true; + }} + class="mx-auto w-max text-center text-xs text-gray-600 hover:underline" + > + Delete + </button> + </div> + {:else} + <div class="mb-1 flex w-max flex-row gap-4"> + <label + for="avatar" + class="btn flex h-8 rounded-lg border bg-white px-3 py-1 text-gray-500 shadow-sm transition-all hover:bg-gray-100" + > + <CarbonUpload class="mr-2 text-xs " /> Upload + </label> + </div> + <p class="text-xs text-red-500">{getError("avatar", form)}</p> + {/if} + </div> + + <label> + <span class="mb-1 text-sm font-semibold">Name</span> + <input + name="name" + class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" + placeholder="My awesome model" + value={assistant?.name ?? ""} + /> + <p class="text-xs text-red-500">{getError("name", form)}</p> + </label> + + <label> + <span class="mb-1 text-sm font-semibold">Description</span> + <textarea + name="description" + class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" + placeholder="He knows everything about python" + value={assistant?.description ?? ""} + /> + <p class="text-xs text-red-500">{getError("description", form)}</p> + </label> + + <label> + <span class="mb-1 text-sm font-semibold">Model</span> + <select name="modelId" class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2"> + {#each models as model} + <option + value={model.id} + selected={assistant + ? assistant?.modelId === model.id + : $settings.activeModel === model.id}>{model.displayName}</option + > + {/each} + <p class="text-xs text-red-500">{getError("modelId", form)}</p> + </select> + </label> + + <label> + <span class="mb-1 text-sm font-semibold">Start messages</span> + <div class="flex flex-col gap-2 md:max-h-32"> + <input + name="exampleInput1" + bind:value={inputMessage1} + class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" + /> + {#if !!inputMessage1 || !!inputMessage2} + <input + name="exampleInput2" + bind:value={inputMessage2} + class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" + /> + {/if} + {#if !!inputMessage2 || !!inputMessage3} + <input + name="exampleInput3" + bind:value={inputMessage3} + class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" + /> + {/if} + {#if !!inputMessage3 || !!inputMessage4} + <input + name="exampleInput4" + bind:value={inputMessage4} + class="w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" + /> + {/if} + </div> + <p class="text-xs text-red-500">{getError("inputMessage1", form)}</p> + </label> + </div> + + <label class="flex flex-col"> + <span class="mb-1 text-sm font-semibold"> Instructions (system prompt) </span> + <textarea + name="preprompt" + class="min-h-[8lh] flex-1 rounded-lg border-2 border-gray-200 bg-gray-100 p-2 text-sm" + placeholder="You'll act as..." + value={assistant?.preprompt ?? ""} + /> + <p class="text-xs text-red-500">{getError("preprompt", form)}</p> + </label> + </div> + + <div class="mt-5 flex justify-end gap-2"> + <a + href={assistant ? `${base}/settings/assistants/${assistant?._id}` : `${base}/settings`} + class="rounded-full bg-gray-200 px-8 py-2 font-semibold text-gray-600">Cancel</a + > + <button + type="submit" + disabled={loading} + aria-disabled={loading} + class="rounded-full bg-black px-8 py-2 font-semibold md:px-20" + class:bg-gray-200={loading} + class:text-gray-600={loading} + class:text-white={!loading} + > + {assistant ? "Save" : "Create"} + {#if loading} + <IconLoading classNames="ml-2 h-min" /> + {/if} + </button> + </div> +</form> diff --git a/src/lib/components/DisclaimerModal.svelte b/src/lib/components/DisclaimerModal.svelte index 7837869d6f28c20df00c15224c401ac1f4c95b70..ecb5f22a895417308bef132a1c1fcc12a52debe5 100644 --- a/src/lib/components/DisclaimerModal.svelte +++ b/src/lib/components/DisclaimerModal.svelte @@ -36,9 +36,8 @@ class:bg-white={$page.data.loginEnabled} class:text-gray-800={$page.data.loginEnabled} class:hover:bg-slate-100={$page.data.loginEnabled} - on:click={(e) => { + on:click|preventDefault|stopPropagation={() => { if (!cookiesAreEnabled()) { - e.preventDefault(); window.open(window.location.href, "_blank"); } diff --git a/src/lib/components/LoginModal.svelte b/src/lib/components/LoginModal.svelte index 1f239644dddb4f896f1acc275d3aa19f513bb410..c333fdc0eec6cd10e9112c6d19e01d81fe99f511 100644 --- a/src/lib/components/LoginModal.svelte +++ b/src/lib/components/LoginModal.svelte @@ -51,7 +51,6 @@ e.preventDefault(); window.open(window.location.href, "_blank"); } - $settings.ethicsModalAccepted = true; }} > diff --git a/src/lib/components/NavConversationItem.svelte b/src/lib/components/NavConversationItem.svelte index b786856c3041b9602671636073c66947c53270c4..ce5325f0d803afd60abc08b044109adb75c67cb6 100644 --- a/src/lib/components/NavConversationItem.svelte +++ b/src/lib/components/NavConversationItem.svelte @@ -7,8 +7,10 @@ import CarbonTrashCan from "~icons/carbon/trash-can"; import CarbonClose from "~icons/carbon/close"; import CarbonEdit from "~icons/carbon/edit"; + import { useSettingsStore } from "$lib/stores/settings"; + import type { ConvSidebar } from "$lib/types/ConvSidebar"; - export let conv: { id: string; title: string }; + export let conv: ConvSidebar; let confirmDelete = false; @@ -16,6 +18,8 @@ deleteConversation: string; editConversationTitle: { id: string; title: string }; }>(); + + const settings = useSettingsStore(); </script> <a @@ -29,11 +33,25 @@ ? 'bg-gray-100 dark:bg-gray-700' : ''}" > - <div class="flex-1 truncate"> + <div class="flex flex-1 items-center truncate"> {#if confirmDelete} - <span class="font-semibold"> Delete </span> + <span class="mr-1 font-semibold"> Delete </span> + {/if} + {#if conv.avatarHash && !$settings.hideEmojiOnSidebar} + <img + src="{base}/settings/assistants/{conv.assistantId}/avatar?hash={conv.avatarHash}" + alt="Assistant avatar" + class="mr-1.5 inline size-4 rounded-full object-cover" + /> + {conv.title.replace(/\p{Emoji}/gu, "")} + {:else if conv.assistantId} + <div + class="mr-1.5 flex size-4 items-center justify-center rounded-full bg-gray-300 text-xs font-bold uppercase text-gray-500" + /> + {conv.title.replace(/\p{Emoji}/gu, "")} + {:else} + {conv.title} {/if} - {conv.title} </div> {#if confirmDelete} diff --git a/src/lib/components/NavMenu.svelte b/src/lib/components/NavMenu.svelte index b7754727efa7caf9a4199ece694a2834a93b401d..3d3975875054ee195bad75e25af303f130c766e1 100644 --- a/src/lib/components/NavMenu.svelte +++ b/src/lib/components/NavMenu.svelte @@ -7,14 +7,9 @@ import { PUBLIC_APP_NAME, PUBLIC_ORIGIN } from "$env/static/public"; import NavConversationItem from "./NavConversationItem.svelte"; import type { LayoutData } from "../../routes/$types"; + import type { ConvSidebar } from "$lib/types/ConvSidebar"; - interface Conv { - id: string; - title: string; - updatedAt: Date; - } - - export let conversations: Array<Conv> = []; + export let conversations: ConvSidebar[] = []; export let canLogin: boolean; export let user: LayoutData["user"]; diff --git a/src/lib/components/chat/AssistantIntroduction.svelte b/src/lib/components/chat/AssistantIntroduction.svelte new file mode 100644 index 0000000000000000000000000000000000000000..4845241f139b9705a3a6ac6cca0fde3e7336d4af --- /dev/null +++ b/src/lib/components/chat/AssistantIntroduction.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import { createEventDispatcher } from "svelte"; + import IconGear from "~icons/bi/gear-fill"; + import { base } from "$app/paths"; + import type { Assistant } from "$lib/types/Assistant"; + + export let assistant: Pick< + Assistant, + "avatar" | "name" | "modelId" | "createdByName" | "exampleInputs" | "_id" | "description" + >; + + const dispatch = createEventDispatcher<{ message: string }>(); +</script> + +<div class="flex h-full w-full flex-col content-center items-center justify-center"> + <div + class="relative mt-auto rounded-2xl bg-gray-100 text-gray-600 dark:border-gray-800 dark:bg-gray-800/60 dark:text-gray-300" + > + <div class="flex items-center gap-4 p-4 pr-10 md:p-8 md:pt-10"> + {#if assistant.avatar} + <img + src={`${base}/settings/assistants/${assistant._id.toString()}/avatar?hash=${ + assistant.avatar + }`} + alt="avatar" + class="size-16 rounded-full object-cover md:size-32" + /> + {:else} + <div + class="flex size-12 flex-none items-center justify-center rounded-full bg-gray-300 object-cover text-xl font-bold uppercase text-gray-500 sm:text-4xl md:h-32 md:w-32 dark:bg-gray-600" + > + {assistant?.name[0]} + </div> + {/if} + + <div class="flex h-full flex-col"> + <p + class="mb-2 w-fit truncate text-ellipsis rounded-full bg-gray-200 px-3 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400" + > + Assistant + </p> + <p class="text-xl font-bold sm:text-2xl">{assistant.name}</p> + <p class="text-sm text-gray-500 dark:text-gray-400"> + {assistant.description} + </p> + + {#if assistant.createdByName} + <p class="pt-2 text-sm text-gray-400 dark:text-gray-500"> + Created by <a + class="hover:underline" + href="https://hf.co/{assistant.createdByName}" + target="_blank" + > + {assistant.createdByName} + </a> + </p> + {/if} + </div> + </div> + <div class="absolute right-2 top-3 sm:top-2"> + <a + href="{base}/settings/assistants/{assistant._id.toString()}" + class="flex size-7 items-center justify-center rounded-full border bg-gray-200 p-1 text-xs hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600" + ><IconGear /></a + > + </div> + </div> + {#if assistant.exampleInputs} + <div class="mx-auto mt-auto w-full gap-8 sm:-mb-8"> + <div class="md:col-span-2 md:mt-6"> + <div class="grid grid-cols-1 gap-3 md:grid-cols-2"> + {#each assistant.exampleInputs as example} + <button + type="button" + class="truncate whitespace-nowrap rounded-xl border bg-gray-50 px-3 py-2 text-left text-smd text-gray-600 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + on:click={() => dispatch("message", example)} + > + {example} + </button> + {/each} + </div> + </div> + </div> + {/if} +</div> diff --git a/src/lib/components/chat/ChatMessages.svelte b/src/lib/components/chat/ChatMessages.svelte index 5031d280efc1b0600ac4e2a9b19fb52838b6da41..f938fd441ec855187788e52a303a33ba47b5f1a9 100644 --- a/src/lib/components/chat/ChatMessages.svelte +++ b/src/lib/components/chat/ChatMessages.svelte @@ -10,12 +10,17 @@ import type { WebSearchUpdate } from "$lib/types/MessageUpdate"; import { browser } from "$app/environment"; import SystemPromptModal from "../SystemPromptModal.svelte"; + import type { Assistant } from "$lib/types/Assistant"; + import AssistantIntroduction from "./AssistantIntroduction.svelte"; + import { page } from "$app/stores"; + import { base } from "$app/paths"; export let messages: Message[]; export let loading: boolean; export let pending: boolean; export let isAuthor: boolean; export let currentModel: Model; + export let assistant: Assistant | undefined; export let models: Model[]; export let preprompt: string | undefined; export let readOnly: boolean; @@ -42,7 +47,29 @@ > <div class="mx-auto flex h-full max-w-3xl flex-col gap-6 px-5 pt-6 sm:gap-8 xl:max-w-4xl"> {#each messages as message, i} - {#if i === 0 && preprompt && preprompt != currentModel.preprompt} + {#if i === 0 && $page.data?.assistant} + <a + class="mx-auto flex items-center gap-1.5 rounded-full border border-gray-100 bg-gray-50 py-1 pl-1 pr-3 text-sm text-gray-800 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700" + href="{base}/settings/assistants/{$page.data.assistant._id}" + > + {#if $page.data?.assistant.avatar} + <img + src="{base}/settings/assistants/{$page.data?.assistant._id.toString()}/avatar?hash=${$page + .data?.assistant.avatar}" + alt="Avatar" + class="size-5 rounded-full object-cover" + /> + {:else} + <div + class="flex size-6 items-center justify-center rounded-full bg-gray-300 font-bold uppercase text-gray-500" + > + {$page.data?.assistant.name[0]} + </div> + {/if} + + {$page.data.assistant.name} + </a> + {:else if i === 0 && preprompt && preprompt != currentModel.preprompt} <SystemPromptModal preprompt={preprompt ?? ""} /> {/if} <ChatMessage @@ -57,7 +84,11 @@ on:continue /> {:else} - <ChatIntroduction {models} {currentModel} on:message /> + {#if !assistant} + <ChatIntroduction {models} {currentModel} on:message /> + {:else} + <AssistantIntroduction {assistant} on:message /> + {/if} {/each} {#if pending && messages[messages.length - 1]?.from === "user"} <ChatMessage diff --git a/src/lib/components/chat/ChatWindow.svelte b/src/lib/components/chat/ChatWindow.svelte index 691b9c3391a79ac342f6e1691cd3995dfe4e48b3..e402aae5e754c40044dd12cf1e7e25e794025f86 100644 --- a/src/lib/components/chat/ChatWindow.svelte +++ b/src/lib/components/chat/ChatWindow.svelte @@ -18,12 +18,12 @@ import LoginModal from "../LoginModal.svelte"; import type { WebSearchUpdate } from "$lib/types/MessageUpdate"; import { page } from "$app/stores"; - import DisclaimerModal from "../DisclaimerModal.svelte"; import FileDropzone from "./FileDropzone.svelte"; import RetryBtn from "../RetryBtn.svelte"; import UploadBtn from "../UploadBtn.svelte"; import file2base64 from "$lib/utils/file2base64"; - import { useSettingsStore } from "$lib/stores/settings"; + import type { Assistant } from "$lib/types/Assistant"; + import { base } from "$app/paths"; import ContinueBtn from "../ContinueBtn.svelte"; export let messages: Message[] = []; @@ -32,6 +32,7 @@ export let shared = false; export let currentModel: Model; export let models: Model[]; + export let assistant: Assistant | undefined = undefined; export let webSearchMessages: WebSearchUpdate[] = []; export let preprompt: string | undefined = undefined; export let files: File[] = []; @@ -78,8 +79,6 @@ $: sources = files.map((file) => file2base64(file)); - const settings = useSettingsStore(); - function onShare() { dispatch("share"); isSharedRecently = true; @@ -99,9 +98,7 @@ </script> <div class="relative min-h-0 min-w-0"> - {#if !$settings.ethicsModalAccepted} - <DisclaimerModal /> - {:else if loginModalOpen} + {#if loginModalOpen} <LoginModal on:close={() => { loginModalOpen = false; @@ -113,6 +110,7 @@ {pending} {currentModel} {models} + {assistant} {messages} readOnly={isReadOnly} isAuthor={!shared} @@ -162,7 +160,7 @@ <div class="w-full"> <div class="flex w-full pb-3"> - {#if $page.data.settings?.searchEnabled} + {#if $page.data.settings?.searchEnabled && !assistant} <WebSearchToggle /> {/if} {#if loading} @@ -252,13 +250,16 @@ class="mt-2 flex justify-between self-stretch px-1 text-xs text-gray-400/90 max-md:mb-2 max-sm:gap-2" > <p> - Model: <a - href={currentModel.modelUrl || "https://huggingface.co/" + currentModel.name} - target="_blank" - rel="noreferrer" - class="hover:underline">{currentModel.displayName}</a - > <span class="max-sm:hidden">·</span><br class="sm:hidden" /> Generated content may be inaccurate - or false. + Model: + {#if !assistant} + <a href="{base}/settings/{currentModel.id}" class="hover:underline" + >{currentModel.displayName}</a + >{:else} + {@const model = models.find((m) => m.id === assistant?.modelId)} + <a href="{base}/settings/assistants/{assistant._id}" class="hover:underline" + >{model?.displayName}</a + >{/if} <span class="max-sm:hidden">·</span><br class="sm:hidden" /> Generated content may + be inaccurate or false. </p> {#if messages.length} <button diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts index 39d0c6a52732c930b62c4c20a8ad11fb5613613c..7facc7ada38889bc13b00614cd652ee4d57371fb 100644 --- a/src/lib/server/database.ts +++ b/src/lib/server/database.ts @@ -7,6 +7,8 @@ import type { Settings } from "$lib/types/Settings"; import type { User } from "$lib/types/User"; import type { MessageEvent } from "$lib/types/MessageEvent"; import type { Session } from "$lib/types/Session"; +import type { Assistant } from "$lib/types/Assistant"; +import type { Report } from "$lib/types/Report"; if (!MONGODB_URL) { throw new Error( @@ -23,6 +25,8 @@ export const connectPromise = client.connect().catch(console.error); const db = client.db(MONGODB_DB_NAME + (import.meta.env.MODE === "test" ? "-test" : "")); const conversations = db.collection<Conversation>("conversations"); +const assistants = db.collection<Assistant>("assistants"); +const reports = db.collection<Report>("reports"); const sharedConversations = db.collection<SharedConversation>("sharedConversations"); const abortedGenerations = db.collection<AbortedGeneration>("abortedGenerations"); const settings = db.collection<Settings>("settings"); @@ -34,6 +38,8 @@ const bucket = new GridFSBucket(db, { bucketName: "files" }); export { client, db }; export const collections = { conversations, + assistants, + reports, sharedConversations, abortedGenerations, settings, @@ -66,4 +72,6 @@ client.on("open", () => { messageEvents.createIndex({ createdAt: 1 }, { expireAfterSeconds: 60 }).catch(console.error); sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(console.error); sessions.createIndex({ sessionId: 1 }, { unique: true }).catch(console.error); + assistants.createIndex({ createdBy: 1 }).catch(console.error); + reports.createIndex({ assistantId: 1 }).catch(console.error); }); diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index 18c55ce6e54a65e23fa377cfb7daec97db747cf1..4839c4fa8e76753986eb3e432d0d1f7fa6d733c7 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -2,6 +2,7 @@ import { browser } from "$app/environment"; import { invalidate } from "$app/navigation"; import { base } from "$app/paths"; import { UrlDependency } from "$lib/types/UrlDependency"; +import type { ObjectId } from "mongodb"; import { getContext, setContext } from "svelte"; import { type Writable, writable, get } from "svelte/store"; @@ -13,7 +14,9 @@ type SettingsStore = { activeModel: string; customPrompts: Record<string, string>; recentlySaved: boolean; + assistants: Array<ObjectId | string>; }; + export function useSettingsStore() { return getContext<Writable<SettingsStore>>("settings"); } @@ -44,6 +47,7 @@ export function createSettingsStore(initialValue: Omit<SettingsStore, "recentlyS }), }); + invalidate(UrlDependency.ConversationList); // set savedRecently to true for 3s baseStore.update((s) => ({ ...s, diff --git a/src/lib/types/Assistant.ts b/src/lib/types/Assistant.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9f64c79fd92988a4c729ce708464fc43eb0c8b7 --- /dev/null +++ b/src/lib/types/Assistant.ts @@ -0,0 +1,15 @@ +import type { ObjectId } from "mongodb"; +import type { User } from "./User"; +import type { Timestamps } from "./Timestamps"; + +export interface Assistant extends Timestamps { + _id: ObjectId; + createdById: User["_id"] | string; // user id or session + createdByName?: User["username"]; + avatar?: string; + name: string; + description?: string; + modelId: string; + exampleInputs: string[]; + preprompt: string; +} diff --git a/src/lib/types/ConvSidebar.ts b/src/lib/types/ConvSidebar.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac0e38583fab047a9aaf0775ad2b4272d33e749a --- /dev/null +++ b/src/lib/types/ConvSidebar.ts @@ -0,0 +1,8 @@ +export interface ConvSidebar { + id: string; + title: string; + updatedAt: Date; + model?: string; + assistantId?: string; + avatarHash?: string; +} diff --git a/src/lib/types/Conversation.ts b/src/lib/types/Conversation.ts index 665a688f6b4bc3151d4701a4c4893b7bf4f80ee1..aa34426b4cc87fe44c9551542e56c431e5253c59 100644 --- a/src/lib/types/Conversation.ts +++ b/src/lib/types/Conversation.ts @@ -2,6 +2,7 @@ import type { ObjectId } from "mongodb"; import type { Message } from "./Message"; import type { Timestamps } from "./Timestamps"; import type { User } from "./User"; +import type { Assistant } from "./Assistant"; export interface Conversation extends Timestamps { _id: ObjectId; @@ -20,4 +21,5 @@ export interface Conversation extends Timestamps { }; preprompt?: string; + assistantId?: Assistant["_id"]; } diff --git a/src/lib/types/Report.ts b/src/lib/types/Report.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4f450f516714c8a81d65468fdfcb0caa47e2df5 --- /dev/null +++ b/src/lib/types/Report.ts @@ -0,0 +1,10 @@ +import type { ObjectId } from "mongodb"; +import type { User } from "./User"; +import type { Assistant } from "./Assistant"; +import type { Timestamps } from "./Timestamps"; + +export interface Report extends Timestamps { + _id: ObjectId; + createdBy: User["_id"] | string; + assistantId: Assistant["_id"]; +} diff --git a/src/lib/types/Settings.ts b/src/lib/types/Settings.ts index 1dc5e764eb32b5ffefba551d8be106bbb06ea182..5a6804e05a891cf527b3592c498350aa890101f8 100644 --- a/src/lib/types/Settings.ts +++ b/src/lib/types/Settings.ts @@ -1,4 +1,5 @@ import { defaultModel } from "$lib/server/models"; +import type { Assistant } from "./Assistant"; import type { Timestamps } from "./Timestamps"; import type { User } from "./User"; @@ -18,6 +19,8 @@ export interface Settings extends Timestamps { // model name and system prompts customPrompts?: Record<string, string>; + + assistants?: Assistant["_id"][]; } // TODO: move this to a constant file along with other constants @@ -25,4 +28,6 @@ export const DEFAULT_SETTINGS = { shareConversationsWithModelAuthors: true, activeModel: defaultModel.id, hideEmojiOnSidebar: false, + customPrompts: {}, + assistants: [], }; diff --git a/src/lib/types/SharedConversation.ts b/src/lib/types/SharedConversation.ts index 1996bcc6ff98cb65e02e6eda4e5157e494ba1e09..49f680da999e70045317350bb4840eaee0ffb055 100644 --- a/src/lib/types/SharedConversation.ts +++ b/src/lib/types/SharedConversation.ts @@ -1,3 +1,4 @@ +import type { Assistant } from "./Assistant"; import type { Message } from "./Message"; import type { Timestamps } from "./Timestamps"; @@ -12,4 +13,5 @@ export interface SharedConversation extends Timestamps { title: string; messages: Message[]; preprompt?: string; + assistantId?: Assistant["_id"]; } diff --git a/src/lib/utils/timeout.ts b/src/lib/utils/timeout.ts new file mode 100644 index 0000000000000000000000000000000000000000..65d229f155eaa726f9427e7531b72b28d9f687bd --- /dev/null +++ b/src/lib/utils/timeout.ts @@ -0,0 +1,6 @@ +export const timeout = <T>(prom: Promise<T>, time: number): Promise<T> => { + let timer: NodeJS.Timeout; + return Promise.race([prom, new Promise<T>((_r, rej) => (timer = setTimeout(rej, time)))]).finally( + () => clearTimeout(timer) + ); +}; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 9ce2c126a4477303749cbc3442abd20b82a1ce6d..fcf4069d50acb55b62f4000d10ec203fc5c5fa6a 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -12,16 +12,22 @@ import { MESSAGES_BEFORE_LOGIN, YDC_API_KEY, USE_LOCAL_WEBSEARCH, + ENABLE_ASSISTANTS, } from "$env/static/private"; +import { ObjectId } from "mongodb"; +import type { ConvSidebar } from "$lib/types/ConvSidebar"; export const load: LayoutServerLoad = async ({ locals, depends }) => { - const { conversations } = collections; depends(UrlDependency.ConversationList); const settings = await collections.settings.findOne(authCondition(locals)); // If the active model in settings is not valid, set it to the default model. This can happen if model was disabled. - if (settings && !validateModel(models).safeParse(settings?.activeModel).success) { + if ( + settings && + !validateModel(models).safeParse(settings?.activeModel).success && + !settings.assistants?.map((el) => el.toString())?.includes(settings?.activeModel) + ) { settings.activeModel = defaultModel.id; await collections.settings.updateOne(authCondition(locals), { $set: { activeModel: defaultModel.id }, @@ -42,7 +48,7 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => { // get the number of messages where `from === "assistant"` across all conversations. const totalMessages = ( - await conversations + await collections.conversations .aggregate([ { $match: authCondition(locals) }, { $project: { messages: 1 } }, @@ -59,33 +65,61 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => { const loginRequired = requiresUser && !locals.user && userHasExceededMessages; + const enableAssistants = ENABLE_ASSISTANTS === "true"; + + const assistantActive = !models.map(({ id }) => id).includes(settings?.activeModel ?? ""); + + const assistant = assistantActive + ? JSON.parse( + JSON.stringify( + await collections.assistants.findOne({ + _id: new ObjectId(settings?.activeModel), + }) + ) + ) + : null; + + const conversations = await collections.conversations + .find(authCondition(locals)) + .sort({ updatedAt: -1 }) + .project< + Pick<Conversation, "title" | "model" | "_id" | "updatedAt" | "createdAt" | "assistantId"> + >({ + title: 1, + model: 1, + _id: 1, + updatedAt: 1, + createdAt: 1, + assistantId: 1, + }) + .toArray(); + + const assistantIds = conversations + .map((conv) => conv.assistantId) + .filter((el) => !!el) as ObjectId[]; + + const assistants = await collections.assistants.find({ _id: { $in: assistantIds } }).toArray(); + return { - conversations: await conversations - .find(authCondition(locals)) - .sort({ updatedAt: -1 }) - .project<Pick<Conversation, "title" | "model" | "_id" | "updatedAt" | "createdAt">>({ - title: 1, - model: 1, - _id: 1, - updatedAt: 1, - createdAt: 1, - }) - .map((conv) => { - // remove emojis if settings say so - if (settings?.hideEmojiOnSidebar) { - conv.title = conv.title.replace(/\p{Emoji}/gu, ""); - } - - // remove invalid unicode and trim whitespaces - conv.title = conv.title.replace(/\uFFFD/gu, "").trimStart(); - return { - id: conv._id.toString(), - title: settings?.hideEmojiOnSidebar ? conv.title.replace(/\p{Emoji}/gu, "") : conv.title, - model: conv.model ?? defaultModel, - updatedAt: conv.updatedAt, - }; - }) - .toArray(), + conversations: conversations.map((conv) => { + if (settings?.hideEmojiOnSidebar) { + conv.title = conv.title.replace(/\p{Emoji}/gu, ""); + } + + // remove invalid unicode and trim whitespaces + conv.title = conv.title.replace(/\uFFFD/gu, "").trimStart(); + + return { + id: conv._id.toString(), + title: conv.title, + model: conv.model ?? defaultModel, + updatedAt: conv.updatedAt, + assistantId: conv.assistantId?.toString(), + avatarHash: + conv.assistantId && + assistants.find((a) => a._id.toString() === conv.assistantId?.toString())?.avatar, + }; + }) satisfies ConvSidebar[], settings: { searchEnabled: !!( SERPAPI_KEY || @@ -102,6 +136,7 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => { settings?.shareConversationsWithModelAuthors ?? DEFAULT_SETTINGS.shareConversationsWithModelAuthors, customPrompts: settings?.customPrompts ?? {}, + assistants: settings?.assistants?.map((el) => el.toString()) ?? [], }, models: models.map((model) => ({ id: model.id, @@ -120,10 +155,13 @@ export const load: LayoutServerLoad = async ({ locals, depends }) => { })), oldModels, user: locals.user && { + id: locals.user._id.toString(), username: locals.user.username, avatarUrl: locals.user.avatarUrl, email: locals.user.email, }, + assistant, + enableAssistants, loginRequired, loginEnabled: requiresUser, guestMode: requiresUser && messagesBeforeLogin > 0, diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 318c21cc3ff471bdb372104aed0e91903bf819d0..18d250df978b29351d39a4e14d60cfa8d25ad5e8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,7 +4,7 @@ import { page } from "$app/stores"; import "../styles/main.css"; import { base } from "$app/paths"; - import { PUBLIC_ORIGIN } from "$env/static/public"; + import { PUBLIC_APP_DESCRIPTION, PUBLIC_ORIGIN } from "$env/static/public"; import { shareConversation } from "$lib/shareConversation"; import { UrlDependency } from "$lib/types/UrlDependency"; @@ -17,6 +17,7 @@ import titleUpdate from "$lib/stores/titleUpdate"; import { createSettingsStore } from "$lib/stores/settings"; import { browser } from "$app/environment"; + import DisclaimerModal from "$lib/components/DisclaimerModal.svelte"; export let data; @@ -120,13 +121,19 @@ <meta name="description" content="The first open source alternative to ChatGPT. 💪" /> <meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:site" content="@huggingface" /> - <meta property="og:title" content={PUBLIC_APP_NAME} /> - <meta property="og:type" content="website" /> - <meta property="og:url" content="{PUBLIC_ORIGIN || $page.url.origin}{base}" /> - <meta - property="og:image" - content="{PUBLIC_ORIGIN || $page.url.origin}{base}/{PUBLIC_APP_ASSETS}/thumbnail.png" - /> + + <!-- use those meta tags everywhere except on the share assistant page --> + <!-- feel free to refacto if there's a better way --> + {#if !$page.url.pathname.includes("/assistant/")} + <meta property="og:title" content={PUBLIC_APP_NAME} /> + <meta property="og:type" content="website" /> + <meta property="og:url" content="{PUBLIC_ORIGIN || $page.url.origin}{base}" /> + <meta + property="og:image" + content="{PUBLIC_ORIGIN || $page.url.origin}{base}/{PUBLIC_APP_ASSETS}/thumbnail.png" + /> + <meta property="og:description" content={PUBLIC_APP_DESCRIPTION} /> + {/if} <link rel="icon" href="{PUBLIC_ORIGIN || $page.url.origin}{base}/{PUBLIC_APP_ASSETS}/favicon.ico" @@ -147,6 +154,10 @@ /> </svelte:head> +{#if !$settings.ethicsModalAccepted} + <DisclaimerModal /> +{/if} + <div class="grid h-full w-screen grid-cols-1 grid-rows-[auto,1fr] overflow-hidden text-smd md:grid-cols-[280px,1fr] md:grid-rows-[1fr] dark:text-gray-300" > diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fa458cfe80b0b2306384f50b856794a888c43ccd..78d93c4a3f362eab499ae983494f17b5a673cb29 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -17,14 +17,32 @@ async function createConversation(message: string) { try { loading = true; + + // check if $settings.activeModel is a valid model + // else check if it's an assistant, and use that model + // else use the first model + + const validModels = data.models.map((model) => model.id); + + let model; + if (validModels.includes($settings.activeModel)) { + model = $settings.activeModel; + } else { + if (validModels.includes(data.assistant?.modelId)) { + model = data.assistant?.modelId; + } else { + model = data.models[0].id; + } + } const res = await fetch(`${base}/conversation`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - model: $settings.activeModel, + model, preprompt: $settings.customPrompts[$settings.activeModel], + assistantId: data.assistant?._id, }), }); @@ -60,6 +78,7 @@ <ChatWindow on:message={(ev) => createConversation(ev.detail)} {loading} + assistant={data.assistant} currentModel={findCurrentModel([...data.models, ...data.oldModels], $settings.activeModel)} models={data.models} bind:files diff --git a/src/routes/assistant/[assistantId]/+page.server.ts b/src/routes/assistant/[assistantId]/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac14877dbc4367ef9498e6bdd4a6207b79039aab --- /dev/null +++ b/src/routes/assistant/[assistantId]/+page.server.ts @@ -0,0 +1,20 @@ +import { base } from "$app/paths"; +import { collections } from "$lib/server/database.js"; +import { redirect } from "@sveltejs/kit"; +import { ObjectId } from "mongodb"; + +export const load = async ({ params }) => { + try { + const assistant = await collections.assistants.findOne({ + _id: new ObjectId(params.assistantId), + }); + + if (!assistant) { + throw redirect(302, `${base}`); + } + + return { assistant: JSON.parse(JSON.stringify(assistant)) }; + } catch { + throw redirect(302, `${base}`); + } +}; diff --git a/src/routes/assistant/[assistantId]/+page.svelte b/src/routes/assistant/[assistantId]/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..52b7bbc0bbdd31a94d71cb6fff4fbc468b6b6970 --- /dev/null +++ b/src/routes/assistant/[assistantId]/+page.svelte @@ -0,0 +1,112 @@ +<script lang="ts"> + import { base } from "$app/paths"; + import { clickOutside } from "$lib/actions/clickOutside"; + import { afterNavigate, goto } from "$app/navigation"; + + import { useSettingsStore } from "$lib/stores/settings"; + import type { PageData } from "./$types"; + import { applyAction, enhance } from "$app/forms"; + import { PUBLIC_APP_NAME, PUBLIC_ORIGIN } from "$env/static/public"; + import { page } from "$app/stores"; + + export let data: PageData; + + let previousPage: string = base; + + afterNavigate(({ from }) => { + if (!from?.url.pathname.includes("settings")) { + previousPage = from?.url.pathname || previousPage; + } + }); + + const settings = useSettingsStore(); +</script> + +<svelte:head> + <meta property="og:title" content={data.assistant.name + " - " + PUBLIC_APP_NAME} /> + <meta property="og:type" content="link" /> + <meta + property="og:description" + content={`Use the ${data.assistant.name} assistant inside of ${PUBLIC_APP_NAME}`} + /> + <meta + property="og:image" + content="{PUBLIC_ORIGIN || $page.url.origin}{base}/assistant/{data.assistant._id}/thumbnail.png" + /> + <meta property="og:url" content={$page.url.href} /> + <meta name="twitter:card" content="summary_large_image" /> +</svelte:head> + +<div + class="fixed inset-0 flex items-center justify-center bg-black/80 backdrop-blur-sm dark:bg-black/50" +> + <dialog + open + use:clickOutside={() => { + goto(previousPage); + }} + class="z-10 flex flex-col content-center items-center gap-x-10 gap-y-2 overflow-hidden rounded-2xl bg-white p-4 text-center shadow-2xl outline-none max-sm:px-6 md:w-96 md:grid-cols-3 md:grid-rows-[auto,1fr] md:p-8" + > + {#if data.assistant.avatar} + <img + class="h-24 w-24 rounded-full object-cover" + src="{base}/settings/assistants/{data.assistant._id}/avatar?hash={data.assistant.avatar}" + alt="avatar" + /> + {:else} + <div + class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-300 font-bold uppercase text-gray-500" + > + {data.assistant.name[0]} + </div> + {/if} + <h1 class="text-2xl font-bold"> + {data.assistant.name} + </h1> + <h3 class="text-sm text-gray-700"> + {data.assistant.description} + </h3> + {#if data.assistant.createdByName} + <p class="text-sm text-gray-500"> + Created by <a + class="hover:underline" + href="https://hf.co/{data.assistant.createdByName}" + target="_blank" + > + {data.assistant.createdByName} + </a> + </p> + {/if} + <button + class="mt-4 w-full rounded-full bg-gray-200 px-4 py-2 font-semibold text-gray-700" + on:click={() => { + goto(previousPage); + }} + > + Cancel + </button> + <form + method="POST" + action="{base}/settings/assistants/{data.assistant._id}?/subscribe" + class="w-full" + use:enhance={() => { + return async ({ result }) => { + // `result` is an `ActionResult` object + if (result.type === "success") { + $settings.activeModel = data.assistant._id; + goto(`${base}`); + } else { + await applyAction(result); + } + }; + }} + > + <button + type="submit" + class=" w-full rounded-full bg-black px-4 py-3 font-semibold text-white" + > + Start chatting + </button> + </form> + </dialog> +</div> diff --git a/src/routes/assistant/[assistantId]/thumbnail.png/+server.ts b/src/routes/assistant/[assistantId]/thumbnail.png/+server.ts new file mode 100644 index 0000000000000000000000000000000000000000..c43d46c1fda9001d4f66643dac06f964cfe0c5fb --- /dev/null +++ b/src/routes/assistant/[assistantId]/thumbnail.png/+server.ts @@ -0,0 +1,64 @@ +import { APP_BASE } from "$env/static/private"; +import ChatThumbnail from "./ChatThumbnail.svelte"; +import { collections } from "$lib/server/database"; +import { error, type RequestHandler } from "@sveltejs/kit"; +import { ObjectId } from "mongodb"; +import type { SvelteComponent } from "svelte"; + +import { Resvg } from "@resvg/resvg-js"; +import satori from "satori"; +import { html } from "satori-html"; +import { base } from "$app/paths"; + +export const GET: RequestHandler = (async ({ url, params, fetch }) => { + const assistant = await collections.assistants.findOne({ + _id: new ObjectId(params.assistantId), + }); + + if (!assistant) { + throw error(404, "Assistant not found."); + } + + const renderedComponent = (ChatThumbnail as unknown as SvelteComponent).render({ + href: url.origin, + name: assistant.name, + description: assistant.description, + createdByName: assistant.createdByName, + avatarUrl: assistant.avatar + ? url.origin + APP_BASE + "/settings/assistants/" + assistant._id + "/avatar" + : undefined, + }); + + const reactLike = html( + "<style>" + renderedComponent.css.code + "</style>" + renderedComponent.html + ); + + const svg = await satori(reactLike, { + width: 1200, + height: 648, + fonts: [ + { + name: "Inter", + data: await fetch(base + "/fonts/Inter-Regular.ttf").then((r) => r.arrayBuffer()), + weight: 500, + }, + { + name: "Inter", + data: await fetch(base + "/fonts/Inter-Bold.ttf").then((r) => r.arrayBuffer()), + weight: 700, + }, + ], + }); + + const png = new Resvg(svg, { + fitTo: { mode: "original" }, + }) + .render() + .asPng(); + + return new Response(png, { + headers: { + "Content-Type": "image/png", + }, + }); +}) satisfies RequestHandler; diff --git a/src/routes/assistant/[assistantId]/thumbnail.png/ChatThumbnail.svelte b/src/routes/assistant/[assistantId]/thumbnail.png/ChatThumbnail.svelte new file mode 100644 index 0000000000000000000000000000000000000000..9c791fa1f2cefa77db768c832b07f2c4769de23d --- /dev/null +++ b/src/routes/assistant/[assistantId]/thumbnail.png/ChatThumbnail.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import { base } from "$app/paths"; + import { PUBLIC_APP_ASSETS } from "$env/static/public"; + + export let href: string = ""; + export let name: string; + export let description: string = ""; + export let createdByName: string | undefined; + export let avatarUrl: string | undefined; + + const imgUrl = `${href}${base}/${PUBLIC_APP_ASSETS}/logo.svg`; +</script> + +<div class="flex h-full w-full flex-col items-center justify-center bg-black p-2"> + <div class="flex w-full max-w-[540px] items-start justify-center text-white"> + {#if avatarUrl} + <img class="h-64 w-64 rounded-full" src={avatarUrl} alt="avatar" /> + {/if} + <div class="ml-10 flex flex-col items-start"> + <p class="mb-2 mt-0 text-3xl font-normal text-gray-400"> + <img class="mr-1.5 h-8 w-8" src={imgUrl} alt="app logo" /> + AI assistant + </p> + <h1 class="m-0 {name.length < 38 ? 'text-5xl' : 'text-4xl'} text-balance font-black"> + {name} + </h1> + <p class="mb-8 text-pretty text-2xl"> + {description.slice(0, 160)} + {#if description.length > 160}...{/if} + </p> + <div class="rounded-full bg-[#FFA800] px-8 py-3 text-3xl font-semibold text-black"> + Start chatting + </div> + </div> + </div> + {#if createdByName} + <p class="absolute bottom-4 right-8 text-2xl text-gray-400"> + An AI assistant created by {createdByName} + </p> + {/if} +</div> diff --git a/src/routes/conversation/+server.ts b/src/routes/conversation/+server.ts index 94901c6e89534b445aaa49640e47a213d0c72dd1..2b6f6f0c9bccf47c8249914c18e144e5b1b56016 100644 --- a/src/routes/conversation/+server.ts +++ b/src/routes/conversation/+server.ts @@ -18,11 +18,11 @@ export const POST: RequestHandler = async ({ locals, request }) => { .object({ fromShare: z.string().optional(), model: validateModel(models), + assistantId: z.string().optional(), preprompt: z.string().optional(), }) .parse(JSON.parse(body)); - let preprompt = values.preprompt; let embeddingModel: string; if (values.fromShare) { @@ -37,8 +37,9 @@ export const POST: RequestHandler = async ({ locals, request }) => { title = conversation.title; messages = conversation.messages; values.model = conversation.model; + values.preprompt = conversation.preprompt; + values.assistantId = conversation.assistantId?.toString(); embeddingModel = conversation.embeddingModel; - preprompt = conversation.preprompt; } const model = models.find((m) => m.name === values.model); @@ -54,7 +55,16 @@ export const POST: RequestHandler = async ({ locals, request }) => { } // Use the model preprompt if there is no conversation/preprompt in the request body - preprompt = preprompt === undefined ? model?.preprompt : preprompt; + const preprompt = await (async () => { + if (values.assistantId) { + const assistant = await collections.assistants.findOne({ + _id: new ObjectId(values.assistantId), + }); + return assistant?.preprompt; + } else { + return values?.preprompt ?? model?.preprompt; + } + })(); const res = await collections.conversations.insertOne({ _id: new ObjectId(), @@ -62,6 +72,7 @@ export const POST: RequestHandler = async ({ locals, request }) => { messages, model: values.model, preprompt: preprompt === model?.preprompt ? model?.preprompt : preprompt, + assistantId: values.assistantId ? new ObjectId(values.assistantId) : undefined, createdAt: new Date(), updatedAt: new Date(), embeddingModel, diff --git a/src/routes/conversation/[id]/+page.server.ts b/src/routes/conversation/[id]/+page.server.ts index ee25b61c05a9815b4ab7ce0f1db14f6594ab3c1a..cbc80ffea127fee00c4d4257d359122b69217213 100644 --- a/src/routes/conversation/[id]/+page.server.ts +++ b/src/routes/conversation/[id]/+page.server.ts @@ -44,11 +44,21 @@ export const load = async ({ params, depends, locals }) => { throw error(404, "Conversation not found."); } } + return { messages: conversation.messages, title: conversation.title, model: conversation.model, preprompt: conversation.preprompt, + assistant: conversation.assistantId + ? JSON.parse( + JSON.stringify( + await collections.assistants.findOne({ + _id: new ObjectId(conversation.assistantId), + }) + ) + ) + : null, shared, }; }; diff --git a/src/routes/conversation/[id]/share/+server.ts b/src/routes/conversation/[id]/share/+server.ts index 4877de755ad4b18cd0fc2562d14d2027ab0273c3..c2a038eb7d1b3a2a37e59d154739f747672aee01 100644 --- a/src/routes/conversation/[id]/share/+server.ts +++ b/src/routes/conversation/[id]/share/+server.ts @@ -40,6 +40,7 @@ export async function POST({ params, url, locals }) { model: conversation.model, embeddingModel: conversation.embeddingModel, preprompt: conversation.preprompt, + assistantId: conversation.assistantId, }; await collections.sharedConversations.insertOne(shared); diff --git a/src/routes/settings/+layout.server.ts b/src/routes/settings/+layout.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab46b8798db1a716b76ddec0616f1f61ed9e0fd9 --- /dev/null +++ b/src/routes/settings/+layout.server.ts @@ -0,0 +1,31 @@ +import { collections } from "$lib/server/database"; +import { ObjectId } from "mongodb"; +import type { LayoutServerLoad } from "./$types"; + +export const load = (async ({ locals, parent }) => { + const { settings } = await parent(); + + // find assistants matching the settings assistants + const assistants = await collections.assistants + .find({ + _id: { $in: settings.assistants.map((el) => new ObjectId(el)) }, + }) + .toArray(); + + return { + assistants: await Promise.all( + assistants.map(async (el) => ({ + ...el, + _id: el._id.toString(), + createdById: undefined, + createdByMe: + el.createdById.toString() === (locals.user?._id ?? locals.sessionId).toString(), + reported: + (await collections.reports.countDocuments({ + assistantId: el._id, + createdBy: locals.user?._id ?? locals.sessionId, + })) > 0, + })) + ), + }; +}) satisfies LayoutServerLoad; diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte index 7c02043683cd9f0ebfd4feb7e97b384e2a945f0a..dba111364b28851c37f6022bdc6a710cecbf3b04 100644 --- a/src/routes/settings/+layout.svelte +++ b/src/routes/settings/+layout.svelte @@ -1,14 +1,16 @@ <script lang="ts"> import { base } from "$app/paths"; import { clickOutside } from "$lib/actions/clickOutside"; - import { browser } from "$app/environment"; import { afterNavigate, goto } from "$app/navigation"; import { page } from "$app/stores"; import { useSettingsStore } from "$lib/stores/settings"; import CarbonClose from "~icons/carbon/close"; import CarbonCheckmark from "~icons/carbon/checkmark"; + import CarbonAdd from "~icons/carbon/add"; import UserIcon from "~icons/carbon/user"; + import { fade, fly } from "svelte/transition"; + import { PUBLIC_APP_ASSETS } from "$env/static/public"; export let data; let previousPage: string = base; @@ -20,25 +22,27 @@ }); const settings = useSettingsStore(); + + const isHuggingChat = PUBLIC_APP_ASSETS === "huggingchat"; </script> <div class="fixed inset-0 flex items-center justify-center bg-black/80 backdrop-blur-sm dark:bg-black/50" + in:fade > <dialog + in:fly={{ y: 100 }} open use:clickOutside={() => { - if (browser) window; goto(previousPage); }} - class="xl: z-10 grid h-[95dvh] w-[90dvw] grid-cols-1 content-start gap-x-10 gap-y-6 overflow-hidden rounded-2xl bg-white p-4 shadow-2xl outline-none sm:h-[80dvh] md:grid-cols-3 md:grid-rows-[auto,1fr] md:p-8 xl:w-[1200px] 2xl:h-[70dvh]" + class="xl: z-10 grid h-[95dvh] w-[90dvw] grid-cols-1 content-start gap-x-8 overflow-hidden rounded-2xl bg-white p-4 shadow-2xl outline-none sm:h-[80dvh] md:grid-cols-3 md:grid-rows-[auto,1fr] md:p-8 xl:w-[1200px] 2xl:h-[70dvh]" > - <div class="col-span-1 flex items-center justify-between md:col-span-3"> + <div class="col-span-1 mb-4 flex items-center justify-between md:col-span-3"> <h2 class="text-xl font-bold">Settings</h2> <button class="btn rounded-lg" on:click={() => { - if (browser) window; goto(previousPage); }} > @@ -46,38 +50,81 @@ </button> </div> <div - class="col-span-1 flex flex-col overflow-y-auto whitespace-nowrap max-md:-mx-4 max-md:h-[245px] max-md:border md:pr-6" + class="col-span-1 flex flex-col overflow-y-auto whitespace-nowrap max-md:-mx-4 max-md:h-[245px] max-md:border max-md:border-b-2 md:pr-6" > + <h3 class="pb-3 pl-3 pt-2 text-[.8rem] text-gray-800 sm:pl-1">Models</h3> + {#each data.models.filter((el) => !el.unlisted) as model} <a href="{base}/settings/{model.id}" - class="group flex h-11 flex-none items-center gap-3 pl-3 pr-2 text-gray-500 hover:bg-gray-100 md:rounded-xl {model.id === - $page.params.model - ? '!bg-gray-100 !text-gray-800' - : ''}" + class="group flex h-10 flex-none items-center gap-2 pl-3 pr-2 text-sm text-gray-500 hover:bg-gray-100 md:rounded-xl + {model.id === $page.params.model ? '!bg-gray-100 !text-gray-800' : ''}" > <div class="truncate">{model.displayName}</div> {#if model.id === $settings.activeModel} <div - class="rounded-lg bg-black px-2 py-1.5 text-xs font-semibold leading-none text-white" + class="ml-auto rounded-lg bg-black px-2 py-1.5 text-xs font-semibold leading-none text-white" > Active </div> {/if} </a> {/each} + <!-- if its huggingchat, the number of assistants owned by the user must be non-zero to show the UI --> + {#if data.enableAssistants && (!isHuggingChat || data.assistants.length >= 1)} + <h3 class="pb-3 pl-3 pt-5 text-[.8rem] text-gray-800 sm:pl-1">Assistants</h3> + {#each data.assistants as assistant} + <a + href="{base}/settings/assistants/{assistant._id.toString()}" + class="group flex h-10 flex-none items-center gap-2 pl-2 pr-2 text-sm text-gray-500 hover:bg-gray-100 md:rounded-xl + {assistant._id.toString() === $page.params.assistantId ? '!bg-gray-100 !text-gray-800' : ''}" + > + {#if assistant.avatar} + <img + src="{base}/settings/assistants/{assistant._id.toString()}/avatar?hash={assistant.avatar}" + alt="Avatar" + class="h-6 w-6 rounded-full object-cover" + /> + {:else} + <div + class="flex size-6 items-center justify-center rounded-full bg-gray-300 font-bold uppercase text-gray-500" + > + {assistant.name[0]} + </div> + {/if} + <div class="truncate">{assistant.name}</div> + {#if assistant._id.toString() === $settings.activeModel} + <div + class="ml-auto rounded-lg bg-black px-2 py-1.5 text-xs font-semibold leading-none text-white" + > + Active + </div> + {/if} + </a> + {/each} + + {#if !data.loginEnabled || (data.loginEnabled && !!data.user)} + <a + href="{base}/settings/assistants/new" + class="group flex h-10 flex-none items-center gap-2 pl-3 pr-2 text-sm text-gray-500 hover:bg-gray-100 md:rounded-xl + {$page.url.pathname === `${base}/settings/assistants/new` ? '!bg-gray-100 !text-gray-800' : ''}" + > + <CarbonAdd /> + <div class="truncate">Create new assistant</div> + </a> + {/if} + {/if} + <a href="{base}/settings" - class="group mt-auto flex h-11 flex-none items-center gap-3 pl-3 pr-2 text-gray-500 hover:bg-gray-100 max-md:order-first md:rounded-xl {$page - .params.model === undefined - ? '!bg-gray-100 !text-gray-800' - : ''}" + class="group mt-auto flex h-10 flex-none items-center gap-2 pl-3 pr-2 text-sm text-gray-500 hover:bg-gray-100 max-md:order-first md:rounded-xl + {$page.url.pathname === `${base}/settings` ? '!bg-gray-100 !text-gray-800' : ''}" > - <UserIcon class="pr-1 text-lg" /> + <UserIcon class="text-lg" /> Application Settings </a> </div> - <div class="col-span-1 overflow-y-auto md:col-span-2"> + <div class="col-span-1 overflow-y-auto px-4 max-md:-mx-4 max-md:pt-6 md:col-span-2"> <slot /> </div> @@ -85,7 +132,7 @@ <div class="absolute bottom-4 right-4 m-2 flex items-center gap-1.5 rounded-full border border-gray-300 bg-gray-200 px-3 py-1 text-black" > - <CarbonCheckmark /> + <CarbonCheckmark class="text-green-500" /> Saved </div> {/if} diff --git a/src/routes/settings/+server.ts b/src/routes/settings/+server.ts index 5455edeb2ac2cbe58cb0a5f7760f5309b0073234..81289bacba2bf77bbbdd160f29b88fec2824c0cc 100644 --- a/src/routes/settings/+server.ts +++ b/src/routes/settings/+server.ts @@ -1,6 +1,5 @@ import { collections } from "$lib/server/database"; import { z } from "zod"; -import { models, validateModel } from "$lib/server/models"; import { authCondition } from "$lib/server/auth"; import { DEFAULT_SETTINGS } from "$lib/types/Settings"; @@ -14,7 +13,7 @@ export async function POST({ request, locals }) { .default(DEFAULT_SETTINGS.shareConversationsWithModelAuthors), hideEmojiOnSidebar: z.boolean().default(DEFAULT_SETTINGS.hideEmojiOnSidebar), ethicsModalAccepted: z.boolean().optional(), - activeModel: validateModel(models).default(DEFAULT_SETTINGS.activeModel), + activeModel: z.string().default(DEFAULT_SETTINGS.activeModel), customPrompts: z.record(z.string()).default({}), }) .parse(body); diff --git a/src/routes/settings/[...model]/+page.svelte b/src/routes/settings/[...model]/+page.svelte index 0c7b915a229b880fa4a93cb9d449c8d1cc185762..1bb368ccfa980caf90b89bfb381680bba7a9341c 100644 --- a/src/routes/settings/[...model]/+page.svelte +++ b/src/routes/settings/[...model]/+page.svelte @@ -78,7 +78,7 @@ value="{PUBLIC_ORIGIN || $page.url.origin}{base}?model={model.id}" classNames="!border-none !shadow-none !py-0 !px-1 !rounded-md" > - <div class="flex items-center gap-1.5"> + <div class="flex items-center gap-1.5 hover:underline"> <CarbonLink />Copy direct link to model </div> </CopyToClipBoardBtn> diff --git a/src/routes/settings/assistants/[assistantId]/+page.server.ts b/src/routes/settings/assistants/[assistantId]/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..a145fe35e53b3cf0aeb119b6261da4ea0f2eaed4 --- /dev/null +++ b/src/routes/settings/assistants/[assistantId]/+page.server.ts @@ -0,0 +1,115 @@ +import { collections } from "$lib/server/database"; +import { type Actions, fail, redirect } from "@sveltejs/kit"; +import { ObjectId } from "mongodb"; +import { authCondition } from "$lib/server/auth"; +import { base } from "$app/paths"; + +async function assistantOnlyIfAuthor(locals: App.Locals, assistantId?: string) { + const assistant = await collections.assistants.findOne({ _id: new ObjectId(assistantId) }); + + if (!assistant) { + throw Error("Assistant not found"); + } + + if (assistant.createdById.toString() !== (locals.user?._id ?? locals.sessionId).toString()) { + throw Error("You are not the author of this assistant"); + } + + return assistant; +} + +export const actions: Actions = { + delete: async ({ params, locals }) => { + let assistant; + try { + assistant = await assistantOnlyIfAuthor(locals, params.assistantId); + } catch (e) { + return fail(400, { error: true, message: (e as Error).message }); + } + + await collections.assistants.deleteOne({ _id: assistant._id }); + + // and remove it from all users settings + await collections.settings.updateMany( + {}, + { + $pull: { assistants: assistant._id }, + } + ); + + // and delete all avatars + const fileCursor = collections.bucket.find({ filename: assistant._id.toString() }); + + // Step 2: Delete the existing file if it exists + let fileId = await fileCursor.next(); + while (fileId) { + await collections.bucket.delete(fileId._id); + fileId = await fileCursor.next(); + } + + throw redirect(302, `${base}/settings`); + }, + report: async ({ params, locals }) => { + // is there already a report from this user for this model ? + const report = await collections.reports.findOne({ + assistantId: new ObjectId(params.assistantId), + createdBy: locals.user?._id ?? locals.sessionId, + }); + + if (report) { + return fail(400, { error: true, message: "Already reported" }); + } + + const { acknowledged } = await collections.reports.insertOne({ + _id: new ObjectId(), + assistantId: new ObjectId(params.assistantId), + createdBy: locals.user?._id ?? locals.sessionId, + createdAt: new Date(), + updatedAt: new Date(), + }); + + if (!acknowledged) { + return fail(500, { error: true, message: "Failed to report assistant" }); + } + return { from: "report", ok: true, message: "Assistant reported" }; + }, + + subscribe: async ({ params, locals }) => { + const assistant = await collections.assistants.findOne({ + _id: new ObjectId(params.assistantId), + }); + + if (!assistant) { + return fail(404, { error: true, message: "Assistant not found" }); + } + + // don't push if it's already there + const settings = await collections.settings.findOne(authCondition(locals)); + + if (settings?.assistants?.includes(assistant._id)) { + return fail(400, { error: true, message: "Already subscribed" }); + } + + await collections.settings.updateOne(authCondition(locals), { + $push: { assistants: assistant._id }, + }); + + return { from: "subscribe", ok: true, message: "Assistant added" }; + }, + + unsubscribe: async ({ params, locals }) => { + const assistant = await collections.assistants.findOne({ + _id: new ObjectId(params.assistantId), + }); + + if (!assistant) { + return fail(404, { error: true, message: "Assistant not found" }); + } + + await collections.settings.updateOne(authCondition(locals), { + $pull: { assistants: assistant._id }, + }); + + throw redirect(302, `${base}/settings`); + }, +}; diff --git a/src/routes/settings/assistants/[assistantId]/+page.svelte b/src/routes/settings/assistants/[assistantId]/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..d1b043a895d9940bdeedd0a91031c5fcd996f2d6 --- /dev/null +++ b/src/routes/settings/assistants/[assistantId]/+page.svelte @@ -0,0 +1,156 @@ +<script lang="ts"> + import { enhance } from "$app/forms"; + import { base } from "$app/paths"; + import { page } from "$app/stores"; + import { PUBLIC_ORIGIN, PUBLIC_SHARE_PREFIX } from "$env/static/public"; + import { useSettingsStore } from "$lib/stores/settings"; + import type { PageData } from "./$types"; + + import CarbonPen from "~icons/carbon/pen"; + import CarbonTrash from "~icons/carbon/trash-can"; + import CarbonCopy from "~icons/carbon/copy-file"; + import CarbonFlag from "~icons/carbon/flag"; + import CarbonLink from "~icons/carbon/link"; + import CopyToClipBoardBtn from "$lib/components/CopyToClipBoardBtn.svelte"; + + export let data: PageData; + + $: assistant = data.assistants.find((el) => el._id.toString() === $page.params.assistantId); + + const settings = useSettingsStore(); + + $: isActive = $settings.activeModel === $page.params.assistantId; + + const prefix = PUBLIC_SHARE_PREFIX || `${PUBLIC_ORIGIN || $page.url.origin}${base}`; + + $: shareUrl = `${prefix}/assistant/${assistant?._id}`; +</script> + +<div class="flex h-full flex-col gap-2"> + <div class="flex gap-6"> + {#if assistant?.avatar} + <!-- crop image if not square --> + <img + src={`${base}/settings/assistants/${assistant?._id}/avatar?hash=${assistant?.avatar}`} + alt="Avatar" + class="h-24 w-24 rounded-full object-cover" + /> + {:else} + <div + class="flex size-16 flex-none items-center justify-center rounded-full bg-gray-300 text-4xl font-semibold uppercase text-gray-500 sm:size-24" + > + {assistant?.name[0]} + </div> + {/if} + + <div> + <h1 class="text-xl font-semibold"> + {assistant?.name} + </h1> + + {#if assistant?.description} + <p class="pb-2 text-sm text-gray-500"> + {assistant.description} + </p> + {/if} + + <p class="text-sm text-gray-500"> + Model: <span class="font-semibold"> {assistant?.modelId} </span> + </p> + <button + class="{isActive + ? 'bg-gray-100' + : 'bg-black text-white'} my-2 flex w-fit items-center rounded-full px-3 py-1" + disabled={isActive} + name="Activate model" + on:click|stopPropagation={() => { + $settings.activeModel = $page.params.assistantId; + }} + > + {isActive ? "Active" : "Activate"} + </button> + </div> + </div> + + <div> + <h2 class="text-lg font-semibold">Direct URL</h2> + + <p class="pb-2 text-sm text-gray-500"> + People with this link will be able to use your assistant. + {#if !assistant?.createdByMe && assistant?.createdByName} + Created by <a + class="underline" + target="_blank" + href={"https://hf.co/" + assistant?.createdByName} + > + {assistant?.createdByName} + </a> + {/if} + </p> + + <div + class="flex flex-row gap-2 rounded-lg border-2 border-gray-200 bg-gray-100 py-2 pl-3 pr-1.5" + > + <input disabled class="flex-1 truncate bg-inherit" value={shareUrl} /> + <CopyToClipBoardBtn + value={shareUrl} + classNames="!border-none !shadow-none !py-0 !px-1 !rounded-md" + > + <div class="flex items-center gap-1.5 text-gray-500 hover:underline"> + <CarbonLink />Copy + </div> + </CopyToClipBoardBtn> + </div> + </div> + + <!-- <div> + <h2 class="mb-2 text-lg font-semibold">Model used</h2> + + <div + class="flex flex-row gap-2 rounded-lg border-2 border-gray-200 bg-gray-100 py-2 pl-3 pr-1.5" + > + <input disabled class="flex-1" value="Model" /> + </div> + </div> --> + + <h2 class="mt-4 text-lg font-semibold">System Instructions</h2> + + <textarea disabled class="h-[8lh] w-full rounded-lg border-2 border-gray-200 bg-gray-100 p-2" + >{assistant?.preprompt}</textarea + > + + <div class="mt-5 flex gap-4"> + {#if assistant?.createdByMe} + <a href="{base}/settings/assistants/{assistant?._id}/edit" class="underline" + ><CarbonPen class="mr-1.5 inline" />Edit assistant</a + > + <form method="POST" action="?/delete" use:enhance> + <button type="submit" class="flex items-center underline"> + <CarbonTrash class="mr-1.5 inline" />Delete assistant</button + > + </form> + {:else} + <form method="POST" action="?/unsubscribe" use:enhance> + <button type="submit" class="underline"> + <CarbonTrash class="mr-1.5 inline" />Remove assistant</button + > + </form> + <form method="POST" action="?/edit" use:enhance class="hidden"> + <button type="submit" class="underline"> + <CarbonCopy class="mr-1.5 inline" />Duplicate assistant</button + > + </form> + {#if !assistant?.reported} + <form method="POST" action="?/report" use:enhance> + <button type="submit" class="underline"> + <CarbonFlag class="mr-1.5 inline" />Report assistant</button + > + </form> + {:else} + <button type="button" disabled class="text-gray-700"> + <CarbonFlag class="mr-1.5 inline" />Reported</button + > + {/if} + {/if} + </div> +</div> diff --git a/src/routes/settings/assistants/[assistantId]/+page.ts b/src/routes/settings/assistants/[assistantId]/+page.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d2ef393ee413ae0a534029dd25cfcf99cbd54cb --- /dev/null +++ b/src/routes/settings/assistants/[assistantId]/+page.ts @@ -0,0 +1,14 @@ +import { base } from "$app/paths"; +import { redirect } from "@sveltejs/kit"; + +export async function load({ parent, params }) { + const data = await parent(); + + const assistant = data.settings.assistants.find((id) => id === params.assistantId); + + if (!assistant) { + throw redirect(302, `${base}/assistant/${params.assistantId}`); + } + + return data; +} diff --git a/src/routes/settings/assistants/[assistantId]/avatar/+server.ts b/src/routes/settings/assistants/[assistantId]/avatar/+server.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b6be2c2cf63022a967d8435baa494d655ee4154 --- /dev/null +++ b/src/routes/settings/assistants/[assistantId]/avatar/+server.ts @@ -0,0 +1,46 @@ +import { collections } from "$lib/server/database"; +import { error, type RequestHandler } from "@sveltejs/kit"; +import { ObjectId } from "mongodb"; + +export const GET: RequestHandler = async ({ params }) => { + const assistant = await collections.assistants.findOne({ + _id: new ObjectId(params.assistantId), + }); + + if (!assistant) { + throw error(404, "No assistant found"); + } + + if (!assistant.avatar) { + throw error(404, "No avatar found"); + } + + const fileId = collections.bucket.find({ filename: assistant._id.toString() }); + + let mime = ""; + + const content = await fileId.next().then(async (file) => { + mime = file?.metadata?.mime; + + if (!file?._id) { + throw error(404, "Avatar not found"); + } + + const fileStream = collections.bucket.openDownloadStream(file?._id); + + const fileBuffer = await new Promise<Buffer>((resolve, reject) => { + const chunks: Uint8Array[] = []; + fileStream.on("data", (chunk) => chunks.push(chunk)); + fileStream.on("error", reject); + fileStream.on("end", () => resolve(Buffer.concat(chunks))); + }); + + return fileBuffer; + }); + + return new Response(content, { + headers: { + "Content-Type": mime ?? "application/octet-stream", + }, + }); +}; diff --git a/src/routes/settings/assistants/[assistantId]/edit/+page.server.ts b/src/routes/settings/assistants/[assistantId]/edit/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..22716f4a7644793b2c94ad86580088895a664fa4 --- /dev/null +++ b/src/routes/settings/assistants/[assistantId]/edit/+page.server.ts @@ -0,0 +1,136 @@ +import { base } from "$app/paths"; +import { requiresUser } from "$lib/server/auth"; +import { collections } from "$lib/server/database"; +import { fail, type Actions, redirect } from "@sveltejs/kit"; +import { ObjectId } from "mongodb"; + +import { z } from "zod"; +import sizeof from "image-size"; +import { sha256 } from "$lib/utils/sha256"; + +const newAsssistantSchema = z.object({ + name: z.string().min(1), + modelId: z.string().min(1), + preprompt: z.string().min(1), + description: z.string().optional(), + exampleInput1: z.string().optional(), + exampleInput2: z.string().optional(), + exampleInput3: z.string().optional(), + exampleInput4: z.string().optional(), + avatar: z.union([z.instanceof(File), z.literal("null")]).optional(), +}); + +const uploadAvatar = async (avatar: File, assistantId: ObjectId): Promise<string> => { + const hash = await sha256(await avatar.text()); + const upload = collections.bucket.openUploadStream(`${assistantId.toString()}`, { + metadata: { type: avatar.type, hash }, + }); + + upload.write((await avatar.arrayBuffer()) as unknown as Buffer); + upload.end(); + + // only return the filename when upload throws a finish event or a 10s time out occurs + return new Promise((resolve, reject) => { + upload.once("finish", () => resolve(hash)); + upload.once("error", reject); + setTimeout(() => reject(new Error("Upload timed out")), 10000); + }); +}; + +export const actions: Actions = { + default: async ({ request, locals, params }) => { + const assistant = await collections.assistants.findOne({ + _id: new ObjectId(params.assistantId), + }); + + if (!assistant) { + throw Error("Assistant not found"); + } + + if (assistant.createdById.toString() !== (locals.user?._id ?? locals.sessionId).toString()) { + throw Error("You are not the author of this assistant"); + } + + const formData = Object.fromEntries(await request.formData()); + + const parse = newAsssistantSchema.safeParse(formData); + + if (!parse.success) { + // Loop through the errors array and create a custom errors array + const errors = parse.error.errors.map((error) => { + return { + field: error.path[0], + message: error.message, + }; + }); + + return fail(400, { error: true, errors }); + } + + // can only create assistants when logged in, IF login is setup + if (!locals.user && requiresUser) { + const errors = [{ field: "preprompt", message: "Must be logged in. Unauthorized" }]; + return fail(400, { error: true, errors }); + } + + const exampleInputs: string[] = [ + parse?.data?.exampleInput1 ?? "", + parse?.data?.exampleInput2 ?? "", + parse?.data?.exampleInput3 ?? "", + parse?.data?.exampleInput4 ?? "", + ].filter((input) => !!input); + + const deleteAvatar = parse.data.avatar === "null"; + + let hash; + if (parse.data.avatar && parse.data.avatar !== "null" && parse.data.avatar.size > 0) { + const dims = sizeof(Buffer.from(await parse.data.avatar.arrayBuffer())); + + if ((dims.height ?? 1000) > 512 || (dims.width ?? 1000) > 512) { + const errors = [{ field: "avatar", message: "Avatar too big" }]; + return fail(400, { error: true, errors }); + } + + const fileCursor = collections.bucket.find({ filename: assistant._id.toString() }); + + // Step 2: Delete the existing file if it exists + let fileId = await fileCursor.next(); + while (fileId) { + await collections.bucket.delete(fileId._id); + fileId = await fileCursor.next(); + } + + hash = await uploadAvatar(parse.data.avatar, assistant._id); + } else if (deleteAvatar) { + // delete the avatar + const fileCursor = collections.bucket.find({ filename: assistant._id.toString() }); + + let fileId = await fileCursor.next(); + while (fileId) { + await collections.bucket.delete(fileId._id); + fileId = await fileCursor.next(); + } + } + + const { acknowledged } = await collections.assistants.replaceOne( + { + _id: assistant._id, + }, + { + createdById: assistant?.createdById, + createdByName: locals.user?.username ?? locals.user?.name, + ...parse.data, + exampleInputs, + avatar: deleteAvatar ? undefined : hash ?? assistant.avatar, + createdAt: new Date(), + updatedAt: new Date(), + } + ); + + if (acknowledged) { + throw redirect(302, `${base}/settings/assistants/${assistant._id}`); + } else { + throw Error("Update failed"); + } + }, +}; diff --git a/src/routes/settings/assistants/[assistantId]/edit/+page.svelte b/src/routes/settings/assistants/[assistantId]/edit/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..682c29dfe02b840a5e8aff8067709e98ea46e285 --- /dev/null +++ b/src/routes/settings/assistants/[assistantId]/edit/+page.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + import type { PageData, ActionData } from "./$types"; + import { page } from "$app/stores"; + import AssistantSettings from "$lib/components/AssistantSettings.svelte"; + + export let data: PageData; + export let form: ActionData; + + $: assistant = data.assistants.find((el) => el._id.toString() === $page.params.assistantId); +</script> + +<AssistantSettings bind:form {assistant} models={data.models} /> diff --git a/src/routes/settings/assistants/new/+page.server.ts b/src/routes/settings/assistants/new/+page.server.ts new file mode 100644 index 0000000000000000000000000000000000000000..bdb077c761a62f0de97e57a67c45e29a71bc4e55 --- /dev/null +++ b/src/routes/settings/assistants/new/+page.server.ts @@ -0,0 +1,112 @@ +import { base } from "$app/paths"; +import { authCondition, requiresUser } from "$lib/server/auth"; +import { collections } from "$lib/server/database"; +import { fail, type Actions, redirect } from "@sveltejs/kit"; +import { ObjectId } from "mongodb"; + +import { z } from "zod"; +import sizeof from "image-size"; +import { sha256 } from "$lib/utils/sha256"; + +const newAsssistantSchema = z.object({ + name: z.string().min(1), + modelId: z.string().min(1), + preprompt: z.string().min(1), + description: z.string().optional(), + exampleInput1: z.string().optional(), + exampleInput2: z.string().optional(), + exampleInput3: z.string().optional(), + exampleInput4: z.string().optional(), + avatar: z.instanceof(File).optional(), +}); + +const uploadAvatar = async (avatar: File, assistantId: ObjectId): Promise<string> => { + const hash = await sha256(await avatar.text()); + const upload = collections.bucket.openUploadStream(`${assistantId.toString()}`, { + metadata: { type: avatar.type, hash }, + }); + + upload.write((await avatar.arrayBuffer()) as unknown as Buffer); + upload.end(); + + // only return the filename when upload throws a finish event or a 10s time out occurs + return new Promise((resolve, reject) => { + upload.once("finish", () => resolve(hash)); + upload.once("error", reject); + setTimeout(() => reject(new Error("Upload timed out")), 10000); + }); +}; + +export const actions: Actions = { + default: async ({ request, locals }) => { + const formData = Object.fromEntries(await request.formData()); + + const parse = newAsssistantSchema.safeParse(formData); + + if (!parse.success) { + // Loop through the errors array and create a custom errors array + const errors = parse.error.errors.map((error) => { + return { + field: error.path[0], + message: error.message, + }; + }); + + return fail(400, { error: true, errors }); + } + + // can only create assistants when logged in, IF login is setup + if (!locals.user && requiresUser) { + const errors = [{ field: "preprompt", message: "Must be logged in. Unauthorized" }]; + return fail(400, { error: true, errors }); + } + + const createdById = locals.user?._id ?? locals.sessionId; + + const newAssistantId = new ObjectId(); + + const exampleInputs: string[] = [ + parse?.data?.exampleInput1 ?? "", + parse?.data?.exampleInput2 ?? "", + parse?.data?.exampleInput3 ?? "", + parse?.data?.exampleInput4 ?? "", + ].filter((input) => !!input); + + let hash; + if (parse.data.avatar && parse.data.avatar.size > 0) { + const dims = sizeof(Buffer.from(await parse.data.avatar.arrayBuffer())); + + if ((dims.height ?? 1000) > 512 || (dims.width ?? 1000) > 512) { + const errors = [ + { + field: "avatar", + message: + "Avatar is too big. Please make sure the size of your avatar is no bigger than 512px by 512px.", + }, + ]; + return fail(400, { error: true, errors }); + } + + hash = await uploadAvatar(parse.data.avatar, newAssistantId); + } + + const { insertedId } = await collections.assistants.insertOne({ + _id: newAssistantId, + createdById, + createdByName: locals.user?.username ?? locals.user?.name, + ...parse.data, + exampleInputs, + avatar: hash, + createdAt: new Date(), + updatedAt: new Date(), + }); + + // add insertedId to user settings + + await collections.settings.updateOne(authCondition(locals), { + $push: { assistants: insertedId }, + }); + + throw redirect(302, `${base}/settings/assistants/${insertedId}`); + }, +}; diff --git a/src/routes/settings/assistants/new/+page.svelte b/src/routes/settings/assistants/new/+page.svelte new file mode 100644 index 0000000000000000000000000000000000000000..e77cbfd26919d846978df0eaf68c2c7d86df9bcd --- /dev/null +++ b/src/routes/settings/assistants/new/+page.svelte @@ -0,0 +1,9 @@ +<script lang="ts"> + import type { ActionData, PageData } from "./$types"; + import AssistantSettings from "$lib/components/AssistantSettings.svelte"; + + export let data: PageData; + export let form: ActionData; +</script> + +<AssistantSettings bind:form models={data.models} /> diff --git a/static/fonts/Inter-Black.ttf b/static/fonts/Inter-Black.ttf new file mode 100644 index 0000000000000000000000000000000000000000..b27822baea48062bf11617ce763e8d623c3a9769 Binary files /dev/null and b/static/fonts/Inter-Black.ttf differ diff --git a/static/fonts/Inter-Bold.ttf b/static/fonts/Inter-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..fe23eeb9c93a377d0f4ab003f1f77b555d19b1d1 Binary files /dev/null and b/static/fonts/Inter-Bold.ttf differ diff --git a/static/fonts/Inter-ExtraBold.ttf b/static/fonts/Inter-ExtraBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..874b1b0dd7c63f46240530a710ccd503d58d866d Binary files /dev/null and b/static/fonts/Inter-ExtraBold.ttf differ diff --git a/static/fonts/Inter-ExtraLight.ttf b/static/fonts/Inter-ExtraLight.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c993e82216cd9b62f025c441fb65b7b7ccf2f96e Binary files /dev/null and b/static/fonts/Inter-ExtraLight.ttf differ diff --git a/static/fonts/Inter-Light.ttf b/static/fonts/Inter-Light.ttf new file mode 100644 index 0000000000000000000000000000000000000000..71188f5cb2815547a5fb785f220910324ad8aa65 Binary files /dev/null and b/static/fonts/Inter-Light.ttf differ diff --git a/static/fonts/Inter-Medium.ttf b/static/fonts/Inter-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..a01f3777a6fc284b7a720c0f8248a27066389ef9 Binary files /dev/null and b/static/fonts/Inter-Medium.ttf differ diff --git a/static/fonts/Inter-Regular.ttf b/static/fonts/Inter-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5e4851f0ab7e0268da6ce903306e2f871ee19821 Binary files /dev/null and b/static/fonts/Inter-Regular.ttf differ diff --git a/static/fonts/Inter-SemiBold.ttf b/static/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ecc7041e23ed1a61574efe48e8bc42441a401d6e Binary files /dev/null and b/static/fonts/Inter-SemiBold.ttf differ diff --git a/static/fonts/Inter-Thin.ttf b/static/fonts/Inter-Thin.ttf new file mode 100644 index 0000000000000000000000000000000000000000..fe77243fc7ae5f860243c768fc7bc659839988a5 Binary files /dev/null and b/static/fonts/Inter-Thin.ttf differ