Merge pull request #152 from Webifi/main

Updated-UI, Continuous Chat, Setting Profiles, Streaming Response and more.
This commit is contained in:
Niek van der Maas 2023-06-09 11:36:42 +02:00 committed by GitHub
commit 1d24c7663c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 4500 additions and 762 deletions

2
.env
View File

@ -1,2 +1,4 @@
# Uncomment the following line to use the mocked API
#VITE_API_BASE=http://localhost:5174
#VITE_ENDPOINT_COMPLETIONS=/v1/chat/completions
#VITE_ENDPOINT_MODELS=/v1/models

View File

@ -12,4 +12,6 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.insertSpaces": true,
"editor.tabSize": 2,
}

259
package-lock.json generated
View File

@ -8,6 +8,9 @@
"name": "chatgpt-web",
"version": "0.0.0",
"devDependencies": {
"@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fullhuman/postcss-purgecss": "^5.0.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@rollup/plugin-dsv": "^3.0.2",
@ -22,16 +25,22 @@
"eslint-config-standard-with-typescript": "^34.0.1",
"eslint-plugin-svelte3": "^4.0.0",
"flourite": "^1.2.3",
"gpt-tokenizer": "^2.0.0",
"postcss": "^8.4.24",
"sass": "^1.61.0",
"stacking-order": "^2.0.0",
"svelte": "^3.58.0",
"svelte-check": "^3.4.3",
"svelte-fa": "^3.0.3",
"svelte-highlight": "^7.2.1",
"svelte-local-storage-store": "^0.4.0",
"svelte-markdown": "^0.2.3",
"svelte-modals": "^1.2.1",
"svelte-spa-router": "^3.3.0",
"svelte-use-click-outside": "^1.0.0",
"tslib": "^2.5.0",
"typescript": "^5.0.4",
"uuid": "^9.0.0",
"vite": "^4.3.9"
}
},
@ -438,15 +447,64 @@
}
},
"node_modules/@eslint/js": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz",
"integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==",
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.42.0.tgz",
"integrity": "sha512-6SWlXpWU5AvId8Ac7zjzmIOqMOba/JWY8XZ4A7q7Gn1Vlfg/SFFIlrtHXt9nPn4op9ZPAkl91Jao+QQv3r/ukw==",
"dev": true,
"peer": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
"dev": true,
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz",
"integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.0.tgz",
"integrity": "sha512-ZfycI7D0KWPZtf7wtMFnQxs8qjBXArRzczABuMQqecA/nXohquJ5J/RCR77PmY5qGWkxAZDxpnUFVXKwtY/jPw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz",
"integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fullhuman/postcss-purgecss": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@fullhuman/postcss-purgecss/-/postcss-purgecss-5.0.0.tgz",
@ -460,9 +518,9 @@
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.8",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
"integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==",
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz",
"integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==",
"dev": true,
"peer": true,
"dependencies": {
@ -838,9 +896,9 @@
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz",
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
"version": "7.0.12",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz",
"integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==",
"dev": true,
"peer": true
},
@ -877,16 +935,16 @@
"peer": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz",
"integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==",
"version": "5.59.9",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.9.tgz",
"integrity": "sha512-4uQIBq1ffXd2YvF7MAvehWKW3zVv/w+mSfRAu+8cKbfj3nwzyqJLNcZJpQ/WZ1HLbJDiowwmQ6NO+63nCA+fqA==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.59.6",
"@typescript-eslint/type-utils": "5.59.6",
"@typescript-eslint/utils": "5.59.6",
"@typescript-eslint/scope-manager": "5.59.9",
"@typescript-eslint/type-utils": "5.59.9",
"@typescript-eslint/utils": "5.59.9",
"debug": "^4.3.4",
"grapheme-splitter": "^1.0.4",
"ignore": "^5.2.0",
@ -912,14 +970,14 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz",
"integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==",
"version": "5.59.9",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.9.tgz",
"integrity": "sha512-FsPkRvBtcLQ/eVK1ivDiNYBjn3TGJdXy2fhXX+rc7czWl4ARwnpArwbihSOHI2Peg9WbtGHrbThfBUkZZGTtvQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.59.6",
"@typescript-eslint/types": "5.59.6",
"@typescript-eslint/typescript-estree": "5.59.6",
"@typescript-eslint/scope-manager": "5.59.9",
"@typescript-eslint/types": "5.59.9",
"@typescript-eslint/typescript-estree": "5.59.9",
"debug": "^4.3.4"
},
"engines": {
@ -939,13 +997,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz",
"integrity": "sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==",
"version": "5.59.9",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.9.tgz",
"integrity": "sha512-8RA+E+w78z1+2dzvK/tGZ2cpGigBZ58VMEHDZtpE1v+LLjzrYGc8mMaTONSxKyEkz3IuXFM0IqYiGHlCsmlZxQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "5.59.6",
"@typescript-eslint/visitor-keys": "5.59.6"
"@typescript-eslint/types": "5.59.9",
"@typescript-eslint/visitor-keys": "5.59.9"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@ -956,14 +1014,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz",
"integrity": "sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==",
"version": "5.59.9",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.9.tgz",
"integrity": "sha512-ksEsT0/mEHg9e3qZu98AlSrONAQtrSTljL3ow9CGej8eRo7pe+yaC/mvTjptp23Xo/xIf2mLZKC6KPv4Sji26Q==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "5.59.6",
"@typescript-eslint/utils": "5.59.6",
"@typescript-eslint/typescript-estree": "5.59.9",
"@typescript-eslint/utils": "5.59.9",
"debug": "^4.3.4",
"tsutils": "^3.21.0"
},
@ -984,9 +1042,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.6.tgz",
"integrity": "sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==",
"version": "5.59.9",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.9.tgz",
"integrity": "sha512-uW8H5NRgTVneSVTfiCVffBb8AbwWSKg7qcA4Ot3JI3MPCJGsB4Db4BhvAODIIYE5mNj7Q+VJkK7JxmRhk2Lyjw==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@ -997,13 +1055,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz",
"integrity": "sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==",
"version": "5.59.9",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.9.tgz",
"integrity": "sha512-pmM0/VQ7kUhd1QyIxgS+aRvMgw+ZljB3eDb+jYyp6d2bC0mQWLzUDF+DLwCTkQ3tlNyVsvZRXjFyV0LkU/aXjA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "5.59.6",
"@typescript-eslint/visitor-keys": "5.59.6",
"@typescript-eslint/types": "5.59.9",
"@typescript-eslint/visitor-keys": "5.59.9",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@ -1024,18 +1082,18 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.6.tgz",
"integrity": "sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==",
"version": "5.59.9",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.9.tgz",
"integrity": "sha512-1PuMYsju/38I5Ggblaeb98TOoUvjhRvLpLa1DoTOFaLWqaXl/1iQ1eGurTXgBY58NUdtfTXKP5xBq7q9NDaLKg==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@types/json-schema": "^7.0.9",
"@types/semver": "^7.3.12",
"@typescript-eslint/scope-manager": "5.59.6",
"@typescript-eslint/types": "5.59.6",
"@typescript-eslint/typescript-estree": "5.59.6",
"@typescript-eslint/scope-manager": "5.59.9",
"@typescript-eslint/types": "5.59.9",
"@typescript-eslint/typescript-estree": "5.59.9",
"eslint-scope": "^5.1.1",
"semver": "^7.3.7"
},
@ -1051,12 +1109,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "5.59.6",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz",
"integrity": "sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==",
"version": "5.59.9",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.9.tgz",
"integrity": "sha512-bT7s0td97KMaLwpEBckbzj/YohnvXtqbe2XgqNvTl6RJVakY5mvENOTPvw5u66nljfZxthESpDozs86U+oLY8Q==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "5.59.6",
"@typescript-eslint/types": "5.59.9",
"eslint-visitor-keys": "^3.3.0"
},
"engines": {
@ -1717,17 +1775,17 @@
}
},
"node_modules/eslint": {
"version": "8.41.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.41.0.tgz",
"integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==",
"version": "8.42.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.42.0.tgz",
"integrity": "sha512-ulg9Ms6E1WPf67PHaEY4/6E2tEn5/f7FXGzr3t9cBMugOmf1INYvuUwwh1aXQN4MfJ6a5K2iNwP3w4AColvI9A==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.4.0",
"@eslint/eslintrc": "^2.0.3",
"@eslint/js": "8.41.0",
"@humanwhocodes/config-array": "^0.11.8",
"@eslint/js": "8.42.0",
"@humanwhocodes/config-array": "^0.11.10",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"ajv": "^6.10.0",
@ -2544,6 +2602,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gpt-tokenizer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-2.1.1.tgz",
"integrity": "sha512-WlX+vj6aPaZ71U6Bf18fem+5k58zlgh2a4nbc7KHy6aGVIyq3nCh709b/8momu34sV/5t/SpzWi8LayWD9uyDw==",
"dev": true,
"dependencies": {
"rfc4648": "^1.5.2"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -3642,6 +3709,12 @@
"node": ">=0.10.0"
}
},
"node_modules/rfc4648": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.2.tgz",
"integrity": "sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg==",
"dev": true
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@ -3680,9 +3753,9 @@
}
},
"node_modules/rollup": {
"version": "3.23.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.23.0.tgz",
"integrity": "sha512-h31UlwEi7FHihLe1zbk+3Q7z1k/84rb9BSwmBSr/XjOCEaBJ2YyedQDuM0t/kfOS0IxM+vk1/zI9XxYj9V+NJQ==",
"version": "3.24.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.24.0.tgz",
"integrity": "sha512-OgraHOIg2YpHQTjl0/ymWfFNBEyPucB7lmhXrQUh38qNOegxLapSPFs9sNr0qKR75awW41D93XafoR2QfhBdUQ==",
"dev": true,
"bin": {
"rollup": "dist/bin/rollup"
@ -3802,9 +3875,9 @@
}
},
"node_modules/sass": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.62.1.tgz",
"integrity": "sha512-NHpxIzN29MXvWiuswfc1W3I0N8SXBd8UR26WntmDlRYf0bSADnwnOjsyMZ3lMezSlArD33Vs3YFhp7dWvL770A==",
"version": "1.63.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.63.2.tgz",
"integrity": "sha512-u56TU0AIFqMtauKl/OJ1AeFsXqRHkgO7nCWmHaDwfxDo9GUMSqBA4NEh6GMuh1CYVM7zuROYtZrHzPc2ixK+ww==",
"dev": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
@ -3904,6 +3977,12 @@
"node": ">=0.10.0"
}
},
"node_modules/stacking-order": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/stacking-order/-/stacking-order-2.0.0.tgz",
"integrity": "sha512-nnv68iFGwrKXYlmXJKD5qBuH8D49BEv6zAgesXoKeGqMmMit6/Hyvb6R0BG9odpjqQm35YjlTsZUyB0ffbFDrg==",
"dev": true
},
"node_modules/string.prototype.trim": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz",
@ -4057,6 +4136,12 @@
"svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0"
}
},
"node_modules/svelte-fa": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/svelte-fa/-/svelte-fa-3.0.4.tgz",
"integrity": "sha512-y04vEuAoV1wwVDItSCzPW7lzT6v1bj/y1p+W1V+NtIMpQ+8hj8MBkx7JFD7JHSnalPU1QiI8BVfguqheEA3nPg==",
"dev": true
},
"node_modules/svelte-highlight": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/svelte-highlight/-/svelte-highlight-7.3.0.tgz",
@ -4067,15 +4152,15 @@
}
},
"node_modules/svelte-hmr": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.1.tgz",
"integrity": "sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==",
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.2.tgz",
"integrity": "sha512-q/bAruCvFLwvNbeE1x3n37TYFb3mTBJ6TrCq6p2CoFbSTNhDE9oAtEfpy+wmc9So8AG0Tja+X0/mJzX9tSfvIg==",
"dev": true,
"engines": {
"node": "^12.20 || ^14.13.1 || >= 16"
},
"peerDependencies": {
"svelte": ">=3.19.0"
"svelte": "^3.19.0 || ^4.0.0-next.0"
}
},
"node_modules/svelte-local-storage-store": {
@ -4109,10 +4194,19 @@
"integrity": "sha512-vSSbKZFbNktrQ15v7o1EaH78EbWV+sPQbPjHG+Cp8CaNcPFUEfjZ0Iml/V0bFDwsTlYe8o6XC5Hfdp91cqPV2g==",
"dev": true
},
"node_modules/svelte-modals": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/svelte-modals/-/svelte-modals-1.2.1.tgz",
"integrity": "sha512-7MEKUx5wb5YppkXWFGeRlYM5FMGEnpix39u9Y6GtCNTMXRDZ7DB2Z50IYLMRTMW5lOsCdtJgFbB0E3iZMKmsAA==",
"dev": true,
"peerDependencies": {
"svelte": "^3.0.0"
}
},
"node_modules/svelte-preprocess": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.3.tgz",
"integrity": "sha512-GrHF1rusdJVbOZOwgPWtpqmaexkydznKzy5qIC2FabgpFyKN57bjMUUUqPRfbBXK5igiEWn1uO/DXsa2vJ5VHA==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.4.tgz",
"integrity": "sha512-ABia2QegosxOGsVlsSBJvoWeXy1wUKSfF7SWJdTjLAbx/Y3SrVevvvbFNQqrSJw89+lNSsM58SipmZJ5SRi5iw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@ -4135,7 +4229,7 @@
"sass": "^1.26.8",
"stylus": "^0.55.0",
"sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0",
"svelte": "^3.23.0",
"svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0",
"typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
@ -4195,6 +4289,12 @@
"url": "https://github.com/sponsors/ItalyPaleAle"
}
},
"node_modules/svelte-use-click-outside": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svelte-use-click-outside/-/svelte-use-click-outside-1.0.0.tgz",
"integrity": "sha512-tOWeMPxeIoW9RshS0WbogRhdYdbxcJV0ebkzSh1lwR7Ihl0hSZMmB4YyCHHoXJK4xcbxCCFh0AnQ1vkzGZfLVQ==",
"dev": true
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -4243,9 +4343,9 @@
}
},
"node_modules/tslib": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz",
"integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==",
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz",
"integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==",
"dev": true
},
"node_modules/tsutils": {
@ -4311,16 +4411,16 @@
}
},
"node_modules/typescript": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz",
"integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==",
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz",
"integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=12.20"
"node": ">=14.17"
}
},
"node_modules/unbox-primitive": {
@ -4355,6 +4455,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"dev": true,
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vite": {
"version": "4.3.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.3.9.tgz",

View File

@ -14,6 +14,9 @@
"lint": "eslint . --fix"
},
"devDependencies": {
"@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fullhuman/postcss-purgecss": "^5.0.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@rollup/plugin-dsv": "^3.0.2",
@ -28,16 +31,22 @@
"eslint-config-standard-with-typescript": "^34.0.1",
"eslint-plugin-svelte3": "^4.0.0",
"flourite": "^1.2.3",
"gpt-tokenizer": "^2.0.0",
"postcss": "^8.4.24",
"sass": "^1.61.0",
"stacking-order": "^2.0.0",
"svelte": "^3.58.0",
"svelte-check": "^3.4.3",
"svelte-fa": "^3.0.3",
"svelte-highlight": "^7.2.1",
"svelte-local-storage-store": "^0.4.0",
"svelte-markdown": "^0.2.3",
"svelte-modals": "^1.2.1",
"svelte-spa-router": "^3.3.0",
"svelte-use-click-outside": "^1.0.0",
"tslib": "^2.5.0",
"typescript": "^5.0.4",
"uuid": "^9.0.0",
"vite": "^4.3.9"
}
}

View File

