Assistants feature (#639)

* First push on assistants

* push fixes

* fix add assistant

* Sign up works

* lint

* mobile layout fixes

* design fixes

* Merge branch 'main' into feature/assistants

* fix copy button

* add error feedback

* hide duplicate feature

* remove wrong comments

* add autoredirect if assistant is missing

* latest changes:
- add edit feature
- hash assistant avatar
- get rid of ugly line
- check for non existent avatar
- make a better looking upload icon

* Update src/routes/conversation/+server.ts

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* reused type more cleanly

* fix type in shared conversation

* fixed feature

* fix: share conv with an assistant

* delete assistant avatars in db when deleting avatar

* affordance on avatar upload

* improve assistant conv start on mobile

* settings modal fly in

* better mobile intro

* mobile padding

* link affordance

* Make assistants disabled by default, but enabled in huggingchat

* lint

* Fix bottom model name

* ui tweaks

* Initial work on chat thumbnails

* fix build

* Get rid of deps

* Update src/routes/settings/assistants/[assistantId]/avatar/+server.ts

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* add comment to app_base

* Use event modifiers

* Use CSS uppercase instead everywhere

* Update src/lib/components/NavMenu.svelte

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* Update src/routes/+layout.server.ts

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* Clearer error message for avatar size check

* one less op on flag check

* revert back preventDefault change in LoginModal

* Update src/routes/settings/+layout.svelte

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* Update src/routes/+layout.server.ts

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* Update src/routes/+layout.server.ts

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* Added app logo in corner of thumbnail and clamped description length

* improved thumbnails

* Remove warnings

* Reuse Assisntants settings component (#678)

* Update Assisntants settings

* format

* [Assistants] Use textToImage task for avatar generation (#662)

* Generate assistants avatar using stablediffusion

* wording

* Update +page.server.ts

Co-authored-by: Michael Fried <mikelfried@gmail.com>

* Add timeout & controls to avatar generation

* Add controls for avatar generation in .env

* Update src/routes/+layout.server.ts

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* Update src/lib/components/AssistantSettings.svelte

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* Fix avatar gen feature flag

* Can only upload avatar if generate is unchecked

---------

Co-authored-by: Michael Fried <mikelfried@gmail.com>
Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>

* layout

* small fixes

* hint

* Show feature if login is not required

* lint

* Only show creator name if it's defined

* tweaks

* thumbnail update

* thumbnail font-size

* Always display model at the bottom

* Bottom links now go to settings

* fix lint

* silent release

* fix bg on share link

* [Assistant] Delete avatar button instead of reset (#725)

* Add rate-limited image generating endpoint

* Add generate avatar button

* add little padding for firefox focus ring

* format

* fix upload image bug

* Fix uploads, replace reset by delete

* left-align buttons

* rm avatar generation feature

* final changes to delete feature

* sys prompt min height

* padding

* Add object-cover everywhere

---------

Co-authored-by: Victor Mustar <victor.mustar@gmail.com>

---------

Co-authored-by: Mishig <mishig.davaadorj@coloradocollege.edu>
Co-authored-by: Victor Mustar <victor.mustar@gmail.com>
Co-authored-by: Michael Fried <mikelfried@gmail.com>
pull/731/head
Nathan Sarrazin 2024-01-24 17:30:02 +01:00 committed by GitHub
parent 13489e80b7
commit 992a8dece9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 1942 additions and 96 deletions

5
.env
View File

@ -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
# PUBLIC_APP_DISCLAIMER=1
ENABLE_ASSISTANTS=false #set to true to enable assistants feature

View File

@ -254,4 +254,5 @@ PUBLIC_GOOGLE_ANALYTICS_ID=G-8Q63TH4CSL
# ADDRESS_HEADER=X-Forwarded-For
# XFF_DEPTH=2
EXPOSE_API=false
ENABLE_ASSISTANTS=true
EXPOSE_API=false

View File

@ -2,7 +2,7 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll": true
"source.fixAll": "explicit"
},
"eslint.validate": ["javascript", "svelte"]
}

370
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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");
}

View File

@ -51,7 +51,6 @@
e.preventDefault();
window.open(window.location.href, "_blank");
}
$settings.ethicsModalAccepted = true;
}}
>

View File

@ -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}

View File

@ -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"];

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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);
});

View File

@ -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,

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
export interface ConvSidebar {
id: string;
title: string;
updatedAt: Date;
model?: string;
assistantId?: string;
avatarHash?: string;
}

View File

@ -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"];
}

View File

@ -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"];
}

View File

@ -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: [],
};

View File

@ -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"];
}

View File

@ -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)
);
};

View File

@ -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;
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, "");
}
const enableAssistants = ENABLE_ASSISTANTS === "true";
// 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(),
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: 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,

View File

@ -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"
>

View File

@ -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

View File

@ -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}`);
}
};

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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,

View File

@ -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,
};
};

View File

@ -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);

View File

@ -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;

View File

@ -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}

View File

@ -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);

View File

@ -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>

View File

@ -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`);
},
};

View File

@ -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>

View File

@ -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;
}

View File

@ -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",
},
});
};

View File

@ -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");
}
},
};

View File

@ -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} />

View File

@ -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}`);
},
};

View File

@ -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} />

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.