diff --git a/.github/readme.md b/.github/readme.md index 28d3a390d..01a3ab8ba 100644 --- a/.github/readme.md +++ b/.github/readme.md @@ -274,7 +274,7 @@ You will need two mandatory directory mappings and a port mapping to allow Silly 1. Open your Command Line 2. Run the following command -`docker create --name='sillytavern' --net='[DockerNet]' -p '8000:8000/tcp' -v '[plugins]':'/home/node/app/plugins':'rw' -v '[config]':'/home/node/app/config':'rw' -v '[data]':'/home/node/app/data':'rw' -v '[extensions]':'/home/node/app/public/scripts/extensions/third-party':'rw' 'ghcr.io/sillytavern/sillytavern:[version]'` +`docker run --name='sillytavern' --net='[DockerNet]' -p '8000:8000/tcp' -v '[plugins]':'/home/node/app/plugins':'rw' -v '[config]':'/home/node/app/config':'rw' -v '[data]':'/home/node/app/data':'rw' -v '[extensions]':'/home/node/app/public/scripts/extensions/third-party':'rw' 'ghcr.io/sillytavern/sillytavern:[version]'` > Note that 8000 is a default listening port. Don't forget to use an appropriate port if you change it in the config. diff --git a/.gitignore b/.gitignore index 7d48fd879..7a88d5bec 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ access.log /vectors/ /cache/ public/css/user.css +public/error/ /plugins/ /data /default/scaffold @@ -52,3 +53,5 @@ public/scripts/extensions/third-party /certs .aider* .env +/StartDev.bat + diff --git a/default/config.yaml b/default/config.yaml index 513b129fa..04c9a528f 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -72,7 +72,7 @@ minLogLevel: 0 ## Set to a positive number to expire session after a certain time of inactivity ## Set to 0 to expire session when the browser is closed ## Set to a negative number to disable session expiration -sessionTimeout: 86400 +sessionTimeout: -1 # Used to sign session cookies. Will be auto-generated if not set cookieSecret: '' # Disable CSRF protection - NOT RECOMMENDED @@ -135,24 +135,26 @@ whitelistImportDomains: ## headers: ## User-Agent: "Googlebot/2.1 (+http://www.google.com/bot.html)" requestOverrides: [] -# -- EXTENSIONS CONFIGURATION -- -# Enable UI extensions -enableExtensions: true -# Automatically update extensions when a release version changes -enableExtensionsAutoUpdate: true + +# EXTENSIONS CONFIGURATION +extensions: + # Enable UI extensions + enabled: true + # Automatically update extensions when a release version changes + autoUpdate: true + models: + # Enables automatic model download from HuggingFace + autoDownload: true + # Additional models for extensions. Expects model IDs from HuggingFace model hub in ONNX format + classification: Cohee/distilbert-base-uncased-go-emotions-onnx + captioning: Xenova/vit-gpt2-image-captioning + embedding: Cohee/jina-embeddings-v2-base-en + speechToText: Xenova/whisper-small + textToSpeech: Xenova/speecht5_tts + # Additional model tokenizers can be downloaded on demand. # Disabling will fallback to another locally available tokenizer. enableDownloadableTokenizers: true -# Extension settings -extras: - # Disables automatic model download from HuggingFace - disableAutoDownload: false - # Extra models for plugins. Expects model IDs from HuggingFace model hub in ONNX format - classificationModel: Cohee/distilbert-base-uncased-go-emotions-onnx - captioningModel: Xenova/vit-gpt2-image-captioning - embeddingModel: Cohee/jina-embeddings-v2-base-en - speechToTextModel: Xenova/whisper-small - textToSpeechModel: Xenova/speecht5_tts # -- OPENAI CONFIGURATION -- # A placeholder message to use in strict prompt post-processing mode when the prompt doesn't start with a user message promptPlaceholder: "[Start a new chat]" diff --git a/default/content/backgrounds/_black.jpg b/default/content/backgrounds/_black.jpg index a451bc161..30ced914c 100644 Binary files a/default/content/backgrounds/_black.jpg and b/default/content/backgrounds/_black.jpg differ diff --git a/default/content/backgrounds/_white.jpg b/default/content/backgrounds/_white.jpg index a7c12e675..f363fbde0 100644 Binary files a/default/content/backgrounds/_white.jpg and b/default/content/backgrounds/_white.jpg differ diff --git a/default/content/index.json b/default/content/index.json index 6df03b78d..82387d0a9 100644 --- a/default/content/index.json +++ b/default/content/index.json @@ -782,5 +782,13 @@ { "filename": "presets/context/Mistral V7.json", "type": "context" + }, + { + "filename": "presets/instruct/DeepSeek-V2.5.json", + "type": "instruct" + }, + { + "filename": "presets/context/DeepSeek-V2.5.json", + "type": "context" } ] diff --git a/default/content/presets/context/DeepSeek-V2.5.json b/default/content/presets/context/DeepSeek-V2.5.json new file mode 100644 index 000000000..49efaba59 --- /dev/null +++ b/default/content/presets/context/DeepSeek-V2.5.json @@ -0,0 +1,11 @@ +{ + "story_string": "{{#if system}}{{system}}\n{{/if}}{{#if wiBefore}}{{wiBefore}}\n{{/if}}{{#if description}}{{description}}\n{{/if}}{{#if personality}}{{char}}'s personality: {{personality}}\n{{/if}}{{#if scenario}}Scenario: {{scenario}}\n{{/if}}{{#if wiAfter}}{{wiAfter}}\n{{/if}}{{#if persona}}{{persona}}\n{{/if}}{{trim}}\n", + "example_separator": "", + "chat_start": "", + "use_stop_strings": false, + "allow_jailbreak": false, + "always_force_name2": true, + "trim_sentences": false, + "single_line": false, + "name": "DeepSeek-V2.5" +} diff --git a/default/content/presets/instruct/DeepSeek-V2.5.json b/default/content/presets/instruct/DeepSeek-V2.5.json new file mode 100644 index 000000000..7990d13c0 --- /dev/null +++ b/default/content/presets/instruct/DeepSeek-V2.5.json @@ -0,0 +1,22 @@ +{ + "input_sequence": "<|User|>", + "output_sequence": "<|Assistant|>", + "first_output_sequence": "", + "last_output_sequence": "", + "system_sequence_prefix": "", + "system_sequence_suffix": "", + "stop_sequence": "", + "wrap": false, + "macro": true, + "names_behavior": "force", + "activation_regex": "", + "skip_examples": false, + "output_suffix": "<|end▁of▁sentence|>", + "input_suffix": "", + "system_sequence": "", + "system_suffix": "", + "user_alignment_message": "", + "last_system_sequence": "", + "system_same_as_user": true, + "name": "DeepSeek-V2.5" +} diff --git a/default/user.css b/default/public/css/user.css similarity index 100% rename from default/user.css rename to default/public/css/user.css diff --git a/default/public/error/forbidden-by-whitelist.html b/default/public/error/forbidden-by-whitelist.html new file mode 100644 index 000000000..70ff71852 --- /dev/null +++ b/default/public/error/forbidden-by-whitelist.html @@ -0,0 +1,22 @@ + + + + + Forbidden + + + +

Forbidden

+

+ If you are the system administrator, add your IP address to the + whitelist or disable whitelist mode by editing + config.yaml in the root directory of your installation. +

+
+

+ Connection from {{ipDetails}} has been blocked. This attempt + has been logged. +

+ + + diff --git a/default/public/error/unauthorized.html b/default/public/error/unauthorized.html new file mode 100644 index 000000000..e3fa5f94d --- /dev/null +++ b/default/public/error/unauthorized.html @@ -0,0 +1,17 @@ + + + + + Unauthorized + + + +

Unauthorized

+

+ If you are the system administrator, you can configure the + basicAuthUser credentials by editing + config.yaml in the root directory of your installation. +

+ + + diff --git a/default/public/error/url-not-found.html b/default/public/error/url-not-found.html new file mode 100644 index 000000000..87974145f --- /dev/null +++ b/default/public/error/url-not-found.html @@ -0,0 +1,15 @@ + + + + + Not found + + + +

Not found

+

+ The requested URL was not found on this server. +

