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 # Uncomment the following line to use the mocked API
#VITE_API_BASE=http://localhost:5174 #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": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": true "source.fixAll.eslint": true
}, },
"editor.insertSpaces": true,
"editor.tabSize": 2,
} }

259
package-lock.json generated
View File

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

View File

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

View File

@ -1,21 +1,15 @@
<script lang="ts"> <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 { wrap } from 'svelte-spa-router/wrap'
import Navbar from './lib/Navbar.svelte' import Navbar from './lib/Navbar.svelte'
import Sidebar from './lib/Sidebar.svelte' import Sidebar from './lib/Sidebar.svelte'
import Footer from './lib/Footer.svelte'
import Home from './lib/Home.svelte' import Home from './lib/Home.svelte'
import Chat from './lib/Chat.svelte' import Chat from './lib/Chat.svelte'
import NewChat from './lib/NewChat.svelte' import NewChat from './lib/NewChat.svelte'
import { chatsStorage, apiKeyStorage } from './lib/Storage.svelte' import { chatsStorage, apiKeyStorage } from './lib/Storage.svelte'
import { Modals, closeModal } from 'svelte-modals'
// Check if the API key is passed in as a "key" query parameter - if so, save it import { dispatchModalEsc, checkModalEsc } from './lib/Util.svelte'
// 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)
}
// The definition of the routes with some conditions // The definition of the routes with some conditions
const routes = { const routes = {
@ -37,23 +31,46 @@
'*': Home '*': Home
} }
const onLocationChange = (...args:any) => {
// close all modals on route change
dispatchModalEsc()
}
$: onLocationChange($location)
</script> </script>
<Navbar /> <Navbar />
<div class="side-bar-column">
<section class="section">
<div class="container is-fullhd">
<div class="columns">
<div class="column is-one-fifth">
<Sidebar /> <Sidebar />
</div> </div>
<div class="column is-four-fifths" id="content"> <div class="main-content-column" id="content">
{#key $location} {#key $location}
<Router {routes} on:conditionsFailed={() => replace('/')}/> <Router {routes} on:conditionsFailed={() => replace('/')}/>
{/key} {/key}
</div> </div>
</div>
</div>
</section>
<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 { @keyframes rotating {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
@ -13,22 +117,22 @@
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
min-height: 100vh; min-height: 100vh;
}
section.section { select option.is-default {
flex-grow: 1; background-color: #0842e058;
} }
.is-disabled {
pointer-events: none;
cursor: default;
opacity: .50;
} }
.rotate { .rotate {
animation: rotating 10s linear infinite; animation: rotating 10s linear infinite;
} }
a.is-disabled {
pointer-events: none;
cursor: default;
opacity: 0.5;
}
.greyscale { .greyscale {
filter: grayscale(100%); filter: grayscale(100%);
} }
@ -45,12 +149,6 @@ a.is-disabled {
width: fit-content; 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 */ /* Swap the border on user messages to the other side */
.user-message>.message-body { .user-message>.message-body {
border-width: 0 4px 0 0 !important; border-width: 0 4px 0 0 !important;
@ -68,9 +166,9 @@ a.is-disabled {
resize: vertical; resize: vertical;
} }
$footer-padding: 3rem 1.5rem; // $footer-padding: 1.5rem 1.5rem;
$fullhd: 2000px; // $fullhd: 2000px;
$modal-content-width: 1000px; // $modal-content-width: 1000px;
@import "/node_modules/bulma/bulma.sass"; @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 $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"; @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 // remove this once https: //github.com/jloh/bulma-prefers-dark/pull/90 is merged and released
background-color: $background-dark; background-color: $background-dark;
}
} }
/* Support for copy code button */ /* 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 */ /* Delete button on side menu chat name */
.menu-list { .menu-list {
a:hover { a:hover {
.delete-button { .delete-button {
@ -126,8 +236,17 @@ $modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove t
background-color: initial; background-color: initial;
} }
} }
.delete-button {
opacity: .8;
}
.delete-button:hover {
opacity: 1;
}
} }
/* Loading chat messages */ /* Loading chat messages */
.is-loading { .is-loading {
opacity: 0.5; opacity: 0.5;
@ -135,14 +254,425 @@ $modal-background-background-color-dark: rgba($dark, 0.86) !default; // remove t
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
border-width: 0.25em; border-width: 0.25em;
display: inline-block;
} }
/* Support for fullwidth dropdowns, see https://github.com/jgthms/bulma/issues/2055 */ /* Support for fullwidth dropdowns, see https://github.com/jgthms/bulma/issues/2055 */
.dropdown.is-fullwidth { .dropdown.is-fullwidth {
display: flex; display: flex;
.dropdown-trigger, .dropdown-trigger, .dropdown-menu {
.dropdown-menu {
width: 100%; 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 { get } from 'svelte/store'
import type { Chat } from './Types.svelte' import type { Chat } from './Types.svelte'
import { chatsStorage } from './Storage.svelte' import { chatsStorage } from './Storage.svelte'
import { getExcludeFromProfile } from './Settings.svelte'
export const exportAsMarkdown = (chatId: number) => { export const exportAsMarkdown = (chatId: number) => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat const chat = chats.find((chat) => chat.id === chatId) as Chat
const messages = chat.messages const messages = chat.messages
console.log(chat)
let markdownContent = `# ${chat.name}\n` let markdownContent = `# ${chat.name}\n`
messages.forEach((message) => { messages.forEach((message) => {
@ -26,4 +26,36 @@
a.click() a.click()
document.body.removeChild(a) 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> </script>

View File

@ -1,12 +1,27 @@
<footer class="footer"> <script lang="ts">
<div class="content has-text-centered"> 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> <p>
<strong>ChatGPT-web</strong> <strong>ChatGPT-web</strong>
by <span class="author">by
<a href="https://niekvandermaas.nl/">Niek van der Maas</a> <a target="_blank" href="https://niekvandermaas.nl/">Niek van der Maas</a>
&mdash; see </span>
<a href="https://github.com/Niek/chatgpt-web">GitHub</a> <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>
for source code. <span style="display:inline-block;width:30px;height:20px;"></span>
</p> </p>
</div> </div>
</footer> </div>

View File

@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { apiKeyStorage } from './Storage.svelte' import { apiKeyStorage } from './Storage.svelte'
import Footer from './Footer.svelte'
$: apiKey = $apiKeyStorage
$: apiKey = $apiKeyStorage
</script> </script>
<article class="message"> <section class="section">
<article class="message">
<div class="message-body"> <div class="message-body">
<strong><a href="https://github.com/Niek/chatgpt-web">ChatGPT-web</a></strong> <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 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 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. <strong>private</strong>. You can also close the browser tab and come back later to continue the conversation.
</div> </div>
</article> </article>
<article class="message" class:is-danger={!apiKey} class:is-warning={apiKey}> <article class="message" class:is-danger={!apiKey} class:is-warning={apiKey}>
<div class="message-body"> <div class="message-body">
Set your OpenAI API key below: Set your OpenAI API key below:
@ -23,7 +26,9 @@
class="field has-addons has-addons-right" class="field has-addons has-addons-right"
on:submit|preventDefault={(event) => { on:submit|preventDefault={(event) => {
if (event.target && event.target[0].value) { 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> </p>
{/if} {/if}
</div> </div>
</article> </article>
{#if apiKey} {#if apiKey}
<article class="message is-info"> <article class="message is-info">
<div class="message-body"> <div class="message-body">
Select an existing chat on the sidebar, or Select an existing chat on the sidebar, or
<a href={'#/chat/new'}>create a new chat</a> <a href={'#/chat/new'}>create a new chat</a>
</div> </div>
</article> </article>
{/if} {/if}
</section>
<Footer pin={true} />

View File

@ -1,80 +1,19 @@
<script lang="ts"> <script lang="ts">
import Code from './Code.svelte' // Iterate messages
import SvelteMarkdown from 'svelte-markdown' import type { Message, Chat } from './Types.svelte'
import type { Message, Model, Usage } from './Types.svelte' import { chatsStorage, globalStorage } from './Storage.svelte'
import EditMessage from './EditMessage.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
}
export let messages : Message[] export let messages : Message[]
export let input: HTMLTextAreaElement export let chatId: number
export let defaultModel: Model
// Reference: https://openai.com/pricing#language-models $: chat = $chatsStorage.find((chat) => chat.id === chatId) as Chat
const tokenPrice : Record<string, [number, number]> = { $: chatSettings = chat.settings
'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)
}
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> </script>
{#each messages as message} {#each messages as message, i}
{#if message.role === 'user'} {#if !((message.summarized) && $globalStorage.hideSummarized) && !(i === 0 && message.role === 'system' && !chatSettings.useSystemPrompt)}
<article {#key message.uuid}<EditMessage bind:message={message} chatId={chatId} />{/key}
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>
{/if} {/if}
{/each} {/each}

View File

@ -1,12 +1,38 @@
<script lang="ts"> <script lang="ts">
import { params } from 'svelte-spa-router'
import { pinMainMenu } from './Storage.svelte'
import logo from '../assets/logo.svg' 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> </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-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={'#/'}> <a class="navbar-item" href={'#/'}>
<img src={logo} alt="ChatGPT-web" width="28" height="28" /> <img src={logo} alt="ChatGPT-web" width="24" height="24" />
<p class="ml-2 is-size-4 has-text-weight-bold">ChatGPT-web</p> <p class="ml-2 is-size-6 has-text-weight-bold">ChatGPT-web</p>
</a> </a>
<div class="chat-option-menu navbar-item is-pulled-right">
<ChatOptionMenu bind:chatId={activeChatId} />
</div>
</div> </div>
</nav> </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"> <script lang="ts">
import { params, replace } from 'svelte-spa-router' import { params } from 'svelte-spa-router'
import ChatMenuItem from './ChatMenuItem.svelte'
import { apiKeyStorage, chatsStorage, clearChats, deleteChat } from './Storage.svelte' import { apiKeyStorage, chatsStorage, pinMainMenu, checkStateChange } from './Storage.svelte'
import { exportAsMarkdown } from './Export.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) $: sortedChats = $chatsStorage.sort((a, b) => b.id - a.id)
$: activeChatId = $params && $params.chatId ? parseInt($params.chatId) : undefined $: 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> </script>
<aside class="menu"> <aside class="menu main-menu" class:pinned={$pinMainMenu} use:clickOutside={() => { $pinMainMenu = false }}>
<p class="menu-label">Chats</p> <div class="menu-expanse">
<ul class="menu-list"> <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} {#if sortedChats.length === 0}
<li><a href={'#'} class="is-disabled">No chats yet...</a></li> <li><a href={'#'} class="is-disabled">No chats yet...</a></li>
{:else} {:else}
<li> {#key $checkStateChange}
<ul> {#each sortedChats as chat, i}
{#each sortedChats as chat} <ChatMenuItem activeChatId={activeChatId} chat={chat} prevChat={sortedChats[i - 1]} nextChat={sortedChats[i + 1]} />
<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>
{/each} {/each}
</ul> {/key}
</li>
{/if} {/if}
</ul> </ul>
<p class="menu-label">Actions</p> <!-- <p class="menu-label">Actions</p> -->
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
<a href={'#/'} class="panel-block" class:is-disabled={!$apiKeyStorage} class:is-active={!activeChatId} <div class="level-right side-actions">
><span class="greyscale mr-2">🔑</span> API key</a {#if !$apiKeyStorage}
> <div class="level-item">
</li> <a href={'#/'} class="panel-block" class:is-disabled={!$apiKeyStorage}
<li> ><span class="greyscale mr-2"><Fa icon={faKey} /></span> API key</a
<a href={'#/chat/new'} class="panel-block" class:is-disabled={!$apiKeyStorage} ></div>
><span class="greyscale mr-2"></span> New chat</a {:else}
> <div class="level-item">
</li> <button on:click={() => { startNewChatWithWarning(activeChatId) }} class="panel-block button" title="Start new chat with default profile" class:is-disabled={!$apiKeyStorage}
<li> ><span class="greyscale mr-2"><Fa icon={faSquarePlus} /></span> New chat</button>
<a class="panel-block" </div>
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>
{/if} {/if}
</div>
</li>
</ul> </ul>
</div>
</aside> </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"> <script context="module" lang="ts">
import { persisted } from 'svelte-local-storage-store' import { persisted } from 'svelte-local-storage-store'
import { get } from 'svelte/store' import { get, writable } from 'svelte/store'
import type { Chat, Message } from './Types.svelte' 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 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 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) const chats = get(chatsStorage)
// Find the max chatId // 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 // Add a new chat
chats.push({ chats.push({
id: chatId, id: chatId,
name: `Chat ${chatId}`, name: `Chat ${chatId}`,
messages: [] settings: profile,
messages: [],
usage: {} as Record<Model, Usage>,
startSession: false,
sessionStarted: false
}) })
chatsStorage.set(chats) chatsStorage.set(chats)
// Apply defaults and prepare it to start
restartProfile(chatId)
return 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 = () => { export const clearChats = () => {
chatsStorage.set([]) 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) => { export const addMessage = (chatId: number, message: Message) => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat 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) chat.messages.push(message)
}
chatsStorage.set(chats) chatsStorage.set(chats)
} }
export const editMessage = (chatId: number, index: number, newMessage: Message) => { export const getMessages = (chatId: number):Message[] => {
const chats = get(chatsStorage) const chats = get(chatsStorage)
const chat = chats.find((chat) => chat.id === chatId) as Chat const chat = chats.find((chat) => chat.id === chatId) as Chat
chat.messages[index] = newMessage return chat.messages
chat.messages.splice(index + 1) // remove the rest of the 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) chatsStorage.set(chats)
} }
@ -52,4 +296,184 @@
const chats = get(chatsStorage) const chats = get(chatsStorage)
chatsStorage.set(chats.filter((chat) => chat.id !== chatId)) 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> </script>

View File

@ -1,4 +1,6 @@
<script context="module" lang="ts"> <script context="module" lang="ts">
// import type internal from "stream";
export const supportedModels = [ // See: https://platform.openai.com/docs/models/model-endpoint-compatibility export const supportedModels = [ // See: https://platform.openai.com/docs/models/model-endpoint-compatibility
'gpt-4', 'gpt-4',
'gpt-4-0314', 'gpt-4-0314',
@ -18,19 +20,26 @@
export type Message = { export type Message = {
role: 'user' | 'assistant' | 'system' | 'error'; role: 'user' | 'assistant' | 'system' | 'error';
content: string; content: string;
uuid: string;
usage?: Usage; usage?: Usage;
model?: Model; model?: Model;
removed?: boolean;
summarized?: string[];
summary?: string[];
suppress?: boolean;
finish_reason?: string;
streaming?: boolean;
}; };
export type Chat = { export type ResponseAlteration = {
id: number; type: 'prompt' | 'replace';
name: string; match: string;
messages: Message[]; replace: string;
}; }
export type Request = { export type Request = {
model?: Model; model?: Model;
messages: Message[]; messages?: Message[];
temperature?: number; temperature?: number;
top_p?: number; top_p?: number;
n?: number; n?: number;
@ -39,29 +48,40 @@
max_tokens?: number; max_tokens?: number;
presence_penalty?: number; presence_penalty?: number;
frequency_penalty?: number; frequency_penalty?: number;
logit_bias?: Record<string, any>; logit_bias?: Record<string, any> | null;
user?: string; user?: string;
}; };
type SettingsNumber = { export type ChatSettings = {
type: 'number'; profile: string,
default: number; characterName: string,
min: number; profileName: string,
max: number; profileDescription: string,
step: number; 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 = { export type Chat = {
type: 'select'; id: number;
default: Model;
options: Model[];
};
export type Settings = {
key: string;
name: string; name: string;
title: string; messages: Message[];
} & (SettingsNumber | SettingsSelect); usage: Record<Model, Usage>;
settings: ChatSettings;
startSession: boolean;
sessionStarted: boolean;
};
type ResponseOK = { type ResponseOK = {
id: string; id: string;
@ -71,6 +91,7 @@
index: number; index: number;
message: Message; message: Message;
finish_reason: string; finish_reason: string;
delta: Message;
}[]; }[];
usage: Usage; usage: Usage;
model: Model; model: Model;
@ -93,4 +114,112 @@
id: string; 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> </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 { } else {
return { return {