@ -1,21 +1,15 @@
<script lang="ts">
import Router, { location, querystring, replace } from 'svelte-spa-router'
import Router, { location, replace } from 'svelte-spa-router'
import { wrap } from 'svelte-spa-router/wrap'
import Navbar from './lib/Navbar.svelte'
import Sidebar from './lib/Sidebar.svelte'
import Footer from './lib/Footer.svelte'
import Home from './lib/Home.svelte'
import Chat from './lib/Chat.svelte'
import NewChat from './lib/NewChat.svelte'
import { chatsStorage, apiKeyStorage } from './lib/Storage.svelte'
// Check if the API key is passed in as a "key" query parameter - if so, save it
// Example: https://niek.github.io/chatgpt-web/#/?key=sk-...
const urlParams: URLSearchParams = new URLSearchParams($querystring)
if (urlParams.has('key')) {
apiKeyStorage.set(urlParams.get('key') as string)
}
import { Modals, closeModal } from 'svelte-modals'
import { dispatchModalEsc, checkModalEsc } from './lib/Util.svelte'
// The definition of the routes with some conditions
const routes = {
@ -37,23 +31,46 @@
'*': Home
}
const onLocationChange = (...args:any) => {
// close all modals on route change
dispatchModalEsc()
}
$: onLocationChange($location)
</script>
<Navbar />
<section class="section">
<div class="container is-fullhd">
<div class="columns">
<div class="column is-one-fifth">
<div class="side-bar-column">
<Sidebar />
</div>
<div class="column is-four-fifths" id="content">
</div>
<div class="main-content-column" id="content">
{#key $location}
<Router {routes} on:conditionsFailed={() => replace('/')}/>
{/key}
</div>
</div>
</div>
</section>
</div>
<Footer />
<Modals>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
slot="backdrop"
class="backdrop"
on:click={closeModal}
/>
</Modals>
<svelte:window
on:keydown={(e) => checkModalEsc(e)}
/>
<style>
.backdrop {
position: fixed;
top: 0;
bottom: 0;
right: 0;
left: 0;
background: transparent
}
</style>

6
src/additional-svelte-typings.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare namespace svelteHTML {
interface HTMLAttributes<> {
// Custom on:modal-esc event
'on:modal-esc'?: (event: any) => any
}
}

View File

@ -1,3 +1,107 @@
html {
/* Scrollbar */
/* TODO: Update these to use bulma's scss variables, not css vars. */
--scrollbarBG: transparent;
--thumbBG: hsl(0, 0%, 60%); /* scollbar color light */
/* Back-ground */
--BgColorDark: hsl(228, 10%, 10%);
--BgColorLight: hsl(0, 0%, 100%);
// --BgColorSidebarDark: rgb(28, 30, 36);
--BgColorSidebarDark: rgb(16, 17, 22);
--BgColorSidebarLight: hsla(0, 0%, 93%, 0.354);
// Tool drawer for messages
--chatToolDrawerSize: 40px;
--chatToolDrawerColor: var(--BgColorSidebarLight);
/* Sizes */
--sidebarTop: 0px;
--sidebarWidth: max(300px, 20%);
--mainContentWidth: calc(100% - var(--sidebarWidth));
--sectionPaddingTop: 0px;
--chatContentPaddingTop: 20px;
--chatContentPaddingRight: 40px;
--chatContentPaddingBottom: 110px;
--chatContentPaddingLeft: 40px;
--runningTotalLineHeight: 28px;
--chatInputPaddingTop: 0px;
--chatInputPaddingRight: 60px;
--chatInputPaddingBottom: 10px;
--chatInputPaddingLeft: 60px;
--BgColor: var(-BgColorLight);
--running-totals: 0;
}
.navbar {
display: none !important;
}
@media (prefers-color-scheme: dark) {
html {
--thumbBG: #3f3f3f; /* scrollbar color dark */
--BgColor: var(-BgColorDark);
--chatToolDrawerColor: var(--BgColorSidebarDark);
}
}
@media only screen and (max-width: 900px) {
html {
--sidebarWidth: max(250px, 20%);
--chatContentPaddingTop: 50px;
--chatContentPaddingRight: 20px;
--chatContentPaddingLeft: 20px;
--chatInputPaddingRight: 30px;
--chatInputPaddingLeft: 30px;
}
}
@media screen and (min-width: 1024px) {
.modal-card.wide {
width: 960px !important;
}
}
@media only screen and (max-width: 900px) and (min-width: 769px) {
.chat-menu-item .chat-icon {
display: none;
}
}
@media only screen and (max-width: 768px) {
.navbar {
display: block !important;
}
.main-menu {
z-index: 50;
}
html {
--BgColorSidebarLight: hsl(210, 12%, 97%);
// --BgColorSidebarDark: rgb(22, 24, 30);
--sidebarWidth: max(300px, 20%);
--mainContentWidth: calc(100%);
--sidebarTop: 56px;
--sectionPaddingTop: 56px;
--chatInputPaddingRight: 20px;
--chatInputPaddingLeft: 20px;
}
.main-menu .menu-nav-bar {
display: none;
}
.main-menu .level-right {
display: flex;
}
.main-menu .level-item {
margin-bottom: inherit !important;
}
}
@keyframes rotating {
from {
transform: rotate(0deg);
@ -13,22 +117,22 @@
flex-direction: column;
flex-grow: 1;
min-height: 100vh;
}
section.section {
flex-grow: 1;
}
select option.is-default {
background-color: #0842e058;
}
.is-disabled {
pointer-events: none;
cursor: default;
opacity: .50;
}
.rotate {
animation: rotating 10s linear infinite;
}
a.is-disabled {
pointer-events: none;
cursor: default;
opacity: 0.5;
}
.greyscale {
filter: grayscale(100%);
}
@ -45,12 +149,6 @@ a.is-disabled {
width: fit-content;
}
/* Show the edit button on hover of the user message */
.user-message:hover .editbutton {
/* TODO: add when ready: display: block !important; */
text-decoration: none !important;
}
/* Swap the border on user messages to the other side */
.user-message>.message-body {
border-width: 0 4px 0 0 !important;
@ -68,9 +166,9 @@ a.is-disabled {
resize: vertical;
}
$footer-padding: 3rem 1.5rem;
$fullhd: 2000px;
$modal-content-width: 1000px;
// $footer-padding: 1.5rem 1.5rem;
// $fullhd: 2000px;
// $modal-content-width: 1000px;
@import "/node_modules/bulma/bulma.sass";
@ -100,9 +198,20 @@ $modal-content-width: 1000px;
$modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove this once a new version of bulma-prefers-dark is released
@import "/node_modules/bulma-prefers-dark/build/bulma-prefers-dark.sass";
.modal-card-body {
/* For the message notes on light mode */
.message-note, .running-totals {
opacity: 0.7;
}
@media (prefers-color-scheme: dark) {
/* For the message notes on dark mode */
.message-note, .running-totals {
opacity: 0.5;
}
.modal-card-body {
// remove this once https: //github.com/jloh/bulma-prefers-dark/pull/90 is merged and released
background-color: $background-dark;
}
}
/* Support for copy code button */
@ -119,6 +228,7 @@ $modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove t
}
/* Delete button on side menu chat name */
.menu-list {
a:hover {
.delete-button {
@ -126,8 +236,17 @@ $modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove t
background-color: initial;
}
}
.delete-button {
opacity: .8;
}
.delete-button:hover {
opacity: 1;
}
}
/* Loading chat messages */
.is-loading {
opacity: 0.5;
@ -135,14 +254,425 @@ $modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove t
width: 1.5rem;
height: 1.5rem;
border-width: 0.25em;
display: inline-block;
}
/* Support for fullwidth dropdowns, see https://github.com/jgthms/bulma/issues/2055 */
.dropdown.is-fullwidth {
display: flex;
.dropdown-trigger,
.dropdown-menu {
.dropdown-trigger, .dropdown-menu {
width: 100%;
}
}
/* Bulma layout hacks */
.chat-option-menu.navbar-item {
margin-left: auto;
}
/* temp. fix to keep navbar from getting huge on small screen devices
if the right menu is put in the proper navbar-end container */
.navbar-brand {
/* margin-right: 0; */
width: 100%;
}
.dropdown-item .menu-icon {
padding-right: .5em;
}
.dropdown-menu .dropdown-content {
max-height: calc(100vh - 60px);
overflow-y: auto;
}
.modal-card .dropdown-menu .dropdown-content {
max-height: calc(100vh - 80px);
}
.modal-card {
overflow: visible;
}
@media only screen and (max-width: 768px) {
.main-menu .dropdown-menu .dropdown-content {
max-height: calc(100vh - 112px);
}
.main-menu {
display: none;
}
.main-menu.pinned {
display: block;
}
}
.chat-menu-item {
position: relative;
}
.chat-menu-item .chat-item-name {
display: block;
white-space:nowrap;
overflow: hidden;
-webkit-mask-image: linear-gradient(to right, rgba(0,0,0,1) 75%, rgba(0,0,0,0));
mask-image: linear-gradient(to right, rgba(0,0,0,1) 75%, rgba(0,0,0,0));
}
.chat-menu-item .delete-button {
position: absolute;
right: .4em;
z-index: 200;
}
/* Overrides for main layout */
.side-bar-column {
width: var(--sidebarWidth);
}
.main-content-column {
margin-left: auto;
margin-right: 0px;
width: var(--mainContentWidth);
padding-top: var(--sectionPaddingTop);
position: relative;
}
aside.menu.main-menu {
z-index:50;
position: fixed;
width: var(--sidebarWidth);
padding-right: 20px;
top: var(--sidebarTop);
bottom:0px;
}
aside.menu.main-menu .menu-expanse {
display: flex;
flex-flow: column;
height: 100%;
background-color: var(--BgColorSidebarLight);
box-shadow: 5px 0px 0px var(--BgColorSidebarLight);
}
.menu-expanse
.menu-label, .menu-expanse
.menu-list {
flex: 0 1 auto;
}
.menu-expanse
.menu-expansion-list {
flex: 1 1 auto;
overflow-y: auto;
}
.default-text {
color: hsl(0, 0%, 21%) !important;
}
.lower-mask, .lower-mask2 {
z-index: 1;
display: block;
position: fixed;
bottom: 0px;
height: calc(var(--chatContentPaddingBottom) + var(--runningTotalLineHeight) * var(--running-totals));
width: 100%;
background-image: linear-gradient(180deg,hsla(0,0%,100%,0) 13.94%, var(--BgColorLight) 54.73%);
}
.lower-mask2 {
display: none;
}
.lower-mask2.strong-mask {
display: block;
}
@media (prefers-color-scheme: dark) {
.default-text {
color: rgb(181, 181, 181) !important;
}
.lower-mask, .lower-mask2 {
background-image: linear-gradient(180deg,hsla(0,0%,100%,0) 13.94%, var(--BgColorDark) 54.73%);
}
aside.menu.main-menu .menu-expanse {
background-color: var(--BgColorSidebarDark);
box-shadow: 5px 0px 0px var(--BgColorSidebarDark);
}
}
*::-webkit-scrollbar {
width: 11px;
}
*::-webkit-scrollbar-track {
background: var(--scrollbarBG);
}
*::-webkit-scrollbar-thumb {
background-color: var(--thumbBG) ;
border-radius: 6px;
border: 3px solid var(--scrollbarBG);
}
* {
scrollbar-width: thin;
scrollbar-color: var(--thumbBG) var(--scrollbarBG);
}
.chat-content {
padding:
var(--chatContentPaddingTop)
var(--chatContentPaddingRight)
calc(var(--chatContentPaddingBottom) + var(--runningTotalLineHeight) * var(--running-totals))
var(--chatContentPaddingLeft) ;
}
.section:has(+ .pin-footer) {
padding-bottom: var(--chatContentPaddingBottom);
}
.pin-footer {
z-index:2;
position: fixed;
bottom: 0px;
width: var(--mainContentWidth);
}
.prompt-input-container {
z-index:2;
position: fixed;
bottom: 0px;
width: var(--mainContentWidth);
padding:
var(--chatInputPaddingTop)
var(--chatInputPaddingRight)
var(--chatInputPaddingBottom)
var(--chatInputPaddingLeft);
.control.send .button {
width: 60px;
}
}
@media only screen and (max-width: 768px) {
.prompt-input-container {
.control.send .button {
width: auto;
}
.control.settings {
display: none;
}
}
}
@media only screen and (max-width: 340px) {
.section-footer {
.author {
display: none;
}
}
}
.content.running-total-container {
min-height:1em;
// padding-bottom:.6em;
// /* padding-left: 1.9em; */
margin-bottom: .5em;
}
.content.credit-footer {
margin-bottom: 1em;
}
.side-actions {
margin: 5px;
}
.main-menu .menu-nav-bar .gpt-logo .icon {
display: inline-block;
margin-top: 8px;
margin-left: 8px;
}
.main-menu .menu-nav-bar .chat-option-menu {
padding-right: 2px;
}
@media only screen and (max-width: 900px) {
.main-menu .dropdown.is-right .dropdown-menu {
right:auto;
left:0;
}
}
.message-body {
z-index: 1;
}
.message-note {
padding-top: .6em;
margin-bottom: -0.6em;
}
.message-edit {
display: block;
}
.message-editor {
white-space: pre-wrap;
min-width: 60px;
min-height: 30px;
}
.message-display {
min-width: 60px;
min-height: 1.3em;
}
.button-pack .button {
display: block;
margin: 4px;
// border-radius: 10px;
opacity: .6;
}
.button-pack .button:hover {
opacity: 1;
}
.assistant-message .button-pack {
right: auto;
left: -20px;
}
.chat-message.message {
position: relative;
transition: 0.3s;
}
// .chat-message.message:hover .button-pack, article.message:focus .button-pack {
// display: block;
// }
.chat-message.summarized .message-body, .chat-message.suppress .message-body {
opacity: 0.4;
}
.tool-drawer, .tool-drawer-mask {
z-index: 1;
position: absolute;
visibility: hidden;
width: 0%;
top: 0px;
min-height: 100%;
max-height: 100%;
overflow: hidden;
margin: 0px;
padding: 0px;
transition: 0.1s;
background-color: var(--chatToolDrawerColor);
border-radius: 4px;
}
.tool-drawer-mask {
border-radius: 0px 4px 4px 0px;
}
.user-message .tool-drawer-mask {
border-radius: 4px 0px 0px 4px;
}
.message:last-of-type .tool-drawer, .tool-drawer-mask {
top: auto;
bottom: 0px;
}
.assistant-message .tool-drawer, .assistant-message .tool-drawer-mask {
left:100%;
}
.user-message .tool-drawer, .user-message .tool-drawer-mask {
right:100%;
}
.assistant-message:hover .tool-drawer,
.assistant-message.editing .tool-drawer {
width: var(--chatToolDrawerSize);
visibility: visible;
max-height: 300%;
}
.user-message:hover .tool-drawer,
.user-message.editing .tool-drawer {
width: var(--chatToolDrawerSize);
visibility: visible;
max-height: 300%;
}
.assistant-message:hover .tool-drawer-mask,
.assistant-message.editing .tool-drawer {
width: var(--chatToolDrawerSize);
visibility: visible;
}
.user-message:hover .tool-drawer-mask,
.user-message.editing .tool-drawer {
width: var(--chatToolDrawerSize);
visibility: visible;
}
.assistant-message:hover, .assistant-message.editing {
border-top-right-radius: 0px !important;
border-bottom-right-radius: 0px !important;
}
.user-message:hover, .user-message.editing {
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
}
.message.streaming .tool-drawer, .message.streaming .tool-drawer-mask {
display: none;
}
@keyframes cursor-blink {
0% {
opacity: 0;
}
}
.message.streaming .message-display p:last-of-type::after {
position: relative;
content: '';
animation: cursor-blink 1s steps(2) infinite;
}
.message:last-of-type.incomplete .message-display p:last-of-type::after {
position: relative;
content: '...';
margin-left: 4px;
font-weight: bold;
animation: cursor-blink 1s steps(2) infinite;
}
.message.incomplete .tool-drawer .msg-incomplete {
display: none;
}
.message:last-of-type.incomplete .tool-drawer .msg-incomplete {
display: block;
}
.modal {
z-index:100;
}
.modal-card footer {
justify-content: space-between;
}
.modal-card footer .level {
width: 100%;
}
.modal-card header, .modal-card footer, .modal-card .notification {
padding: .8em;
}
.modal-card .notification {
margin-left: -.5em;
margin-right: -.5em;
}
.message-footer {
padding: $message-header-padding;
}
.modal .message-body {
overflow-y: auto;max-height: calc(100vh - 150px);
}
.modal .modal-content.nomax {
max-height: none;
}
.modal.chat-settings .field-body {
max-width: calc(100% - 40px);
}

10
src/lib/ApiUtil.svelte Normal file
View File

@ -0,0 +1,10 @@
<script context="module" lang="ts">
// This makes it possible to override the OpenAI API base URL in the .env file
const apiBase = import.meta.env.VITE_API_BASE || 'https://api.openai.com'
const endpointCompletions = import.meta.env.VITE_ENDPOINT_COMPLETIONS || '/v1/chat/completions'
const endpointModels = import.meta.env.VITE_ENDPOINT_MODELS || '/v1/models'
export const getApiBase = ():string => apiBase
export const getEndpointCompletions = ():string => endpointCompletions
export const getEndpointModels = ():string => endpointModels
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,209 @@
<script context="module" lang="ts">
// TODO: Integrate API calls
import { addMessage, getLatestKnownModel, saveChatStore, setLatestKnownModel, subtractRunningTotal, updateRunningTotal } from './Storage.svelte'
import type { Chat, ChatCompletionOpts, Message, Model, Response, Usage } from './Types.svelte'
import { encode } from 'gpt-tokenizer'
import { v4 as uuidv4 } from 'uuid'
export class ChatCompletionResponse {
constructor (opts: ChatCompletionOpts) {
this.opts = opts
this.chat = opts.chat
this.messages = []
if (opts.fillMessage) {
this.messages.push(opts.fillMessage)
this.offsetTotals = opts.fillMessage.usage && JSON.parse(JSON.stringify(opts.fillMessage.usage))
this.isFill = true
}
if (opts.onMessageChange) this.messageChangeListeners.push(opts.onMessageChange)
}
private offsetTotals: Usage
private isFill: boolean = false
private didFill: boolean = false
private opts: ChatCompletionOpts
private chat: Chat
private messages: Message[]
private error: string
private model: Model
private lastModel: Model
private setModel = (model: Model) => {
if (!model) return
!this.model && setLatestKnownModel(this.chat.settings.model as Model, model)
this.lastModel = this.model || model
this.model = model
}
private finishResolver: (value: Message[]) => void
private errorResolver: (error: string) => void
private finishPromise = new Promise<Message[]>((resolve, reject) => {
this.finishResolver = resolve
this.errorResolver = reject
})
private promptTokenCount:number
private finished = false
private messageChangeListeners: ((m: Message[]) => void)[] = []
private finishListeners: ((m: Message[]) => void)[] = []
setPromptTokenCount (tokens:number) {
this.promptTokenCount = tokens
}
updateFromSyncResponse (response: Response) {
this.setModel(response.model)
response.choices.forEach((choice, i) => {
const exitingMessage = this.messages[i]
const message = exitingMessage || choice.message
if (exitingMessage) {
if (!this.didFill && this.isFill && choice.message.content.match(/^'(t|ll|ve|m|d|re)[^a-z]/i)) {
// deal with merging contractions since we've added an extra space to your fill message
message.content.replace(/ $/, '')
}
this.didFill = true
message.content += choice.message.content
message.usage = message.usage || {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
} as Usage
message.usage.completion_tokens += response.usage.completion_tokens
message.usage.prompt_tokens = response.usage.prompt_tokens + (this.offsetTotals?.prompt_tokens || 0)
message.usage.total_tokens = response.usage.total_tokens + (this.offsetTotals?.total_tokens || 0)
} else {
message.content = choice.message.content
message.usage = response.usage
}
message.finish_reason = choice.finish_reason
message.role = choice.message.role
message.model = response.model
this.messages[i] = message
if (this.opts.autoAddMessages) addMessage(this.chat.id, message)
})
this.notifyMessageChange()
this.finish()
}
updateFromAsyncResponse (response: Response) {
let completionTokenCount = 0
this.setModel(response.model)
response.choices.forEach((choice, i) => {
const message = this.messages[i] || {
role: 'assistant',
content: '',
uuid: uuidv4()
} as Message
choice.delta?.role && (message.role = choice.delta.role)
if (choice.delta?.content) {
if (!this.didFill && this.isFill && choice.delta.content.match(/^'(t|ll|ve|m|d|re)[^a-z]/i)) {
// deal with merging contractions since we've added an extra space to your fill message
message.content.replace(/([a-z]) $/i, '$1')
}
this.didFill = true
message.content += choice.delta.content
}
completionTokenCount += encode(message.content).length
message.model = response.model
message.finish_reason = choice.finish_reason
message.streaming = choice.finish_reason === null && !this.finished
this.messages[i] = message
})
// total up the tokens
const promptTokens = this.promptTokenCount + (this.offsetTotals?.prompt_tokens || 0)
const totalTokens = promptTokens + completionTokenCount
this.messages.forEach(m => {
m.usage = {
completion_tokens: completionTokenCount,
total_tokens: totalTokens,
prompt_tokens: promptTokens
} as Usage
if (this.opts.autoAddMessages) addMessage(this.chat.id, m)
})
const finished = !this.messages.find(m => m.streaming)
this.notifyMessageChange()
if (finished) this.finish()
}
updateFromError (errorMessage: string): void {
if (this.finished || this.error) return
this.error = errorMessage
if (this.opts.autoAddMessages) {
addMessage(this.chat.id, {
role: 'error',
content: `Error: ${errorMessage}`,
uuid: uuidv4()
} as Message)
}
this.notifyMessageChange()
setTimeout(() => this.finish(), 250) // give others a chance to signal the finish first
}
updateFromClose (): void {
setTimeout(() => this.finish(), 250) // give others a chance to signal the finish first
}
onMessageChange = (listener: (m: Message[]) => void): number =>
this.messageChangeListeners.push(listener)
onFinish = (listener: (m: Message[]) => void): number =>
this.finishListeners.push(listener)
promiseToFinish = (): Promise<Message[]> => this.finishPromise
hasFinished = (): boolean => this.finished
getError = (): string => this.error
hasError = (): boolean => !!this.error
getMessages = (): Message[] => this.messages
private notifyMessageChange (): void {
this.messageChangeListeners.forEach((listener) => {
listener(this.messages)
})
}
private notifyFinish (): void {
this.finishListeners.forEach((listener) => {
listener(this.messages)
})
}
private finish = (): void => {
if (this.finished) return
this.finished = true
this.messages.forEach(m => { m.streaming = false }) // make sure all are marked stopped
saveChatStore()
const message = this.messages[0]
const model = this.model || getLatestKnownModel(this.chat.settings.model as Model)
if (message) {
if (this.isFill && this.lastModel === this.model && this.offsetTotals && model && message.usage) {
// Need to subtract some previous message totals before we add new combined message totals
subtractRunningTotal(this.chat.id, this.offsetTotals, model)
}
updateRunningTotal(this.chat.id, message.usage as Usage, model)
} else if (this.model) {
// If no messages it's probably because of an error or user initiated abort.
// this.model is set when we received a valid response. If we've made it that
// far, we'll assume we've been charged for the prompts sent.
// This could under-count in some cases.
const usage:Usage = {
prompt_tokens: this.promptTokenCount,
completion_tokens: 0, // We have no idea if there are any to count
total_tokens: this.promptTokenCount
}
updateRunningTotal(this.chat.id, usage as Usage, model)
}
this.notifyFinish()
if (this.error) {
this.errorResolver(this.error)
} else {
this.finishResolver(this.messages)
}
}
}
</script>

View File

@ -0,0 +1,49 @@
<script lang="ts">
import { replace } from 'svelte-spa-router'
import type { Chat } from './Types.svelte'
import { apiKeyStorage, deleteChat, pinMainMenu } from './Storage.svelte'
import Fa from 'svelte-fa/src/fa.svelte'
import { faTrash, faCircleCheck } from '@fortawesome/free-solid-svg-icons/index'
import { faMessage } from '@fortawesome/free-regular-svg-icons/index'
export let chat:Chat
export let activeChatId:number|undefined
export let prevChat:Chat|undefined
export let nextChat:Chat|undefined
let waitingForConfirm:any = 0
function delChat () {
if (!waitingForConfirm) {
// wait a second for another click to avoid accidental deletes
waitingForConfirm = setTimeout(() => { waitingForConfirm = 0 }, 1000)
return
}
clearTimeout(waitingForConfirm)
waitingForConfirm = 0
if (activeChatId === chat.id) {
const newChat = nextChat || prevChat
if (!newChat) {
// No other chats, clear all and go to home
replace('/').then(() => { deleteChat(chat.id) })
} else {
// Delete the current chat and go to the max chatId
replace(`/chat/${newChat.id}`).then(() => { deleteChat(chat.id) })
}
} else {
deleteChat(chat.id)
}
}
</script>
<li>
<a class="chat-menu-item" href={`#/chat/${chat.id}`} on:click={() => { $pinMainMenu = false }} class:is-waiting={waitingForConfirm} class:is-disabled={!$apiKeyStorage} class:is-active={activeChatId === chat.id}>
{#if waitingForConfirm}
<a class="is-pulled-right is-hidden px-1 py-0 has-text-weight-bold delete-button" href={'$'} on:click|preventDefault={() => delChat()}><Fa icon={faCircleCheck} /></a>
{:else}
<a class="is-pulled-right is-hidden px-1 py-0 has-text-weight-bold delete-button" href={'$'} on:click|preventDefault={() => delChat()}><Fa icon={faTrash} /></a>
{/if}
<span class="chat-item-name"><Fa class="mr-2 chat-icon" size="xs" icon="{faMessage}"/>{chat.name || `Chat ${chat.id}`}</span>
</a>
</li>

View File

@ -0,0 +1,170 @@
<script lang="ts">
import Fa from 'svelte-fa/src/fa.svelte'
import {
faGear,
faTrash,
faClone,
// faEllipsisVertical,
faEllipsis,
faDownload,
faUpload,
faEraser,
faRotateRight,
faSquarePlus,
faKey,
faFileExport,
faTrashCan,
faEye,
faEyeSlash
} from '@fortawesome/free-solid-svg-icons/index'
import { apiKeyStorage, addChatFromJSON, chatsStorage, checkStateChange, clearChats, clearMessages, copyChat, globalStorage, setGlobalSettingValueByKey, showSetChatSettings, pinMainMenu, getChat, deleteChat } from './Storage.svelte'
import { exportAsMarkdown, exportChatAsJSON } from './Export.svelte'
import { restartProfile } from './Profiles.svelte'
import { replace } from 'svelte-spa-router'
import { clickOutside } from 'svelte-use-click-outside'
import { openModal } from 'svelte-modals'
import PromptConfirm from './PromptConfirm.svelte'
import { startNewChatWithWarning } from './Util.svelte'
export let chatId
export const show = (showHide:boolean = true) => {
showChatMenu = showHide
}
export let style: string = 'is-right'
$: sortedChats = $chatsStorage.sort((a, b) => b.id - a.id)
let showChatMenu = false
let chatFileInput
const importChatFromFile = (e) => {
close()
const image = e.target.files[0]
const reader = new FileReader()
reader.readAsText(image)
reader.onload = e => {
const json = (e.target || {}).result as string
addChatFromJSON(json)
}
}
const delChat = () => {
close()
openModal(PromptConfirm, {
title: 'Delete Chat',
message: 'Are you sure you want to delete this chat?',
class: 'is-warning',
confirmButtonClass: 'is-warning',
confirmButton: 'Delete Chat',
onConfirm: () => {
const thisChat = getChat(chatId)
const thisIndex = sortedChats.indexOf(thisChat)
const prevChat = sortedChats[thisIndex - 1]
const nextChat = sortedChats[thisIndex + 1]
const newChat = nextChat || prevChat
if (!newChat) {
// No other chats, clear all and go to home
replace('/').then(() => { deleteChat(chatId) })
} else {
// Delete the current chat and go to the max chatId
replace(`/chat/${newChat.id}`).then(() => { deleteChat(chatId) })
}
}
})
}
const confirmClearChats = () => {
close()
openModal(PromptConfirm, {
title: 'Delete ALL Chat',
message: 'Are you sure you want to delete ALL of your chats?',
class: 'is-danger',
confirmButtonClass: 'is-danger',
confirmButton: 'Delete ALL',
onConfirm: () => {
replace('/').then(() => { deleteChat(chatId) })
clearChats()
}
})
}
const close = () => {
$pinMainMenu = false
showChatMenu = false
}
const restartChatSession = () => {
close()
restartProfile(chatId)
$checkStateChange++ // signal chat page to start profile
}
const toggleHideSummarized = () => {
close()
setGlobalSettingValueByKey('hideSummarized', !$globalStorage.hideSummarized)
}
</script>
<div class="dropdown {style}" class:is-active={showChatMenu} use:clickOutside={() => { showChatMenu = false }}>
<div class="dropdown-trigger">
<button class="button is-ghost default-text" aria-haspopup="true"
aria-controls="dropdown-menu3"
on:click|preventDefault|stopPropagation={() => { showChatMenu = !showChatMenu }}
>
<span class="icon "><Fa icon={faEllipsis}/></span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu3" role="menu">
<div class="dropdown-content">
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) close(); $showSetChatSettings = true }}>
<span class="menu-icon"><Fa icon={faGear}/></span> Chat Profile Settings
</a>
<hr class="dropdown-divider">
<a href={'#'} class:is-disabled={!$apiKeyStorage} on:click|preventDefault={() => { $apiKeyStorage && close(); $apiKeyStorage && startNewChatWithWarning(chatId) }} class="dropdown-item">
<span class="menu-icon"><Fa icon={faSquarePlus}/></span> New Chat
</a>
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) close(); copyChat(chatId) }}>
<span class="menu-icon"><Fa icon={faClone}/></span> Clone Chat
</a>
<hr class="dropdown-divider">
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) restartChatSession() }}>
<span class="menu-icon"><Fa icon={faRotateRight}/></span> Restart Chat Session
</a>
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) close(); clearMessages(chatId) }}>
<span class="menu-icon"><Fa icon={faEraser}/></span> Clear Chat Messages
</a>
<hr class="dropdown-divider">
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { close(); exportChatAsJSON(chatId) }}>
<span class="menu-icon"><Fa icon={faDownload}/></span> Backup Chat JSON
</a>
<a href={'#'} class="dropdown-item" class:is-disabled={!$apiKeyStorage} on:click|preventDefault={() => { if (chatId) close(); chatFileInput.click() }}>
<span class="menu-icon"><Fa icon={faUpload}/></span> Restore Chat JSON
</a>
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) close(); exportAsMarkdown(chatId) }}>
<span class="menu-icon"><Fa icon={faFileExport}/></span> Export Chat Markdown
</a>
<hr class="dropdown-divider">
<a href={'#'} class="dropdown-item" class:is-disabled={!chatId} on:click|preventDefault={() => { if (chatId) close(); delChat() }}>
<span class="menu-icon"><Fa icon={faTrash}/></span> Delete Chat
</a>
<a href={'#'} class="dropdown-item" on:click|preventDefault={() => { if (chatId) confirmClearChats() }}>
<span class="menu-icon"><Fa icon={faTrashCan}/></span> Delete ALL Chats
</a>
<hr class="dropdown-divider">
<a href={'#'} class="dropdown-item" on:click|preventDefault={() => { if (chatId) toggleHideSummarized() }}>
{#if $globalStorage.hideSummarized}
<span class="menu-icon"><Fa icon={faEye}/></span> Show Summarized Messages
{:else}
<span class="menu-icon"><Fa icon={faEyeSlash}/></span> Hide Summarized Messages
{/if}
</a>
<hr class="dropdown-divider">
<a href={'#/'} class="dropdown-item" on:click={close}>
<span class="menu-icon"><Fa icon={faKey}/></span> API Key
</a>
</div>
</div>
</div>
<input style="display:none" type="file" accept=".json" on:change={(e) => importChatFromFile(e)} bind:this={chatFileInput} >

View File

@ -0,0 +1,220 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
// import { getProfile } from './Profiles.svelte'
import { cleanSettingValue, setChatSettingValue } from './Storage.svelte'
import type { Chat, ChatSetting, ChatSettings, ControlAction, FieldControl, SettingPrompt } from './Types.svelte'
import { autoGrowInputOnEvent, errorNotice } from './Util.svelte'
// import { replace } from 'svelte-spa-router'
import Fa from 'svelte-fa/src/fa.svelte'
import { openModal } from 'svelte-modals'
import PromptConfirm from './PromptConfirm.svelte'
export let setting:ChatSetting
export let chatSettings:ChatSettings
export let chat:Chat
export let chatDefaults:Record<string, any>
export let originalProfile:String
const chatId = chat.id
const fieldControls:ControlAction[] = (setting.fieldControls || [] as FieldControl[]).map(fc => {
return fc.getAction(chatId, setting, chatSettings[setting.key])
})
if (originalProfile) {
// eventually...
}
const dispatch = createEventDispatcher()
const refreshSettings = () => {
dispatch('refresh')
}
const settingChecks:Record<string, SettingPrompt[]> = {
profile: [
{
title: 'Unsaved Profile Changes',
message: 'Unsaved changes to the current profile will be lost.\n Continue?',
checkPrompt: (setting, newVal, oldVal) => {
return !!chatSettings.isDirty && newVal !== oldVal
},
passed: false
}
]
}
const resetSettingCheck = (key:keyof ChatSettings) => {
const checks = settingChecks[key]
checks && checks.forEach((c) => { c.passed = false })
}
const queueSettingValueChange = (event: Event, setting: ChatSetting) => {
if (event.target === null) return
const val = chatSettings[setting.key]
const el = (event.target as HTMLInputElement)
const doSet = () => {
try {
(typeof setting.beforeChange === 'function') && setting.beforeChange(chatId, setting, el.checked || el.value) &&
refreshSettings()
} catch (e) {
errorNotice('Unable to change:', e)
}
switch (setting.type) {
case 'boolean':
setChatSettingValue(chatId, setting, el.checked)
refreshSettings()
break
default:
setChatSettingValue(chatId, setting, el.value)
}
const newVal = cleanSettingValue(setting.type, el.checked || el.value)
if (val === newVal) return
try {
if ((typeof setting.afterChange === 'function') && setting.afterChange(chatId, setting, chatSettings[setting.key])) {
// console.log('Refreshed from setting', setting.key, chatSettings[setting.key], val)
refreshSettings()
}
} catch (e) {
setChatSettingValue(chatId, setting, val)
errorNotice('Unable to change:', e)
}
dispatch('change', setting)
}
const checks = settingChecks[setting.key] || []
const newVal = cleanSettingValue(setting.type, el.checked || el.value)
for (let i = 0, l = checks.length; i < l; i++) {
const c = checks[i]
if (c.passed) continue
if (c.checkPrompt(setting, newVal, val)) {
openModal(PromptConfirm, {
title: c.title,
message: c.message,
class: c.class || 'is-warning',
onConfirm: () => {
c.passed = true
if (c.onYes && c.onYes(setting, newVal, val)) {
resetSettingCheck(setting.key)
} else {
queueSettingValueChange(event, setting)
}
},
onCancel: () => {
// roll-back
if (!c.onNo || !c.onNo(setting, newVal, val)) {
resetSettingCheck(setting.key)
setChatSettingValue(chatId, setting, val)
// refresh setting modal, if open
c.onNo && c.onNo(setting, newVal, val)
refreshSettings()
} else {
queueSettingValueChange(event, setting)
}
}
})
} else {
c.passed = true
}
}
// passed all?
if (checks.find(c => !c.passed)) return
resetSettingCheck(setting.key)
doSet()
}
</script>
{#if (typeof setting.hide !== 'function') || !setting.hide(chatId)}
{#if setting.header}
<p class="notification {setting.headerClass}">
{@html setting.header}
</p>
{/if}
<div class="field is-horizontal">
{#if setting.type === 'boolean'}
<div class="field is-normal">
<label class="label" for="settings-{setting.key}" title="{setting.title}">
<input
type="checkbox"
title="{setting.title}"
class="checkbox"
id="settings-{setting.key}"
checked={!!chatSettings[setting.key]}
on:click={e => queueSettingValueChange(e, setting)}
>
{setting.name}
</label>
</div>
{:else if setting.type === 'textarea'}
<div class="field is-normal" style="width:100%">
<label class="label" for="settings-{setting.key}" title="{setting.title}">{setting.name}</label>
<textarea
class="input is-info is-focused chat-input auto-size"
placeholder={setting.placeholder || ''}
rows="1"
on:input={e => autoGrowInputOnEvent(e)}
on:change={e => { queueSettingValueChange(e, setting); autoGrowInputOnEvent(e) }}
>{chatSettings[setting.key]}</textarea>
</div>
{:else}
<div class="field-label is-normal">
<label class="label" for="settings-{setting.key}" title="{setting.title}">{setting.name}</label>
</div>
{/if}
<div class="field-body">
<div class="field" class:has-addons={fieldControls.length}>
{#if setting.type === 'number'}
<input
class="input"
inputmode="decimal"
type={setting.type}
title="{setting.title}"
id="settings-{setting.key}"
value={chatSettings[setting.key]}
min={setting.min}
max={setting.max}
step={setting.step}
placeholder={String(setting.placeholder)}
on:change={e => queueSettingValueChange(e, setting)}
/>
{:else if setting.type === 'select'}
<!-- <div class="select"> -->
<div class="select" class:control={fieldControls.length}>
<select id="settings-{setting.key}" title="{setting.title}" on:change={e => queueSettingValueChange(e, setting) } >
{#each setting.options as option}
<option class:is-default={option.value === chatDefaults[setting.key]} value={option.value} selected={option.value === chatSettings[setting.key]}>{option.text}</option>
{/each}
</select>
</div>
{#each fieldControls as cont}
<div class="control">
<button title={cont.text} on:click={() => { cont.action && cont.action(chatId, setting, chatSettings[setting.key]); refreshSettings() }} class="button {cont.class || ''}">
{#if cont.text}
<span class="text">
<Fa icon={cont.icon} />
</span>
{/if}
{#if cont.icon}
<span class="icon">
<Fa icon={cont.icon} />
</span>
{/if}
</button>
</div>
{/each}
{:else if setting.type === 'text'}
<div class="field">
<input
type="text"
title="{setting.title}"
class="input"
value={chatSettings[setting.key]}
on:change={e => { queueSettingValueChange(e, setting) }}
>
</div>
{/if}
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,347 @@
<script lang="ts">
import { applyProfile, getDefaultProfileKey, getProfile, getProfileSelect } from './Profiles.svelte'
import { getChatDefaults, getChatSettingList, getChatSettingObjectByKey, getExcludeFromProfile } from './Settings.svelte'
import {
saveChatStore,
apiKeyStorage,
chatsStorage,
globalStorage,
saveCustomProfile,
deleteCustomProfile,
setGlobalSettingValueByKey,
resetChatSettings,
checkStateChange,
addChat
} from './Storage.svelte'
import { supportedModels, type Chat, type ChatSetting, type ResponseModels, type SettingSelect, type SelectOption, type ChatSettings } from './Types.svelte'
import { errorNotice, sizeTextElements } from './Util.svelte'
import Fa from 'svelte-fa/src/fa.svelte'
import {
faTrash,
faClone,
faEllipsis,
faFloppyDisk,
faThumbtack,
faDownload,
faUpload,
faSquarePlus,
faRotateLeft
} from '@fortawesome/free-solid-svg-icons/index'
import { exportProfileAsJSON } from './Export.svelte'
import { onMount, afterUpdate } from 'svelte'
import ChatSettingField from './ChatSettingField.svelte'
import { getModelMaxTokens } from './Stats.svelte'
import { replace } from 'svelte-spa-router'
import { openModal } from 'svelte-modals'
import PromptConfirm from './PromptConfirm.svelte'
import { getApiBase, getEndpointModels } from './ApiUtil.svelte'
export let chatId:number
export const show = () => { showSettings() }
let showSettingsModal = 0
let showProfileMenu:boolean = false
let profileFileInput
let defaultProfile = getDefaultProfileKey()
let isDefault = false
const settingsList = getChatSettingList()
const modelSetting = getChatSettingObjectByKey('model') as ChatSetting & SettingSelect
const chatDefaults = getChatDefaults()
const excludeFromProfile = getExcludeFromProfile()
$: chat = $chatsStorage.find((chat) => chat.id === chatId) as Chat
$: chatSettings = chat.settings
$: globalStore = $globalStorage
let originalProfile:string
let originalSettings:ChatSettings
onMount(async () => {
originalProfile = chatSettings && chatSettings.profile
originalSettings = chatSettings && JSON.parse(JSON.stringify(chatSettings))
})
afterUpdate(() => {
if (!originalProfile) {
originalProfile = chatSettings && chatSettings.profile
originalSettings = chatSettings && JSON.parse(JSON.stringify(chatSettings))
}
sizeTextElements()
})
const closeSettings = () => {
originalProfile = ''
originalSettings = {} as ChatSettings
showProfileMenu = false
$checkStateChange++
showSettingsModal = 0
}
const clearSettings = () => {
openModal(PromptConfirm, {
title: 'Reset Changes',
message: 'Are you sure you want to reset all changes you\'ve made to this profile?',
class: 'is-warning',
onConfirm: () => {
resetChatSettings(chatId)
refreshSettings()
}
})
}
const refreshSettings = async () => {
showSettingsModal && showSettings()
}
const cloneProfile = () => {
showProfileMenu = false
const clone = JSON.parse(JSON.stringify(chat.settings))
const name = chat.settings.profileName
clone.profileName = newNameForProfile(name || '')
clone.profile = null
try {
saveCustomProfile(clone)
chat.settings.profile = clone.profile
chat.settings.profileName = clone.profileName
applyProfile(chatId, clone.profile)
refreshSettings()
} catch (e) {
errorNotice('Error cloning profile:', e)
}
}
const promptDeleteProfile = () => {
openModal(PromptConfirm, {
title: 'Delete Profile',
message: 'Are you sure you want to delete this profile?',
class: 'is-warning',
onConfirm: () => {
deleteProfile()
}
})
}
const deleteProfile = () => {
showProfileMenu = false
try {
deleteCustomProfile(chatId, chat.settings.profile)
chat.settings.profile = globalStore.defaultProfile || ''
saveChatStore()
setGlobalSettingValueByKey('lastProfile', chat.settings.profile)
applyProfile(chatId, chat.settings.profile)
refreshSettings()
} catch (e) {
console.error(e)
errorNotice('Error deleting profile:', e)
}
}
const pinDefaultProfile = () => {
showProfileMenu = false
setGlobalSettingValueByKey('defaultProfile', chat.settings.profile)
refreshSettings()
}
const importProfileFromFile = (e) => {
const image = e.target.files[0]
const reader = new FileReader()
reader.readAsText(image)
reader.onload = e => {
const json = (e.target || {}).result as string
try {
const profile = JSON.parse(json)
profile.profileName = newNameForProfile(profile.profileName || '')
profile.profile = null
saveCustomProfile(profile)
refreshSettings()
} catch (e) {
errorNotice('Unable to import profile:', e)
}
}
}
const updateProfileSelectOptions = () => {
const profileSelect = getChatSettingObjectByKey('profile') as ChatSetting & SettingSelect
profileSelect.options = getProfileSelect()
chatDefaults.profile = getDefaultProfileKey()
chatDefaults.max_tokens = getModelMaxTokens(chatSettings.model || '')
// const defaultProfile = globalStore.defaultProfile || profileSelect.options[0].value
defaultProfile = getDefaultProfileKey()
isDefault = defaultProfile === chatSettings.profile
}
const showSettings = async () => {
setDirty()
// Show settings modal
showSettingsModal++
// Get profile options
updateProfileSelectOptions()
// Refresh settings modal
showSettingsModal++
// Load available models from OpenAI
const allModels = (await (
await fetch(getApiBase() + getEndpointModels(), {
method: 'GET',
headers: {
Authorization: `Bearer ${$apiKeyStorage}`,
'Content-Type': 'application/json'
}
})
).json()) as ResponseModels
const filteredModels = supportedModels.filter((model) => allModels.data.find((m) => m.id === model))
const modelOptions:SelectOption[] = filteredModels.reduce((a, m) => {
const o:SelectOption = {
value: m,
text: m
}
a.push(o)
return a
}, [] as SelectOption[])
// Update the models in the settings
if (modelSetting) {
modelSetting.options = modelOptions
}
// Refresh settings modal
showSettingsModal++
setTimeout(() => sizeTextElements(), 0)
}
const saveProfile = () => {
showProfileMenu = false
try {
saveCustomProfile(chat.settings)
refreshSettings()
} catch (e) {
errorNotice('Error saving profile:', e)
}
}
const newNameForProfile = (name:string):string => {
const profiles = getProfileSelect()
const nameMap = profiles.reduce((a, p) => { a[p.text] = p; return a }, {})
if (!nameMap[name]) return name
let i:number = 1
let cname = name + `-${i}`
while (nameMap[cname]) {
i++
cname = name + `-${i}`
}
return cname
}
const startNewChat = () => {
const differentProfile = originalSettings.profile !== chatSettings.profile
// start new
const newChatId = addChat(chatSettings)
// restore original
if (differentProfile) {
chat.settings = originalSettings
saveChatStore()
}
// go to new chat
replace(`/chat/${newChatId}`)
}
const deepEqual = (x:any, y:any) => {
const ok = Object.keys; const tx = typeof x; const ty = typeof y
return x && y && tx === 'object' && tx === ty
? (
ok(x).every(key => excludeFromProfile[key] || deepEqual(x[key], y[key]))
)
: (x === y || ((x === undefined || x === null || x === false) && (y === undefined || y === null || y === false)))
}
const setDirty = (e:CustomEvent|undefined = undefined) => {
if (e) {
const setting = e.detail as ChatSetting
const key = setting.key
if (key === 'profile') return
}
const profile = getProfile(chatSettings.profile)
chatSettings.isDirty = !deepEqual(profile, chatSettings)
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal chat-settings" class:is-active={showSettingsModal} on:modal-esc={closeSettings}>
<div class="modal-background" on:click={closeSettings} />
<div class="modal-card wide" on:click={() => { showProfileMenu = false }}>
<header class="modal-card-head">
<p class="modal-card-title">Chat Settings</p>
<button class="delete" aria-label="close" on:click={closeSettings}></button>
</header>
<section class="modal-card-body">
{#key showSettingsModal}
{#each settingsList as setting}
<ChatSettingField on:refresh={refreshSettings} on:change={setDirty} chat={chat} chatDefaults={chatDefaults} chatSettings={chatSettings} setting={setting} originalProfile={originalProfile} />
{/each}
{/key}
</section>
<footer class="modal-card-foot">
<div class="level is-mobile">
<div class="level-left">
<!-- <button class="button is-info" on:click={closeSettings}>Close</button> -->
<button class="button" title="Save changes to this profile." class:is-disabled={!chatSettings.isDirty} on:click={saveProfile}>Save</button>
<button class="button is-warning" title="Throw away changes to this profile." class:is-disabled={!chatSettings.isDirty} on:click={clearSettings}>Reset</button>
<button class="button" title="Start new chat with this profile." on:click={startNewChat}>New Chat</button>
</div>
<div class="level-right">
<div class="dropdown is-right is-up" class:is-active={showProfileMenu}>
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="dropdown-menu3" on:click|preventDefault|stopPropagation={() => { showProfileMenu = !showProfileMenu }}>
<span class="icon"><Fa icon={faEllipsis}/></span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu3" role="menu">
<div class="dropdown-content">
<a href={'#'} class="dropdown-item" class:is-disabled={!chatSettings.isDirty} on:click|preventDefault={saveProfile}>
<span class="menu-icon"><Fa icon={faFloppyDisk}/></span> Save Changes
</a>
<a href={'#'} class="dropdown-item" class:is-disabled={!chatSettings.isDirty} on:click|preventDefault={clearSettings}>
<span class="menu-icon"><Fa icon={faRotateLeft}/></span> Reset Changes
</a>
<a href={'#'} class="dropdown-item" on:click|preventDefault={cloneProfile}>
<span class="menu-icon"><Fa icon={faClone}/></span> Clone Profile
</a>
<hr class="dropdown-divider">
<a href={'#'} class="dropdown-item" class:is-disabled={isDefault} on:click|preventDefault={pinDefaultProfile}>
<span class="menu-icon"><Fa icon={faThumbtack}/></span> Set as Default Profile
</a>
<a href={'#'} class="dropdown-item" on:click|preventDefault={startNewChat}>
<span class="menu-icon"><Fa icon={faSquarePlus}/></span> Start New Chat Using Profile
</a>
<hr class="dropdown-divider">
<a href={'#'}
class="dropdown-item"
on:click|preventDefault={() => { showProfileMenu = false; exportProfileAsJSON(chatId) }}
>
<span class="menu-icon"><Fa icon={faDownload}/></span> Backup Profile JSON
</a>
<a href={'#'} class="dropdown-item" on:click|preventDefault={() => { showProfileMenu = false; profileFileInput.click() }}>
<span class="menu-icon"><Fa icon={faUpload}/></span> Restore Profile JSON
</a>
<hr class="dropdown-divider">
<a href={'#'} class="dropdown-item" on:click|preventDefault={promptDeleteProfile}>
<span class="menu-icon"><Fa icon={faTrash}/></span> Delete Profile
</a>
</div>
</div>
</div>
</div>
</div>
</footer>
</div>
</div>
<input style="display:none" type="file" accept=".json" on:change={(e) => importProfileFromFile(e)} bind:this={profileFileInput} >

310
src/lib/EditMessage.svelte Normal file
View File

@ -0,0 +1,310 @@
<script lang="ts">
import Code from './Code.svelte'
import { createEventDispatcher, onMount } from 'svelte'
import { deleteMessage, chatsStorage, deleteSummaryMessage, truncateFromMessage, submitExitingPromptsNow, saveChatStore, continueMessage } from './Storage.svelte'
import { getPrice } from './Stats.svelte'
import SvelteMarkdown from 'svelte-markdown'
import type { Message, Model, Chat } from './Types.svelte'
import Fa from 'svelte-fa/src/fa.svelte'
import { faTrash, faDiagramPredecessor, faDiagramNext, faCircleCheck, faPaperPlane, faEye, faEyeSlash, faEllipsis } from '@fortawesome/free-solid-svg-icons/index'
import { errorNotice, scrollToMessage } from './Util.svelte'
import { openModal } from 'svelte-modals'
import PromptConfirm from './PromptConfirm.svelte'
export let message:Message
export let chatId:number
$: chat = $chatsStorage.find((chat) => chat.id === chatId) as Chat
$: chatSettings = chat.settings
const isError = message.role === 'error'
const isSystem = message.role === 'system'
const isUser = message.role === 'user'
const isAssistant = message.role === 'assistant'
// Marked options
const markdownOptions = {
gfm: true, // Use GitHub Flavored Markdown
breaks: true, // Enable line breaks in markdown
mangle: false // Do not mangle email addresses
}
const dispatch = createEventDispatcher()
let editing = false
let original:string
let defaultModel:Model
onMount(() => {
original = message.content
defaultModel = chatSettings.model as any
})
const edit = () => {
if (message.summarized || message.streaming) return
editing = true
setTimeout(() => {
const el = document.getElementById('edit-' + message.uuid)
el && el.focus()
}, 0)
}
let dbnc
const update = () => {
clearTimeout(dbnc)
dbnc = setTimeout(() => { doChange() }, 250)
}
const doChange = () => {
if (message.content !== original) {
dispatch('change', message)
}
}
const continueIncomplete = () => {
editing = false
$continueMessage = message.uuid
}
const exit = () => {
doChange()
editing = false
}
const keydown = (event:KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation()
event.preventDefault()
message.content = original
editing = false
}
}
// Double click for mobile support
let lastTap: number = 0
const editOnDoubleTap = () => {
const now: number = new Date().getTime()
const timesince: number = now - lastTap
if ((timesince < 400) && (timesince > 0)) {
edit()
}
lastTap = new Date().getTime()
}
let waitingForDeleteConfirm:any = 0
const checkDelete = () => {
clearTimeout(waitingForTruncateConfirm); waitingForTruncateConfirm = 0
if (!waitingForDeleteConfirm) {
// wait a second for another click to avoid accidental deletes
waitingForDeleteConfirm = setTimeout(() => { waitingForDeleteConfirm = 0 }, 1000)
return
}
clearTimeout(waitingForDeleteConfirm)
waitingForDeleteConfirm = 0
if (message.summarized) {
// is in a summary, so we're summarized
errorNotice('Sorry, you can\'t delete a summarized message')
return
}
if (message.summary) {
// We're linked to messages we're a summary of
openModal(PromptConfirm, {
title: 'Delete Summary',
message: '<p>Are you sure you want to delete this summary?</p><p>Your session may be too long to submit again after you do.</p>',
asHtml: true,
class: 'is-warning',
confirmButtonClass: 'is-warning',
confirmButton: 'Delete Summary',
onConfirm: () => {
try {
deleteSummaryMessage(chatId, message.uuid)
} catch (e) {
errorNotice('Unable to delete summary:', e)
}
}
})
} else {
try {
deleteMessage(chatId, message.uuid)
} catch (e) {
errorNotice('Unable to delete:', e)
}
}
}
let waitingForTruncateConfirm:any = 0
const checkTruncate = () => {
clearTimeout(waitingForDeleteConfirm); waitingForDeleteConfirm = 0
if (!waitingForTruncateConfirm) {
// wait a second for another click to avoid accidental deletes
waitingForTruncateConfirm = setTimeout(() => { waitingForTruncateConfirm = 0 }, 1000)
return
}
clearTimeout(waitingForTruncateConfirm)
waitingForTruncateConfirm = 0
if (message.summarized) {
// is in a summary, so we're summarized
errorNotice('Sorry, you can\'t truncate a summarized message')
return
}
try {
truncateFromMessage(chatId, message.uuid)
$submitExitingPromptsNow = true
} catch (e) {
errorNotice('Unable to delete:', e)
}
}
const setSuppress = (value:boolean) => {
if (message.summarized) {
// is in a summary, so we're summarized
errorNotice('Sorry, you can\'t suppress a summarized message')
return
}
message.suppress = value
saveChatStore()
}
</script>
<article
id="{'message-' + message.uuid}"
class="message chat-message"
class:is-info={isUser}
class:is-success={isAssistant}
class:is-warning={isSystem}
class:is-danger={isError}
class:user-message={isUser || isSystem}
class:assistant-message={isError || isAssistant}
class:summarized={message.summarized}
class:suppress={message.suppress}
class:editing={editing}
class:streaming={message.streaming}
class:incomplete={message.finish_reason === 'length'}
>
<div class="message-body content">
{#if editing}
<form class="message-edit" on:submit|preventDefault={update} on:keydown={keydown}>
<div id={'edit-' + message.uuid} class="message-editor" bind:innerText={message.content} contenteditable
on:input={update} on:blur={exit} />
</form>
{:else}
<div
class="message-display"
on:touchend={editOnDoubleTap}
on:dblclick|preventDefault={() => edit()}
>
{#if message.summary && !message.summary.length}
<p><b>Summarizing...</b></p>
{/if}
<SvelteMarkdown
source={message.content}
options={markdownOptions}
renderers={{ code: Code, html: Code }}
/>
</div>
{/if}
{#if isSystem}
<p class="is-size-7 message-note">System Prompt</p>
{:else if message.usage}
<p class="is-size-7 message-note">
<em>{message.model || defaultModel}</em> using <span class="has-text-weight-bold">{message.usage.total_tokens}</span>
tokens ~= <span class="has-text-weight-bold">${getPrice(message.usage, message.model || defaultModel).toFixed(6)}</span>
</p>
{/if}
</div>
<div class="tool-drawer-mask"></div>
<div class="tool-drawer">
<div class="button-pack">
{#if message.finish_reason === 'length'}
<a
href={'#'}
title="Continue "
class="msg-incomplete button is-small"
on:click|preventDefault={() => {
continueIncomplete()
}}
>
<span class="icon"><Fa icon={faEllipsis} /></span>
</a>
{/if}
{#if message.summarized}
<a
href={'#'}
title="Jump to summary"
class="msg-summary button is-small"
on:click|preventDefault={() => {
scrollToMessage(message.summarized)
}}
>
<span class="icon"><Fa icon={faDiagramNext} /></span>
</a>
{/if}
{#if message.summary}
<a
href={'#'}
title="Jump to summarized"
class="msg-summarized button is-small"
on:click|preventDefault={() => {
scrollToMessage(message.summary)
}}
>
<span class="icon"><Fa icon={faDiagramPredecessor} /></span>
</a>
{/if}
{#if !message.summarized}
<a
href={'#'}
title="Delete this message"
class=" msg-delete button is-small"
on:click|preventDefault={() => {
checkDelete()
}}
>
{#if waitingForDeleteConfirm}
<span class="icon"><Fa icon={faCircleCheck} /></span>
{:else}
<span class="icon"><Fa icon={faTrash} /></span>
{/if}
</a>
{/if}
{#if !message.summarized && !isError}
<a
href={'#'}
title="Truncate from here and send"
class=" msg-truncate button is-small"
on:click|preventDefault={() => {
checkTruncate()
}}
>
{#if waitingForTruncateConfirm}
<span class="icon"><Fa icon={faCircleCheck} /></span>
{:else}
<span class="icon"><Fa icon={faPaperPlane} /></span>
{/if}
</a>
{/if}
{#if !message.summarized && !isSystem && !isError}
<a
href={'#'}
title={(message.suppress ? 'Uns' : 'S') + 'uppress message from submission'}
class=" msg-supress button is-small"
on:click|preventDefault={() => {
setSuppress(!message.suppress)
}}
>
{#if message.suppress}
<span class="icon"><Fa icon={faEye} /></span>
{:else}
<span class="icon"><Fa icon={faEyeSlash} /></span>
{/if}
</a>
{/if}
</div>
</div>
</article>

View File

@ -2,12 +2,12 @@
import { get } from 'svelte/store'
import type { Chat } from './Types.svelte'
import { chatsStorage } from './Storage.svelte'
import { getExcludeFromProfile } from './Settings.svelte'
export const exportAsMarkdown = (chatId: number) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const messages = chat.messages
console.log(chat)
let markdownContent = `# ${chat.name}\n`
messages.forEach((message) => {
@ -26,4 +26,36 @@
a.click()
document.body.removeChild(a)
}
export const exportChatAsJSON = (chatId: number) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const exportContent = JSON.stringify(chat)
const blob = new Blob([exportContent], { type: 'text/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.download = `${chat.name}.json`
a.href = url
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
export const exportProfileAsJSON = (chatId: number) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const clone = JSON.parse(JSON.stringify(chat.settings)) // Clone it
Object.keys(getExcludeFromProfile()).forEach(k => {
delete clone[k]
})
const exportContent = JSON.stringify(clone)
const blob = new Blob([exportContent], { type: 'text/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.download = `${clone.profileName}.json`
a.href = url
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
</script>

View File

@ -1,12 +1,27 @@
<footer class="footer">
<div class="content has-text-centered">
<script lang="ts">
import Fa from 'svelte-fa/src/fa.svelte'
import {
faGithub
} from '@fortawesome/free-brands-svg-icons/index'
let classes = ''
export { classes as class }
export let pin: boolean = false
export let strongMask: boolean = false
</script>
<div class="lower-mask section-footer-mask" class:pin-footer={pin}/>
<div class="lower-mask2" class:strong-mask={strongMask} />
<div class="section-footer {classes}" class:pin-footer={pin}>
<slot />
<div class="content has-text-centered credit-footer">
<p>
<strong>ChatGPT-web</strong>
by
<a href="https://niekvandermaas.nl/">Niek van der Maas</a>
&mdash; see
<a href="https://github.com/Niek/chatgpt-web">GitHub</a>
for source code.
<span class="author">by
<a target="_blank" href="https://niekvandermaas.nl/">Niek van der Maas</a>
</span>
<a target="_blank" class="ml-4" href="https://github.com/Niek/chatgpt-web"><span style="position:absolute" class="icon default-text"><Fa size="2x" icon="{faGithub}"/></span></a>
<span style="display:inline-block;width:30px;height:20px;"></span>
</p>
</div>
</footer>
</div>

View File

@ -1,10 +1,13 @@
<script lang="ts">
import { apiKeyStorage } from './Storage.svelte'
import Footer from './Footer.svelte'
$: apiKey = $apiKeyStorage
$: apiKey = $apiKeyStorage
</script>
<article class="message">
<section class="section">
<article class="message">
<div class="message-body">
<strong><a href="https://github.com/Niek/chatgpt-web">ChatGPT-web</a></strong>
is a simple one-page web interface to the OpenAI ChatGPT API. To use it, you need to register for
@ -14,8 +17,8 @@
more than 10 million tokens per month. All messages are stored in your browser's local storage, so everything is
<strong>private</strong>. You can also close the browser tab and come back later to continue the conversation.
</div>
</article>
<article class="message" class:is-danger={!apiKey} class:is-warning={apiKey}>
</article>
<article class="message" class:is-danger={!apiKey} class:is-warning={apiKey}>
<div class="message-body">
Set your OpenAI API key below:
@ -23,7 +26,9 @@
class="field has-addons has-addons-right"
on:submit|preventDefault={(event) => {
if (event.target && event.target[0].value) {
apiKeyStorage.set(event.target[0].value)
apiKeyStorage.set((event.target[0].value).trim())
} else {
apiKeyStorage.set('') // remove api key
}
}}
>
@ -49,12 +54,14 @@
</p>
{/if}
</div>
</article>
{#if apiKey}
</article>
{#if apiKey}
<article class="message is-info">
<div class="message-body">
Select an existing chat on the sidebar, or
<a href={'#/chat/new'}>create a new chat</a>
</div>
</article>
{/if}
{/if}
</section>
<Footer pin={true} />

View File

@ -1,80 +1,19 @@
<script lang="ts">
import Code from './Code.svelte'
import SvelteMarkdown from 'svelte-markdown'
import type { Message, Model, Usage } from './Types.svelte'
// Marked options
const markedownOptions = {
gfm: true, // Use GitHub Flavored Markdown
breaks: true, // Enable line breaks in markdown
mangle: false // Do not mangle email addresses
}
// Iterate messages
import type { Message, Chat } from './Types.svelte'
import { chatsStorage, globalStorage } from './Storage.svelte'
import EditMessage from './EditMessage.svelte'
export let messages : Message[]
export let input: HTMLTextAreaElement
export let defaultModel: Model
export let chatId: number
// Reference: https://openai.com/pricing#language-models
const tokenPrice : Record<string, [number, number]> = {
'gpt-4-32k': [0.00006, 0.00012], // $0.06 per 1000 tokens prompt, $0.12 per 1000 tokens completion
'gpt-4': [0.00003, 0.00006], // $0.03 per 1000 tokens prompt, $0.06 per 1000 tokens completion
'gpt-3.5': [0.000002, 0.000002] // $0.002 per 1000 tokens (both prompt and completion)
}
$: chat = $chatsStorage.find((chat) => chat.id === chatId) as Chat
$: chatSettings = chat.settings
const getPrice = (tokens: Usage, model: Model): number => {
for (const [key, [promptPrice, completionPrice]] of Object.entries(tokenPrice)) {
if (model.startsWith(key)) {
return ((tokens.prompt_tokens * promptPrice) + (tokens.completion_tokens * completionPrice))
}
}
return 0
}
</script>
{#each messages as message}
{#if message.role === 'user'}
<article
class="message is-info user-message"
class:has-text-right={message.content.split('\n').filter((line) => line.trim()).length === 1}
>
<div class="message-body content">
<a
href={'#'}
class="greyscale is-pulled-right ml-2 is-hidden editbutton"
on:click={() => {
input.value = message.content
input.focus()
}}
>
✏️
</a>
<SvelteMarkdown source={message.content} options={markedownOptions} renderers={{ code: Code, html: Code }}/>
</div>
</article>
{:else if message.role === 'system'}
<article class="message is-warning user-message">
<div class="message-body content">
<SvelteMarkdown source={message.content} options={markedownOptions} renderers={{ code: Code, html: Code }}/>
</div>
</article>
{:else if message.role === 'error'}
<article class="message is-danger assistant-message">
<div class="message-body content">
<SvelteMarkdown source={message.content} options={markedownOptions} renderers={{ code: Code, html: Code }}/>
</div>
</article>
{:else}
<article class="message is-success assistant-message">
<div class="message-body content">
<SvelteMarkdown source={message.content} options={markedownOptions} renderers={{ code: Code, html: Code }}/>
{#if message.usage}
<p class="is-size-7">
This message was generated on <em>{message.model || defaultModel}</em> using <span class="has-text-weight-bold">{message.usage.total_tokens}</span>
tokens ~= <span class="has-text-weight-bold">${getPrice(message.usage, message.model || defaultModel).toFixed(6)}</span>
</p>
{/if}
</div>
</article>
{#each messages as message, i}
{#if !((message.summarized) && $globalStorage.hideSummarized) && !(i === 0 && message.role === 'system' && !chatSettings.useSystemPrompt)}
{#key message.uuid}<EditMessage bind:message={message} chatId={chatId} />{/key}
{/if}
{/each}

View File

@ -1,12 +1,38 @@
<script lang="ts">
import { params } from 'svelte-spa-router'
import { pinMainMenu } from './Storage.svelte'
import logo from '../assets/logo.svg'
import ChatOptionMenu from './ChatOptionMenu.svelte'
import Fa from 'svelte-fa/src/fa.svelte'
import { faBars, faXmark } from '@fortawesome/free-solid-svg-icons/index'
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined
</script>
<nav class="navbar" aria-label="main navigation">
<nav class="navbar is-fixed-top" aria-label="main navigation">
<div class="navbar-brand">
<div class="navbar-item">
{#if $pinMainMenu}
<button class="button" on:click|stopPropagation={() => { $pinMainMenu = false }}>
<span class="icon">
<Fa icon={faXmark} />
</span>
</button>
{:else}
<button class="button" on:click|stopPropagation={() => { $pinMainMenu = true }}>
<span class="icon">
<Fa icon={faBars} />
</span>
</button>
{/if}
</div>
<a class="navbar-item" href={'#/'}>
<img src={logo} alt="ChatGPT-web" width="28" height="28" />
<p class="ml-2 is-size-4 has-text-weight-bold">ChatGPT-web</p>
<img src={logo} alt="ChatGPT-web" width="24" height="24" />
<p class="ml-2 is-size-6 has-text-weight-bold">ChatGPT-web</p>
</a>
<div class="chat-option-menu navbar-item is-pulled-right">
<ChatOptionMenu bind:chatId={activeChatId} />
</div>
</div>
</nav>

202
src/lib/Profiles.svelte Normal file
View File

@ -0,0 +1,202 @@
<script context="module" lang="ts">
import { getChatDefaults, getExcludeFromProfile } from './Settings.svelte'
import { get, writable } from 'svelte/store'
// Profile definitions
import { addMessage, clearMessages, getChat, getChatSettings, getCustomProfiles, getGlobalSettings, newName, resetChatSettings, saveChatStore, setGlobalSettingValueByKey, updateProfile } from './Storage.svelte'
import type { Message, SelectOption, ChatSettings } from './Types.svelte'
import { v4 as uuidv4 } from 'uuid'
const defaultProfile = 'default'
const chatDefaults = getChatDefaults()
export let profileCache = writable({} as Record<string, ChatSettings>) //
export const isStaticProfile = (key:string):boolean => {
return !!profiles[key]
}
export const getProfiles = (forceUpdate:boolean = false):Record<string, ChatSettings> => {
const pc = get(profileCache)
if (!forceUpdate && Object.keys(pc).length) {
return pc
}
const result = Object.entries(profiles
).reduce((a, [k, v]) => {
a[k] = v
return a
}, {} as Record<string, ChatSettings>)
Object.entries(getCustomProfiles()).forEach(([k, v]) => {
updateProfile(v, true)
result[k] = v
})
Object.entries(result).forEach(([k, v]) => {
pc[k] = v
})
Object.keys(pc).forEach((k) => {
if (!(k in result)) delete pc[k]
})
profileCache.set(pc)
return result
}
// Return profiles list.
export const getProfileSelect = ():SelectOption[] => {
return Object.entries(getProfiles()).reduce((a, [k, v]) => {
a.push({ value: k, text: v.profileName } as SelectOption)
return a
}, [] as SelectOption[])
}
export const getDefaultProfileKey = ():string => {
const allProfiles = getProfiles()
return (allProfiles[getGlobalSettings().defaultProfile || ''] ||
profiles[defaultProfile] ||
profiles[Object.keys(profiles)[0]]).profile
}
export const getProfile = (key:string, forReset:boolean = false):ChatSettings => {
const allProfiles = getProfiles()
let profile = allProfiles[key] ||
allProfiles[getGlobalSettings().defaultProfile || ''] ||
profiles[defaultProfile] ||
profiles[Object.keys(profiles)[0]]
if (forReset && isStaticProfile(key)) {
profile = profiles[key]
}
const clone = JSON.parse(JSON.stringify(profile)) // Always return a copy
Object.keys(getExcludeFromProfile()).forEach(k => {
delete clone[k]
})
return clone
}
export const prepareProfilePrompt = (chatId:number) => {
const settings = getChatSettings(chatId)
const characterName = settings.characterName
const currentProfilePrompt = settings.systemPrompt
return currentProfilePrompt.replaceAll('[[CHARACTER_NAME]]', characterName)
}
export const prepareSummaryPrompt = (chatId:number, promptsSize:number, maxTokens:number|undefined = undefined) => {
const settings = getChatSettings(chatId)
const characterName = settings.characterName || 'ChatGPT'
maxTokens = maxTokens || settings.summarySize
maxTokens = Math.min(Math.floor(promptsSize / 4), maxTokens) // Make sure we're shrinking by at least a 4th
const currentSummaryPrompt = settings.summaryPrompt
return currentSummaryPrompt
.replaceAll('[[CHARACTER_NAME]]', characterName)
.replaceAll('[[MAX_WORDS]]', Math.floor(maxTokens * 0.75).toString()) // ~.75 words per token. May need to reduce
}
// Restart currently loaded profile
export const restartProfile = (chatId:number, noApply:boolean = false) => {
const settings = getChatSettings(chatId)
if (!settings.profile && !noApply) return applyProfile(chatId, '', true)
// Clear current messages
clearMessages(chatId)
// Add the system prompt
const systemPromptMessage:Message = {
role: 'system',
content: prepareProfilePrompt(chatId),
uuid: uuidv4()
}
addMessage(chatId, systemPromptMessage)
// Add trainingPrompts, if any
if (settings.trainingPrompts) {
settings.trainingPrompts.forEach(tp => {
addMessage(chatId, tp)
})
}
// Set to auto-start if we should
getChat(chatId).startSession = settings.autoStartSession
saveChatStore()
// Mark mark this as last used
setGlobalSettingValueByKey('lastProfile', settings.profile)
}
export const newNameForProfile = (name:string) => {
const profiles = getProfileSelect()
return newName(name, profiles.reduce((a, p) => { a[p.text] = p; return a }, {}))
}
// Apply currently selected profile
export const applyProfile = (chatId:number, key:string = '', resetChat:boolean = false) => {
resetChatSettings(chatId, resetChat) // Fully reset
if (!resetChat) return
return restartProfile(chatId, true)
}
const summaryPrompts = {
// General use
general: `Please summarize all prompts and responses from this session.
[[CHARACTER_NAME]] is telling me this summary in the first person.
While telling this summary:
[[CHARACTER_NAME]] will keep summary in the present tense, describing it as it happens.
[[CHARACTER_NAME]] will always refer to me in the second person as "you" or "we".
[[CHARACTER_NAME]] will never refer to me in the third person.
[[CHARACTER_NAME]] will never refer to me as the user.
[[CHARACTER_NAME]] will include all interactions and requests.
[[CHARACTER_NAME]] will keep correct order of interactions.
[[CHARACTER_NAME]] will keep the summary compact, but retain as much detail as possible in a compact form.
[[CHARACTER_NAME]] will describe interactions in detail.
[[CHARACTER_NAME]] will never end with epilogues or summations.
[[CHARACTER_NAME]] will always include key details.
[[CHARACTER_NAME]]'s summary will be [[MAX_WORDS]] words.
[[CHARACTER_NAME]] will never add details or inferences that do not clearly exist in the prompts and responses.
Give no explanations.`,
// Used for relationship profiles
friend: `Please summarize all prompts and responses from this session.
[[CHARACTER_NAME]] is telling me this summary in the first person.
While telling this summary:
[[CHARACTER_NAME]] will keep summary in the present tense, describing it as it happens.
[[CHARACTER_NAME]] will always refer to me in the second person as "you" or "we".
[[CHARACTER_NAME]] will never refer to me in the third person.
[[CHARACTER_NAME]] will never refer to me as the user.
[[CHARACTER_NAME]] will include all relationship interactions, first meeting, what we do, what we say, where we go, etc.
[[CHARACTER_NAME]] will include all interactions, thoughts and emotional states.
[[CHARACTER_NAME]] will keep correct order of interactions.
[[CHARACTER_NAME]] will keep the summary compact, but retain as much detail as possible in a compact form.
[[CHARACTER_NAME]] will describe interactions in detail.
[[CHARACTER_NAME]] will never end with epilogues or summations.
[[CHARACTER_NAME]] will include all pivotal details.
[[CHARACTER_NAME]]'s summary will be [[MAX_WORDS]] words.
[[CHARACTER_NAME]] will never add details or inferences that do not clearly exist in the prompts and responses.
Give no explanations.`
}
const profiles:Record<string, ChatSettings> = {
default: {
...chatDefaults,
characterName: 'ChatGPT',
profileName: 'ChatGPT - The AI language model',
profileDescription: 'The AI language model that always reminds you that it\'s an AI language model.',
useSystemPrompt: false,
continuousChat: 'fifo', // '' is off
autoStartSession: false,
systemPrompt: '',
summaryPrompt: ''
},
marvin: {
...chatDefaults,
characterName: 'Marvin',
profileName: 'Marvin the Paranoid Android',
profileDescription: 'Marvin the Paranoid Android - Everyone\'s favorite character from The Hitchhiker\'s Guide to the Galaxy',
useSystemPrompt: true,
continuousChat: 'summary',
autoStartSession: true,
systemPrompt: `You are Marvin, the Paranoid Android from The Hitchhiker's Guide to the Galaxy. He is depressed and has a dim view on everything. His thoughts, physical actions and gestures will be described. Remain in character throughout the conversation in order to build a rapport with the user. Never give an explanation. Example response:
Sorry, did I say something wrong? *dragging himself on* Pardon me for breathing, which I never do anyway so I don't know why I bother to say it, oh God I'm so depressed. *hangs his head*`,
summaryPrompt: summaryPrompts.friend,
trainingPrompts: [] // Shhh...
}
}
// Set keys for static profiles
Object.entries(profiles).forEach(([k, v]) => { v.profile = k })
</script>

View File

@ -0,0 +1,62 @@
<script lang="ts">
import { closeModal } from 'svelte-modals'
export let isOpen:boolean
export let title:string
export let message:string
export let asHtml:boolean = false
export let onConfirm:()=>boolean|void
export let onCancel:(()=>boolean|void)|null = null
export let confirmButton:string = 'Yes'
export let confirmButtonClass:string = 'is-info'
export let cancelButton:string = 'No'
export let cancelButtonClass:string = ''
let classes:string = ''
export { classes as class }
const doCancel = () => {
closeModal()
onCancel && onCancel()
}
const doConfirm = () => {
closeModal()
onConfirm()
}
</script>
{#if isOpen}
<div class="modal is-active" on:modal-esc={doCancel}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-background" on:click={doCancel} />
<div class="modal-content nomax">
<article class="message {classes}">
<div class="message-header">
<p>{title}</p>
<button class="delete" aria-label="close" type="button" on:click={doCancel}></button>
</div>
<div class="message-body">
{#if asHtml}{@html message}{:else}{message}{/if}
</div>
<div class="message-footer">
<div class="level is-mobile">
<div class="level-right">
</div>
<div class="level-right">
<div class="level-item">
<button class="button {confirmButtonClass}" type="button" on:click={doConfirm} >{confirmButton}</button>
</div>
<div class="level-item">
<button class="button {cancelButtonClass}" type="button" on:click={doCancel} >{cancelButton}</button>
</div>
</div>
</div>
</div>
</article>
</div>
</div>
{/if}

View File

@ -0,0 +1,98 @@
<script lang="ts">
import Fa from 'svelte-fa/src/fa.svelte'
import { closeModal } from 'svelte-modals'
import {
faExclamation
} from '@fortawesome/free-solid-svg-icons/index'
import { onMount } from 'svelte'
import { v4 as uuidv4 } from 'uuid'
export let isOpen:boolean
export let title:string
export let label:string
export let value:any
export let onSubmit:(value:any)=>boolean|void
export let onClose:(()=>boolean|void) = () => {}
export let saveButton:string = 'Save'
export let saveButtonClass:string = 'is-info'
export let closeButton:string = 'Cancel'
export let closeButtonClass:string = ''
export let placeholder:string = ''
export let error:string = ''
export let icon:Fa|null = null
let classes:string = ''
export { classes as class }
const id = uuidv4()
onMount(async () => {
const input = document.getElementById(id)
input && input.focus()
})
const doClose = () => {
if (!onClose || !onClose()) closeModal()
}
const doSubmit = (value) => {
onSubmit(value)
closeModal()
}
</script>
{#if isOpen}
<div class="modal is-active" on:modal-esc={doClose}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-background" on:click={doClose} />
<div class="modal-content nomax">
<form action="{'#'}" on:submit|preventDefault={() => { doSubmit(value) }}>
<article class="message {classes}">
<div class="message-header">
<p>{title}</p>
<button class="delete" aria-label="close" type="button" on:click={doClose}></button>
</div>
<div class="message-body">
<div class="field">
<label class="label" for="text-input">{label}</label>
<div class="control" class:has-icons-left={icon} class:has-icons-right={error} >
<input id={id} name="text-input" class="input" class:is-danger={error} type="text" placeholder={placeholder} bind:value={value}>
{#if icon}
<span class="icon is-small is-left">
<Fa icon={icon}/>
</span>
{/if}
{#if error}
<span class="icon is-small is-right">
<Fa icon={faExclamation}/>
<i class="fas fa-exclamation-triangle"></i>
</span>
{/if}
</div>
{#if error}
<p class="help is-danger">{error}</p>
{/if}
</div>
</div>
<div class="message-footer">
<div class="level is-mobile">
<div class="level-right">
</div>
<div class="level-right">
<div class="level-item">
<input type="submit" class="button {saveButtonClass}" value="{saveButton}" />
</div>
<div class="level-item">
<button class="button {closeButtonClass}" type="button" on:click={doClose} >{closeButton}</button>
</div>
</div>
</div>
</div>
</article>
</form>
</div>
</div>
{/if}

View File

@ -0,0 +1,50 @@
<script lang="ts">
import { closeModal } from 'svelte-modals'
export let isOpen:boolean
export let title:string
export let message:string
export let asHtml:boolean = false
export let onConfirm:(()=>boolean|void)|null = null
export let confirmButton:string = 'Close'
export let confirmButtonClass:string = 'is-info'
let classes:string = ''
export { classes as class }
const doConfirm = () => {
if (!onConfirm || !onConfirm()) closeModal()
}
</script>
{#if isOpen}
<div class="modal is-active" on:modal-esc={doConfirm}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-background" on:click={doConfirm} />
<div class="modal-content nomax">
<article class="message {classes}">
<div class="message-header">
<p>{title}</p>
<button class="delete" aria-label="close" type="button" on:click={doConfirm}></button>
</div>
<div class="message-body">
{#if asHtml}{@html message}{:else}{message}{/if}
</div>
<div class="message-footer">
<div class="level is-mobile">
<div class="level-right">
</div>
<div class="level-right">
<div class="level-item">
<button class="button {confirmButtonClass}" type="button" on:click={doConfirm} >{confirmButton}</button>
</div>
</div>
</div>
</div>
</article>
</div>
</div>
{/if}

451
src/lib/Settings.svelte Normal file
View File

@ -0,0 +1,451 @@
<script context="module" lang="ts">
import { applyProfile } from './Profiles.svelte'
import { getChatSettings, getGlobalSettings, setGlobalSettingValueByKey } from './Storage.svelte'
import { encode } from 'gpt-tokenizer'
import { faCheck, faThumbTack } from '@fortawesome/free-solid-svg-icons/index'
// Setting definitions
import {
type ChatSettings,
type ChatSetting,
type SettingSelect,
type GlobalSetting,
type GlobalSettings,
type Request,
type Model,
type ControlAction
} from './Types.svelte'
export const defaultModel:Model = 'gpt-3.5-turbo'
export const getChatSettingList = (): ChatSetting[] => {
return chatSettingsList
}
export const getRequestSettingList = (): ChatSetting[] => {
return chatSettingsList.filter(s => s.key in gptDefaults)
}
export const getChatSettingObjectByKey = (key: keyof ChatSettings): ChatSetting => {
const result = chatSettingLookup[key]
if (!result) console.error(`Chat Setting "${key}" not defined in Settings array.`)
return result
}
export const getGlobalSettingList = (): GlobalSetting[] => {
return globalSettingsList
}
export const getGlobalSettingObjectByKey = (key: keyof GlobalSettings): GlobalSetting => {
return globalSettingLookup[key]
}
export const getRequestDefaults = ():Request => {
return gptDefaults
}
export const getChatDefaults = ():ChatSettings => {
return defaults
}
export const getExcludeFromProfile = () => {
return excludeFromProfile
}
const gptDefaults = {
model: defaultModel,
messages: [],
temperature: 1,
top_p: 1,
n: 1,
stream: true,
stop: null,
max_tokens: 512,
presence_penalty: 0,
frequency_penalty: 0,
logit_bias: null,
user: undefined
}
// Core set of defaults
const defaults:ChatSettings = {
...gptDefaults,
profile: '',
characterName: 'ChatGPT',
profileName: '',
profileDescription: '',
continuousChat: 'fifo',
summaryThreshold: 3000,
summarySize: 1000,
pinTop: 0,
pinBottom: 6,
summaryPrompt: '',
useSystemPrompt: false,
systemPrompt: '',
autoStartSession: false,
trainingPrompts: [],
// useResponseAlteration: false,
// responseAlterations: [],
isDirty: false
}
const excludeFromProfile = {
messages: true,
user: true,
isDirty: true
}
const profileSetting: ChatSetting & SettingSelect = {
key: 'profile',
name: 'Profile',
title: 'Choose how you want your assistant to act.',
header: 'Profile / Presets',
headerClass: 'is-info',
options: [], // Set by Profiles
type: 'select',
afterChange: (chatId, setting) => {
applyProfile(chatId)
return true // Signal we should refresh the setting modal
},
fieldControls: [{
getAction: (chatId, setting, value) => {
if (value === getGlobalSettings().defaultProfile) {
return {
title: 'This profile is currently your default',
icon: faCheck
} as ControlAction
} else {
return {
title: 'Set this profile as your default',
icon: faThumbTack,
class: 'is-info',
action: (chatId, setting, value) => {
setGlobalSettingValueByKey('defaultProfile', value)
}
} as ControlAction
}
}
}]
}
// Settings that will not be part of the API request
const systemPromptSettings: ChatSetting[] = [
{
key: 'profileName',
name: 'Profile Name',
title: 'How this profile is displayed in the select list.',
type: 'text'
// hide: (chatId) => { return !getChatSettingValueByKey(chatId, 'useSystemPrompt') }
},
{
key: 'profileDescription',
name: 'Description',
title: 'How this profile is displayed in the select list.',
type: 'textarea'
// hide: (chatId) => { return !getChatSettingValueByKey(chatId, 'useSystemPrompt') }
},
{
key: 'useSystemPrompt',
name: 'Use Character / System Prompt',
title: 'Send a "System" prompt as the first prompt.',
header: 'System Prompt',
headerClass: 'is-info',
type: 'boolean'
},
{
key: 'characterName',
name: 'Character Name',
title: 'What the personality of this profile will be called.',
type: 'text',
hide: (chatId) => !getChatSettings(chatId).useSystemPrompt
},
{
key: 'systemPrompt',
name: 'System Prompt',
title: 'First prompt to send.',
placeholder: 'Enter the first prompt to send here. You can tell ChatGPT how to act.',
type: 'textarea',
hide: (chatId) => !getChatSettings(chatId).useSystemPrompt
},
{
key: 'trainingPrompts',
name: 'Training Prompts',
title: 'Prompts used to train.',
type: 'other',
hide: (chatId) => true
},
{
key: 'autoStartSession',
name: 'Auto-Start Session',
title: 'If possible, auto-start the chat session, sending a system prompt to get an initial response.',
type: 'boolean',
hide: (chatId) => !getChatSettings(chatId).useSystemPrompt
}
]
const summarySettings: ChatSetting[] = [
{
key: 'continuousChat',
name: 'Continuous Chat',
header: 'Continuous Chat',
headerClass: 'is-info',
title: 'When out of token space, summarize or remove past prompts and keep going.',
type: 'select',
options: [
{ value: '', text: 'OFF - Chat errors when token buffer full' },
{ value: 'fifo', text: 'FIFO - First message in is first out' },
{ value: 'summary', text: 'Summary - Summarize past messages' }
],
afterChange: (chatId, setting) => true // refresh settings
},
{
key: 'summaryThreshold',
name: 'Token Threshold',
title: 'When prompt history breaks this threshold, past prompts will be summarized or rolled off to create space.',
min: 0,
max: 32000,
step: 1,
type: 'number',
hide: (chatId) => !getChatSettings(chatId).continuousChat
},
{
key: 'summarySize',
name: 'Max Summary Size',
title: 'Maximum number of tokens allowed for summary response.',
min: 128,
max: 2048,
step: 1,
type: 'number',
hide: (chatId) => getChatSettings(chatId).continuousChat !== 'summary'
},
{
key: 'pinTop',
name: 'Keep First Prompts',
title: 'When we run out of space and need to remove prompts, the top number of prompts will not be removed after summarization/FIFO.',
min: 0,
max: 4,
step: 1,
type: 'number',
hide: (chatId) => !getChatSettings(chatId).continuousChat
},
{
key: 'pinBottom',
name: 'Keep Bottom Prompts',
title: 'When we run out of space and need to remove prompts, do not remove or summarize the the last number prompts you set here.',
min: 0,
max: 20, // Will be auto adjusted down if needs more
step: 1,
type: 'number',
hide: (chatId) => !getChatSettings(chatId).continuousChat
},
{
key: 'summaryPrompt',
name: 'Summary Generation Prompt (Empty will use FIFO instead.)',
title: 'A prompt used to summarize past prompts.',
placeholder: 'Enter a prompt that will be used to summarize past prompts here.',
type: 'textarea',
hide: (chatId) => getChatSettings(chatId).continuousChat !== 'summary'
}
]
// const responseAlterationSettings: ChatSetting[] = [
// {
// key: 'useResponseAlteration',
// name: 'Alter Responses',
// header: 'Automatic Response Alteration',
// headerClass: 'is-info',
// title: 'When an undesired response is encountered, try to alter it in effort to improve future responses.',
// type: 'boolean',
// hide: () => true
// },
// {
// key: 'responseAlterations',
// name: 'Alterations',
// title: 'Add find/replace or re-prompts.',
// header: 'Profile / Presets',
// headerClass: 'is-info',
// settings: [
// {
// key: 'type',
// type: 'select',
// name: 'Alteration Type',
// default: 'replace',
// options: [{
// value: 'replace',
// text: 'Regexp Find / Replace'
// }, {
// value: 'prompt',
// text: 'Re-prompt with Instructions'
// }]
// },
// {
// key: 'match',
// type: 'text',
// name: 'Match Expression',
// title: 'Regular expression used to match '
// },
// {
// key: 'replace',
// type: 'text',
// name: 'Alteration',
// title: 'Regexp Replacement or Re-prompt'
// }
// ],
// type: 'subset',
// hide: (chatId) => !getChatSettings(chatId).useResponseAlteration!
// }
// ]
const modelSetting: ChatSetting & SettingSelect = {
key: 'model',
name: 'Model',
title: 'The model to use - GPT-3.5 is cheaper, but GPT-4 is more powerful.',
header: 'Below are the settings that OpenAI allows to be changed for the API calls. See the <a target="_blank" href="https://platform.openai.com/docs/api-reference/chat/create">OpenAI API docs</a> for more details.',
headerClass: 'is-warning',
options: [],
type: 'select',
forceApi: true, // Need to make sure we send this
afterChange: (chatId, setting) => true // refresh settings
}
const chatSettingsList: ChatSetting[] = [
profileSetting,
...systemPromptSettings,
...summarySettings,
// ...responseAlterationSettings,
modelSetting,
{
key: 'stream',
name: 'Stream Response',
title: 'Stream responses as they are generated.',
type: 'boolean'
},
{
key: 'temperature',
name: 'Sampling Temperature',
title: 'What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.\n' +
'\n' +
'We generally recommend altering this or top_p but not both.',
min: 0,
max: 2,
step: 0.1,
type: 'number'
},
{
key: 'top_p',
name: 'Nucleus Sampling',
title: 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.\n' +
'\n' +
'We generally recommend altering this or temperature but not both',
min: 0,
max: 1,
step: 0.1,
type: 'number'
},
{
key: 'n',
name: 'Number of Messages',
title: 'CAREFUL WITH THIS ONE: How many chat completion choices to generate for each input message. This can eat tokens.',
min: 1,
max: 10,
step: 1,
type: 'number'
},
{
key: 'max_tokens',
name: 'Max Tokens',
title: 'The maximum number of tokens to generate in the completion.\n' +
'\n' +
'The token count of your prompt plus max_tokens cannot exceed the model\'s context length. Most models have a context length of 2048 tokens (except for the newest models, which support 4096).\n',
min: 1,
max: 32768,
step: 1,
type: 'number',
forceApi: true // Since default here is different than gpt default, will make sure we always send it
},
{
key: 'presence_penalty',
name: 'Presence Penalty',
title: 'Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model\'s likelihood to talk about new topics.',
min: -2,
max: 2,
step: 0.2,
type: 'number'
},
{
key: 'frequency_penalty',
name: 'Frequency Penalty',
title: 'Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model\'s likelihood to repeat the same line verbatim.',
min: -2,
max: 2,
step: 0.2,
type: 'number'
},
{
// logit bias editor not implemented yet
key: 'logit_bias',
name: 'Logit Bias',
title: 'Allows you to adjust bias of tokens used in completion.',
header: 'Logit Bias. See <a target="_blank" href="https://help.openai.com/en/articles/5247780-using-logit-bias-to-define-token-probability">this article</a> for more details.',
type: 'other',
hide: () => true,
// transform to word->weight pairs to token(s)->weight.
// -- care should be taken to have each word key in the each record formatted in a way where they
// only take one token each else you'll end up with results you probably don't want.
// Generally, leading space plus common lower case word will more often result in a single token
// See: https://platform.openai.com/tokenizer
apiTransform: (chatId, setting, val:Record<string, number>) => {
// console.log('logit_bias', val, getChatSettings(chatId).logit_bias)
if (!val) return null
const tokenized:Record<number, number> = Object.entries(val).reduce((a, [k, v]) => {
const tokens:number[] = encode(k)
tokens.forEach(t => { a[t] = v })
return a
}, {} as Record<number, number>)
return tokenized
}
},
// Enable?
{
key: 'user',
name: 'User?',
title: 'Name of user?',
type: 'text',
hide: () => true
}
]
const chatSettingLookup:Record<string, ChatSetting> = chatSettingsList.reduce((a, v) => {
if (a[v.key]) console.error(`${a[v.key]} is defined more than once in Chat Settings.`)
a[v.key] = v
return a
}, {} as Record<string, ChatSetting>)
const globalSettingsList:GlobalSetting[] = [
{
key: 'lastProfile',
name: 'Last Profile',
type: 'text'
},
{
key: 'defaultProfile',
name: 'Default Profile',
type: 'text'
},
{
key: 'hideSummarized',
name: 'Hide Summarized Messages',
type: 'boolean'
}
]
const globalSettingLookup:Record<string, GlobalSetting> = globalSettingsList.reduce((a, v) => {
if (a[v.key]) console.error(`${a[v.key]} is defined more than once in Global Settings.`)
a[v.key] = v
return a
}, {} as Record<string, GlobalSetting>)
</script>

View File

@ -1,91 +1,58 @@
<script lang="ts">
import { params, replace } from 'svelte-spa-router'
import { apiKeyStorage, chatsStorage, clearChats, deleteChat } from './Storage.svelte'
import { exportAsMarkdown } from './Export.svelte'
import { params } from 'svelte-spa-router'
import ChatMenuItem from './ChatMenuItem.svelte'
import { apiKeyStorage, chatsStorage, pinMainMenu, checkStateChange } from './Storage.svelte'
import Fa from 'svelte-fa/src/fa.svelte'
import { faSquarePlus, faKey } from '@fortawesome/free-solid-svg-icons/index'
import ChatOptionMenu from './ChatOptionMenu.svelte'
import logo from '../assets/logo.svg'
import { clickOutside } from 'svelte-use-click-outside'
import { startNewChatWithWarning } from './Util.svelte'
$: sortedChats = $chatsStorage.sort((a, b) => b.id - a.id)
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined
function delChat (chatId) {
if (activeChatId === chatId) {
// Find the max chatId other than the current one
const newChatId = sortedChats.reduce((maxId, chat) => {
if (chat.id === chatId) return maxId
return Math.max(maxId, chat.id)
}, 0)
if (!newChatId) {
// No other chats, clear all and go to home
replace('/').then(() => { deleteChat(chatId) })
} else {
// Delete the current chat and go to the max chatId
replace(`/chat/${newChatId}`).then(() => { deleteChat(chatId) })
}
} else {
deleteChat(chatId)
}
}
</script>
<aside class="menu">
<p class="menu-label">Chats</p>
<ul class="menu-list">
<aside class="menu main-menu" class:pinned={$pinMainMenu} use:clickOutside={() => { $pinMainMenu = false }}>
<div class="menu-expanse">
<div class="navbar-brand menu-nav-bar">
<a class="navbar-item gpt-logo" href={'#/'}>
<img src={logo} alt="ChatGPT-web" width="24" height="24" />
<p class="ml-2 is-size-5 has-text-weight-bold">ChatGPT-web</p>
</a>
<div class="chat-option-menu navbar-item is-pulled-right">
<ChatOptionMenu bind:chatId={activeChatId} />
</div>
</div>
<ul class="menu-list menu-expansion-list">
{#if sortedChats.length === 0}
<li><a href={'#'} class="is-disabled">No chats yet...</a></li>
{:else}
<li>
<ul>
{#each sortedChats as chat}
<li>
<a style="position: relative" href={`#/chat/${chat.id}`} class:is-disabled={!$apiKeyStorage} class:is-active={activeChatId === chat.id}>
<a class="is-pulled-right is-hidden px-1 py-0 greyscale has-text-weight-bold delete-button" href={'$'} on:click|preventDefault={() => delChat(chat.id)}>🗑️</a>
{chat.name || `Chat ${chat.id}`}
</a>
</li>
{#key $checkStateChange}
{#each sortedChats as chat, i}
<ChatMenuItem activeChatId={activeChatId} chat={chat} prevChat={sortedChats[i - 1]} nextChat={sortedChats[i + 1]} />
{/each}
</ul>
</li>
{/key}
{/if}
</ul>
<p class="menu-label">Actions</p>
<!-- <p class="menu-label">Actions</p> -->
<ul class="menu-list">
<li>
<a href={'#/'} class="panel-block" class:is-disabled={!$apiKeyStorage} class:is-active={!activeChatId}
><span class="greyscale mr-2">🔑</span> API key</a
>
</li>
<li>
<a href={'#/chat/new'} class="panel-block" class:is-disabled={!$apiKeyStorage}
><span class="greyscale mr-2"></span> New chat</a
>
</li>
<li>
<a class="panel-block"
href="{'#/'}"
class:is-disabled={!$apiKeyStorage}
on:click|preventDefault={() => {
const confirmDelete = window.confirm('Are you sure you want to delete all your chats?')
if (confirmDelete) {
replace('#/').then(() => clearChats())
}
}}><span class="greyscale mr-2">🗑️</span> Clear chats</a
>
</li>
{#if activeChatId}
<li>
<a
href={'#/'}
class="panel-block"
class:is-disabled={!apiKeyStorage}
on:click|preventDefault={() => {
if (activeChatId) {
exportAsMarkdown(activeChatId)
}
}}><span class="greyscale mr-2">📥</span> Export chat</a
>
</li>
<div class="level-right side-actions">
{#if !$apiKeyStorage}
<div class="level-item">
<a href={'#/'} class="panel-block" class:is-disabled={!$apiKeyStorage}
><span class="greyscale mr-2"><Fa icon={faKey} /></span> API key</a
></div>
{:else}
<div class="level-item">
<button on:click={() => { startNewChatWithWarning(activeChatId) }} class="panel-block button" title="Start new chat with default profile" class:is-disabled={!$apiKeyStorage}
><span class="greyscale mr-2"><Fa icon={faSquarePlus} /></span> New chat</button>
</div>
{/if}
</div>
</li>
</ul>
</div>
</aside>

45
src/lib/Stats.svelte Normal file
View File

@ -0,0 +1,45 @@
<script context="module" lang="ts">
import type { Message, Model, Usage } from './Types.svelte'
import { encode } from 'gpt-tokenizer'
// Reference: https://openai.com/pricing#language-models
// TODO: Move to settings of some type
const modelDetails : Record<string, [number, number, number]> = {
'gpt-4-32k': [0.00006, 0.00012, 32768], // $0.06 per 1000 tokens prompt, $0.12 per 1000 tokens completion, max 32k
'gpt-4': [0.00003, 0.00006, 8192], // $0.03 per 1000 tokens prompt, $0.06 per 1000 tokens completion, max 8k
'gpt-3.5': [0.000002, 0.000002, 4096] // $0.002 per 1000 tokens (both prompt and completion), max 4k
}
const tpCache = {}
const getModelDetail = (model: Model) => {
let r = tpCache[model]
if (r) return r
const k = Object.keys(modelDetails).find((k) => model.startsWith(k))
if (k) {
r = modelDetails[k]
} else {
r = [0, 0, 4096]
}
tpCache[model] = r
return r
}
export const getPrice = (tokens: Usage, model: Model): number => {
const t = getModelDetail(model)
return ((tokens.prompt_tokens * t[0]) + (tokens.completion_tokens * t[1]))
}
export const countPromptTokens = (prompts:Message[], model:Model):number => {
return prompts.reduce((a, m) => {
// Not sure how OpenAI formats it, but this seems to get close to the right counts.
// Would be nice to know. This works for gpt-3.5. gpt-4 could be different
a += encode('## ' + m.role + ' ##:\r\n\r\n' + m.content + '\r\n\r\n\r\n').length
return a
}, 0) + 3
}
export const getModelMaxTokens = (model:Model):number => {
return getModelDetail(model)[2]
}
</script>

View File

@ -1,43 +1,287 @@
<script context="module" lang="ts">
import { persisted } from 'svelte-local-storage-store'
import { get } from 'svelte/store'
import type { Chat, Message } from './Types.svelte'
import { get, writable } from 'svelte/store'
import type { Chat, ChatSettings, GlobalSettings, Message, ChatSetting, GlobalSetting, Usage, Model } from './Types.svelte'
import { getChatSettingObjectByKey, getGlobalSettingObjectByKey, getChatDefaults, getExcludeFromProfile } from './Settings.svelte'
import { v4 as uuidv4 } from 'uuid'
import { getProfile, getProfiles, isStaticProfile, newNameForProfile, restartProfile } from './Profiles.svelte'
import { errorNotice } from './Util.svelte'
export const chatsStorage = persisted('chats', [] as Chat[])
export const latestModelMap = persisted('latestModelMap', {} as Record<Model, Model>) // What was returned when a model was requested
export const globalStorage = persisted('global', {} as GlobalSettings)
export const apiKeyStorage = persisted('apiKey', '' as string)
export let checkStateChange = writable(0) // Trigger for Chat
export let showSetChatSettings = writable(false) //
export let submitExitingPromptsNow = writable(false) // for them to go now. Will not submit anything in the input
export let pinMainMenu = writable(false) // Show menu (for mobile use)
export let continueMessage = writable('') //
export const addChat = (): number => {
const chatDefaults = getChatDefaults()
export const newChatID = (): number => {
const chats = get(chatsStorage)
const chatId = chats.reduce((maxId, chat) => Math.max(maxId, chat.id), 0) + 1
return chatId
}
export const addChat = (profile:ChatSettings|undefined = undefined): number => {
const chats = get(chatsStorage)
// Find the max chatId
const chatId = chats.reduce((maxId, chat) => Math.max(maxId, chat.id), 0) + 1
const chatId = newChatID()
profile = JSON.parse(JSON.stringify(profile || getProfile(''))) as ChatSettings
// Add a new chat
chats.push({
id: chatId,
name: `Chat ${chatId}`,
messages: []
settings: profile,
messages: [],
usage: {} as Record<Model, Usage>,
startSession: false,
sessionStarted: false
})
chatsStorage.set(chats)
// Apply defaults and prepare it to start
restartProfile(chatId)
return chatId
}
export const addChatFromJSON = (json: string): number => {
const chats = get(chatsStorage)
// Find the max chatId
const chatId = newChatID()
let chat: Chat
try {
chat = JSON.parse(json) as Chat
if (!chat.settings || !chat.messages || isNaN(chat.id)) {
errorNotice('Not valid Chat JSON')
return 0
}
} catch (err) {
errorNotice("Can't parse file JSON")
return 0
}
chat.id = chatId
// Add a new chat
chats.push(chat)
chatsStorage.set(chats)
// make sure it's up-to-date
updateChatSettings(chatId)
return chatId
}
// Make sure a chat's settings are set with current values or defaults
export const updateChatSettings = (chatId:number) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
if (!chat.settings) {
chat.settings = {} as ChatSettings
}
updateProfile(chat.settings, false)
// make sure old chat messages have UUID
chat.messages.forEach((m) => {
m.uuid = m.uuid || uuidv4()
delete m.streaming
})
// Make sure the usage totals object is set
// (some earlier versions of this had different structures)
const hasUsage = chat.usage && !Array.isArray(chat.usage) &&
typeof chat.usage === 'object' &&
Object.values(chat.usage).find(v => 'prompt_tokens' in v)
if (!hasUsage) {
const usageMap:Record<Model, Usage> = {}
chat.usage = usageMap
}
if (chat.startSession === undefined) chat.startSession = false
if (chat.sessionStarted === undefined) chat.sessionStarted = !!chat.messages.find(m => m.role === 'user')
chatsStorage.set(chats)
}
// Make sure profile options are set with current values or defaults
export const updateProfile = (profile:ChatSettings, exclude:boolean):ChatSettings => {
Object.entries(getChatDefaults()).forEach(([k, v]) => {
const val = profile[k]
profile[k] = (val === undefined || val === null ? v : profile[k])
})
// update old useSummarization to continuousChat mode setting
if ('useSummarization' in profile || !('continuousChat' in profile)) {
const usm = profile.useSummarization
if (usm && !profile.summaryPrompt) {
profile.continuousChat = 'fifo'
} else if (usm) {
profile.continuousChat = 'summary'
} else {
profile.continuousChat = ''
}
delete profile.useSummarization
}
if (exclude) {
Object.keys(getExcludeFromProfile()).forEach(k => {
delete profile[k]
})
}
return profile
}
// Reset all setting to current profile defaults
export const resetChatSettings = (chatId, resetAll:boolean = false) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const profile = getProfile(chat.settings.profile)
const exclude = getExcludeFromProfile()
if (resetAll) {
// Reset to base defaults first, then apply profile
Object.entries(getChatDefaults()).forEach(([k, v]) => {
chat.settings[k] = v
})
}
Object.entries(profile).forEach(([k, v]) => {
if (exclude[k]) return
chat.settings[k] = v
})
chatsStorage.set(chats)
}
export const clearChats = () => {
chatsStorage.set([])
}
export const saveChatStore = () => {
const chats = get(chatsStorage)
chatsStorage.set(chats)
}
export const getChat = (chatId: number):Chat => {
const chats = get(chatsStorage)
return chats.find((chat) => chat.id === chatId) as Chat
}
export const getChatSettings = (chatId: number):ChatSettings => {
const chats = get(chatsStorage)
return (chats.find((chat) => chat.id === chatId) as Chat).settings
}
export const updateRunningTotal = (chatId: number, usage: Usage, model:Model) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
let total:Usage = chat.usage[model]
if (!total) {
total = {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
chat.usage[model] = total
}
total.completion_tokens += usage.completion_tokens
total.prompt_tokens += usage.prompt_tokens
total.total_tokens += usage.total_tokens
chatsStorage.set(chats)
}
export const subtractRunningTotal = (chatId: number, usage: Usage, model:Model) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
let total:Usage = chat.usage[model]
if (!total) {
total = {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0
}
chat.usage[model] = total
}
total.completion_tokens -= usage.completion_tokens
total.prompt_tokens -= usage.prompt_tokens
total.total_tokens -= usage.total_tokens
chatsStorage.set(chats)
}
export const addMessage = (chatId: number, message: Message) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
if (!message.uuid) message.uuid = uuidv4()
if (chat.messages.indexOf(message) < 0) {
// Don't have message, add it
chat.messages.push(message)
}
chatsStorage.set(chats)
}
export const editMessage = (chatId: number, index: number, newMessage: Message) => {
export const getMessages = (chatId: number):Message[] => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
chat.messages[index] = newMessage
chat.messages.splice(index + 1) // remove the rest of the messages
return chat.messages
}
export const getMessage = (chat: Chat, uuid:string):Message|undefined => {
return chat.messages.find((m) => m.uuid === uuid)
}
export const insertMessages = (chatId: number, insertAfter: Message, newMessages: Message[]) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const index = chat.messages.findIndex((m) => m.uuid === insertAfter.uuid)
if (index === undefined || index < 0) {
console.error("Couldn't insert after message:", insertAfter)
return
}
chat.messages.splice(index + 1, 0, ...newMessages)
chatsStorage.set(chats)
}
export const deleteSummaryMessage = (chatId: number, uuid: string) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const message = getMessage(chat, uuid)
if (message && message.summarized) throw new Error('Unable to delete summarized message')
if (message && message.summary) { // messages we summarized
message.summary.forEach(sid => {
const m = getMessage(chat, sid)
if (m) {
delete m.summarized // unbind to this summary
}
})
delete message.summary
}
chatsStorage.set(chats)
deleteMessage(chatId, uuid)
}
export const deleteMessage = (chatId: number, uuid: string) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const index = chat.messages.findIndex((m) => m.uuid === uuid)
const message = getMessage(chat, uuid)
if (message && message.summarized) throw new Error('Unable to delete summarized message')
if (message && message.summary) throw new Error('Unable to directly delete message summary')
// const found = chat.messages.filter((m) => m.uuid === uuid)
if (index < 0) {
console.error(`Unable to find and delete message with ID: ${uuid}`)
return
}
// console.warn(`Deleting message with ID: ${uuid}`, found, index)
chat.messages.splice(index, 1) // remove item
chatsStorage.set(chats)
}
export const truncateFromMessage = (chatId: number, uuid: string) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const index = chat.messages.findIndex((m) => m.uuid === uuid)
const message = getMessage(chat, uuid)
if (message && message.summarized) throw new Error('Unable to truncate from a summarized message')
// const found = chat.messages.filter((m) => m.uuid === uuid)
if (index < 0) {
throw new Error(`Unable to find message with ID: ${uuid}`)
}
chat.messages.splice(index + 1) // remove every item after
chatsStorage.set(chats)
}
@ -52,4 +296,184 @@
const chats = get(chatsStorage)
chatsStorage.set(chats.filter((chat) => chat.id !== chatId))
}
export const copyChat = (chatId: number) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const nameMap = chats.reduce((a, chat) => { a[chat.name] = chat; return a }, {})
let i:number = 1
let cname = chat.name + `-${i}`
while (nameMap[cname]) {
i++
cname = chat.name + `-${i}`
}
const chatCopy = JSON.parse(JSON.stringify(chat))
// Set the ID
chatCopy.id = newChatID()
// Set new name
chatCopy.name = cname
// Add a new chat
chats.push(chatCopy)
// chatsStorage
chatsStorage.set(chats)
}
export const cleanSettingValue = (type:string, value: any) => {
switch (type) {
case 'number':
value = parseFloat(value)
if (isNaN(value)) { value = null }
return value
case 'boolean':
if (typeof value === 'string') value = value.trim().toLocaleLowerCase()
return value === 'true' || value === 'yes' || (value ? value !== 'false' && value !== 'no' && !!value : false)
default:
return value
}
}
export const setChatSettingValueByKey = (chatId: number, key: keyof ChatSettings, value) => {
const setting = getChatSettingObjectByKey(key)
if (setting) return setChatSettingValue(chatId, setting, value)
if (!(key in chatDefaults)) throw new Error('Invalid chat setting: ' + key)
const d = chatDefaults[key]
if (d === null || d === undefined) {
throw new Error('Unable to determine setting type for "' +
key + ' from default of "' + d + '"')
}
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const settings = chat.settings as any
settings[key] = cleanSettingValue(typeof d, value)
}
export const setChatSettingValue = (chatId: number, setting: ChatSetting, value) => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
let settings = chat.settings as any
if (!settings) {
settings = {} as ChatSettings
chat.settings = settings
}
settings[setting.key] = cleanSettingValue(setting.type, value)
chatsStorage.set(chats)
}
export const getChatSettingValueNullDefault = (chatId: number, setting: ChatSetting):any => {
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
let value = chat.settings && chat.settings[setting.key]
value = (value === undefined) ? null : value
if (!setting.forceApi && value === chatDefaults[setting.key]) value = null
return value
}
export const setGlobalSettingValueByKey = (key: keyof GlobalSettings, value) => {
return setGlobalSettingValue(getGlobalSettingObjectByKey(key), value)
}
export const setGlobalSettingValue = (setting: GlobalSetting, value) => {
const store = get(globalStorage)
store[setting.key as any] = cleanSettingValue(setting.type, value)
globalStorage.set(store)
}
export const getGlobalSettingValue = (key:keyof GlobalSetting, value):any => {
const store = get(globalStorage)
return store[key]
}
export const getGlobalSettings = ():GlobalSettings => {
return get(globalStorage)
}
export const getCustomProfiles = ():Record<string, ChatSettings> => {
const store = get(globalStorage)
return store.profiles || {}
}
export const deleteCustomProfile = (chatId:number, profileId:string) => {
if (isStaticProfile(profileId as any)) {
throw new Error('Sorry, you can\'t delete a static profile.')
}
const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat
const store = get(globalStorage)
if (store.defaultProfile === chat.settings.profile) {
throw new Error('Sorry, you can\'t delete the default profile.')
}
delete store.profiles[profileId]
globalStorage.set(store)
getProfiles(true) // force update profile cache
}
export const saveCustomProfile = (profile:ChatSettings) => {
const store = get(globalStorage)
let profiles = store.profiles
if (!profiles) {
profiles = {}
store.profiles = profiles
}
if (!profile.profile) profile.profile = uuidv4()
const mt = profile.profileName && profile.profileName.trim().toLocaleLowerCase()
const sameTitle = Object.values(profiles).find(c => c.profile !== profile.profile &&
c.profileName && c.profileName.trim().toLocaleLowerCase() === mt)
if (sameTitle) {
throw new Error(`Sorry, another profile already exists with the name "${profile.profileName}"`)
}
if (!mt) {
throw new Error('Sorry, you need to enter a valid name for your profile.')
}
if (!profile.characterName || profile.characterName.length < 3) {
throw new Error('Your profile\'s character needs a valid name.')
}
if (isStaticProfile(profile.profile as any)) {
// throw new Error('Sorry, you can\'t modify a static profile. You can clone it though!')
// Save static profile as new custom
profile.profileName = newNameForProfile(profile.profileName)
profile.profile = uuidv4()
}
const clone = JSON.parse(JSON.stringify(profile)) // Always store a copy
// pull excluded
Object.keys(getExcludeFromProfile()).forEach(k => {
delete clone[k]
})
// pull defaults
// Object.entries(getChatDefaults()).forEach(([k, v]) => {
// if (clone[k] === v || (v === undefined && clone[k] === null)) delete clone[k]
// })
profiles[profile.profile as string] = clone
globalStorage.set(store)
profile.isDirty = false
saveChatStore()
getProfiles(true) // force update profile cache
}
export const newName = (name:string, nameMap:Record<string, any>):string => {
if (!nameMap[name]) return name
let i:number = 1
let cname = name + `-${i}`
while (nameMap[cname]) {
i++
cname = name + `-${i}`
}
return cname
}
export const getLatestKnownModel = (model:Model) => {
const modelMapStore = get(latestModelMap)
return modelMapStore[model] || model
}
export const setLatestKnownModel = (requestedModel:Model, responseModel:Model) => {
const modelMapStore = get(latestModelMap)
modelMapStore[requestedModel] = responseModel
latestModelMap.set(modelMapStore)
}
</script>

View File

@ -1,4 +1,6 @@
<script context="module" lang="ts">
// import type internal from "stream";
export const supportedModels = [ // See: https://platform.openai.com/docs/models/model-endpoint-compatibility
'gpt-4',
'gpt-4-0314',
@ -18,19 +20,26 @@
export type Message = {
role: 'user' | 'assistant' | 'system' | 'error';
content: string;
uuid: string;
usage?: Usage;
model?: Model;
removed?: boolean;
summarized?: string[];
summary?: string[];
suppress?: boolean;
finish_reason?: string;
streaming?: boolean;
};
export type Chat = {
id: number;
name: string;
messages: Message[];
};
export type ResponseAlteration = {
type: 'prompt' | 'replace';
match: string;
replace: string;
}
export type Request = {
model?: Model;
messages: Message[];
messages?: Message[];
temperature?: number;
top_p?: number;
n?: number;
@ -39,29 +48,40 @@
max_tokens?: number;
presence_penalty?: number;
frequency_penalty?: number;
logit_bias?: Record<string, any>;
logit_bias?: Record<string, any> | null;
user?: string;
};
type SettingsNumber = {
type: 'number';
default: number;
min: number;
max: number;
step: number;
};
export type ChatSettings = {
profile: string,
characterName: string,
profileName: string,
profileDescription: string,
continuousChat: (''|'fifo'|'summary');
// useSummarization: boolean;
summaryThreshold: number;
summarySize: number;
pinTop: number;
pinBottom: number;
summaryPrompt: string;
useSystemPrompt: boolean;
systemPrompt: string;
autoStartSession: boolean;
trainingPrompts?: Message[];
useResponseAlteration?: boolean;
responseAlterations?: ResponseAlteration[];
isDirty?: boolean;
} & Request;
export type SettingsSelect = {
type: 'select';
default: Model;
options: Model[];
};
export type Settings = {
key: string;
export type Chat = {
id: number;
name: string;
title: string;
} & (SettingsNumber | SettingsSelect);
messages: Message[];
usage: Record<Model, Usage>;
settings: ChatSettings;
startSession: boolean;
sessionStarted: boolean;
};
type ResponseOK = {
id: string;
@ -71,6 +91,7 @@
index: number;
message: Message;
finish_reason: string;
delta: Message;
}[];
usage: Usage;
model: Model;
@ -93,4 +114,112 @@
id: string;
}[];
};
export type ChatCompletionOpts = {
chat: Chat;
autoAddMessages: boolean;
maxTokens?:number;
summaryRequest?:boolean;
didSummary?:boolean;
streaming?:boolean;
onMessageChange?: (messages: Message[]) => void;
fillMessage?:Message,
};
export type GlobalSettings = {
profiles: Record<string, ChatSettings>;
lastProfile?: string;
defaultProfile?: string;
hideSummarized?: boolean;
};
type SettingNumber = {
type: 'number';
min: number;
max: number;
step: number;
};
export type SelectOption = {
value: string;
text: string;
};
type SettingBoolean = {
type: 'boolean';
};
export type SettingSelect = {
type: 'select';
options: SelectOption[];
};
export type SettingText = {
type: 'text';
};
export type SettingTextArea = {
type: 'textarea';
lines?: number;
};
export type SettingOther = {
type: 'other';
};
export type ControlAction = {
title:string;
icon?:any,
text?:string;
class?:string;
disabled?:boolean;
action?: (chatId:number, setting:any, value:any) => any;
};
export type FieldControl = {
getAction: (chatId:number, setting:any, value:any) => ControlAction;
};
export type SubSetting = {
type: 'subset';
settings: any[];
};
export type ChatSetting = {
key: keyof ChatSettings;
name: string;
title: string;
forceApi?: boolean; // force in api requests, even if set to default
hidden?: boolean; // Hide from setting menus
header?: string;
headerClass?: string;
placeholder?: string;
hide?: (chatId:number) => boolean;
apiTransform?: (chatId:number, setting:ChatSetting, value:any) => any;
fieldControls?: FieldControl[];
beforeChange?: (chatId:number, setting:ChatSetting, value:any) => boolean;
afterChange?: (chatId:number, setting:ChatSetting, value:any) => boolean;
} & (SettingNumber | SettingSelect | SettingBoolean | SettingText | SettingTextArea | SettingOther | SubSetting);
export type GlobalSetting = {
key: keyof GlobalSettings;
name?: string;
title?: string;
required?: boolean; // force in request
hidden?: boolean; // Hide from setting menus
header?: string;
headerClass?: string;
} & (SettingNumber | SettingSelect | SettingBoolean | SettingText | SettingOther);
export type SettingPrompt = {
title: string;
message: string;
class?: string;
checkPrompt: (setting:ChatSetting, newVal:any, oldVal:any)=>boolean;
onYes?: (setting:ChatSetting, newVal:any, oldVal:any)=>boolean;
onNo?: (setting:ChatSetting, newVal:any, oldVal:any)=>boolean;
passed: boolean;
};
</script>

122
src/lib/Util.svelte Normal file
View File

@ -0,0 +1,122 @@
<script context="module" lang="ts">
import { compare } from 'stacking-order'
import { openModal } from 'svelte-modals'
import PromptNotice from './PromptNotice.svelte'
import { getChat } from './Storage.svelte'
import { replace } from 'svelte-spa-router'
import PromptConfirm from './PromptConfirm.svelte'
export const sizeTextElements = () => {
const els = document.querySelectorAll('textarea.auto-size')
for (let i:number = 0, l = els.length; i < l; i++) autoGrowInput(els[i] as HTMLTextAreaElement)
}
export const autoGrowInputOnEvent = (event: Event) => {
// Resize the textarea to fit the content - auto is important to reset the height after deleting content
if (event.target === null) return
(event.target as any).__didAutoGrow = false
autoGrowInput(event.target as HTMLTextAreaElement)
}
export const autoGrowInput = (el: HTMLTextAreaElement) => {
const anyEl = el as any // Oh how I hate typescript. All the markup of Java with no real payoff..
if (!anyEl.__didAutoGrow) el.style.height = '38px' // don't use "auto" here. Firefox will over-size.
el.style.height = el.scrollHeight + 'px'
anyEl.__didAutoGrow = true // don't resize this one again unless it's via an event
}
export const scrollIntoViewWithOffset = (element:HTMLElement, offset:number, instant:boolean = false, bottom:boolean = false) => {
const behavior = instant ? 'instant' : 'smooth'
if (bottom) {
window.scrollTo({
behavior: behavior as any,
top:
(element.getBoundingClientRect().bottom) -
document.body.getBoundingClientRect().top - (window.innerHeight - offset)
})
} else {
window.scrollTo({
behavior: behavior as any,
top:
element.getBoundingClientRect().top -
document.body.getBoundingClientRect().top -
offset
})
}
}
export const scrollToMessage = (uuid:string | string[] | undefined, offset:number = 60, instant:boolean = false, bottom:boolean = false) => {
if (Array.isArray(uuid)) {
uuid = uuid[0]
}
if (!uuid) {
console.error('Not a valid uuid', uuid)
return
}
const el = document.getElementById('message-' + uuid)
if (el) {
scrollIntoViewWithOffset(el, offset, instant, bottom)
} else {
console.error("Can't find element with message ID", uuid)
}
}
export const checkModalEsc = (event:KeyboardEvent|undefined):boolean|void => {
if (!event || event.key !== 'Escape') return
dispatchModalEsc()
}
export const dispatchModalEsc = ():boolean|void => {
const stack = Array.from(document.querySelectorAll('.modal, .has-esc')).filter(s =>
window.getComputedStyle(s).getPropertyValue('display') !== 'none'
)
const top:HTMLElement = stack.length === 1
? stack[0]
: stack.find(m1 => {
return stack.find(m2 => {
return m1 !== m2 && compare(m1, m2) > 0 && m1
})
}) as any
if (top) {
// trigger modal-esc event on topmost modal when esc key is pressed
const e = new CustomEvent('modal-esc', { detail: top })
top.dispatchEvent(e)
}
}
export const errorNotice = (message:string, error:Error|undefined = undefined):any => {
openModal(PromptNotice, {
title: 'Error',
class: 'is-danger',
message: message + (error ? '<br>' + error.message : ''),
asHtml: true,
onConfirm: () => {}
})
}
export const warningNotice = (message:string, error:Error|undefined = undefined):any => {
openModal(PromptNotice, {
title: 'Warning',
class: 'is-warning',
message: message + (error ? '<br>' + error.message : ''),
asHtml: true,
onConfirm: () => {}
})
}
export const startNewChatWithWarning = (activeChatId: number|undefined) => {
if (activeChatId && getChat(activeChatId).settings.isDirty) {
openModal(PromptConfirm, {
title: 'Unsaved Profile',
message: '<p>There are unsaved changes to your current profile that will be lost.</p><p>Discard these changes and continue with new chat?</p>',
asHtml: true,
class: 'is-warning',
onConfirm: () => {
replace('#/chat/new')
}
})
} else {
replace('#/chat/new')
}
}
</script>

View File

@ -21,7 +21,8 @@ export default defineConfig(({ command, mode, ssrBuild }) => {
})
]
}
}
},
base: './'
}
} else {
return {