+ + + diff --git a/index.d.ts b/index.d.ts index 015d8e353..35f34c22c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,6 +1,24 @@ import { UserDirectoryList, User } from "./src/users"; +import { CsrfSyncedToken } from "csrf-sync"; declare global { + declare namespace CookieSessionInterfaces { + export interface CookieSessionObject { + /** + * The CSRF token for the session. + */ + csrfToken: CsrfSyncedToken; + /** + * Authenticated user handle. + */ + handle: string; + /** + * Last time the session was extended. + */ + touch: number; + } + } + namespace Express { export interface Request { user: { @@ -15,11 +33,3 @@ declare global { */ var DATA_ROOT: string; } - -declare module 'express-session' { - export interface SessionData { - handle: string; - touch: number; - // other properties... - } - } diff --git a/jsconfig.json b/jsconfig.json index 042caf675..4130eaedc 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -15,7 +15,7 @@ "**/node_modules/**", "**/dist/**", "**/.git/**", - "public/lib/**", + "public/**", "backups/**", "data/**", "cache/**", diff --git a/package-lock.json b/package-lock.json index 9410c323f..4e79f9f50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sillytavern", - "version": "1.12.10", + "version": "1.12.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sillytavern", - "version": "1.12.10", + "version": "1.12.11", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -26,7 +26,7 @@ "cookie-parser": "^1.4.6", "cookie-session": "^2.1.0", "cors": "^2.8.5", - "csrf-csrf": "^2.2.3", + "csrf-sync": "^4.0.3", "diff-match-patch": "^1.0.5", "dompurify": "^3.1.7", "droll": "^0.2.1", @@ -2987,10 +2987,10 @@ "node": "*" } }, - "node_modules/csrf-csrf": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/csrf-csrf/-/csrf-csrf-2.2.4.tgz", - "integrity": "sha512-LuhBmy5RfRmEfeqeYqgaAuS1eDpVtKZB/Eiec9xiKQLBynJxrGVRdM2yRT/YMl1Njo/yKh2L9AYsIwSlTPnx2A==", + "node_modules/csrf-sync": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/csrf-sync/-/csrf-sync-4.0.3.tgz", + "integrity": "sha512-wXzltBBzt/7imzDt6ZT7G/axQG7jo4Sm0uXDUzFY8hR59qhDHdjqpW2hojS4oAVIZDzwlMQloIVCTJoDDh0wwA==", "license": "ISC", "dependencies": { "http-errors": "^2.0.0" diff --git a/package.json b/package.json index fc424f438..b14a487b8 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "cookie-parser": "^1.4.6", "cookie-session": "^2.1.0", "cors": "^2.8.5", - "csrf-csrf": "^2.2.3", + "csrf-sync": "^4.0.3", "diff-match-patch": "^1.0.5", "dompurify": "^3.1.7", "droll": "^0.2.1", @@ -86,7 +86,7 @@ "type": "git", "url": "https://github.com/SillyTavern/SillyTavern.git" }, - "version": "1.12.10", + "version": "1.12.11", "scripts": { "start": "node server.js", "start:deno": "deno run --allow-run --allow-net --allow-read --allow-write --allow-sys --allow-env server.js", diff --git a/post-install.js b/post-install.js index 97745bb15..46ad160cb 100644 --- a/post-install.js +++ b/post-install.js @@ -64,6 +64,46 @@ const keyMigrationMap = [ newKey: 'backups.chat.throttleInterval', migrate: (value) => value, }, + { + oldKey: 'enableExtensions', + newKey: 'extensions.enabled', + migrate: (value) => value, + }, + { + oldKey: 'enableExtensionsAutoUpdate', + newKey: 'extensions.autoUpdate', + migrate: (value) => value, + }, + { + oldKey: 'extras.disableAutoDownload', + newKey: 'extensions.models.autoDownload', + migrate: (value) => !value, + }, + { + oldKey: 'extras.classificationModel', + newKey: 'extensions.models.classification', + migrate: (value) => value, + }, + { + oldKey: 'extras.captioningModel', + newKey: 'extensions.models.captioning', + migrate: (value) => value, + }, + { + oldKey: 'extras.embeddingModel', + newKey: 'extensions.models.embedding', + migrate: (value) => value, + }, + { + oldKey: 'extras.speechToTextModel', + newKey: 'extensions.models.speechToText', + migrate: (value) => value, + }, + { + oldKey: 'extras.textToSpeechModel', + newKey: 'extensions.models.textToSpeech', + migrate: (value) => value, + }, ]; /** @@ -73,7 +113,7 @@ const keyMigrationMap = [ * @returns {string[]} Array of all keys in the object */ function getAllKeys(obj, prefix = '') { - if (typeof obj !== 'object' || Array.isArray(obj)) { + if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) { return []; } @@ -173,20 +213,60 @@ function addMissingConfigValues() { * Creates the default config files if they don't exist yet. */ function createDefaultFiles() { - const files = { - config: './config.yaml', - user: './public/css/user.css', - }; + /** + * @typedef DefaultItem + * @type {object} + * @property {'file' | 'directory'} type - Whether the item should be copied as a single file or merged into a directory structure. + * @property {string} defaultPath - The path to the default item (typically in `default/`). + * @property {string} productionPath - The path to the copied item for production use. + */ - for (const file of Object.values(files)) { + /** @type {DefaultItem[]} */ + const defaultItems = [ + { + type: 'file', + defaultPath: './default/config.yaml', + productionPath: './config.yaml', + }, + { + type: 'directory', + defaultPath: './default/public/', + productionPath: './public/', + }, + ]; + + for (const defaultItem of defaultItems) { try { - if (!fs.existsSync(file)) { - const defaultFilePath = path.join('./default', path.parse(file).base); - fs.copyFileSync(defaultFilePath, file); - console.log(color.green(`Created default file: ${file}`)); + if (defaultItem.type === 'file') { + if (!fs.existsSync(defaultItem.productionPath)) { + fs.copyFileSync( + defaultItem.defaultPath, + defaultItem.productionPath, + ); + console.log( + color.green(`Created default file: ${defaultItem.productionPath}`), + ); + } + } else if (defaultItem.type === 'directory') { + fs.cpSync(defaultItem.defaultPath, defaultItem.productionPath, { + force: false, // Don't overwrite existing files! + recursive: true, + }); + console.log( + color.green(`Synchronized missing files: ${defaultItem.productionPath}`), + ); + } else { + throw new Error( + 'FATAL: Unexpected default file format in `post-install.js#createDefaultFiles()`.', + ); } } catch (error) { - console.error(color.red(`FATAL: Could not write default file: ${file}`), error); + console.error( + color.red( + `FATAL: Could not write default ${defaultItem.type}: ${defaultItem.productionPath}`, + ), + error, + ); } } } diff --git a/public/img/vllm.svg b/public/img/vllm.svg new file mode 100644 index 000000000..764d5d4c8 --- /dev/null +++ b/public/img/vllm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/index.html b/public/index.html index 39f695090..40602018c 100644 --- a/public/index.html +++ b/public/index.html @@ -1977,12 +1977,12 @@ -
+
@@ -2692,7 +2692,7 @@ -
+
Reverse Proxy
@@ -2755,7 +2755,7 @@
-
+
@@ -3062,7 +3062,9 @@ - + + + @@ -3209,6 +3211,7 @@
@@ -3796,6 +3799,39 @@
+
+

+ Reasoning +

+
+ +
+
+ Prefix + +
+
+ Suffix + +
+
+
+
+ Separator + +
+
+ Max Additions + +
+
+
+

Miscellaneous

@@ -6218,14 +6254,26 @@
- + + - +
+
+ + Reasoning +
+
+
+
+
+
+
+
+
+
@@ -6325,7 +6373,10 @@ Avatar
-
+
+ + +
diff --git a/public/locales/ar-sa.json b/public/locales/ar-sa.json index ba367ad46..46f8a2805 100644 --- a/public/locales/ar-sa.json +++ b/public/locales/ar-sa.json @@ -1376,7 +1376,7 @@ "char_import_2": "Chub Lorebook (رابط مباشر أو معرف)", "char_import_3": "حرف JanitorAI (رابط مباشر أو UUID)", "char_import_4": "حرف Pygmalion.chat (رابط مباشر أو UUID)", - "char_import_5": "حرف AICharacterCard.com (رابط مباشر أو معرف)", + "char_import_5": "حرف AICharacterCards.com (رابط مباشر أو معرف)", "char_import_6": "رابط PNG المباشر (راجع", "char_import_7": "للمضيفين المسموح بهم)", "char_import_8": "شخصية RisuRealm (رابط مباشر)", diff --git a/public/locales/de-de.json b/public/locales/de-de.json index 92c56c0b7..a10d80e0b 100644 --- a/public/locales/de-de.json +++ b/public/locales/de-de.json @@ -1376,7 +1376,7 @@ "char_import_2": "Chub Lorebook (Direktlink oder ID)", "char_import_3": "JanitorAI-Charakter (Direktlink oder UUID)", "char_import_4": "Pygmalion.chat-Charakter (Direktlink oder UUID)", - "char_import_5": "AICharacterCard.com-Charakter (Direktlink oder ID)", + "char_import_5": "AICharacterCards.com-Charakter (Direktlink oder ID)", "char_import_6": "Direkter PNG-Link (siehe", "char_import_7": "für erlaubte Hosts)", "char_import_8": "RisuRealm-Charakter (Direktlink)", diff --git a/public/locales/es-es.json b/public/locales/es-es.json index 23a8f97be..334a283b3 100644 --- a/public/locales/es-es.json +++ b/public/locales/es-es.json @@ -1376,7 +1376,7 @@ "char_import_2": "Chub Lorebook (enlace directo o ID)", "char_import_3": "Carácter de JanitorAI (enlace directo o UUID)", "char_import_4": "Carácter Pygmalion.chat (enlace directo o UUID)", - "char_import_5": "Carácter AICharacterCard.com (enlace directo o ID)", + "char_import_5": "Carácter AICharacterCards.com (enlace directo o ID)", "char_import_6": "Enlace PNG directo (consulte", "char_import_7": "para hosts permitidos)", "char_import_8": "Personaje RisuRealm (Enlace directo)", diff --git a/public/locales/fr-fr.json b/public/locales/fr-fr.json index 96124e043..22d163a65 100644 --- a/public/locales/fr-fr.json +++ b/public/locales/fr-fr.json @@ -1297,7 +1297,7 @@ "char_import_2": "Lorebook de Chub (lien direct ou ID)", "char_import_3": "Personnage de JanitorAI (lien direct ou UUID)", "char_import_4": "Personnage de Pygmalion.chat (lien direct ou UUID)", - "char_import_5": "Personnage de AICharacterCard.com (lien direct ou identifiant)", + "char_import_5": "Personnage de AICharacterCards.com (lien direct ou identifiant)", "char_import_6": "Lien PNG direct (voir", "char_import_7": "pour les hôtes autorisés)", "char_import_8": "Personnage de RisuRealm (lien direct)", @@ -1385,7 +1385,7 @@ "enable_functions_desc_1": "Autorise l'utilisation", "enable_functions_desc_2": "outils de fonction", "enable_functions_desc_3": "Peut être utilisé par diverses extensions pour fournir des fonctionnalités supplémentaires.", - "Show model thoughts": "Afficher les pensées du modèle", + "Show model reasoning": "Afficher les pensées du modèle", "Display the model's internal thoughts in the response.": "Afficher les pensées internes du modèle dans la réponse.", "Confirm token parsing with": "Confirmer l'analyse des tokens avec", "openai_logit_bias_no_items": "Aucun élément", diff --git a/public/locales/is-is.json b/public/locales/is-is.json index 576437e67..2cacc1511 100644 --- a/public/locales/is-is.json +++ b/public/locales/is-is.json @@ -1376,7 +1376,7 @@ "char_import_2": "Chub Lorebook (beinn hlekkur eða auðkenni)", "char_import_3": "JanitorAI karakter (beinn hlekkur eða UUID)", "char_import_4": "Pygmalion.chat karakter (beinn hlekkur eða UUID)", - "char_import_5": "AICharacterCard.com Karakter (beinn hlekkur eða auðkenni)", + "char_import_5": "AICharacterCards.com Karakter (beinn hlekkur eða auðkenni)", "char_import_6": "Beinn PNG hlekkur (sjá", "char_import_7": "fyrir leyfilega gestgjafa)", "char_import_8": "RisuRealm karakter (beinn hlekkur)", diff --git a/public/locales/it-it.json b/public/locales/it-it.json index 807a03e91..eeea5c0fb 100644 --- a/public/locales/it-it.json +++ b/public/locales/it-it.json @@ -1376,7 +1376,7 @@ "char_import_2": "Lorebook di Chub (collegamento diretto o ID)", "char_import_3": "Carattere JanitorAI (collegamento diretto o UUID)", "char_import_4": "Carattere Pygmalion.chat (collegamento diretto o UUID)", - "char_import_5": "Carattere AICharacterCard.com (Link diretto o ID)", + "char_import_5": "Carattere AICharacterCards.com (Link diretto o ID)", "char_import_6": "Collegamento PNG diretto (fare riferimento a", "char_import_7": "per gli host consentiti)", "char_import_8": "Personaggio RisuRealm (collegamento diretto)", diff --git a/public/locales/ja-jp.json b/public/locales/ja-jp.json index bd5fa8281..2b3119e6e 100644 --- a/public/locales/ja-jp.json +++ b/public/locales/ja-jp.json @@ -1378,7 +1378,7 @@ "char_import_2": "Chub ロアブック (直接リンクまたは ID)", "char_import_3": "JanitorAI キャラクター (直接リンクまたは UUID)", "char_import_4": "Pygmalion.chat キャラクター (直接リンクまたは UUID)", - "char_import_5": "AICharacterCard.com キャラクター (直接リンクまたは ID)", + "char_import_5": "AICharacterCards.com キャラクター (直接リンクまたは ID)", "char_import_6": "直接PNGリンク(参照", "char_import_7": "許可されたホストの場合)", "char_import_8": "RisuRealm キャラクター (直接リンク)", diff --git a/public/locales/ko-kr.json b/public/locales/ko-kr.json index 044e57841..2c2dfca86 100644 --- a/public/locales/ko-kr.json +++ b/public/locales/ko-kr.json @@ -1395,7 +1395,7 @@ "char_import_2": "Chub Lorebook(직접 링크 또는 ID)", "char_import_3": "JanitorAI 캐릭터(직접 링크 또는 UUID)", "char_import_4": "Pygmalion.chat 문자(직접 링크 또는 UUID)", - "char_import_5": "AICharacterCard.com 캐릭터(직접 링크 또는 ID)", + "char_import_5": "AICharacterCards.com 캐릭터(직접 링크 또는 ID)", "char_import_6": "직접 PNG 링크(참조", "char_import_7": "허용된 호스트의 경우)", "char_import_8": "RisuRealm 캐릭터 (직접링크)", diff --git a/public/locales/nl-nl.json b/public/locales/nl-nl.json index 327b332b0..c4820f5b8 100644 --- a/public/locales/nl-nl.json +++ b/public/locales/nl-nl.json @@ -1376,7 +1376,7 @@ "char_import_2": "Chub Lorebook (directe link of ID)", "char_import_3": "JanitorAI-personage (directe link of UUID)", "char_import_4": "Pygmalion.chat-teken (directe link of UUID)", - "char_import_5": "AICharacterCard.com-teken (directe link of ID)", + "char_import_5": "AICharacterCards.com-teken (directe link of ID)", "char_import_6": "Directe PNG-link (zie", "char_import_7": "voor toegestane hosts)", "char_import_8": "RisuRealm-personage (directe link)", diff --git a/public/locales/pt-pt.json b/public/locales/pt-pt.json index d0f1d2681..5a2fba569 100644 --- a/public/locales/pt-pt.json +++ b/public/locales/pt-pt.json @@ -1376,7 +1376,7 @@ "char_import_2": "Chub Lorebook (link direto ou ID)", "char_import_3": "Personagem JanitorAI (Link Direto ou UUID)", "char_import_4": "Caractere Pygmalion.chat (Link Direto ou UUID)", - "char_import_5": "Personagem AICharacterCard.com (link direto ou ID)", + "char_import_5": "Personagem AICharacterCards.com (link direto ou ID)", "char_import_6": "Link PNG direto (consulte", "char_import_7": "para hosts permitidos)", "char_import_8": "Personagem RisuRealm (link direto)", diff --git a/public/locales/ru-ru.json b/public/locales/ru-ru.json index 8e304da71..4c67beaa9 100644 --- a/public/locales/ru-ru.json +++ b/public/locales/ru-ru.json @@ -966,7 +966,7 @@ "char_import_2": "Лорбук с Chub (прямая ссылка или ID)", "char_import_3": "Персонаж с JanitorAI (прямая ссылка или UUID)", "char_import_4": "Персонаж с Pygmalion.chat (прямая ссылка или UUID)", - "char_import_5": "Персонаж с AICharacterCard.com (прямая ссылка или ID)", + "char_import_5": "Персонаж с AICharacterCards.com (прямая ссылка или ID)", "char_import_6": "Прямая ссылка на PNG-файл (чтобы узнать список разрешённых хостов, загляните в", "char_import_7": ")", "Grammar String": "Грамматика", diff --git a/public/locales/uk-ua.json b/public/locales/uk-ua.json index b60945a5b..216ed3928 100644 --- a/public/locales/uk-ua.json +++ b/public/locales/uk-ua.json @@ -1376,7 +1376,7 @@ "char_import_2": "Chub Lorebook (пряме посилання або ID)", "char_import_3": "Символ JanitorAI (пряме посилання або UUID)", "char_import_4": "Символ Pygmalion.chat (пряме посилання або UUID)", - "char_import_5": "Символ AICharacterCard.com (пряме посилання або ідентифікатор)", + "char_import_5": "Символ AICharacterCards.com (пряме посилання або ідентифікатор)", "char_import_6": "Пряме посилання на PNG (див", "char_import_7": "для дозволених хостів)", "char_import_8": "Персонаж RisuRealm (пряме посилання)", diff --git a/public/locales/vi-vn.json b/public/locales/vi-vn.json index be9894621..36459e94f 100644 --- a/public/locales/vi-vn.json +++ b/public/locales/vi-vn.json @@ -1376,7 +1376,7 @@ "char_import_2": "Chub (Nhập URL trực tiếp hoặc ID)", "char_import_3": "JanitorAI (Nhập URL trực tiếp hoặc UUID)", "char_import_4": "Pygmalion.chat (Nhập URL trực tiếp hoặc UUID)", - "char_import_5": "AICharacterCard.com (Nhập URL trực tiếp hoặc ID)", + "char_import_5": "AICharacterCards.com (Nhập URL trực tiếp hoặc ID)", "char_import_6": "Nhập PNG trực tiếp (tham khảo", "char_import_7": "đối với các máy chủ được phép)", "char_import_8": "RisuRealm (URL trực tiếp)", diff --git a/public/locales/zh-cn.json b/public/locales/zh-cn.json index bf45663d6..d951491c5 100644 --- a/public/locales/zh-cn.json +++ b/public/locales/zh-cn.json @@ -266,7 +266,7 @@ "Use system prompt": "使用系统提示词", "Merges_all_system_messages_desc_1": "合并所有系统消息,直到第一条具有非系统角色的消息,然后通过", "Merges_all_system_messages_desc_2": "字段发送。", - "Show model thoughts": "展示思维链", + "Show model reasoning": "展示思维链", "Display the model's internal thoughts in the response.": "展示模型在回复时的内部思维链。", "Assistant Prefill": "AI预填", "Expand the editor": "展开编辑器", @@ -1191,9 +1191,9 @@ "welcome_message_part_8": "您可随时通过", "welcome_message_part_9": "图标来更改此设置。", "Persona Name:": "用户角色名称:", - "Temporarily disable automatic replies from this character": "暂时禁用此角色的自动回复", - "Enable automatic replies from this character": "启用此角色的自动回复", - "Trigger a message from this character": "从此角色触发消息", + "Temporarily disable automatic replies from this character": "临时禁言此角色", + "Enable automatic replies from this character": "解除禁言此角色", + "Trigger a message from this character": "强制触发该角色发言", "Move up": "向上移动", "Move down": "向下移动", "View character card": "查看角色卡片", @@ -1829,7 +1829,7 @@ "char_import_2": "Chub 知识书(直链或ID)", "char_import_3": "JanitorAI 角色(直链或UUID)", "char_import_4": "Pygmalion.chat 角色(直链或UUID)", - "char_import_5": "AICharacterCard.com 角色(直链或ID)", + "char_import_5": "AICharacterCards.com 角色(直链或ID)", "char_import_6": "被允许的PNG直链(请参阅", "char_import_7": ")", "char_import_8": "RisuRealm 角色(直链)", @@ -1838,7 +1838,7 @@ "Enter the Git URL of the extension to install": "输入扩展程序的 Git URL 以安装", "Disclaimer:": "免责声明:", "Please be aware that using external extensions can have unintended side effects and may pose security risks. Always make sure you trust the source before importing an extension. We are not responsible for any damage caused by third-party extensions.": "使用外部的扩展程序可能存在意料外的副作用和安全隐患。在导入扩展程序前,请一定确认其来源可信。我们不为第三方扩展程序造成的任何损失负责。", - "Prompt Itemization": "将提示词分条", + "Prompt Itemization": "提示词拆分", "Show Raw Prompt": "显示原始提示词", "Copy Prompt": "复制提示词", "Show Prompt Differences": "显示提示词差异", @@ -2045,8 +2045,8 @@ "Post a GitHub issue": "在 GitHub 发布问题", "Contact the developers": "联系开发者", "If you're connected to an API, try asking me something!": "若您已经配置好API,尝试发送些什么吧!", - "Title/Memo": "标题/备忘录", - "Strategy": "Strategy", - "Position": "位置", - "Trigger %": "触发率 %" + "Title/Memo": "标题(备忘)", + "Strategy": "触发策略", + "Position": "插入位置", + "Trigger %": "触发概率%" } diff --git a/public/locales/zh-tw.json b/public/locales/zh-tw.json index 48edfbbaa..c8ec26b22 100644 --- a/public/locales/zh-tw.json +++ b/public/locales/zh-tw.json @@ -28,14 +28,14 @@ "Prose Augmenter": "散文增強器", "Text Adventure": "文字冒險", "response legth(tokens)": "回應長度(符元數)", - "Streaming": "串流", - "Streaming_desc": "生成時逐位顯示回應。當此功能關閉時,回應將在完成後一次顯示。", + "Streaming": "即時串流", + "Streaming_desc": "逐字顯示生成中的回應內容。關閉時,回應將在生成完成後一次性顯示。", "context size(tokens)": "上下文長度(符元數)", "unlocked": "解鎖", "Only enable this if your model supports context sizes greater than 8192 tokens": "僅在您的模型支援超過 8192 個符元的上下文長度時啟用此功能", - "Max prompt cost:": "最大提示詞費用", - "Display the response bit by bit as it is generated.": "生成時逐位顯示回應。", - "When this is off, responses will be displayed all at once when they are complete.": "關閉時,回應將在完成後一次性顯示。", + "Max prompt cost:": "最大提示詞費用:", + "Display the response bit by bit as it is generated.": "逐字顯示生成中的回應內容。", + "When this is off, responses will be displayed all at once when they are complete.": "關閉時,回應將在生成完成後一次性顯示。", "Temperature": "溫度", "rep.pen": "重複懲罰", "Rep. Pen. Range.": "重複懲罰範圍", @@ -880,7 +880,7 @@ "popup-button-no": "取消", "popup-button-cancel": "關閉", "popup-button-import": "匯入", - "Advanced Definitions": "- 進階定義", + "Advanced Definitions": "進階定義", "Prompt Overrides": "提示詞覆寫", "(For Chat Completion and Instruct Mode)": "(用於聊天補全和指令模式)", "Insert {{original}} into either box to include the respective default prompt from system settings.": "在任一框中插入 {{original}} 以包含系統設定中的預設提示詞。", @@ -1381,7 +1381,7 @@ "char_import_2": "Chub Lorebook(直接連結或 ID)", "char_import_3": "JanitorAI 角色(直接連結或 ID)", "char_import_4": "Pygmalion.chat 角色(直接連結或 ID)", - "char_import_5": "AICharacterCard.com 角色(直接連結或 ID)", + "char_import_5": "AICharacterCards.com 角色(直接連結或 ID)", "char_import_6": "直接 PNG 連結(請參閱", "char_import_7": "對於允許的主機)", "char_import_8": "RisuRealm角色(直接連結)", @@ -1745,7 +1745,7 @@ "Sprite Folder Override": "表情立繪資料夾覆蓋", "Sprite set:": "立繪組:", "Show Gallery": "查看圖庫", - "Sticky": "固定", + "Sticky": "黏性", "Style Preset": "預設樣式", "Summarize chat messages for vector generation": "摘要聊天訊息以進行向量化處理", "Summarize chat messages when sending": "傳送時摘要聊天內容", @@ -2124,7 +2124,7 @@ "from other websites": "以從其他網站新增。", "Go to the": "前往", "to install additional features.": "以安裝更多功能。", - "If you're connected to an API, try asking me something!": "若您已連接 API,嘗試問我一些問題吧!", + "If you're connected to an API, try asking me something!": "若您已連線 API,嘗試問我一些問題吧!", "Title/Memo": "標題/備註", "Strategy": "插入策略", "Position": "位置", @@ -2357,7 +2357,7 @@ "Forbid": "禁止", "Aphrodite only. Determines the order of samplers. Skew is always applied post-softmax, so it's not included here.": "僅限 Aphrodite 使用。決定採樣器的順序。偏移總是在 softmax 後應用,因此不包括在此。", "Aphrodite only. Determines the order of samplers.": "僅限 Aphrodite 使用。決定採樣器的順序。", - "Show model thoughts": "顯示模型思維鏈", + "Show model reasoning": "顯示模型思維鏈", "Display the model's internal thoughts in the response.": "在回應中顯示模型的思維鏈(內部思考過程)。", "Generic (OpenAI-compatible) [LM Studio, LiteLLM, etc.]": "通用(兼容 OpenAI)[LM Studio, LiteLLM 等]", "Model ID (optional)": "模型 ID(可選)", @@ -2386,5 +2386,71 @@ "Your preset contains proxy and/or custom endpoint settings.": "此預設包含代理和/或自訂端點設定。", "Do you want to remove these fields before exporting?": "是否要在匯出前移除這些欄位?", "Delete the preset? This action is irreversible and your current settings will be overwritten.": "確定刪除此預設?刪除後無法復原,且此設定將被覆蓋。", - "Update all": "全部更新" + "Update all": "全部更新", + "Automatically chooses an alternative provider if chosen providers can't serve your request.": "當所選提供者無法滿足您的請求時,自動選擇替代提供者。", + "Use extension settings": "使用擴充功能設定", + "To use instruct formatting, switch to OpenRouter under Text Completion API.": "若要使用指令格式,請在文本補全 API 下切換至 OpenRouter。", + "Automatically 'continue' a response if the model stopped before reaching a certain amount of tokens.": "如果模型在達到一定數量的符元前停止,則自動繼續生成回應。", + "Toggle entry's active state.": "切換條目的啟用狀態。", + "Non-sticky": "無黏性", + "No cooldown": "無冷卻時間", + "No delay": "無延遲", + "Included settings:": "包含設定:", + "Click on the setting name to omit it from the profile.": "點擊設定名稱以從設定檔中省略。", + "Tints the chat background and/or character sprites.": "調整聊天背景或角色圖片的色調。", + "Only chunk on custom boundary": "僅在自定邊界進行分塊(chunk)", + "help_macros_firstDisplayedMessageId": "載入到可見聊天中的第一則訊息的 ID。", + "Couldn't import tags:": "無法匯入標籤:", + "Select providers. No selection = all providers.": "選擇供應商。未選擇=所有供應商。", + "Select a model": "選擇模型", + "Search models...": "搜尋模型⋯", + "[Currently loaded]": "[當前加載]", + "Search providers...": "搜尋供應商⋯", + "No-sticky": "無固定", + "Create a new World Info": "建立新世界資訊", + "Enter a name for the new file:": "輸入新檔案的名稱:", + "Valid World Info file name is required": "需要有效的世界資訊檔案名稱", + "World Info file has an invalid format": "世界資訊檔案格式無效", + "World Info file has no entries": "世界資訊檔案中無任何條目", + "Character not found.": "未找到該角色。", + "Open a chat to get a name of the chat-bound lorebook": "開啟聊天以取得綁定聊天的知識書名稱。", + "File is not valid: ${0}": "檔案無效或格式不正確:${0}", + "The world with ${0} is invalid or corrupted.": "世界 ${0} 無效或已損壞。", + "Deactivated all worlds": "已停用所有世界", + "No world found named: ${0}": "未找到名為 ${0} 的世界", + "Activated world: ${0}": "已啟用世界:${0}", + "Deactivated world: ${0}": "已停用世界:${0}", + "World was not active: ${0}": "世界 ${0} 未啟用", + "The world '${0}' has been imported and linked to the character successfully.": "世界「${0}」已成功匯入並綁定至角色。", + "World/Lorebook imported": "世界/知識書已匯入", + "Are you sure you want to import '${0}'?": "確定要匯入「${0}」嗎?", + "Built-in Extensions:": "內建擴充功能:", + "Installed Extensions:": "已安裝的擴充功能:", + "Loading third-party extensions... Please wait...": "正在載入第三方擴充功能,請稍候⋯", + "The page will be reloaded shortly...": "頁面即將重新載入⋯", + "Extensions state changed": "擴充功能狀態已更改", + "Error loading extensions. See browser console for details.": "載入擴充功能時出現錯誤。詳情請查看瀏覽器控制台。", + "You don't have permission to update global extensions.": "您無權更新全域擴充功能。", + "Extension update failed": "擴充功能更新失敗", + "Extension ${0} updated to ${1}": "擴充功能 ${0} 已更新至 ${1}", + "Reload the page to apply updates": "重新加載頁面以使用更新", + "You don't have permission to delete global extensions.": "您無權刪除全域擴充功能。", + "Are you sure you want to delete ${0}?": "確定要刪除 ${0} 嗎?", + "You don't have permission to move extensions.": "您無權移動擴充功能。", + "Are you sure you want to move ${0} to your local extensions? This will make it available only for you.": "確定要將 ${0} 移至本地擴充功能嗎?此後僅您可使用。", + "Are you sure you want to move ${0} to the global extensions? This will make it available for all users.": "確定要將 ${0} 移至全域擴充功能嗎?此後所有使用者皆可使用。", + "Extension ${0} moved.": "擴充功能 ${0} 已移動。", + "Extension ${0} deleted": "擴充功能 ${0} 已刪除。", + "Please wait...": "請稍候⋯", + "Installing extension": "正在安裝擴充功能", + "Extension installation failed": "擴充功能安裝失敗", + "Extension '${0}' by ${1} (version ${2}) has been installed successfully!": "擴充功能 '${0}' 由 ${1} 提供(版本 ${2})已成功安裝!", + "Extension installation successful": "擴充功能安裝成功", + "Extension updates available": "有可用的擴充功能更新", + "Auto-updating extensions. This may take several minutes.": "正在自動更新擴充功能。這可能需要幾分鐘時間。", + "Install": "安裝", + "Modules provided by your Extras API:": "由您的 Extras API 提供的模組:", + "Not connected to the API!": "未連線到 API!", + "ext_type_system": "這是內建的擴充功能,無法刪除,且會跟隨系統更新。", + "Valid": "已驗證" } diff --git a/public/script.js b/public/script.js index e8e04bbd8..48bf112a6 100644 --- a/public/script.js +++ b/public/script.js @@ -168,6 +168,7 @@ import { isTrueBoolean, toggleDrawer, isElementInViewport, + copyText, } from './scripts/utils.js'; import { debounce_timeout } from './scripts/constants.js'; @@ -266,6 +267,7 @@ import { initSettingsSearch } from './scripts/setting-search.js'; import { initBulkEdit } from './scripts/bulk-edit.js'; import { deriveTemplatesFromChatTemplate } from './scripts/chat-templates.js'; import { getContext } from './scripts/st-context.js'; +import { initReasoning, PromptReasoning } from './scripts/reasoning.js'; // API OBJECT FOR EXTERNAL WIRING globalThis.SillyTavern = { @@ -442,6 +444,7 @@ export const event_types = { MESSAGE_DELETED: 'message_deleted', MESSAGE_UPDATED: 'message_updated', MESSAGE_FILE_EMBEDDED: 'message_file_embedded', + MORE_MESSAGES_LOADED: 'more_messages_loaded', IMPERSONATE_READY: 'impersonate_ready', CHAT_CHANGED: 'chat_id_changed', GENERATION_AFTER_COMMANDS: 'GENERATION_AFTER_COMMANDS', @@ -722,6 +725,7 @@ async function getSystemMessages() { is_user: false, is_system: true, mes: await renderTemplateAsync('assistantNote'), + uses_system_ui: true, extra: { isSmallSys: true, }, @@ -979,6 +983,7 @@ async function firstLoadInit() { initServerHistory(); initSettingsSearch(); initBulkEdit(); + initReasoning(); await initScrapers(); doDailyExtensionUpdatesCheck(); await hideLoader(); @@ -1828,7 +1833,7 @@ export async function replaceCurrentChat() { } } -export function showMoreMessages(messagesToLoad = null) { +export async function showMoreMessages(messagesToLoad = null) { const firstDisplayedMesId = $('#chat').children('.mes').first().attr('mesid'); let messageId = Number(firstDisplayedMesId); let count = messagesToLoad || power_user.chat_truncation || Number.MAX_SAFE_INTEGER; @@ -1858,6 +1863,8 @@ export function showMoreMessages(messagesToLoad = null) { const newHeight = $('#chat').prop('scrollHeight'); $('#chat').scrollTop(newHeight - prevHeight); } + + await eventSource.emit(event_types.MORE_MESSAGES_LOADED); } export async function printMessages() { @@ -2195,6 +2202,7 @@ function getMessageFromTemplate({ isUser, avatarImg, bias, + reasoning, isSystem, title, timerValue, @@ -2219,6 +2227,7 @@ function getMessageFromTemplate({ mes.find('.avatar img').attr('src', avatarImg); mes.find('.ch_name .name_text').text(characterName); mes.find('.mes_bias').html(bias); + mes.find('.mes_reasoning').html(reasoning); mes.find('.timestamp').text(timestamp).attr('title', `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`); mes.find('.mesIDDisplay').text(`#${mesId}`); tokenCount && mes.find('.tokenCounterDisplay').text(`${tokenCount}t`); @@ -2233,10 +2242,16 @@ function getMessageFromTemplate({ return mes; } +/** + * Re-renders a message block with updated content. + * @param {number} messageId Message ID + * @param {object} message Message object + */ export function updateMessageBlock(messageId, message) { const messageElement = $(`#chat [mesid="${messageId}"]`); const text = message?.extra?.display_text ?? message.mes; messageElement.find('.mes_text').html(messageFormatting(text, message.name, message.is_system, message.is_user, messageId)); + messageElement.find('.mes_reasoning').html(messageFormatting(message.extra?.reasoning ?? '', '', false, false, -1)); addCopyToCodeBlocks(messageElement); appendMediaToMessage(message, messageElement); } @@ -2315,16 +2330,15 @@ export function addCopyToCodeBlocks(messageElement) { const codeBlocks = $(messageElement).find('pre code'); for (let i = 0; i < codeBlocks.length; i++) { hljs.highlightElement(codeBlocks.get(i)); - if (navigator.clipboard !== undefined) { - const copyButton = document.createElement('i'); - copyButton.classList.add('fa-solid', 'fa-copy', 'code-copy', 'interactable'); - copyButton.title = 'Copy code'; - codeBlocks.get(i).appendChild(copyButton); - copyButton.addEventListener('pointerup', function (event) { - navigator.clipboard.writeText(codeBlocks.get(i).innerText); - toastr.info(t`Copied!`, '', { timeOut: 2000 }); - }); - } + const copyButton = document.createElement('i'); + copyButton.classList.add('fa-solid', 'fa-copy', 'code-copy', 'interactable'); + copyButton.title = 'Copy code'; + codeBlocks.get(i).appendChild(copyButton); + copyButton.addEventListener('pointerup', async function () { + const text = codeBlocks.get(i).innerText; + await copyText(text); + toastr.info(t`Copied!`, '', { timeOut: 2000 }); + }); } } @@ -2396,6 +2410,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll sanitizerOverrides, ); const bias = messageFormatting(mes.extra?.bias ?? '', '', false, false, -1); + const reasoning = messageFormatting(mes.extra?.reasoning ?? '', '', false, false, -1); let bookmarkLink = mes?.extra?.bookmark_link ?? ''; let params = { @@ -2405,6 +2420,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll isUser: mes.is_user, avatarImg: avatarImg, bias: bias, + reasoning: reasoning, isSystem: isSystem, title: title, bookmarkLink: bookmarkLink, @@ -2464,6 +2480,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll const swipeMessage = chatElement.find(`[mesid="${chat.length - 1}"]`); swipeMessage.attr('swipeid', params.swipeId); swipeMessage.find('.mes_text').html(messageText).attr('title', title); + swipeMessage.find('.mes_reasoning').html(reasoning); swipeMessage.find('.timestamp').text(timestamp).attr('title', `${params.extra.api} - ${params.extra.model}`); appendMediaToMessage(mes, swipeMessage); if (power_user.timestamp_model_icon && params.extra?.api) { @@ -2724,7 +2741,7 @@ export function getStoppingStrings(isImpersonate, isContinue) { export async function generateQuietPrompt(quiet_prompt, quietToLoud, skipWIAN, quietImage = null, quietName = null, responseLength = null, force_chid = null) { console.log('got into genQuietPrompt'); const responseLengthCustomized = typeof responseLength === 'number' && responseLength > 0; - let eventHook = () => {}; + let eventHook = () => { }; try { /** @type {GenerateOptions} */ const options = { @@ -3074,6 +3091,7 @@ class StreamingProcessor { this.messageTextDom = null; this.messageTimerDom = null; this.messageTokenCounterDom = null; + this.messageReasoningDom = null; /** @type {HTMLTextAreaElement} */ this.sendTextarea = document.querySelector('#send_textarea'); this.type = type; @@ -3089,6 +3107,7 @@ class StreamingProcessor { /** @type {import('./scripts/logprobs.js').TokenLogprobs[]} */ this.messageLogprobs = []; this.toolCalls = []; + this.reasoning = ''; } #checkDomElements(messageId) { @@ -3097,6 +3116,7 @@ class StreamingProcessor { this.messageTextDom = this.messageDom?.querySelector('.mes_text'); this.messageTimerDom = this.messageDom?.querySelector('.mes_timer'); this.messageTokenCounterDom = this.messageDom?.querySelector('.tokenCounterDisplay'); + this.messageReasoningDom = this.messageDom?.querySelector('.mes_reasoning'); } } @@ -3174,18 +3194,27 @@ class StreamingProcessor { this.#checkDomElements(messageId); this.#updateMessageBlockVisibility(); const currentTime = new Date(); - // Don't waste time calculating token count for streaming - const currentTokenCount = isFinal && power_user.message_token_count_enabled ? getTokenCount(processedText, 0) : 0; - const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount); chat[messageId]['mes'] = processedText; chat[messageId]['gen_started'] = this.timeStarted; chat[messageId]['gen_finished'] = currentTime; - if (currentTokenCount) { - if (!chat[messageId]['extra']) { - chat[messageId]['extra'] = {}; - } + if (!chat[messageId]['extra']) { + chat[messageId]['extra'] = {}; + } + if (this.reasoning) { + chat[messageId]['extra']['reasoning'] = this.reasoning; + if (this.messageReasoningDom instanceof HTMLElement) { + const formattedReasoning = messageFormatting(this.reasoning, '', false, false, -1); + this.messageReasoningDom.innerHTML = formattedReasoning; + } + } + + // Don't waste time calculating token count for streaming + const tokenCountText = (this.reasoning || '') + processedText; + const currentTokenCount = isFinal && power_user.message_token_count_enabled ? getTokenCount(tokenCountText, 0) : 0; + + if (currentTokenCount) { chat[messageId]['extra']['token_count'] = currentTokenCount; if (this.messageTokenCounterDom instanceof HTMLElement) { this.messageTokenCounterDom.textContent = `${currentTokenCount}t`; @@ -3207,10 +3236,13 @@ class StreamingProcessor { if (this.messageTextDom instanceof HTMLElement) { this.messageTextDom.innerHTML = formattedText; } + + const timePassed = formatGenerationTimer(this.timeStarted, currentTime, currentTokenCount); if (this.messageTimerDom instanceof HTMLElement) { this.messageTimerDom.textContent = timePassed.timerValue; this.messageTimerDom.title = timePassed.timerTitle; } + this.setFirstSwipe(messageId); } @@ -3317,7 +3349,7 @@ class StreamingProcessor { } /** - * @returns {Generator<{ text: string, swipes: string[], logprobs: import('./scripts/logprobs.js').TokenLogprobs, toolCalls: any[] }, void, void>} + * @returns {Generator<{ text: string, swipes: string[], logprobs: import('./scripts/logprobs.js').TokenLogprobs, toolCalls: any[], state: any }, void, void>} */ *nullStreamingGeneration() { throw new Error('Generation function for streaming is not hooked up'); @@ -3339,7 +3371,7 @@ class StreamingProcessor { try { const sw = new Stopwatch(1000 / power_user.streaming_fps); const timestamps = []; - for await (const { text, swipes, logprobs, toolCalls } of this.generator()) { + for await (const { text, swipes, logprobs, toolCalls, state } of this.generator()) { timestamps.push(Date.now()); if (this.isStopped) { return; @@ -3351,6 +3383,7 @@ class StreamingProcessor { if (logprobs) { this.messageLogprobs.push(...(Array.isArray(logprobs) ? logprobs : [logprobs])); } + this.reasoning = state?.reasoning ?? ''; await eventSource.emit(event_types.STREAM_TOKEN_RECEIVED, text); await sw.tick(() => this.onProgressStreaming(this.messageId, this.continueMessage + text)); } @@ -3390,7 +3423,7 @@ export async function generateRaw(prompt, api, instructOverride, quietToLoud, sy const responseLengthCustomized = typeof responseLength === 'number' && responseLength > 0; const isInstruct = power_user.instruct.enabled && api !== 'openai' && api !== 'novel' && !instructOverride; const isQuiet = true; - let eventHook = () => {}; + let eventHook = () => { }; if (systemPrompt) { systemPrompt = substituteParams(systemPrompt); @@ -3817,6 +3850,14 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro coreChat.pop(); } + const reasoning = new PromptReasoning(); + for (let i = coreChat.length - 1; i >= 0; i--) { + if (reasoning.isLimitReached()) { + break; + } + coreChat[i] = { ...coreChat[i], mes: reasoning.addToMessage(coreChat[i].mes, coreChat[i].extra?.reasoning) }; + } + coreChat = await Promise.all(coreChat.map(async (chatItem, index) => { let message = chatItem.mes; let regexType = chatItem.is_user ? regex_placement.USER_INPUT : regex_placement.AI_OUTPUT; @@ -3902,7 +3943,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro * @returns {string[]} Examples array with block heading */ function parseMesExamples(examplesStr) { - if (examplesStr.length === 0 || examplesStr === '') { + if (!examplesStr || examplesStr.length === 0 || examplesStr === '') { return []; } @@ -4738,6 +4779,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro //const getData = await response.json(); let getMessage = extractMessageFromData(data); let title = extractTitleFromData(data); + let reasoning = extractReasoningFromData(data); kobold_horde_model = title; const swipes = extractMultiSwipes(data, type); @@ -4764,10 +4806,10 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro else { // Without streaming we'll be having a full message on continuation. Treat it as a last chunk. if (originalType !== 'continue') { - ({ type, getMessage } = await saveReply(type, getMessage, false, title, swipes)); + ({ type, getMessage } = await saveReply(type, getMessage, false, title, swipes, reasoning)); } else { - ({ type, getMessage } = await saveReply('appendFinal', getMessage, false, title, swipes)); + ({ type, getMessage } = await saveReply('appendFinal', getMessage, false, title, swipes, reasoning)); } // This relies on `saveReply` having been called to add the message to the chat, so it must be last. @@ -5469,7 +5511,7 @@ async function promptItemize(itemizedPrompts, requestedMesId) { } else { diffPrevPrompt.style.display = 'none'; } - popup.dlg.querySelector('#copyPromptToClipboard').addEventListener('click', function () { + popup.dlg.querySelector('#copyPromptToClipboard').addEventListener('pointerup', async function () { let rawPrompt = itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt; let rawPromptValues = rawPrompt; @@ -5477,7 +5519,7 @@ async function promptItemize(itemizedPrompts, requestedMesId) { rawPromptValues = rawPrompt.map(x => x.content).join('\n'); } - navigator.clipboard.writeText(rawPromptValues); + await copyText(rawPromptValues); toastr.info(t`Copied!`); }); @@ -5671,6 +5713,26 @@ function extractMessageFromData(data) { } } +/** + * Extracts the reasoning from the response data. + * @param {object} data Response data + * @returns {string} Extracted reasoning + */ +function extractReasoningFromData(data) { + if (main_api === 'openai' && oai_settings.show_thoughts) { + switch (oai_settings.chat_completion_source) { + case chat_completion_sources.DEEPSEEK: + return data?.choices?.[0]?.message?.reasoning_content ?? ''; + case chat_completion_sources.OPENROUTER: + return data?.choices?.[0]?.message?.reasoning ?? ''; + case chat_completion_sources.MAKERSUITE: + return data?.responseContent?.parts?.filter(part => part.thought)?.map(part => part.text)?.join('\n\n') ?? ''; + } + } + + return ''; +} + /** * Extracts multiswipe swipes from the response data. * @param {Object} data Response data @@ -5851,7 +5913,7 @@ export function cleanUpMessage(getMessage, isImpersonate, isContinue, displayInc return getMessage; } -export async function saveReply(type, getMessage, fromStreaming, title, swipes) { +export async function saveReply(type, getMessage, fromStreaming, title, swipes, reasoning) { if (type != 'append' && type != 'continue' && type != 'appendFinal' && chat.length && (chat[chat.length - 1]['swipe_id'] === undefined || chat[chat.length - 1]['is_user'])) { type = 'normal'; @@ -5876,8 +5938,10 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes) chat[chat.length - 1]['send_date'] = getMessageTimeStamp(); chat[chat.length - 1]['extra']['api'] = getGeneratingApi(); chat[chat.length - 1]['extra']['model'] = getGeneratingModel(); + chat[chat.length - 1]['extra']['reasoning'] = reasoning; if (power_user.message_token_count_enabled) { - chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(chat[chat.length - 1]['mes'], 0); + const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes']; + chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0); } const chat_id = (chat.length - 1); await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id); @@ -5896,8 +5960,10 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes) chat[chat.length - 1]['send_date'] = getMessageTimeStamp(); chat[chat.length - 1]['extra']['api'] = getGeneratingApi(); chat[chat.length - 1]['extra']['model'] = getGeneratingModel(); + chat[chat.length - 1]['extra']['reasoning'] += reasoning; if (power_user.message_token_count_enabled) { - chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(chat[chat.length - 1]['mes'], 0); + const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes']; + chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0); } const chat_id = (chat.length - 1); await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id); @@ -5913,8 +5979,10 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes) chat[chat.length - 1]['send_date'] = getMessageTimeStamp(); chat[chat.length - 1]['extra']['api'] = getGeneratingApi(); chat[chat.length - 1]['extra']['model'] = getGeneratingModel(); + chat[chat.length - 1]['extra']['reasoning'] += reasoning; if (power_user.message_token_count_enabled) { - chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(chat[chat.length - 1]['mes'], 0); + const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes']; + chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0); } const chat_id = (chat.length - 1); await eventSource.emit(event_types.MESSAGE_RECEIVED, chat_id); @@ -5930,6 +5998,7 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes) chat[chat.length - 1]['send_date'] = getMessageTimeStamp(); chat[chat.length - 1]['extra']['api'] = getGeneratingApi(); chat[chat.length - 1]['extra']['model'] = getGeneratingModel(); + chat[chat.length - 1]['extra']['reasoning'] = reasoning; if (power_user.trim_spaces) { getMessage = getMessage.trim(); } @@ -5939,7 +6008,8 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes) chat[chat.length - 1]['gen_finished'] = generationFinished; if (power_user.message_token_count_enabled) { - chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(chat[chat.length - 1]['mes'], 0); + const tokenCountText = (reasoning || '') + chat[chat.length - 1]['mes']; + chat[chat.length - 1]['extra']['token_count'] = await getTokenCountAsync(tokenCountText, 0); } if (selected_group) { @@ -6004,6 +6074,19 @@ export async function saveReply(type, getMessage, fromStreaming, title, swipes) return { type, getMessage }; } +export function syncCurrentSwipeInfoExtras() { + if (!chat.length) { + return; + } + const currentMessage = chat[chat.length - 1]; + if (currentMessage && Array.isArray(currentMessage.swipe_info) && typeof currentMessage.swipe_id === 'number') { + const swipeInfo = currentMessage.swipe_info[currentMessage.swipe_id]; + if (swipeInfo && typeof swipeInfo === 'object') { + swipeInfo.extra = structuredClone(currentMessage.extra); + } + } +} + function saveImageToMessage(img, mes) { if (mes && img.image) { if (!mes.extra || typeof mes.extra !== 'object') { @@ -7982,9 +8065,23 @@ function updateEditArrowClasses() { } } -function closeMessageEditor() { - if (this_edit_mes_id) { - $(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_cancel`).click(); +/** + * Closes the message editor. + * @param {'message'|'reasoning'|'all'} what What to close. Default is 'all'. + */ +export function closeMessageEditor(what = 'all') { + if (what === 'message' || what === 'all') { + if (this_edit_mes_id) { + $(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_cancel`).click(); + } + } + if (what === 'reasoning' || what === 'all') { + document.querySelectorAll('.reasoning_edit_textarea').forEach((el) => { + const cancelButton = el.closest('.mes')?.querySelector('.mes_reasoning_edit_cancel'); + if (cancelButton instanceof HTMLElement) { + cancelButton.click(); + } + }); } } @@ -8417,6 +8514,9 @@ function swipe_left() { // when we swipe left..but no generation. streamingProcessor.onStopStreaming(); } + // Make sure ad-hoc changes to extras are saved before swiping away + syncCurrentSwipeInfoExtras(); + const swipe_duration = 120; const swipe_range = '700px'; chat[chat.length - 1]['swipe_id']--; @@ -8468,7 +8568,8 @@ function swipe_left() { // when we swipe left..but no generation. } const swipeMessage = $('#chat').find(`[mesid="${chat.length - 1}"]`); - const tokenCount = await getTokenCountAsync(chat[chat.length - 1].mes, 0); + const tokenCountText = (chat[chat.length - 1]?.extra?.reasoning || '') + chat[chat.length - 1].mes; + const tokenCount = await getTokenCountAsync(tokenCountText, 0); chat[chat.length - 1]['extra']['token_count'] = tokenCount; swipeMessage.find('.tokenCounterDisplay').text(`${tokenCount}t`); } @@ -8551,6 +8652,9 @@ const swipe_right = () => { return unblockGeneration(); } + // Make sure ad-hoc changes to extras are saved before swiping away + syncCurrentSwipeInfoExtras(); + const swipe_duration = 200; const swipe_range = 700; //console.log(swipe_range); @@ -8632,6 +8736,7 @@ const swipe_right = () => { // resets the timer swipeMessage.find('.mes_timer').html(''); swipeMessage.find('.tokenCounterDisplay').text(''); + swipeMessage.find('.mes_reasoning').html(''); } else { //console.log('showing previously generated swipe candidate, or "..."'); //console.log('onclick right swipe calling addOneMessage'); @@ -8642,7 +8747,8 @@ const swipe_right = () => { chat[chat.length - 1].extra = {}; } - const tokenCount = await getTokenCountAsync(chat[chat.length - 1].mes, 0); + const tokenCountText = (chat[chat.length - 1]?.extra?.reasoning || '') + chat[chat.length - 1].mes; + const tokenCount = await getTokenCountAsync(tokenCountText, 0); chat[chat.length - 1]['extra']['token_count'] = tokenCount; swipeMessage.find('.tokenCounterDisplay').text(`${tokenCount}t`); } @@ -9444,7 +9550,8 @@ function addDebugFunctions() { message.extra = {}; } - message.extra.token_count = await getTokenCountAsync(message.mes, 0); + const tokenCountText = (message?.extra?.reasoning || '') + message.mes; + message.extra.token_count = await getTokenCountAsync(tokenCountText, 0); } await saveChatConditional(); @@ -9507,7 +9614,7 @@ API Settings: ${JSON.stringify(getSettingsContents[getSettingsContents.main_api //console.log(logMessage); try { - await navigator.clipboard.writeText(logMessage); + await copyText(logMessage); toastr.info('Your ST API setup data has been copied to the clipboard.'); } catch (error) { toastr.error('Failed to copy ST Setup to clipboard:', error); @@ -10572,24 +10679,18 @@ jQuery(async function () { setTimeout(function () { $('#shadow_select_chat_popup').css('display', 'none'); }, animation_duration); }); - if (navigator.clipboard === undefined) { - // No clipboard support - $('.mes_copy').remove(); - } - else { - $(document).on('pointerup', '.mes_copy', function () { - if (this_chid !== undefined || selected_group || name2 === neutralCharacterName) { - try { - const messageId = $(this).closest('.mes').attr('mesid'); - const text = chat[messageId]['mes']; - navigator.clipboard.writeText(text); - toastr.info('Copied!', '', { timeOut: 2000 }); - } catch (err) { - console.error('Failed to copy: ', err); - } + $(document).on('pointerup', '.mes_copy', async function () { + if (this_chid !== undefined || selected_group || name2 === neutralCharacterName) { + try { + const messageId = $(this).closest('.mes').attr('mesid'); + const text = chat[messageId]['mes']; + await copyText(text); + toastr.info('Copied!', '', { timeOut: 2000 }); + } catch (err) { + console.error('Failed to copy: ', err); } - }); - } + } + }); $(document).on('pointerup', '.mes_prompt', async function () { let mesIdForItemization = $(this).closest('.mes').attr('mesId'); @@ -11219,14 +11320,15 @@ jQuery(async function () { $(document).keyup(function (e) { if (e.key === 'Escape') { - const isEditVisible = $('#curEditTextarea').is(':visible'); + const isEditVisible = $('#curEditTextarea').is(':visible') || $('.reasoning_edit_textarea').length > 0; if (isEditVisible && power_user.auto_save_msg_edits === false) { - closeMessageEditor(); + closeMessageEditor('all'); $('#send_textarea').focus(); return; } if (isEditVisible && power_user.auto_save_msg_edits === true) { $(`#chat .mes[mesid="${this_edit_mes_id}"] .mes_edit_done`).click(); + closeMessageEditor('reasoning'); $('#send_textarea').focus(); return; } @@ -11460,8 +11562,8 @@ jQuery(async function () { $('#avatar-and-name-block').slideToggle(); }); - $(document).on('mouseup touchend', '#show_more_messages', () => { - showMoreMessages(); + $(document).on('mouseup touchend', '#show_more_messages', async function () { + await showMoreMessages(); }); $(document).on('click', '.open_characters_library', async function () { @@ -11483,4 +11585,3 @@ jQuery(async function () { initCustomSelectedSamplers(); }); - diff --git a/public/scripts/backgrounds.js b/public/scripts/backgrounds.js index f83d4e044..683e9c4ee 100644 --- a/public/scripts/backgrounds.js +++ b/public/scripts/backgrounds.js @@ -482,10 +482,10 @@ function highlightNewBackground(bg) { */ function setFittingClass(fitting) { const backgrounds = $('#bg1, #bg_custom'); - backgrounds.toggleClass('cover', fitting === 'cover'); - backgrounds.toggleClass('contain', fitting === 'contain'); - backgrounds.toggleClass('stretch', fitting === 'stretch'); - backgrounds.toggleClass('center', fitting === 'center'); + for (const option of ['cover', 'contain', 'stretch', 'center']) { + backgrounds.toggleClass(option, option === fitting); + } + background_settings.fitting = fitting; } function onBackgroundFilterInput() { diff --git a/public/scripts/chat-templates.js b/public/scripts/chat-templates.js index 6797f1cf5..c68bd99d5 100644 --- a/public/scripts/chat-templates.js +++ b/public/scripts/chat-templates.js @@ -60,6 +60,16 @@ const hash_derivations = { // Tulu-3-70B 'Tulu' , + + // DeepSeek V2.5 + '54d400beedcd17f464e10063e0577f6f798fa896266a912d8a366f8a2fcc0bca': + 'DeepSeek-V2.5' + , + + // DeepSeek R1 + 'b6835114b7303ddd78919a82e4d9f7d8c26ed0d7dfc36beeb12d524f6144eab1': + 'DeepSeek-V2.5' + , }; const substr_derivations = { diff --git a/public/scripts/chats.js b/public/scripts/chats.js index fbf7af3fc..94ad882bb 100644 --- a/public/scripts/chats.js +++ b/public/scripts/chats.js @@ -11,6 +11,7 @@ import { getCurrentChatId, getRequestHeaders, hideSwipeButtons, + name1, name2, reloadCurrentChat, saveChatDebounced, @@ -21,6 +22,7 @@ import { chat_metadata, neutralCharacterName, updateChatMetadata, + system_message_types, } from '../script.js'; import { selected_group } from './group-chats.js'; import { power_user } from './power-user.js'; @@ -34,6 +36,7 @@ import { humanFileSize, saveBase64AsFile, extractTextFromOffice, + download, } from './utils.js'; import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js'; import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; @@ -41,6 +44,7 @@ import { ScraperManager } from './scrapers.js'; import { DragAndDropHandler } from './dragdrop.js'; import { renderTemplateAsync } from './templates.js'; import { t } from './i18n.js'; +import { humanizedDateTime } from './RossAscends-mods.js'; /** * @typedef {Object} FileAttachment @@ -1437,6 +1441,19 @@ jQuery(function () { await viewMessageFile(messageId); }); + $(document).on('click', '.assistant_note_export', async function () { + const chatToSave = [ + { + user_name: name1, + character_name: name2, + chat_metadata: chat_metadata, + }, + ...chat.filter(x => x?.extra?.type !== system_message_types.ASSISTANT_NOTE), + ]; + + download(JSON.stringify(chatToSave, null, 4), `Assistant - ${humanizedDateTime()}.json`, 'application/json'); + }); + // Do not change. #attachFile is added by extension. $(document).on('click', '#attachFile', function () { $('#file_form_input').trigger('click'); diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index e8da94a6f..c0ba05067 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -38,7 +38,8 @@ export let modules = []; let activeExtensions = new Set(); const getApiUrl = () => extension_settings.apiUrl; -const sortManifests = (a, b) => parseInt(a.loading_order) - parseInt(b.loading_order) || String(a.display_name).localeCompare(String(b.display_name)); +const sortManifestsByOrder = (a, b) => parseInt(a.loading_order) - parseInt(b.loading_order) || String(a.display_name).localeCompare(String(b.display_name)); +const sortManifestsByName = (a, b) => String(a.display_name).localeCompare(String(b.display_name)) || parseInt(a.loading_order) - parseInt(b.loading_order); let connectedToApi = false; /** @@ -355,7 +356,7 @@ async function getManifests(names) { * @returns {Promise} */ async function activateExtensions() { - const extensions = Object.entries(manifests).sort((a, b) => sortManifests(a[1], b[1])); + const extensions = Object.entries(manifests).sort((a, b) => sortManifestsByOrder(a[1], b[1])); const promises = []; for (let entry of extensions) { @@ -712,7 +713,10 @@ async function showExtensionsDetails() { htmlExternal.append(htmlLoading); - const extensions = Object.entries(manifests).sort((a, b) => sortManifests(a[1], b[1])).map(getExtensionData); + const sortOrderKey = 'extensions_sortByName'; + const sortByName = localStorage.getItem(sortOrderKey) === 'true'; + const sortFn = sortByName ? sortManifestsByName : sortManifestsByOrder; + const extensions = Object.entries(manifests).sort((a, b) => sortFn(a[1], b[1])).map(getExtensionData); extensions.forEach(value => { const { isExternal, extensionHtml } = value; @@ -729,7 +733,6 @@ async function showExtensionsDetails() { /** @type {import('./popup.js').CustomPopupButton} */ const updateAllButton = { text: t`Update all`, - appendAtEnd: true, action: async () => { requiresReload = true; await autoUpdateExtensions(true); @@ -737,13 +740,23 @@ async function showExtensionsDetails() { }, }; + /** @type {import('./popup.js').CustomPopupButton} */ + const sortOrderButton = { + text: sortByName ? t`Sort: Display Name` : t`Sort: Loading Order`, + action: async () => { + abortController.abort(); + localStorage.setItem(sortOrderKey, sortByName ? 'false' : 'true'); + await showExtensionsDetails(); + }, + }; + let waitingForSave = false; const popup = new Popup(html, POPUP_TYPE.TEXT, '', { okButton: t`Close`, wide: true, large: true, - customButtons: [updateAllButton], + customButtons: [sortOrderButton, updateAllButton], allowVerticalScrolling: true, onClosing: async () => { if (waitingForSave) { @@ -762,7 +775,7 @@ async function showExtensionsDetails() { }); popupPromise = popup.show(); popup.content.scrollTop = initialScrollTop; - checkForUpdatesManual(abortController.signal).finally(() => htmlLoading.remove()); + checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoading.remove()); } catch (error) { toastr.error(t`Error loading extensions. See browser console for details.`); console.error(error); @@ -1073,12 +1086,13 @@ function processVersionCheckQueue() { /** * Performs a manual check for updates on all 3rd-party extensions. + * @param {function} sortFn Sort function * @param {AbortSignal} abortSignal Signal to abort the operation * @returns {Promise} */ -async function checkForUpdatesManual(abortSignal) { +async function checkForUpdatesManual(sortFn, abortSignal) { const promises = []; - for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party')).sort((a, b) => sortManifests(manifests[a], manifests[b]))) { + for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party')).sort((a, b) => sortFn(manifests[a], manifests[b]))) { const externalId = id.replace('third-party', ''); const promise = enqueueVersionCheck(async () => { try { @@ -1223,7 +1237,7 @@ export async function runGenerationInterceptors(chat, contextSize, type) { exitImmediately = immediately; }; - for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifests(a, b))) { + for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifestsByOrder(a, b))) { const interceptorKey = manifest.generate_interceptor; if (typeof globalThis[interceptorKey] === 'function') { try { diff --git a/public/scripts/extensions/caption/settings.html b/public/scripts/extensions/caption/settings.html index 187e47876..3f344da0c 100644 --- a/public/scripts/extensions/caption/settings.html +++ b/public/scripts/extensions/caption/settings.html @@ -54,6 +54,8 @@ + + diff --git a/public/scripts/extensions/tts/index.js b/public/scripts/extensions/tts/index.js index 4b8978422..279737f7a 100644 --- a/public/scripts/extensions/tts/index.js +++ b/public/scripts/extensions/tts/index.js @@ -466,7 +466,7 @@ async function processTtsQueue() { } if (extension_settings.tts.skip_tags) { - text = text.replace(/<.*?>.*?<\/.*?>/g, '').trim(); + text = text.replace(/<.*?>[\s\S]*?<\/.*?>/g, '').trim(); } if (!extension_settings.tts.pass_asterisks) { diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js index 635343f33..c0d7f39e9 100644 --- a/public/scripts/group-chats.js +++ b/public/scripts/group-chats.js @@ -81,6 +81,7 @@ import { t } from './i18n.js'; export { selected_group, + openGroupId, is_group_automode_enabled, hideMutedSprites, is_group_generating, @@ -1367,6 +1368,15 @@ function getGroupCharacterBlock(character) { template.find('.ch_fav').val(isFav); template.toggleClass('is_fav', isFav); + const auxFieldName = power_user.aux_field || 'character_version'; + const auxFieldValue = (character.data && character.data[auxFieldName]) || ''; + if (auxFieldValue) { + template.find('.character_version').text(auxFieldValue); + } + else { + template.find('.character_version').hide(); + } + let queuePosition = groupChatQueueOrder.get(character.avatar); if (queuePosition) { template.find('.queue_position').text(queuePosition); diff --git a/public/scripts/kai-settings.js b/public/scripts/kai-settings.js index 6efadce87..65d47fc4b 100644 --- a/public/scripts/kai-settings.js +++ b/public/scripts/kai-settings.js @@ -188,7 +188,7 @@ export async function generateKoboldWithStreaming(generate_data, signal) { if (data?.token) { text += data.token; } - yield { text, swipes: [], toolCalls: [] }; + yield { text, swipes: [], toolCalls: [], state: {} }; } }; } diff --git a/public/scripts/nai-settings.js b/public/scripts/nai-settings.js index f95e7d9f6..91ff09ef6 100644 --- a/public/scripts/nai-settings.js +++ b/public/scripts/nai-settings.js @@ -746,7 +746,7 @@ export async function generateNovelWithStreaming(generate_data, signal) { text += data.token; } - yield { text, swipes: [], logprobs: parseNovelAILogprobs(data.logprobs), toolCalls: [] }; + yield { text, swipes: [], logprobs: parseNovelAILogprobs(data.logprobs), toolCalls: [], state: {} }; } }; } diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 4ec0c7749..f1d898430 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -1096,8 +1096,8 @@ async function preparePromptsForChatCompletion({ Scenario, charPersonality, name // Unordered prompts without marker { role: 'system', content: impersonationPrompt, identifier: 'impersonate' }, { role: 'system', content: quietPrompt, identifier: 'quietPrompt' }, - { role: 'system', content: bias, identifier: 'bias' }, { role: 'system', content: groupNudge, identifier: 'groupNudge' }, + { role: 'assistant', content: bias, identifier: 'bias' }, ]; // Tavern Extras - Summary @@ -1922,7 +1922,7 @@ async function sendOpenAIRequest(type, messages, signal) { } // Proxy is only supported for Claude, OpenAI, Mistral, and Google MakerSuite - if (oai_settings.reverse_proxy && [chat_completion_sources.CLAUDE, chat_completion_sources.OPENAI, chat_completion_sources.MISTRALAI, chat_completion_sources.MAKERSUITE].includes(oai_settings.chat_completion_source)) { + if (oai_settings.reverse_proxy && [chat_completion_sources.CLAUDE, chat_completion_sources.OPENAI, chat_completion_sources.MISTRALAI, chat_completion_sources.MAKERSUITE, chat_completion_sources.DEEPSEEK].includes(oai_settings.chat_completion_source)) { await validateReverseProxy(); generate_data['reverse_proxy'] = oai_settings.reverse_proxy; generate_data['proxy_password'] = oai_settings.proxy_password; @@ -2030,6 +2030,16 @@ async function sendOpenAIRequest(type, messages, signal) { // https://api-docs.deepseek.com/api/create-chat-completion if (isDeepSeek) { generate_data.top_p = generate_data.top_p || Number.EPSILON; + + if (generate_data.model.endsWith('-reasoner')) { + delete generate_data.top_p; + delete generate_data.temperature; + delete generate_data.frequency_penalty; + delete generate_data.presence_penalty; + delete generate_data.top_logprobs; + delete generate_data.logprobs; + delete generate_data.logit_bias; + } } if ((isOAI || isOpenRouter || isMistral || isCustom || isCohere || isNano) && oai_settings.seed >= 0) { @@ -2085,6 +2095,7 @@ async function sendOpenAIRequest(type, messages, signal) { let text = ''; const swipes = []; const toolCalls = []; + const state = { reasoning: '' }; while (true) { const { done, value } = await reader.read(); if (done) return; @@ -2095,14 +2106,14 @@ async function sendOpenAIRequest(type, messages, signal) { if (Array.isArray(parsed?.choices) && parsed?.choices?.[0]?.index > 0) { const swipeIndex = parsed.choices[0].index - 1; - swipes[swipeIndex] = (swipes[swipeIndex] || '') + getStreamingReply(parsed); + swipes[swipeIndex] = (swipes[swipeIndex] || '') + getStreamingReply(parsed, state); } else { - text += getStreamingReply(parsed); + text += getStreamingReply(parsed, state); } ToolManager.parseToolCalls(toolCalls, parsed); - yield { text, swipes: swipes, logprobs: parseChatCompletionLogprobs(parsed), toolCalls: toolCalls }; + yield { text, swipes: swipes, logprobs: parseChatCompletionLogprobs(parsed), toolCalls: toolCalls, state: state }; } }; } @@ -2129,14 +2140,33 @@ async function sendOpenAIRequest(type, messages, signal) { } } -function getStreamingReply(data) { +/** + * Extracts the reply from the response data from a chat completions-like source + * @param {object} data Response data from the chat completions-like source + * @param {object} state Additional state to keep track of + * @returns {string} The reply extracted from the response data + */ +function getStreamingReply(data, state) { if (oai_settings.chat_completion_source === chat_completion_sources.CLAUDE) { return data?.delta?.text || ''; } else if (oai_settings.chat_completion_source === chat_completion_sources.MAKERSUITE) { - return data?.candidates?.[0]?.content?.parts?.filter(x => oai_settings.show_thoughts || !x.thought)?.map(x => x.text)?.filter(x => x)?.join('\n\n') || ''; + if (oai_settings.show_thoughts) { + state.reasoning += (data?.candidates?.[0]?.content?.parts?.filter(x => x.thought)?.map(x => x.text)?.[0] || ''); + } + return data?.candidates?.[0]?.content?.parts?.filter(x => !x.thought)?.map(x => x.text)?.[0] || ''; } else if (oai_settings.chat_completion_source === chat_completion_sources.COHERE) { return data?.delta?.message?.content?.text || data?.delta?.message?.tool_plan || ''; - } else { + } else if (oai_settings.chat_completion_source === chat_completion_sources.DEEPSEEK) { + if (oai_settings.show_thoughts) { + state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning_content)?.[0]?.delta?.reasoning_content || ''); + } + return data.choices?.[0]?.delta?.content || ''; + } else if (oai_settings.chat_completion_source === chat_completion_sources.OPENROUTER) { + if (oai_settings.show_thoughts) { + state.reasoning += (data.choices?.filter(x => x?.delta?.reasoning)?.[0]?.delta?.reasoning || ''); + } + return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? ''; + } else { return data.choices?.[0]?.delta?.content ?? data.choices?.[0]?.message?.content ?? data.choices?.[0]?.text ?? ''; } } @@ -3346,7 +3376,7 @@ async function getStatusOpen() { chat_completion_source: oai_settings.chat_completion_source, }; - if (oai_settings.reverse_proxy && [chat_completion_sources.CLAUDE, chat_completion_sources.OPENAI, chat_completion_sources.MISTRALAI, chat_completion_sources.MAKERSUITE].includes(oai_settings.chat_completion_source)) { + if (oai_settings.reverse_proxy && [chat_completion_sources.CLAUDE, chat_completion_sources.OPENAI, chat_completion_sources.MISTRALAI, chat_completion_sources.MAKERSUITE, chat_completion_sources.DEEPSEEK].includes(oai_settings.chat_completion_source)) { await validateReverseProxy(); } @@ -4204,7 +4234,7 @@ async function onModelChange() { $('#openai_max_context').attr('max', max_32k); } else if (value.includes('gemini-1.5-pro') || value.includes('gemini-exp-1206')) { $('#openai_max_context').attr('max', max_2mil); - } else if (value.includes('gemini-1.5-flash') || value.includes('gemini-2.0-flash-exp')) { + } else if (value.includes('gemini-1.5-flash') || value.includes('gemini-2.0-flash-exp') || value.includes('gemini-2.0-flash-thinking-exp')) { $('#openai_max_context').attr('max', max_1mil); } else if (value.includes('gemini-1.0-pro') || value === 'gemini-pro') { $('#openai_max_context').attr('max', max_32k); @@ -4488,7 +4518,7 @@ async function onModelChange() { if (oai_settings.chat_completion_source === chat_completion_sources.DEEPSEEK) { if (oai_settings.max_context_unlocked) { $('#openai_max_context').attr('max', unlocked_max); - } else if (oai_settings.deepseek_model == 'deepseek-chat') { + } else if (['deepseek-reasoner', 'deepseek-chat'].includes(oai_settings.deepseek_model)) { $('#openai_max_context').attr('max', max_64k); } else if (oai_settings.deepseek_model == 'deepseek-coder') { $('#openai_max_context').attr('max', max_16k); @@ -4725,7 +4755,7 @@ async function onConnectButtonClick(e) { await writeSecret(SECRET_KEYS.DEEPSEEK, api_key_deepseek); } - if (!secret_state[SECRET_KEYS.DEEPSEEK]) { + if (!secret_state[SECRET_KEYS.DEEPSEEK] && !oai_settings.reverse_proxy) { console.log('No secret key saved for DeepSeek'); return; } @@ -4901,6 +4931,8 @@ export function isImageInliningSupported() { const visionSupportedModels = [ 'gpt-4-vision', 'gemini-2.0-flash-thinking-exp-1219', + 'gemini-2.0-flash-thinking-exp-01-21', + 'gemini-2.0-flash-thinking-exp', 'gemini-2.0-flash-exp', 'gemini-1.5-flash', 'gemini-1.5-flash-latest', diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js index b3b7f41d5..2a74f8722 100644 --- a/public/scripts/power-user.js +++ b/public/scripts/power-user.js @@ -253,6 +253,14 @@ let power_user = { content: 'Write {{char}}\'s next reply in a fictional chat between {{char}} and {{user}}.', }, + reasoning: { + add_to_prompts: false, + prefix: '\n', + suffix: '\n', + separator: '\n\n', + max_additions: 1, + }, + personas: {}, default_persona: null, persona_descriptions: {}, @@ -2534,7 +2542,7 @@ async function loadUntilMesId(mesId) { let target; while (getFirstDisplayedMessageId() > mesId && getFirstDisplayedMessageId() !== 0) { - showMoreMessages(); + await showMoreMessages(); await delay(1); target = $('#chat').find(`.mes[mesid=${mesId}]`); diff --git a/public/scripts/reasoning.js b/public/scripts/reasoning.js new file mode 100644 index 000000000..c9b0efc3b --- /dev/null +++ b/public/scripts/reasoning.js @@ -0,0 +1,297 @@ +import { chat, closeMessageEditor, saveChatConditional, saveSettingsDebounced, substituteParams, updateMessageBlock } from '../script.js'; +import { t } from './i18n.js'; +import { MacrosParser } from './macros.js'; +import { Popup } from './popup.js'; +import { power_user } from './power-user.js'; +import { SlashCommand } from './slash-commands/SlashCommand.js'; +import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; +import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js'; +import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; +import { copyText } from './utils.js'; + +/** + * Gets a message from a jQuery element. + * @param {Element} element + * @returns {{messageId: number, message: object, messageBlock: JQuery}} + */ +function getMessageFromJquery(element) { + const messageBlock = $(element).closest('.mes'); + const messageId = Number(messageBlock.attr('mesid')); + const message = chat[messageId]; + return { messageId: messageId, message, messageBlock }; +} + +/** + * Helper class for adding reasoning to messages. + * Keeps track of the number of reasoning additions. + */ +export class PromptReasoning { + static REASONING_PLACEHOLDER = '\u200B'; + static REASONING_PLACEHOLDER_REGEX = new RegExp(`${PromptReasoning.REASONING_PLACEHOLDER}$`); + + constructor() { + this.counter = 0; + } + + /** + * Checks if the limit of reasoning additions has been reached. + * @returns {boolean} True if the limit of reasoning additions has been reached, false otherwise. + */ + isLimitReached() { + if (!power_user.reasoning.add_to_prompts) { + return true; + } + + return this.counter >= power_user.reasoning.max_additions; + } + + /** + * Add reasoning to a message according to the power user settings. + * @param {string} content Message content + * @param {string} reasoning Message reasoning + * @returns {string} Message content with reasoning + */ + addToMessage(content, reasoning) { + // Disabled or reached limit of additions + if (!power_user.reasoning.add_to_prompts || this.counter >= power_user.reasoning.max_additions) { + return content; + } + + // No reasoning provided or a placeholder + if (!reasoning || reasoning === PromptReasoning.REASONING_PLACEHOLDER) { + return content; + } + + // Increment the counter + this.counter++; + + // Substitute macros in variable parts + const prefix = substituteParams(power_user.reasoning.prefix || ''); + const separator = substituteParams(power_user.reasoning.separator || ''); + const suffix = substituteParams(power_user.reasoning.suffix || ''); + + // Combine parts with reasoning and content + return `${prefix}${reasoning}${suffix}${separator}${content}`; + } +} + +function loadReasoningSettings() { + $('#reasoning_add_to_prompts').prop('checked', power_user.reasoning.add_to_prompts); + $('#reasoning_add_to_prompts').on('change', function () { + power_user.reasoning.add_to_prompts = !!$(this).prop('checked'); + saveSettingsDebounced(); + }); + + $('#reasoning_prefix').val(power_user.reasoning.prefix); + $('#reasoning_prefix').on('input', function () { + power_user.reasoning.prefix = String($(this).val()); + saveSettingsDebounced(); + }); + + $('#reasoning_suffix').val(power_user.reasoning.suffix); + $('#reasoning_suffix').on('input', function () { + power_user.reasoning.suffix = String($(this).val()); + saveSettingsDebounced(); + }); + + $('#reasoning_separator').val(power_user.reasoning.separator); + $('#reasoning_separator').on('input', function () { + power_user.reasoning.separator = String($(this).val()); + saveSettingsDebounced(); + }); + + $('#reasoning_max_additions').val(power_user.reasoning.max_additions); + $('#reasoning_max_additions').on('input', function () { + power_user.reasoning.max_additions = Number($(this).val()); + saveSettingsDebounced(); + }); +} + +function registerReasoningSlashCommands() { + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'reasoning-get', + returns: ARGUMENT_TYPE.STRING, + helpString: t`Get the contents of a reasoning block of a message. Returns an empty string if the message does not have a reasoning block.`, + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'Message ID. If not provided, the message ID of the last message is used.', + typeList: ARGUMENT_TYPE.NUMBER, + enumProvider: commonEnumProviders.messages(), + }), + ], + callback: (_args, value) => { + const messageId = !isNaN(Number(value)) ? Number(value) : chat.length - 1; + const message = chat[messageId]; + const reasoning = String(message?.extra?.reasoning ?? ''); + return reasoning.replace(PromptReasoning.REASONING_PLACEHOLDER_REGEX, ''); + }, + })); + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'reasoning-set', + returns: ARGUMENT_TYPE.STRING, + helpString: t`Set the reasoning block of a message. Returns the reasoning block content.`, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'at', + description: 'Message ID. If not provided, the message ID of the last message is used.', + typeList: ARGUMENT_TYPE.NUMBER, + enumProvider: commonEnumProviders.messages(), + }), + ], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'Reasoning block content.', + typeList: ARGUMENT_TYPE.STRING, + }), + ], + callback: async (args, value) => { + const messageId = !isNaN(Number(args[0])) ? Number(args[0]) : chat.length - 1; + const message = chat[messageId]; + if (!message?.extra) { + return ''; + } + + message.extra.reasoning = String(value ?? ''); + await saveChatConditional(); + + closeMessageEditor('reasoning'); + updateMessageBlock(messageId, message); + return message.extra.reasoning; + }, + })); +} + +function registerReasoningMacros() { + MacrosParser.registerMacro('reasoningPrefix', () => power_user.reasoning.prefix, t`Reasoning Prefix`); + MacrosParser.registerMacro('reasoningSuffix', () => power_user.reasoning.suffix, t`Reasoning Suffix`); + MacrosParser.registerMacro('reasoningSeparator', () => power_user.reasoning.separator, t`Reasoning Separator`); +} + +function setReasoningEventHandlers(){ + $(document).on('click', '.mes_reasoning_copy', (e) => { + e.stopPropagation(); + e.preventDefault(); + }); + + $(document).on('click', '.mes_reasoning_edit', function (e) { + e.stopPropagation(); + e.preventDefault(); + const { message, messageBlock } = getMessageFromJquery(this); + if (!message?.extra) { + return; + } + + const reasoning = String(message?.extra?.reasoning ?? ''); + const chatElement = document.getElementById('chat'); + const textarea = document.createElement('textarea'); + const reasoningBlock = messageBlock.find('.mes_reasoning'); + textarea.classList.add('reasoning_edit_textarea'); + textarea.value = reasoning.replace(PromptReasoning.REASONING_PLACEHOLDER_REGEX, ''); + $(textarea).insertBefore(reasoningBlock); + + if (!CSS.supports('field-sizing', 'content')) { + const resetHeight = function () { + const scrollTop = chatElement.scrollTop; + textarea.style.height = '0px'; + textarea.style.height = `${textarea.scrollHeight}px`; + chatElement.scrollTop = scrollTop; + }; + + textarea.addEventListener('input', resetHeight); + resetHeight(); + } + + textarea.focus(); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + + const textareaRect = textarea.getBoundingClientRect(); + const chatRect = chatElement.getBoundingClientRect(); + + // Scroll if textarea bottom is below visible area + if (textareaRect.bottom > chatRect.bottom) { + const scrollOffset = textareaRect.bottom - chatRect.bottom; + chatElement.scrollTop += scrollOffset; + } + }); + + $(document).on('click', '.mes_reasoning_edit_done', async function (e) { + e.stopPropagation(); + e.preventDefault(); + const { message, messageId, messageBlock } = getMessageFromJquery(this); + if (!message?.extra) { + return; + } + + const textarea = messageBlock.find('.reasoning_edit_textarea'); + const reasoning = String(textarea.val()); + message.extra.reasoning = reasoning; + await saveChatConditional(); + updateMessageBlock(messageId, message); + textarea.remove(); + }); + + $(document).on('click', '.mes_reasoning_edit_cancel', function (e) { + e.stopPropagation(); + e.preventDefault(); + + const { messageBlock } = getMessageFromJquery(this); + const textarea = messageBlock.find('.reasoning_edit_textarea'); + textarea.remove(); + }); + + $(document).on('click', '.mes_edit_add_reasoning', async function () { + const { message, messageId } = getMessageFromJquery(this); + if (!message?.extra) { + return; + } + + if (message.extra.reasoning) { + toastr.info(t`Reasoning already exists.`, t`Edit Message`); + return; + } + + message.extra.reasoning = PromptReasoning.REASONING_PLACEHOLDER; + await saveChatConditional(); + closeMessageEditor(); + updateMessageBlock(messageId, message); + }); + + $(document).on('click', '.mes_reasoning_delete', async function (e) { + e.stopPropagation(); + e.preventDefault(); + + const confirm = await Popup.show.confirm(t`Are you sure you want to clear the reasoning?`, t`Visible message contents will stay intact.`); + + if (!confirm) { + return; + } + + const { message, messageId } = getMessageFromJquery(this); + if (!message?.extra) { + return; + } + message.extra.reasoning = ''; + await saveChatConditional(); + updateMessageBlock(messageId, message); + }); + + $(document).on('pointerup', '.mes_reasoning_copy', async function () { + const { message } = getMessageFromJquery(this); + const reasoning = String(message?.extra?.reasoning ?? '').replace(PromptReasoning.REASONING_PLACEHOLDER_REGEX, ''); + + if (!reasoning) { + return; + } + + await copyText(reasoning); + toastr.info(t`Copied!`, '', { timeOut: 2000 }); + }); +} + +export function initReasoning() { + loadReasoningSettings(); + setReasoningEventHandlers(); + registerReasoningSlashCommands(); + registerReasoningMacros(); +} diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index 33725bf2d..531a8f51f 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -42,6 +42,7 @@ import { showMoreMessages, stopGeneration, substituteParams, + syncCurrentSwipeInfoExtras, system_avatar, system_message_types, this_chid, @@ -1968,8 +1969,8 @@ export function initDefaultSlashCommands() { SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'chat-render', helpString: 'Renders a specified number of messages into the chat window. Displays all messages if no argument is provided.', - callback: (args, number) => { - showMoreMessages(number && !isNaN(Number(number)) ? Number(number) : Number.MAX_SAFE_INTEGER); + callback: async (args, number) => { + await showMoreMessages(number && !isNaN(Number(number)) ? Number(number) : Number.MAX_SAFE_INTEGER); if (isTrueBoolean(String(args?.scroll ?? ''))) { $('#chat').scrollTop(0); } @@ -2814,8 +2815,11 @@ async function addSwipeCallback(args, value) { const newSwipeId = lastMessage.swipes.length - 1; if (isTrueBoolean(args.switch)) { + // Make sure ad-hoc changes to extras are saved before swiping away + syncCurrentSwipeInfoExtras(); lastMessage.swipe_id = newSwipeId; lastMessage.mes = lastMessage.swipes[newSwipeId]; + lastMessage.extra = structuredClone(lastMessage.swipe_info?.[newSwipeId]?.extra ?? lastMessage.extra ?? {}); } await saveChatConditional(); diff --git a/public/scripts/sse-stream.js b/public/scripts/sse-stream.js index c1c5f1bd6..17a31b567 100644 --- a/public/scripts/sse-stream.js +++ b/public/scripts/sse-stream.js @@ -220,6 +220,36 @@ async function* parseStreamData(json) { } return; } + else if (typeof json.choices[0].delta.reasoning_content === 'string' && json.choices[0].delta.reasoning_content.length > 0) { + for (let j = 0; j < json.choices[0].delta.reasoning_content.length; j++) { + const str = json.choices[0].delta.reasoning_content[j]; + const isLastSymbol = j === json.choices[0].delta.reasoning_content.length - 1; + const choiceClone = structuredClone(json.choices[0]); + choiceClone.delta.reasoning_content = str; + choiceClone.delta.content = isLastSymbol ? choiceClone.delta.content : ''; + const choices = [choiceClone]; + yield { + data: { ...json, choices }, + chunk: str, + }; + } + return; + } + else if (typeof json.choices[0].delta.reasoning === 'string' && json.choices[0].delta.reasoning.length > 0) { + for (let j = 0; j < json.choices[0].delta.reasoning.length; j++) { + const str = json.choices[0].delta.reasoning[j]; + const isLastSymbol = j === json.choices[0].delta.reasoning.length - 1; + const choiceClone = structuredClone(json.choices[0]); + choiceClone.delta.reasoning = str; + choiceClone.delta.content = isLastSymbol ? choiceClone.delta.content : ''; + const choices = [choiceClone]; + yield { + data: { ...json, choices }, + chunk: str, + }; + } + return; + } else if (typeof json.choices[0].delta.content === 'string' && json.choices[0].delta.content.length > 0) { for (let j = 0; j < json.choices[0].delta.content.length; j++) { const str = json.choices[0].delta.content[j]; diff --git a/public/scripts/st-context.js b/public/scripts/st-context.js index 9cbf18210..1c7a46199 100644 --- a/public/scripts/st-context.js +++ b/public/scripts/st-context.js @@ -1,6 +1,7 @@ import { activateSendButtons, addOneMessage, + appendMediaToMessage, callPopup, characters, chat, @@ -12,6 +13,7 @@ import { extension_prompts, Generate, generateQuietPrompt, + getCharacters, getCurrentChatId, getRequestHeaders, getThumbnailUrl, @@ -40,6 +42,7 @@ import { substituteParamsExtended, this_chid, updateChatMetadata, + updateMessageBlock, } from '../script.js'; import { extension_settings, @@ -55,7 +58,7 @@ import { MacrosParser } from './macros.js'; import { oai_settings } from './openai.js'; import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; import { power_user, registerDebugFunction } from './power-user.js'; -import { isMobile, shouldSendOnEnter } from './RossAscends-mods.js'; +import { humanizedDateTime, isMobile, shouldSendOnEnter } from './RossAscends-mods.js'; import { ScraperManager } from './scrapers.js'; import { executeSlashCommands, executeSlashCommandsWithOptions, registerSlashCommand } from './slash-commands.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; @@ -65,7 +68,7 @@ import { tag_map, tags } from './tags.js'; import { textgenerationwebui_settings } from './textgen-settings.js'; import { tokenizers, getTextTokens, getTokenCount, getTokenCountAsync, getTokenizerModel } from './tokenizers.js'; import { ToolManager } from './tool-calling.js'; -import { timestampToMoment } from './utils.js'; +import { timestampToMoment, uuidv4 } from './utils.js'; export function getContext() { return { @@ -167,6 +170,11 @@ export function getContext() { chatCompletionSettings: oai_settings, textCompletionSettings: textgenerationwebui_settings, powerUserSettings: power_user, + getCharacters, + uuidv4, + humanizedDateTime, + updateMessageBlock, + appendMediaToMessage, }; } diff --git a/public/scripts/templates/assistantNote.html b/public/scripts/templates/assistantNote.html index 5984f8f26..f17211245 100644 --- a/public/scripts/templates/assistantNote.html +++ b/public/scripts/templates/assistantNote.html @@ -1,3 +1,9 @@ -
- Note: this chat is temporary and will be deleted as soon as you leave it. +
+
+ Note: this chat is temporary and will be deleted as soon as you leave it. + Click the button to save it as a file. +
+
diff --git a/public/scripts/templates/importCharacters.html b/public/scripts/templates/importCharacters.html index ee226f741..4bc4807bf 100644 --- a/public/scripts/templates/importCharacters.html +++ b/public/scripts/templates/importCharacters.html @@ -7,7 +7,7 @@
  • Chub Lorebook (Direct Link or ID)
    Example: lorebooks/bartleby/example-lorebook
  • JanitorAI Character (Direct Link or UUID)
    Example: ddd1498a-a370-4136-b138-a8cd9461fdfe_character-aqua-the-useless-goddess
  • Pygmalion.chat Character (Direct Link or UUID)
    Example: a7ca95a1-0c88-4e23-91b3-149db1e78ab9
  • -
  • AICharacterCard.com Character (Direct Link or ID)
    Example: AICC/aicharcards/the-game-master
  • +
  • AICharacterCards.com Character (Direct Link or ID)
    Example: AICC/aicharcards/the-game-master
  • Direct PNG Link (refer to config.yaml for allowed hosts)
    Example: https://files.catbox.moe/notarealfile.png
  • RisuRealm Character (Direct Link)
    Example: https://realm.risuai.net/character/3ca54c71-6efe-46a2-b9d0-4f62df23d712
  • diff --git a/public/scripts/textgen-models.js b/public/scripts/textgen-models.js index 8f4366333..824d5eec4 100644 --- a/public/scripts/textgen-models.js +++ b/public/scripts/textgen-models.js @@ -319,8 +319,6 @@ export async function loadFeatherlessModels(data) { return; } - // Sort the data by model id (default A-Z) - data.sort((a, b) => a.id.localeCompare(b.id)); originalModels = data; // Store the original data for search featherlessModels = data; @@ -334,10 +332,8 @@ export async function loadFeatherlessModels(data) { // Retrieve the stored number of items per page or default to 10 const perPage = Number(localStorage.getItem(storageKey)) || 10; - // Initialize pagination with the full set of models - const currentModelIndex = data.findIndex(x => x.id === textgen_settings.featherless_model); - featherlessCurrentPage = currentModelIndex >= 0 ? (currentModelIndex / perPage) + 1 : 1; - setupPagination(originalModels, perPage); + // Initialize pagination + applyFiltersAndSort(); // Function to set up pagination (also used for filtered results) function setupPagination(models, perPage, pageNumber = featherlessCurrentPage) { @@ -383,7 +379,7 @@ export async function loadFeatherlessModels(data) { const dateAddedDiv = document.createElement('div'); dateAddedDiv.classList.add('model-date-added'); - dateAddedDiv.textContent = `Added On: ${new Date(model.updated_at).toLocaleDateString()}`; + dateAddedDiv.textContent = `Added On: ${new Date(model.created * 1000).toLocaleDateString()}`; detailsContainer.appendChild(modelClassDiv); detailsContainer.appendChild(contextLengthDiv); @@ -472,6 +468,7 @@ export async function loadFeatherlessModels(data) { featherlessTop = await fetchFeatherlessStats(); } const featherlessIds = featherlessTop.map(stat => stat.id); + if (selectedCategory === 'New') { featherlessNew = await fetchFeatherlessNew(); } @@ -493,7 +490,7 @@ export async function loadFeatherlessModels(data) { return matchesSearch && matchesClass && matchesNew; } else { - return matchesSearch; + return matchesSearch && matchesClass; } }); @@ -502,11 +499,14 @@ export async function loadFeatherlessModels(data) { } else if (selectedSortOrder === 'desc') { filteredModels.sort((a, b) => b.id.localeCompare(a.id)); } else if (selectedSortOrder === 'date_asc') { - filteredModels.sort((a, b) => a.updated_at.localeCompare(b.updated_at)); + filteredModels.sort((a, b) => a.created - b.created); } else if (selectedSortOrder === 'date_desc') { - filteredModels.sort((a, b) => b.updated_at.localeCompare(a.updated_at)); + filteredModels.sort((a, b) => b.created - a.created); } + const currentModelIndex = filteredModels.findIndex(x => x.id === textgen_settings.featherless_model); + featherlessCurrentPage = currentModelIndex >= 0 ? (currentModelIndex / perPage) + 1 : 1; + setupPagination(filteredModels, Number(localStorage.getItem(storageKey)) || perPage, featherlessCurrentPage); } @@ -528,7 +528,7 @@ async function fetchFeatherlessStats() { } async function fetchFeatherlessNew() { - const response = await fetch('https://api.featherless.ai/feather/models?sort=-created_at&perPage=10'); + const response = await fetch('https://api.featherless.ai/feather/models?sort=-created_at&perPage=20'); const data = await response.json(); return data.items; } diff --git a/public/scripts/textgen-settings.js b/public/scripts/textgen-settings.js index e80c7e910..19b729374 100644 --- a/public/scripts/textgen-settings.js +++ b/public/scripts/textgen-settings.js @@ -986,6 +986,7 @@ export async function generateTextGenWithStreaming(generate_data, signal) { let logprobs = null; const swipes = []; const toolCalls = []; + const state = {}; while (true) { const { done, value } = await reader.read(); if (done) return; @@ -1004,7 +1005,7 @@ export async function generateTextGenWithStreaming(generate_data, signal) { logprobs = parseTextgenLogprobs(newText, data.choices?.[0]?.logprobs || data?.completion_probabilities); } - yield { text, swipes, logprobs, toolCalls }; + yield { text, swipes, logprobs, toolCalls, state }; } }; } @@ -1231,7 +1232,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, 'top_p': settings.top_p, 'typical_p': settings.typical_p, 'typical': settings.typical_p, - 'sampler_seed': settings.seed, + 'sampler_seed': settings.seed >= 0 ? settings.seed : undefined, 'min_p': settings.min_p, 'repetition_penalty': settings.rep_pen, 'frequency_penalty': settings.freq_pen, @@ -1294,7 +1295,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, 'temperature_last': (settings.type === OOBA || settings.type === APHRODITE || settings.type == TABBY) ? settings.temperature_last : undefined, 'speculative_ngram': settings.type === TABBY ? settings.speculative_ngram : undefined, 'do_sample': settings.type === OOBA ? settings.do_sample : undefined, - 'seed': settings.seed, + 'seed': settings.seed >= 0 ? settings.seed : undefined, 'guidance_scale': cfgValues?.guidanceScale?.value ?? settings.guidance_scale ?? 1, 'negative_prompt': cfgValues?.negativePrompt ?? substituteParams(settings.negative_prompt) ?? '', 'grammar_string': settings.grammar_string, diff --git a/public/scripts/utils.js b/public/scripts/utils.js index 60b49797a..a479aee52 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -391,6 +391,26 @@ export function getStringHash(str, seed = 0) { return 4294967296 * (2097151 & h2) + (h1 >>> 0); } +/** + * Copy text to clipboard. Use navigator.clipboard.writeText if available, otherwise use document.execCommand. + * @param {string} text - The text to copy to the clipboard. + * @returns {Promise} A promise that resolves when the text has been copied to the clipboard. + */ +export function copyText(text) { + if (navigator.clipboard) { + return navigator.clipboard.writeText(text); + } + + const parent = document.querySelector('dialog[open]:last-of-type') ?? document.body; + const textArea = document.createElement('textarea'); + textArea.value = text; + parent.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand('copy'); + parent.removeChild(textArea); +} + /** * Map of debounced functions to their timers. * Weak map is used to avoid memory leaks. diff --git a/public/style.css b/public/style.css index f5d8da36d..08fe9b68b 100644 --- a/public/style.css +++ b/public/style.css @@ -292,36 +292,44 @@ input[type='checkbox']:focus-visible { filter: grayscale(25%); } -.mes_text table { +.mes_text table, +.mes_reasoning table { border-spacing: 0; border-collapse: collapse; margin-bottom: 10px; } .mes_text td, -.mes_text th { +.mes_text th, +.mes_reasoning td, +.mes_reasoning th { border: 1px solid; border-collapse: collapse; padding: 0.25em; } -.mes_text p { +.mes_text p, +.mes_reasoning p { margin-top: 0; margin-bottom: 10px; } -.mes_text li tt { +.mes_text li tt, +.mes_reasoning li tt { display: inline-block; } .mes_text ol, -.mes_text ul { +.mes_text ul, +.mes_reasoning ol, +.mes_reasoning ul { margin-top: 5px; margin-bottom: 5px; } .mes_text br, -.mes_bias br { +.mes_bias br, +.mes_reasoning br { content: ' '; } @@ -332,25 +340,83 @@ input[type='checkbox']:focus-visible { color: var(--SmartThemeQuoteColor); } +.mes_reasoning { + display: block; + border: 1px solid var(--SmartThemeBorderColor); + background-color: var(--black30a); + border-radius: 5px; + padding: 5px; + margin: 5px 0; + overflow-y: auto; +} + +.mes_reasoning_summary { + cursor: pointer; + position: relative; + margin: 2px; +} + +@supports not selector(:has(*)) { + .mes_reasoning_details { + display: none !important; + } +} + +.mes_bias:empty, +.mes_reasoning:empty, +.mes_reasoning_details:has(.mes_reasoning:empty), +.mes_block:has(.edit_textarea) .mes_reasoning_details, +.mes_reasoning_details:not([open]) .mes_reasoning_actions, +.mes_reasoning_details:has(.reasoning_edit_textarea) .mes_reasoning, +.mes_reasoning_details:not(:has(.reasoning_edit_textarea)) .mes_reasoning_actions .mes_button.mes_reasoning_edit_done, +.mes_reasoning_details:not(:has(.reasoning_edit_textarea)) .mes_reasoning_actions .mes_button.mes_reasoning_edit_cancel, +.mes_reasoning_details:has(.reasoning_edit_textarea) .mes_reasoning_actions .mes_button:not(.mes_reasoning_edit_done, .mes_reasoning_edit_cancel) { + display: none; +} + +.mes_reasoning_actions { + position: absolute; + right: 0; + top: 0; + + display: flex; + gap: 4px; + flex-wrap: nowrap; + justify-content: flex-end; + transition: all 200ms; + overflow-x: hidden; + padding: 1px; +} + +.mes_reasoning_summary>span { + margin-left: 0.5em; +} + .mes_text i, -.mes_text em { +.mes_text em, +.mes_reasoning i, +.mes_reasoning em { color: var(--SmartThemeEmColor); } -.mes_text u { +.mes_text u, +.mes_reasoning u { color: var(--SmartThemeUnderlineColor); } -.mes_text q { +.mes_text q, +.mes_reasoning q { color: var(--SmartThemeQuoteColor); } .mes_text font[color] em, -.mes_text font[color] i { - color: inherit; -} - -.mes_text font[color] q { +.mes_text font[color] i, +.mes_text font[color] u, +.mes_text font[color] q, +.mes_reasoning font[color] em, +.mes_reasoning font[color] i, +.mes_reasoning font[color] u, +.mes_reasoning font[color] q { color: inherit; } @@ -358,7 +424,8 @@ input[type='checkbox']:focus-visible { display: block; } -.mes_text blockquote { +.mes_text blockquote, +.mes_reasoning blockquote { border-left: 3px solid var(--SmartThemeQuoteColor); padding-left: 10px; background-color: var(--black30a); @@ -368,18 +435,24 @@ input[type='checkbox']:focus-visible { .mes_text strong em, .mes_text strong, .mes_text h2, -.mes_text h1 { +.mes_text h1, +.mes_reasoning strong em, +.mes_reasoning strong, +.mes_reasoning h2, +.mes_reasoning h1 { font-weight: bold; } -.mes_text pre code { +.mes_text pre code, +.mes_reasoning pre code { position: relative; display: block; overflow-x: auto; padding: 1em; } -.mes_text img:not(.mes_img) { +.mes_text img:not(.mes_img), +.mes_reasoning img:not(.mes_img) { max-width: 100%; max-height: var(--doc-height); } @@ -1022,6 +1095,11 @@ body .panelControlBar { /*only affects bubblechat to make it sit nicely at the bottom*/ } +.last_mes:has(.mes_text:empty):has(.mes_reasoning_details[open]) .mes_reasoning:not(:empty) { + margin-bottom: 30px; +} + +.last_mes .mes_reasoning, .last_mes .mes_text { padding-right: 30px; } @@ -1235,14 +1313,18 @@ body.swipeAllMessages .mes:not(.last_mes) .swipes-counter { overflow-y: clip; } -.mes_text { +.mes_text, +.mes_reasoning { font-weight: 500; line-height: calc(var(--mainFontSize) + .5rem); + max-width: 100%; + overflow-wrap: anywhere; +} + +.mes_text { padding-left: 0; padding-top: 5px; padding-bottom: 5px; - max-width: 100%; - overflow-wrap: anywhere; } br { @@ -2921,7 +3003,7 @@ input[type=search]:focus::-webkit-search-cancel-button { position: relative; } -#rm_print_characters_block .ch_name, +.character_name_block .ch_name, .avatar-container .ch_name { flex: 1 1 auto; white-space: nowrap; @@ -2931,6 +3013,13 @@ input[type=search]:focus::-webkit-search-cancel-button { display: block; } +.character_name_block .character_version { + text-overflow: ellipsis; + overflow: hidden; + text-wrap: nowrap; + max-width: 50%; +} + #rm_print_characters_block .character_name_block> :last-child { flex: 0 100000 auto; /* Force shrinking first */ @@ -4143,10 +4232,12 @@ input[type="range"]::-webkit-slider-thumb { align-items: center; } +.mes_reasoning_edit_cancel, .mes_edit_cancel.menu_button { background-color: var(--crimson70a); } +.mes_reasoning_edit_done, .mes_edit_done.menu_button { background-color: var(--okGreen70a); } @@ -4155,6 +4246,7 @@ input[type="range"]::-webkit-slider-thumb { opacity: 1; } +.reasoning_edit_textarea, .edit_textarea { padding: 5px; margin: 0; @@ -5643,6 +5735,7 @@ body:not(.movingUI) .drawer-content.maximized { .model-card .details-container { text-align: right; + line-height: 0.9; } .model-card:hover { @@ -5665,7 +5758,7 @@ body:not(.movingUI) .drawer-content.maximized { } .model-title { - font-size: 13px; + font-size: calc(var(--mainFontSize) * 0.95); font-weight: bold; overflow: hidden; } @@ -5681,7 +5774,7 @@ body:not(.movingUI) .drawer-content.maximized { .model-class, .model-context-length, .model-date-added { - font-size: 10px; + font-size: calc(var(--mainFontSize) * 0.75); } .model-class, @@ -5763,3 +5856,17 @@ body:not(.movingUI) .drawer-content.maximized { .alternate_greetings_list { overflow-y: scroll; } + +.mes_text div[data-type="assistant_note"]:has(.assistant_note_export) { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 0 2px; +} + +.mes_text div[data-type="assistant_note"]:has(.assistant_note_export)>div:not(.assistant_note_export) { + flex: 1; +} diff --git a/server.js b/server.js index b3ee0e8a2..3fd56bfd3 100644 --- a/server.js +++ b/server.js @@ -18,10 +18,9 @@ import { hideBin } from 'yargs/helpers'; // express/server related library imports import cors from 'cors'; -import { doubleCsrf } from 'csrf-csrf'; +import { csrfSync } from 'csrf-sync'; import express from 'express'; import compression from 'compression'; -import cookieParser from 'cookie-parser'; import cookieSession from 'cookie-session'; import multer from 'multer'; import responseTime from 'response-time'; @@ -40,7 +39,6 @@ util.inspect.defaultOptions.depth = 4; import { loadPlugins } from './src/plugin-loader.js'; import { initUserStorage, - getCsrfSecret, getCookieSecret, getCookieSessionName, getAllEnabledUsers, @@ -67,6 +65,7 @@ import { forwardFetchResponse, removeColorFormatting, getSeparator, + safeReadFileSync, setupLogLevel, } from './src/util.js'; import { UPLOADS_DIRECTORY } from './src/constants.js'; @@ -348,8 +347,8 @@ if (enableCorsProxy) { } function getSessionCookieAge() { - // Defaults to 24 hours in seconds if not set - const configValue = getConfigValue('sessionTimeout', 24 * 60 * 60); + // Defaults to "no expiration" if not set + const configValue = getConfigValue('sessionTimeout', -1); // Convert to milliseconds if (configValue > 0) { @@ -378,27 +377,38 @@ app.use(setUserDataMiddleware); // CSRF Protection // if (!disableCsrf) { - const COOKIES_SECRET = getCookieSecret(); - - const { generateToken, doubleCsrfProtection } = doubleCsrf({ - getSecret: getCsrfSecret, - cookieName: 'X-CSRF-Token', - cookieOptions: { - sameSite: 'strict', - secure: false, + const csrfSyncProtection = csrfSync({ + getTokenFromState: (req) => { + if (!req.session) { + console.error('(CSRF error) getTokenFromState: Session object not initialized'); + return; + } + return req.session.csrfToken; }, - size: 64, - getTokenFromRequest: (req) => req.headers['x-csrf-token'], + getTokenFromRequest: (req) => { + return req.headers['x-csrf-token']?.toString(); + }, + storeTokenInState: (req, token) => { + if (!req.session) { + console.error('(CSRF error) storeTokenInState: Session object not initialized'); + return; + } + req.session.csrfToken = token; + }, + size: 32, }); app.get('/csrf-token', (req, res) => { res.json({ - 'token': generateToken(res, req), + 'token': csrfSyncProtection.generateToken(req), }); }); - app.use(cookieParser(COOKIES_SECRET)); - app.use(doubleCsrfProtection); + // Customize the error message + csrfSyncProtection.invalidCsrfTokenError.message = color.red('Invalid CSRF token. Please refresh the page and try again.'); + csrfSyncProtection.invalidCsrfTokenError.stack = undefined; + + app.use(csrfSyncProtection.csrfSynchronisedProtection); } else { console.warn('\nCSRF protection is disabled. This will make your server vulnerable to CSRF attacks.\n'); app.get('/csrf-token', (req, res) => { @@ -925,6 +935,16 @@ async function verifySecuritySettings() { } } +/** + * Registers a not-found error response if a not-found error page exists. Should only be called after all other middlewares have been registered. + */ +function apply404Middleware() { + const notFoundWebpage = safeReadFileSync('./public/error/url-not-found.html') ?? ''; + app.use((req, res) => { + res.status(404).send(notFoundWebpage); + }); +} + // User storage module needs to be initialized before starting the server initUserStorage(dataRoot) .then(ensurePublicDirectoriesExist) @@ -932,4 +952,5 @@ initUserStorage(dataRoot) .then(migrateSystemPrompts) .then(verifySecuritySettings) .then(preSetupTasks) + .then(apply404Middleware) .finally(startServer); diff --git a/src/endpoints/avatars.js b/src/endpoints/avatars.js index 73b995ffb..f84527670 100644 --- a/src/endpoints/avatars.js +++ b/src/endpoints/avatars.js @@ -9,6 +9,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic'; import { jsonParser, urlencodedParser } from '../express-common.js'; import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js'; import { getImages, tryParse } from '../util.js'; +import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; export const router = express.Router(); @@ -17,7 +18,7 @@ router.post('/get', jsonParser, function (request, response) { response.send(JSON.stringify(images)); }); -router.post('/delete', jsonParser, function (request, response) { +router.post('/delete', jsonParser, getFileNameValidationFunction('avatar'), function (request, response) { if (!request.body) return response.sendStatus(400); if (request.body.avatar !== sanitize(request.body.avatar)) { diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js index 771f29855..0d7079165 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -37,6 +37,8 @@ import { getTiktokenTokenizer, sentencepieceTokenizers, TEXT_COMPLETION_MODELS, + webTokenizers, + getWebTokenizer, } from '../tokenizers.js'; const API_OPENAI = 'https://api.openai.com/v1'; @@ -61,6 +63,7 @@ const API_DEEPSEEK = 'https://api.deepseek.com/beta'; * @returns */ function postProcessPrompt(messages, type, names) { + const addAssistantPrefix = x => x.length && (x[x.length - 1].role !== 'assistant' || (x[x.length - 1].prefix = true)) ? x : x; switch (type) { case 'merge': case 'claude': @@ -70,7 +73,9 @@ function postProcessPrompt(messages, type, names) { case 'strict': return mergeMessages(messages, names, true, true); case 'deepseek': - return (x => x.length && (x[x.length - 1].role !== 'assistant' || (x[x.length - 1].prefix = true)) ? x : x)(mergeMessages(messages, names, true, false)); + return addAssistantPrefix(mergeMessages(messages, names, true, false)); + case 'deepseek-reasoner': + return addAssistantPrefix(mergeMessages(messages, names, true, true)); default: return messages; } @@ -284,6 +289,7 @@ async function sendMakerSuiteRequest(request, response) { const model = String(request.body.model); const stream = Boolean(request.body.stream); const showThoughts = Boolean(request.body.show_thoughts); + const isThinking = model.includes('thinking'); const generationConfig = { stopSequences: request.body.stop, @@ -324,6 +330,12 @@ async function sendMakerSuiteRequest(request, response) { body.systemInstruction = prompt.system_instruction; } + if (isThinking && showThoughts) { + generationConfig.thinkingConfig = { + includeThoughts: true, + }; + } + return body; } @@ -337,7 +349,6 @@ async function sendMakerSuiteRequest(request, response) { controller.abort(); }); - const isThinking = model.includes('thinking'); const apiVersion = isThinking ? 'v1alpha' : 'v1beta'; const responseType = (stream ? 'streamGenerateContent' : 'generateContent'); @@ -382,11 +393,7 @@ async function sendMakerSuiteRequest(request, response) { const responseContent = candidates[0].content ?? candidates[0].output; console.error('Google AI Studio response:', responseContent); - if (Array.isArray(responseContent?.parts) && isThinking && !showThoughts) { - responseContent.parts = responseContent.parts.filter(part => !part.thought); - } - - const responseText = typeof responseContent === 'string' ? responseContent : responseContent?.parts?.map(part => part.text)?.join('\n\n'); + const responseText = typeof responseContent === 'string' ? responseContent : responseContent?.parts?.filter(part => !part.thought)?.map(part => part.text)?.join('\n\n'); if (!responseText) { let message = 'Google AI Studio Candidate text empty'; console.error(message, generateResponseJson); @@ -394,7 +401,7 @@ async function sendMakerSuiteRequest(request, response) { } // Wrap it back to OAI format - const reply = { choices: [{ 'message': { 'content': responseText } }] }; + const reply = { choices: [{ 'message': { 'content': responseText } }], responseContent }; return response.send(reply); } } catch (error) { @@ -639,6 +646,89 @@ async function sendCohereRequest(request, response) { } } +/** + * Sends a request to DeepSeek API. + * @param {express.Request} request Express request + * @param {express.Response} response Express response + */ +async function sendDeepSeekRequest(request, response) { + const apiUrl = new URL(request.body.reverse_proxy || API_DEEPSEEK).toString(); + const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.DEEPSEEK); + + if (!apiKey && !request.body.reverse_proxy) { + console.log('DeepSeek API key is missing.'); + return response.status(400).send({ error: true }); + } + + const controller = new AbortController(); + request.socket.removeAllListeners('close'); + request.socket.on('close', function () { + controller.abort(); + }); + + try { + let bodyParams = {}; + + if (request.body.logprobs > 0) { + bodyParams['top_logprobs'] = request.body.logprobs; + bodyParams['logprobs'] = true; + } + + const postProcessType = String(request.body.model).endsWith('-reasoner') ? 'deepseek-reasoner' : 'deepseek'; + const processedMessages = postProcessPrompt(request.body.messages, postProcessType, getPromptNames(request)); + + const requestBody = { + 'messages': processedMessages, + 'model': request.body.model, + 'temperature': request.body.temperature, + 'max_tokens': request.body.max_tokens, + 'stream': request.body.stream, + 'presence_penalty': request.body.presence_penalty, + 'frequency_penalty': request.body.frequency_penalty, + 'top_p': request.body.top_p, + 'stop': request.body.stop, + 'seed': request.body.seed, + ...bodyParams, + }; + + const config = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + apiKey, + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }; + + console.log('DeepSeek request:', requestBody); + + const generateResponse = await fetch(apiUrl + '/chat/completions', config); + + if (request.body.stream) { + forwardFetchResponse(generateResponse, response); + } else { + if (!generateResponse.ok) { + const errorText = await generateResponse.text(); + console.log(`DeepSeek API returned error: ${generateResponse.status} ${generateResponse.statusText} ${errorText}`); + const errorJson = tryParse(errorText) ?? { error: true }; + return response.status(500).send(errorJson); + } + const generateResponseJson = await generateResponse.json(); + console.log('DeepSeek response:', generateResponseJson); + return response.send(generateResponseJson); + } + } catch (error) { + console.log('Error communicating with DeepSeek API: ', error); + if (!response.headersSent) { + response.send({ error: true }); + } else { + response.end(); + } + } +} + + export const router = express.Router(); router.post('/status', jsonParser, async function (request, response_getstatus_openai) { @@ -683,8 +773,8 @@ router.post('/status', jsonParser, async function (request, response_getstatus_o api_key_openai = readSecret(request.user.directories, SECRET_KEYS.NANOGPT); headers = {}; } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.DEEPSEEK) { - api_url = API_DEEPSEEK.replace('/beta', ''); - api_key_openai = readSecret(request.user.directories, SECRET_KEYS.DEEPSEEK); + api_url = new URL(request.body.reverse_proxy || API_DEEPSEEK.replace('/beta', '')); + api_key_openai = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.DEEPSEEK); headers = {}; } else { console.error('This chat completion source is not supported yet.'); @@ -779,6 +869,14 @@ router.post('/bias', jsonParser, async function (request, response) { return response.send({}); } encodeFunction = (text) => new Uint32Array(instance.encodeIds(text)); + } else if (webTokenizers.includes(model)) { + const tokenizer = getWebTokenizer(model); + const instance = await tokenizer?.get(); + if (!instance) { + console.warn('Tokenizer not initialized:', model); + return response.send({}); + } + encodeFunction = (text) => new Uint32Array(instance.encode(text)); } else { const tokenizer = getTiktokenTokenizer(model); encodeFunction = (tokenizer.encode.bind(tokenizer)); @@ -843,6 +941,7 @@ router.post('/generate', jsonParser, function (request, response) { case CHAT_COMPLETION_SOURCES.MAKERSUITE: return sendMakerSuiteRequest(request, response); case CHAT_COMPLETION_SOURCES.MISTRALAI: return sendMistralAIRequest(request, response); case CHAT_COMPLETION_SOURCES.COHERE: return sendCohereRequest(request, response); + case CHAT_COMPLETION_SOURCES.DEEPSEEK: return sendDeepSeekRequest(request, response); } let apiUrl; @@ -901,6 +1000,10 @@ router.post('/generate', jsonParser, function (request, response) { bodyParams['route'] = 'fallback'; } + if (request.body.show_thoughts) { + bodyParams['include_reasoning'] = true; + } + let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1); if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && request.body.model?.startsWith('anthropic/claude-3')) { cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth); @@ -956,18 +1059,6 @@ router.post('/generate', jsonParser, function (request, response) { apiKey = readSecret(request.user.directories, SECRET_KEYS.BLOCKENTROPY); headers = {}; bodyParams = {}; - } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.DEEPSEEK) { - apiUrl = API_DEEPSEEK; - apiKey = readSecret(request.user.directories, SECRET_KEYS.DEEPSEEK); - headers = {}; - bodyParams = {}; - - if (request.body.logprobs > 0) { - bodyParams['top_logprobs'] = request.body.logprobs; - bodyParams['logprobs'] = true; - } - - request.body.messages = postProcessPrompt(request.body.messages, 'deepseek', getPromptNames(request)); } else { console.error('This chat completion source is not supported yet.'); return response.status(400).send({ error: true }); @@ -1105,4 +1196,3 @@ router.post('/generate', jsonParser, function (request, response) { } } }); - diff --git a/src/endpoints/backgrounds.js b/src/endpoints/backgrounds.js index 89b3c0c8a..4ef726504 100644 --- a/src/endpoints/backgrounds.js +++ b/src/endpoints/backgrounds.js @@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename'; import { jsonParser, urlencodedParser } from '../express-common.js'; import { invalidateThumbnail } from './thumbnails.js'; import { getImages } from '../util.js'; +import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; export const router = express.Router(); @@ -15,7 +16,7 @@ router.post('/all', jsonParser, function (request, response) { response.send(JSON.stringify(images)); }); -router.post('/delete', jsonParser, function (request, response) { +router.post('/delete', jsonParser, getFileNameValidationFunction('bg'), function (request, response) { if (!request.body) return response.sendStatus(400); if (request.body.bg !== sanitize(request.body.bg)) { diff --git a/src/endpoints/characters.js b/src/endpoints/characters.js index 7ad9d2f43..3207beb61 100644 --- a/src/endpoints/characters.js +++ b/src/endpoints/characters.js @@ -14,6 +14,7 @@ import jimp from 'jimp'; import { AVATAR_WIDTH, AVATAR_HEIGHT } from '../constants.js'; import { jsonParser, urlencodedParser } from '../express-common.js'; +import { default as validateAvatarUrlMiddleware, getFileNameValidationFunction } from '../middleware/validateFileName.js'; import { deepMerge, humanizedISO8601DateTime, tryParse, extractFileFromZipBuffer, MemoryLimitedMap, getConfigValue } from '../util.js'; import { TavernCardValidator } from '../validator/TavernCardValidator.js'; import { parse, write } from '../character-card-parser.js'; @@ -73,12 +74,18 @@ async function writeCharacterData(inputFile, data, outputFile, request, crop = u * Read the image, resize, and save it as a PNG into the buffer. * @returns {Promise} Image buffer */ - function getInputImage() { - if (Buffer.isBuffer(inputFile)) { - return parseImageBuffer(inputFile, crop); - } + async function getInputImage() { + try { + if (Buffer.isBuffer(inputFile)) { + return await parseImageBuffer(inputFile, crop); + } - return tryReadImage(inputFile, crop); + return await tryReadImage(inputFile, crop); + } catch (error) { + const message = Buffer.isBuffer(inputFile) ? 'Failed to read image buffer.' : `Failed to read image: ${inputFile}.`; + console.warn(message, 'Using a fallback image.', error); + return await fs.promises.readFile(defaultAvatarPath); + } } const inputImage = await getInputImage(); @@ -756,7 +763,7 @@ router.post('/create', urlencodedParser, async function (request, response) { } }); -router.post('/rename', jsonParser, async function (request, response) { +router.post('/rename', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { if (!request.body.avatar_url || !request.body.new_name) { return response.sendStatus(400); } @@ -803,7 +810,7 @@ router.post('/rename', jsonParser, async function (request, response) { } }); -router.post('/edit', urlencodedParser, async function (request, response) { +router.post('/edit', urlencodedParser, validateAvatarUrlMiddleware, async function (request, response) { if (!request.body) { console.error('Error: no response body detected'); response.status(400).send('Error: no response body detected'); @@ -852,7 +859,7 @@ router.post('/edit', urlencodedParser, async function (request, response) { * @param {Object} response - The HTTP response object. * @returns {void} */ -router.post('/edit-attribute', jsonParser, async function (request, response) { +router.post('/edit-attribute', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { console.debug(request.body); if (!request.body) { console.error('Error: no response body detected'); @@ -898,7 +905,7 @@ router.post('/edit-attribute', jsonParser, async function (request, response) { * * @returns {void} * */ -router.post('/merge-attributes', jsonParser, async function (request, response) { +router.post('/merge-attributes', jsonParser, getFileNameValidationFunction('avatar'), async function (request, response) { try { const update = request.body; const avatarPath = path.join(request.user.directories.characters, update.avatar); @@ -929,7 +936,7 @@ router.post('/merge-attributes', jsonParser, async function (request, response) } }); -router.post('/delete', jsonParser, async function (request, response) { +router.post('/delete', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { if (!request.body || !request.body.avatar_url) { return response.sendStatus(400); } @@ -992,7 +999,7 @@ router.post('/all', jsonParser, async function (request, response) { } }); -router.post('/get', jsonParser, async function (request, response) { +router.post('/get', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { try { if (!request.body) return response.sendStatus(400); const item = request.body.avatar_url; @@ -1011,7 +1018,7 @@ router.post('/get', jsonParser, async function (request, response) { } }); -router.post('/chats', jsonParser, async function (request, response) { +router.post('/chats', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { if (!request.body) return response.sendStatus(400); const characterDirectory = (request.body.avatar_url).replace('.png', ''); @@ -1160,7 +1167,7 @@ router.post('/import', urlencodedParser, async function (request, response) { } }); -router.post('/duplicate', jsonParser, async function (request, response) { +router.post('/duplicate', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { try { if (!request.body.avatar_url) { console.error('avatar URL not found in request body'); @@ -1206,7 +1213,7 @@ router.post('/duplicate', jsonParser, async function (request, response) { } }); -router.post('/export', jsonParser, async function (request, response) { +router.post('/export', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { try { if (!request.body.format || !request.body.avatar_url) { return response.sendStatus(400); diff --git a/src/endpoints/chats.js b/src/endpoints/chats.js index c4cd6bedc..803bb1ca5 100644 --- a/src/endpoints/chats.js +++ b/src/endpoints/chats.js @@ -9,6 +9,7 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic'; import _ from 'lodash'; import { jsonParser, urlencodedParser } from '../express-common.js'; +import validateAvatarUrlMiddleware from '../middleware/validateFileName.js'; import { getConfigValue, humanizedISO8601DateTime, @@ -264,9 +265,37 @@ function flattenChubChat(userName, characterName, lines) { return (lines ?? []).map(convert).join('\n'); } +/** + * Imports a chat from RisuAI format. + * @param {string} userName User name + * @param {string} characterName Character name + * @param {object} jsonData Imported chat data + * @returns {string} Chat data + */ +function importRisuChat(userName, characterName, jsonData) { + /** @type {object[]} */ + const chat = [{ + user_name: userName, + character_name: characterName, + create_date: humanizedISO8601DateTime(), + }]; + + for (const message of jsonData.data.message) { + const isUser = message.role === 'user'; + chat.push({ + name: message.name ?? (isUser ? userName : characterName), + is_user: isUser, + send_date: Number(message.time ?? Date.now()), + mes: message.data ?? '', + }); + } + + return chat.map(obj => JSON.stringify(obj)).join('\n'); +} + export const router = express.Router(); -router.post('/save', jsonParser, function (request, response) { +router.post('/save', jsonParser, validateAvatarUrlMiddleware, function (request, response) { try { const directoryName = String(request.body.avatar_url).replace('.png', ''); const chatData = request.body.chat; @@ -282,7 +311,7 @@ router.post('/save', jsonParser, function (request, response) { } }); -router.post('/get', jsonParser, function (request, response) { +router.post('/get', jsonParser, validateAvatarUrlMiddleware, function (request, response) { try { const dirName = String(request.body.avatar_url).replace('.png', ''); const directoryPath = path.join(request.user.directories.chats, dirName); @@ -319,7 +348,7 @@ router.post('/get', jsonParser, function (request, response) { }); -router.post('/rename', jsonParser, async function (request, response) { +router.post('/rename', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { if (!request.body || !request.body.original_file || !request.body.renamed_file) { return response.sendStatus(400); } @@ -344,7 +373,7 @@ router.post('/rename', jsonParser, async function (request, response) { return response.send({ ok: true, sanitizedFileName }); }); -router.post('/delete', jsonParser, function (request, response) { +router.post('/delete', jsonParser, validateAvatarUrlMiddleware, function (request, response) { const dirName = String(request.body.avatar_url).replace('.png', ''); const fileName = String(request.body.chatfile); const filePath = path.join(request.user.directories.chats, dirName, sanitize(fileName)); @@ -360,7 +389,7 @@ router.post('/delete', jsonParser, function (request, response) { return response.send('ok'); }); -router.post('/export', jsonParser, async function (request, response) { +router.post('/export', jsonParser, validateAvatarUrlMiddleware, async function (request, response) { if (!request.body.file || (!request.body.avatar_url && request.body.is_group === false)) { return response.sendStatus(400); } @@ -449,7 +478,7 @@ router.post('/group/import', urlencodedParser, function (request, response) { } }); -router.post('/import', urlencodedParser, function (request, response) { +router.post('/import', urlencodedParser, validateAvatarUrlMiddleware, function (request, response) { if (!request.body) return response.sendStatus(400); const format = request.body.file_type; @@ -480,6 +509,8 @@ router.post('/import', urlencodedParser, function (request, response) { importFunc = importOobaChat; } else if (Array.isArray(jsonData.messages)) { // Agnai's format importFunc = importAgnaiChat; + } else if (jsonData.type === 'risuChat') { // RisuAI format + importFunc = importRisuChat; } else { // Unknown format console.error('Incorrect chat format .json'); return response.send({ error: true }); @@ -595,7 +626,7 @@ router.post('/group/save', jsonParser, (request, response) => { return response.send({ ok: true }); }); -router.post('/search', jsonParser, function (request, response) { +router.post('/search', jsonParser, validateAvatarUrlMiddleware, function (request, response) { try { const { query, avatar_url, group_id } = request.body; let chatFiles = []; diff --git a/src/endpoints/search.js b/src/endpoints/search.js index 36c2f29ff..1894d84f6 100644 --- a/src/endpoints/search.js +++ b/src/endpoints/search.js @@ -4,6 +4,8 @@ import express from 'express'; import { decode } from 'html-entities'; import { readSecret, SECRET_KEYS } from './secrets.js'; import { jsonParser } from '../express-common.js'; +import { trimV1 } from '../util.js'; +import { setAdditionalHeaders } from '../additional-headers.js'; export const router = express.Router(); @@ -257,6 +259,41 @@ router.post('/tavily', jsonParser, async (request, response) => { } }); +router.post('/koboldcpp', jsonParser, async (request, response) => { + try { + const { query, url } = request.body; + + if (!url) { + console.error('No URL provided for KoboldCpp search'); + return response.sendStatus(400); + } + + console.debug('KoboldCpp search query', query); + + const baseUrl = trimV1(url); + const args = { + method: 'POST', + headers: {}, + body: JSON.stringify({ q: query }), + }; + + setAdditionalHeaders(request, args, baseUrl); + const result = await fetch(`${baseUrl}/api/extra/websearch`, args); + + if (!result.ok) { + const text = await result.text(); + console.error('KoboldCpp request failed', result.statusText, text); + return response.status(500).send(text); + } + + const data = await result.json(); + return response.json(data); + } catch (error) { + console.error(error); + return response.sendStatus(500); + } +}); + router.post('/visit', jsonParser, async (request, response) => { try { const url = request.body.url; diff --git a/src/endpoints/settings.js b/src/endpoints/settings.js index 195095d90..887fd664b 100644 --- a/src/endpoints/settings.js +++ b/src/endpoints/settings.js @@ -9,9 +9,10 @@ import { SETTINGS_FILE } from '../constants.js'; import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js'; import { jsonParser } from '../express-common.js'; import { getAllUserHandles, getUserDirectories } from '../users.js'; +import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; -const ENABLE_EXTENSIONS = getConfigValue('enableExtensions', true); -const ENABLE_EXTENSIONS_AUTO_UPDATE = getConfigValue('enableExtensionsAutoUpdate', true); +const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true); +const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true); const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); // 10 minutes @@ -296,7 +297,7 @@ router.post('/get-snapshots', jsonParser, async (request, response) => { } }); -router.post('/load-snapshot', jsonParser, async (request, response) => { +router.post('/load-snapshot', jsonParser, getFileNameValidationFunction('name'), async (request, response) => { try { const userFilesPattern = getFilePrefix(request.user.profile.handle); @@ -330,7 +331,7 @@ router.post('/make-snapshot', jsonParser, async (request, response) => { } }); -router.post('/restore-snapshot', jsonParser, async (request, response) => { +router.post('/restore-snapshot', jsonParser, getFileNameValidationFunction('name'), async (request, response) => { try { const userFilesPattern = getFilePrefix(request.user.profile.handle); diff --git a/src/endpoints/tokenizers.js b/src/endpoints/tokenizers.js index 0f68f79c2..6d839b1d3 100644 --- a/src/endpoints/tokenizers.js +++ b/src/endpoints/tokenizers.js @@ -238,6 +238,15 @@ export const sentencepieceTokenizers = [ 'jamba', ]; +export const webTokenizers = [ + 'claude', + 'llama3', + 'command-r', + 'qwen2', + 'nemo', + 'deepseek', +]; + /** * Gets the Sentencepiece tokenizer by the model name. * @param {string} model Sentencepiece model name @@ -275,6 +284,39 @@ export function getSentencepiceTokenizer(model) { return null; } +/** + * Gets the Web tokenizer by the model name. + * @param {string} model Web tokenizer model name + * @returns {WebTokenizer|null} Web tokenizer + */ +export function getWebTokenizer(model) { + if (model.includes('llama3')) { + return llama3_tokenizer; + } + + if (model.includes('claude')) { + return claude_tokenizer; + } + + if (model.includes('command-r')) { + return commandTokenizer; + } + + if (model.includes('qwen2')) { + return qwen2Tokenizer; + } + + if (model.includes('nemo')) { + return nemoTokenizer; + } + + if (model.includes('deepseek')) { + return deepseekTokenizer; + } + + return null; +} + /** * Counts the token ids for the given text using the Sentencepiece tokenizer. * @param {SentencePieceTokenizer} tokenizer Sentencepiece tokenizer diff --git a/src/endpoints/users-private.js b/src/endpoints/users-private.js index 65b3ca6c6..b324491e0 100644 --- a/src/endpoints/users-private.js +++ b/src/endpoints/users-private.js @@ -23,6 +23,7 @@ router.post('/logout', async (request, response) => { } request.session.handle = null; + request.session.csrfToken = null; request.session = null; return response.sendStatus(204); } catch (error) { diff --git a/src/endpoints/vectors.js b/src/endpoints/vectors.js index 41b5ab7ce..3cab324d7 100644 --- a/src/endpoints/vectors.js +++ b/src/endpoints/vectors.js @@ -164,7 +164,7 @@ function getSourceSettings(source, request) { }; case 'transformers': return { - model: getConfigValue('extras.embeddingModel', ''), + model: getConfigValue('extensions.models.embedding', ''), }; case 'palm': return { diff --git a/src/middleware/basicAuth.js b/src/middleware/basicAuth.js index 87b7fbcf8..b75856289 100644 --- a/src/middleware/basicAuth.js +++ b/src/middleware/basicAuth.js @@ -5,17 +5,18 @@ import { Buffer } from 'node:buffer'; import storage from 'node-persist'; import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; -import { getConfig, getConfigValue } from '../util.js'; +import { getConfig, getConfigValue, safeReadFileSync } from '../util.js'; const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false); const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false); -const unauthorizedResponse = (res) => { - res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); - return res.status(401).send('Authentication required'); -}; - const basicAuthMiddleware = async function (request, response, callback) { + const unauthorizedWebpage = safeReadFileSync('./public/error/unauthorized.html') ?? ''; + const unauthorizedResponse = (res) => { + res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"'); + return res.status(401).send(unauthorizedWebpage); + }; + const config = getConfig(); const authHeader = request.headers.authorization; diff --git a/src/middleware/validateFileName.js b/src/middleware/validateFileName.js new file mode 100644 index 000000000..ccfb0e88d --- /dev/null +++ b/src/middleware/validateFileName.js @@ -0,0 +1,34 @@ +import path from 'node:path'; + +/** + * Gets a middleware function that validates the field in the request body. + * @param {string} fieldName Field name + * @returns {import('express').RequestHandler} Middleware function + */ +export function getFileNameValidationFunction(fieldName) { + /** + * Validates the field in the request body. + * @param {import('express').Request} req Request object + * @param {import('express').Response} res Response object + * @param {import('express').NextFunction} next Next middleware + */ + return function validateAvatarUrlMiddleware(req, res, next) { + if (req.body && fieldName in req.body && typeof req.body[fieldName] === 'string') { + const forbiddenRegExp = path.sep === '/' ? /[/\x00]/ : /[/\x00\\]/; + if (forbiddenRegExp.test(req.body[fieldName])) { + console.error('An error occurred while validating the request body', { + handle: req.user.profile.handle, + path: req.originalUrl, + field: fieldName, + value: req.body[fieldName], + }); + return res.sendStatus(400); + } + } + + next(); + }; +} + +const avatarUrlValidationFunction = getFileNameValidationFunction('avatar_url'); +export default avatarUrlValidationFunction; diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js index 9864e19ae..4df5a6798 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -1,10 +1,11 @@ import path from 'node:path'; import fs from 'node:fs'; import process from 'node:process'; +import Handlebars from 'handlebars'; import ipMatching from 'ip-matching'; import { getIpFromRequest } from '../express-common.js'; -import { color, getConfigValue } from '../util.js'; +import { color, getConfigValue, safeReadFileSync } from '../util.js'; const whitelistPath = path.join(process.cwd(), './whitelist.txt'); const enableForwardedWhitelist = getConfigValue('enableForwardedWhitelist', false); @@ -52,12 +53,16 @@ function getForwardedIp(req) { * @returns {import('express').RequestHandler} The middleware function */ export default function whitelistMiddleware(whitelistMode, listen) { + const forbiddenWebpage = Handlebars.compile( + safeReadFileSync('./public/error/forbidden-by-whitelist.html') ?? '', + ); + return function (req, res, next) { const clientIp = getIpFromRequest(req); const forwardedIp = getForwardedIp(req); + const userAgent = req.headers['user-agent']; if (listen && !knownIPs.has(clientIp)) { - const userAgent = req.headers['user-agent']; console.log(color.yellow(`New connection from ${clientIp}; User Agent: ${userAgent}\n`)); knownIPs.add(clientIp); @@ -76,9 +81,15 @@ export default function whitelistMiddleware(whitelistMode, listen) { || forwardedIp && whitelistMode === true && !whitelist.some(x => ipMatching.matches(forwardedIp, ipMatching.getMatch(x))) ) { // Log the connection attempt with real IP address - const ipDetails = forwardedIp ? `${clientIp} (forwarded from ${forwardedIp})` : clientIp; - console.log(color.red('Forbidden: Connection attempt from ' + ipDetails + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.\n')); - return res.status(403).send('Forbidden: Connection attempt from ' + ipDetails + '. If you are attempting to connect, please add your IP address in whitelist or disable whitelist mode in config.yaml in root of SillyTavern folder.'); + const ipDetails = forwardedIp + ? `${clientIp} (forwarded from ${forwardedIp})` + : clientIp; + console.log( + color.red( + `Blocked connection from ${clientIp}; User Agent: ${userAgent}\n\tTo allow this connection, add its IP address to the whitelist or disable whitelist mode by editing config.yaml in the root directory of your SillyTavern installation.\n`, + ), + ); + return res.status(403).send(forbiddenWebpage({ ipDetails })); } next(); }; diff --git a/src/prompt-converters.js b/src/prompt-converters.js index e38813c99..49afc1f2f 100644 --- a/src/prompt-converters.js +++ b/src/prompt-converters.js @@ -360,6 +360,8 @@ export function convertCohereMessages(messages, names) { */ export function convertGooglePrompt(messages, model, useSysPrompt, names) { const visionSupportedModels = [ + 'gemini-2.0-flash-thinking-exp', + 'gemini-2.0-flash-thinking-exp-01-21', 'gemini-2.0-flash-thinking-exp-1219', 'gemini-2.0-flash-exp', 'gemini-1.5-flash', diff --git a/src/transformers.js b/src/transformers.js index 3413101a6..c8f8da4f6 100644 --- a/src/transformers.js +++ b/src/transformers.js @@ -19,31 +19,31 @@ const tasks = { 'text-classification': { defaultModel: 'Cohee/distilbert-base-uncased-go-emotions-onnx', pipeline: null, - configField: 'extras.classificationModel', + configField: 'extensions.models.classification', quantized: true, }, 'image-to-text': { defaultModel: 'Xenova/vit-gpt2-image-captioning', pipeline: null, - configField: 'extras.captioningModel', + configField: 'extensions.models.captioning', quantized: true, }, 'feature-extraction': { defaultModel: 'Xenova/all-mpnet-base-v2', pipeline: null, - configField: 'extras.embeddingModel', + configField: 'extensions.models.embedding', quantized: true, }, 'automatic-speech-recognition': { defaultModel: 'Xenova/whisper-small', pipeline: null, - configField: 'extras.speechToTextModel', + configField: 'extensions.models.speechToText', quantized: true, }, 'text-to-speech': { defaultModel: 'Xenova/speecht5_tts', pipeline: null, - configField: 'extras.textToSpeechModel', + configField: 'extensions.models.textToSpeech', quantized: false, }, }; @@ -132,7 +132,7 @@ export async function getPipeline(task, forceModel = '') { const cacheDir = path.join(globalThis.DATA_ROOT, '_cache'); const model = forceModel || getModelForTask(task); - const localOnly = getConfigValue('extras.disableAutoDownload', false); + const localOnly = !getConfigValue('extensions.models.autoDownload', true); console.log('Initializing transformers.js pipeline for task', task, 'with model', model); const instance = await pipeline(task, model, { cache_dir: cacheDir, quantized: tasks[task].quantized ?? true, local_files_only: localOnly }); tasks[task].pipeline = instance; diff --git a/src/users.js b/src/users.js index 36a5d62b3..c8df39779 100644 --- a/src/users.js +++ b/src/users.js @@ -458,7 +458,8 @@ export function getPasswordSalt() { */ export function getCookieSessionName() { // Get server hostname and hash it to generate a session suffix - const suffix = crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 8); + const hostname = os.hostname() || 'localhost'; + const suffix = crypto.createHash('sha256').update(hostname).digest('hex').slice(0, 8); return `session-${suffix}`; } diff --git a/src/util.js b/src/util.js index d8df3bfee..e991356b1 100644 --- a/src/util.js +++ b/src/util.js @@ -889,3 +889,14 @@ export class MemoryLimitedMap { return this.map[Symbol.iterator](); } } + +/** + * A 'safe' version of `fs.readFileSync()`. Returns the contents of a file if it exists, falling back to a default value if not. + * @param {string} filePath Path of the file to be read. + * @param {Parameters[1]} options Options object to pass through to `fs.readFileSync()` (default: `{ encoding: 'utf-8' }`). + * @returns The contents at `filePath` if it exists, or `null` if not. + */ +export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) { + if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); + return null; +}