diff --git a/.dockerignore b/.dockerignore index 99976ae65..9e0a20629 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,4 @@ access.log /data /cache .DS_Store +/public/scripts/extensions/third-party diff --git a/.gitignore b/.gitignore index 235983338..7d48fd879 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ public/css/user.css /default/scaffold public/scripts/extensions/third-party /certs +.aider* +.env diff --git a/.npmignore b/.npmignore index 150ad23aa..0c99b6680 100644 --- a/.npmignore +++ b/.npmignore @@ -11,3 +11,4 @@ access.log .github .vscode .git +/public/scripts/extensions/third-party diff --git a/Dockerfile b/Dockerfile index 26160a65d..395335739 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,6 @@ ARG APP_HOME=/home/node/app # Install system dependencies RUN apk add gcompat tini git -# Ensure proper handling of kernel signals -ENTRYPOINT [ "tini", "--" ] - # Create app directory WORKDIR ${APP_HOME} @@ -42,4 +39,5 @@ RUN \ EXPOSE 8000 -CMD [ "./docker-entrypoint.sh" ] +# Ensure proper handling of kernel signals +ENTRYPOINT ["tini", "--", "./docker-entrypoint.sh"] diff --git a/default/config.yaml b/default/config.yaml index 730ffa967..a3de53021 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -98,6 +98,8 @@ skipContentCheck: false disableChatBackup: false # Number of backups to keep for each chat and settings file numberOfBackups: 50 +# Maximum number of chat backups to keep per user (starting from the most recent). Set to -1 to keep all backups. +maxTotalChatBackups: -1 # Interval in milliseconds to throttle chat backups per user chatBackupThrottleInterval: 10000 # Allowed hosts for card downloads diff --git a/default/content/presets/instruct/ChatML-Names.json b/default/content/presets/instruct/ChatML-Names.json index 41e14e9b5..2ad703b33 100644 --- a/default/content/presets/instruct/ChatML-Names.json +++ b/default/content/presets/instruct/ChatML-Names.json @@ -1,6 +1,6 @@ { - "input_sequence": "<|im_start|>[{{name}}]", - "output_sequence": "<|im_start|>[{{name}}]", + "input_sequence": "<|im_start|>{{name}}", + "output_sequence": "<|im_start|>{{name}}", "last_output_sequence": "", "system_sequence": "<|im_start|>system", "stop_sequence": "<|im_end|>", diff --git a/default/content/presets/instruct/Llama-3-Instruct-Names.json b/default/content/presets/instruct/Llama-3-Instruct-Names.json index f0b4f1439..4412df51a 100644 --- a/default/content/presets/instruct/Llama-3-Instruct-Names.json +++ b/default/content/presets/instruct/Llama-3-Instruct-Names.json @@ -1,6 +1,6 @@ { - "input_sequence": "<|start_header_id|>[{{name}}]<|end_header_id|>\n\n", - "output_sequence": "<|start_header_id|>[{{name}}]<|end_header_id|>\n\n", + "input_sequence": "<|start_header_id|>{{name}}<|end_header_id|>\n\n", + "output_sequence": "<|start_header_id|>{{name}}<|end_header_id|>\n\n", "last_output_sequence": "", "system_sequence": "<|start_header_id|>system<|end_header_id|>\n\n", "stop_sequence": "<|eot_id|>", diff --git a/default/content/presets/openai/Default.json b/default/content/presets/openai/Default.json index 345199cc1..f2eaa5d53 100644 --- a/default/content/presets/openai/Default.json +++ b/default/content/presets/openai/Default.json @@ -39,8 +39,8 @@ "proxy_password": "", "max_context_unlocked": false, "wi_format": "{0}", - "scenario_format": "[Circumstances and context of the dialogue: {{scenario}}]", - "personality_format": "[{{char}}'s personality: {{personality}}]", + "scenario_format": "{{scenario}}", + "personality_format": "{{personality}}", "group_nudge_prompt": "[Write the next reply only as {{char}}.]", "stream_openai": true, "prompts": [ diff --git a/default/content/settings.json b/default/content/settings.json index 815abcfde..729215d3c 100644 --- a/default/content/settings.json +++ b/default/content/settings.json @@ -599,18 +599,22 @@ "Default (none)": [], "Anti-bond": [ { + "id": "22154f79-dd98-41bc-8e34-87015d6a0eaf", "text": " bond", "value": -50 }, { + "id": "8ad2d5c4-d8ef-49e4-bc5e-13e7f4690e0f", "text": " future", "value": -50 }, { + "id": "52a4b280-0956-4940-ac52-4111f83e4046", "text": " bonding", "value": -50 }, { + "id": "e63037c7-c9d1-4724-ab2d-7756008b433b", "text": " connection", "value": -25 } diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 169a6517e..0d6125ad4 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -6,4 +6,4 @@ if [ ! -e "config/config.yaml" ]; then fi # Start the server -exec node server.js --listen +exec node server.js --listen "$@" diff --git a/package-lock.json b/package-lock.json index 2419fe002..e20796d14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@popperjs/core": "^2.11.8", "@zeldafan0225/ai_horde": "^5.1.0", "archiver": "^7.0.1", - "bing-translate-api": "^2.9.1", + "bing-translate-api": "^4.0.2", "body-parser": "^1.20.2", "bowser": "^2.11.0", "command-exists": "^1.2.9", @@ -63,7 +63,9 @@ "showdown": "^2.1.0", "sillytavern-transformers": "2.14.6", "simple-git": "^3.19.1", + "slidetoggle": "^4.0.0", "tiktoken": "^1.0.16", + "url-join": "^5.0.0", "vectra": "^0.2.2", "wavefile": "^11.0.0", "webpack": "^5.95.0", @@ -86,6 +88,9 @@ "@types/deno": "^2.0.0", "@types/express": "^4.17.21", "@types/jquery": "^3.5.29", + "@types/jquery-cropper": "^1.0.4", + "@types/jquery.transit": "^0.9.33", + "@types/jqueryui": "^1.12.23", "@types/lodash": "^4.17.10", "@types/mime-types": "^2.1.4", "@types/multer": "^1.4.12", @@ -95,6 +100,7 @@ "@types/png-chunks-encode": "^1.0.2", "@types/png-chunks-extract": "^1.0.2", "@types/response-time": "^2.3.8", + "@types/select2": "^4.0.63", "@types/toastr": "^2.1.43", "@types/write-file-atomic": "^4.0.3", "@types/yargs": "^17.0.33", @@ -1226,6 +1232,36 @@ "@types/sizzle": "*" } }, + "node_modules/@types/jquery-cropper": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/jquery-cropper/-/jquery-cropper-1.0.4.tgz", + "integrity": "sha512-YMyUoY+rhB8yc3xM1B/daNaSq5+q93rzvRx6HP8K9mmvXEviTH3/rldlYNCGd0TmE/kLlZYJsruYhu9wY350PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jquery": "*" + } + }, + "node_modules/@types/jquery.transit": { + "version": "0.9.33", + "resolved": "https://registry.npmjs.org/@types/jquery.transit/-/jquery.transit-0.9.33.tgz", + "integrity": "sha512-gEDi1Lw7qfHFxtcnm2dg0F3Z5yG+84Sn0gDpGbd+u+r2RxsCcdQzfUmFKzHGBjWflZ9CXOZiAkenKOSvwLITrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jquery": "*" + } + }, + "node_modules/@types/jqueryui": { + "version": "1.12.23", + "resolved": "https://registry.npmjs.org/@types/jqueryui/-/jqueryui-1.12.23.tgz", + "integrity": "sha512-pm1yVNVI29B9IGw41anCEzA5eR2r1pYc7flqD471ZT7B0yUXIY7YNe/zq7LGpihIGXNzWyG+Q4YQSzv2AF3fNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jquery": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1377,6 +1413,16 @@ "@types/node": "*" } }, + "node_modules/@types/select2": { + "version": "4.0.63", + "resolved": "https://registry.npmjs.org/@types/select2/-/select2-4.0.63.tgz", + "integrity": "sha512-/DXUfPSj3iVTGlRYRYPCFKKSogAGP/j+Z0fIMXbBiBtmmZj0WH7vnfNuckafq9C43KnqPPQW2TI/Rj/vTSGnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jquery": "*" + } + }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -2115,9 +2161,9 @@ } }, "node_modules/bing-translate-api": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/bing-translate-api/-/bing-translate-api-2.9.1.tgz", - "integrity": "sha512-DaYqa7iupfj+fj/KeaeZSp5FUY/ZR4sZ6jqoTP0RHkYOUfo7wwoxlhYDkh4VcvBBzuVORnBEgdXBVQrfM4kk7g==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/bing-translate-api/-/bing-translate-api-4.0.2.tgz", + "integrity": "sha512-JJ8XUehnxzOhHU91oy86xEtp8OOMjVEjCZJX042fKxoO19NNvxJ5omeCcxQNFoPbDqVpBJwqiGVquL0oPdQm1Q==", "license": "MIT", "dependencies": { "got": "^11.8.6" @@ -6531,6 +6577,12 @@ "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", "license": "MIT" }, + "node_modules/slidetoggle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slidetoggle/-/slidetoggle-4.0.0.tgz", + "integrity": "sha512-6qvrOS1dnDFEr41UEEFFRQE8nswaAFIYZAHer6dVlznRIjHyCISjNJoxIn5U5QlAbZfBBxTELQk4jS7miHto1A==", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -7046,6 +7098,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", diff --git a/package.json b/package.json index 777d99076..cea368850 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@popperjs/core": "^2.11.8", "@zeldafan0225/ai_horde": "^5.1.0", "archiver": "^7.0.1", - "bing-translate-api": "^2.9.1", + "bing-translate-api": "^4.0.2", "body-parser": "^1.20.2", "bowser": "^2.11.0", "command-exists": "^1.2.9", @@ -53,7 +53,9 @@ "showdown": "^2.1.0", "sillytavern-transformers": "2.14.6", "simple-git": "^3.19.1", + "slidetoggle": "^4.0.0", "tiktoken": "^1.0.16", + "url-join": "^5.0.0", "vectra": "^0.2.2", "wavefile": "^11.0.0", "webpack": "^5.95.0", @@ -114,6 +116,9 @@ "@types/deno": "^2.0.0", "@types/express": "^4.17.21", "@types/jquery": "^3.5.29", + "@types/jquery-cropper": "^1.0.4", + "@types/jquery.transit": "^0.9.33", + "@types/jqueryui": "^1.12.23", "@types/lodash": "^4.17.10", "@types/mime-types": "^2.1.4", "@types/multer": "^1.4.12", @@ -123,6 +128,7 @@ "@types/png-chunks-encode": "^1.0.2", "@types/png-chunks-extract": "^1.0.2", "@types/response-time": "^2.3.8", + "@types/select2": "^4.0.63", "@types/toastr": "^2.1.43", "@types/write-file-atomic": "^4.0.3", "@types/yargs": "^17.0.33", diff --git a/public/css/extensions-panel.css b/public/css/extensions-panel.css index da005bd70..ad0bd0127 100644 --- a/public/css/extensions-panel.css +++ b/public/css/extensions-panel.css @@ -65,7 +65,7 @@ label[for="extensions_autoconnect"] { } .extensions_info .extension_enabled { - color: green; + font-weight: bold; } .extensions_info .extension_disabled { @@ -76,13 +76,44 @@ label[for="extensions_autoconnect"] { color: gray; } -input.extension_missing[type="checkbox"] { - opacity: 0.5; +.extensions_info .extension_modules { + font-size: 0.8em; + font-weight: normal; } -#extensions_list .disabled { - text-decoration: line-through; - color: lightgray; +.extensions_info .extension_block { + display: flex; + flex-wrap: nowrap; + padding: 5px; + margin-bottom: 5px; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 10px; + align-items: baseline; + justify-content: space-between; + gap: 5px; +} + +.extensions_info .extension_name { + font-size: 1.05em; +} + +.extensions_info .extension_version { + opacity: 0.8; + font-size: 0.8em; + font-weight: normal; + margin-left: 2px; +} + +.extensions_info .extension_block a { + color: var(--SmartThemeBodyColor); +} + +.extensions_info .extension_name.update_available { + color: limegreen; +} + +input.extension_missing[type="checkbox"] { + opacity: 0.5; } .update-button { @@ -105,3 +136,13 @@ input.extension_missing[type="checkbox"] { #extensionsMenu>div.extension_container:empty { display: none; } + +.extensions_info .extension_text_block { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.extensions_info .extension_actions { + flex-wrap: nowrap; +} diff --git a/public/global.d.ts b/public/global.d.ts index 202d48443..f6935383d 100644 --- a/public/global.d.ts +++ b/public/global.d.ts @@ -20,400 +20,24 @@ declare global { pagination(method: 'getCurrentPageNum'): number; pagination(method: string, options?: any): JQuery; pagination(options?: any): JQuery; - transition(options?: any, complete?: function): JQuery; - autocomplete(options?: any): JQuery; - autocomplete(method: string, options?: any): JQuery; - slider(options?: any): JQuery; - slider(method: string, func: string, options?: any): JQuery; - cropper(options?: any): JQuery; izoomify(options?: any): JQuery; + } - //#region select2 + namespace Select2 { + interface Options { + /** + * Extends Select2 v4 plugin by adding an option to set a placeholder for the 'search' input field + * [Custom Field] + * @default '' + */ + searchInputPlaceholder?: string; - /** - * Initializes or modifies a select2 instance with provided options - * - * @param options - Configuration options for the select2 instance - * @returns The jQuery object for chaining - */ - select2(options?: Select2Options): JQuery; - - /** - * Retrieves data currently selected in the select2 instance - * - * @param field - A string specifying the 'data' method - * @returns An array of selected items - */ - select2(field: 'data'): any[]; - - /** - * Calls the specified select2 method - * - * @param method - The name of the select2 method to invoke - * @returns The jQuery object for chaining - */ - select2(method: 'open' | 'close' | 'destroy' | 'focus' | 'val', value?: any): JQuery; - - //#endregion - - //#region sortable - - /** - * Initializes or updates a sortable instance with the provided options - * - * @param options - Configuration options for the sortable instance - * @returns The jQuery object for chaining - */ - sortable(options?: SortableOptions): JQuery; - - /** - * Calls a sortable method to perform actions on the instance - * - * @param method - The name of the sortable method to invoke - * @returns The jQuery object for chaining - */ - sortable(method: 'destroy' | 'disable' | 'enable' | 'refresh' | 'toArray'): JQuery; - - /** - * Retrieves the sortable's instance object. If the element does not have an associated instance, undefined is returned. - * - * @returns The instance of the sortable object - */ - sortable(method: 'instance'): object; - - /** - * Retrieves the current option value for the specified option - * - * @param method - The string 'option' to retrieve an option value - * @param optionName - The name of the option to retrieve - * @returns The value of the specified option - */ - sortable(method: 'option', optionName: string): any; - - /** - * Sets the value of the specified option - * - * @param method - The string 'option' to set an option value - * @param optionName - The name of the option to set - * @param value - The value to assign to the option - * @returns The jQuery object for chaining - */ - sortable(method: 'option', optionName: string, value: any): JQuery; - - /** - * Sets multiple options using an object - * - * @param method - The string 'option' to set options - * @param options - An object containing multiple option key-value pairs - * @returns The jQuery object for chaining - */ - sortable(method: 'option', options: SortableOptions): JQuery; - - //#endregion + /** + * Extends select2 plugin by adding a custom css class for the 'search' input field + * [Custom Field] + * @default '' + */ + searchInputCssClass?: string; + } } } - -//#region select2 - -/** Options for configuring a select2 instance */ -interface Select2Options { - /** - * Provides support for ajax data sources - * @param params - Parameters including the search term - * @param callback - A callback function to handle the results - * @default null - */ - ajax?: { - url: string; - dataType?: string; - delay?: number; - data?: (params: any) => any; - processResults?: (data: any, params: any) => any; - } | { transport: (params, success, failure) => any }; - - /** - * Provides support for clearable selections - * @default false - */ - allowClear?: boolean; - - /** - * See Using Select2 with AMD or CommonJS loaders - * @default './i18n/' - */ - amdLanguageBase?: string; - - /** - * Controls whether the dropdown is closed after a selection is made - * @default true - */ - closeOnSelect?: boolean; - - /** - * Allows rendering dropdown options from an array - * @default null - */ - data?: object[]; - - /** - * Used to override the built-in DataAdapter - * @default SelectAdapter - */ - dataAdapter?: SelectAdapter; - - /** - * Enable debugging messages in the browser console - * @default false - */ - debug?: boolean; - - /** - * Sets the dir attribute on the selection and dropdown containers to indicate the direction of the text - * @default 'ltr' - */ - dir?: string; - - /** - * When set to true, the select control will be disabled - * @default false - */ - disabled?: boolean; - - /** - * Used to override the built-in DropdownAdapter - * @default DropdownAdapter - */ - dropdownAdapter?: DropdownAdapter; - - /** - * @default false - */ - dropdownAutoWidth?: boolean; - - /** - * Adds additional CSS classes to the dropdown container. The helper :all: can be used to add all CSS classes present on the original element - * @default '' - */ - selectionCssClass?: string; - - /** - * Implements automatic selection when the dropdown is closed - * @default false - */ - selectOnClose?: boolean; - - sorter?: function; - - /** - * When set to `true`, allows the user to create new tags that aren't pre-populated - * Used to enable free text responses - * @default false - */ - tags?: boolean | object[]; - - /** - * Customizes the way that search results are rendered - * @param item - The item object to format - * @returns The formatted representation - * @default null - */ - templateResult?: (item: any) => JQuery | string; - - /** - * Customizes the way that selections are rendered - * @param item - The selected item object to format - * @returns The formatted representation - * @default null - */ - templateSelection?: (item: any) => JQuery | string; - - /** - * Allows you to set the theme - * @default 'default' - */ - theme?: string; - - /** - * A callback that handles automatic tokenization of free-text entry - * @default null - */ - tokenizer?: (input: { _type: string, term: string }, selection: { options: object }, callback: (Select2Option) => any) => { term: string }; - - /** - * The list of characters that should be used as token separators - * @default null - */ - tokenSeparators?: string[]; - - /** - * Supports customization of the container width - * @default 'resolve' - */ - width?: string; - - /** - * If true, resolves issue for multiselects using closeOnSelect: false that caused the list of results to scroll to the first selection after each select/unselect - * @default false - */ - scrollAfterSelect?: boolean; - - /** - * Extends Select2 v4 plugin by adding an option to set a placeholder for the 'search' input field - * [Custom Field] - * @default '' - */ - searchInputPlaceholder?: string; - - /** - * Extends select2 plugin by adding a custom css class for the 'searcH' input field - * [Custom Field] - * @default '' - */ - searchInputCssClass?: string; -} - -//#endregion - -//#region sortable - -/** Options for configuring a sortable instance */ -interface SortableOptions { - /** - * When set, prevents the sortable items from being dragged unless clicked with a delay - * @default 0 - */ - delay?: number; - - /** - * Class name for elements to handle sorting. Elements with this class can be dragged to sort. - * @default '' - */ - handle?: string; - - /** - * Whether to allow sorting between different connected lists - * @default false - */ - connectWith?: string | boolean; - - /** - * Function called when sorting starts - * @param event - The event object - * @param ui - The UI object containing the helper and position information - */ - start?: (event: Event, ui: SortableUI) => void; - - /** - * Function called when sorting stops - * @param event - The event object - * @param ui - The UI object containing the helper and position information - */ - stop?: (event: Event, ui: SortableUI) => void; - - /** - * Function called when sorting updates - * @param event - The event object - * @param ui - The UI object containing the helper and position information - */ - update?: (event: Event, ui: SortableUI) => void; - - /** - * Specifies which items inside the element should be sortable - * @default '> *' - */ - items?: string; -} - -/** UI object passed to sortable event handlers */ -interface SortableUI { - /** jQuery object representing the helper element */ - helper: JQuery; - /** The current position of the helper element */ - position: { top: number; left: number }; - /** Original position of the helper element */ - originalPosition: { top: number; left: number }; - /** jQuery object representing the item being sorted */ - item: JQuery; - /** The placeholder element used during sorting */ - placeholder: JQuery; -} - -//#endregion diff --git a/public/img/generic.svg b/public/img/generic.svg new file mode 100644 index 000000000..2411ed069 --- /dev/null +++ b/public/img/generic.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/public/index.html b/public/index.html index 99dd6a030..b3045bdab 100644 --- a/public/index.html +++ b/public/index.html @@ -651,6 +651,19 @@ +
+
+ Middle-out Transform + +
+
+ +
+
Max prompt cost: Unknown
@@ -1235,7 +1248,8 @@ -
+ +
Top K
@@ -1251,7 +1265,7 @@
-
+
Typical P
@@ -1259,7 +1273,7 @@
-
+
Min P
@@ -1267,7 +1281,7 @@
-
+
Top A
@@ -1275,7 +1289,7 @@
-
+
TFS
@@ -1307,7 +1321,7 @@
-
+
Repetition Penalty @@ -1569,7 +1583,7 @@ Ignore EOS Token
-
-
+
-
+

Banned Tokens/Strings @@ -1735,6 +1749,7 @@
Tail Free Sampling
Min P
Exclude Top Choices
+
DRY

+
+
+

+ Sampler Order +
+

+
+ Aphrodite only. Determines the order of samplers. +
+
+
DRY
+
Penalties
+
No Repeat Ngram
+
Dynatemp & Temperature
+
Top Nsigma
+
Top P & Top K
+
Top A
+
Min P
+
Tail-Free Sampling
+
Eta Cutoff
+
Epsilon Cutoff
+
Typical P
+
Cubic and Quadratic Sampling
+
XTC
+
+ +
@@ -1921,8 +1965,8 @@
@@ -2022,7 +2066,7 @@ - +
@@ -2031,8 +2075,8 @@
  • - - KoboldAI Horde Website + + AI Horde Website
  • @@ -2158,10 +2202,10 @@

    API Type

    @@ -2292,6 +2337,24 @@
+
+

API key (optional)

+
+ + +
+
+ For privacy reasons, your API key will be hidden after you reload the page. +
+
+

Server URL

+ Example: http://127.0.0.1:5000 + +
+ + +
-
@@ -3071,6 +3132,9 @@

Groq Model

- - - + + + + @@ -4649,6 +4714,13 @@ Background Image + +

Persona Description

@@ -4930,7 +5004,7 @@ - + @@ -5540,26 +5614,6 @@
-
-
-
-

- Chat Lorebook for -

-
-
- - A selected World Info will be bound to this chat. When generating an AI reply, - it will be combined with the entries from global and character lorebooks. - -
-
- -
-
-
@@ -5977,6 +6031,7 @@
+ @@ -5984,6 +6039,7 @@
+ diff --git a/public/lib.js b/public/lib.js index 2870d018d..4c6f318af 100644 --- a/public/lib.js +++ b/public/lib.js @@ -19,6 +19,7 @@ import seedrandom from 'seedrandom'; import * as Popper from '@popperjs/core'; import droll from 'droll'; import morphdom from 'morphdom'; +import { toggle as slideToggle } from 'slidetoggle'; /** * Expose the libraries to the 'window' object. @@ -94,6 +95,7 @@ export default { Popper, droll, morphdom, + slideToggle, }; export { @@ -115,4 +117,5 @@ export { Popper, droll, morphdom, + slideToggle, }; diff --git a/public/lib/eventemitter.js b/public/lib/eventemitter.js index 51ac4eca3..abf2b8dbc 100644 --- a/public/lib/eventemitter.js +++ b/public/lib/eventemitter.js @@ -95,13 +95,14 @@ EventEmitter.prototype.removeListener = function (event, listener) { }; EventEmitter.prototype.emit = async function (event) { + let args = [].slice.call(arguments, 1); if (localStorage.getItem('eventTracing') === 'true') { console.trace('Event emitted: ' + event, args); } else { console.debug('Event emitted: ' + event); } - var i, listeners, length, args = [].slice.call(arguments, 1); + let i, listeners, length; if (typeof this.events[event] === 'object') { listeners = this.events[event].slice(); @@ -120,13 +121,14 @@ EventEmitter.prototype.emit = async function (event) { }; EventEmitter.prototype.emitAndWait = function (event) { + let args = [].slice.call(arguments, 1); if (localStorage.getItem('eventTracing') === 'true') { console.trace('Event emitted: ' + event, args); } else { console.debug('Event emitted: ' + event); } - var i, listeners, length, args = [].slice.call(arguments, 1); + let i, listeners, length; if (typeof this.events[event] === 'object') { listeners = this.events[event].slice(); diff --git a/public/locales/ar-sa.json b/public/locales/ar-sa.json index aac6e5788..4766366db 100644 --- a/public/locales/ar-sa.json +++ b/public/locales/ar-sa.json @@ -267,7 +267,7 @@ "Text Completion": "اكتمال النص", "Chat Completion": "إكمال الدردشة", "NovelAI": "NovelAI", - "KoboldAI Horde": "جماعة KoboldAI", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "تجنب إرسال معلومات حساسة إلى الجماعة.", "Review the Privacy statement": "مراجعة بيان الخصوصية", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "قم بتشغيل تطبيق DrawThings مع تمكين مفتاح HTTP API في واجهة المستخدم! يجب أن يكون الخادم قابلاً للوصول من الجهاز المضيف SillyTavern.", "sd_vlad_url": "مثال: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "يجب أن يكون الخادم قابلاً للوصول من الجهاز المضيف SillyTavern.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "تلميح: احفظ مفتاح API في إعدادات Horde KoboldAI API لاستخدامه هنا.", + "Hint: Save an API key in AI Horde API settings to use it here.": "تلميح: احفظ مفتاح API في إعدادات AI Horde API لاستخدامه هنا.", "Allow NSFW images from Horde": "السماح بصور NSFW من Horde", "Sanitize prompts (recommended)": "مطالبات التعقيم (مستحسن)", "Automatically adjust generation parameters to ensure free image generations.": "قم بضبط معلمات الإنشاء تلقائيًا لضمان إنشاء صور مجانية.", diff --git a/public/locales/de-de.json b/public/locales/de-de.json index 3bef32f41..8453123a2 100644 --- a/public/locales/de-de.json +++ b/public/locales/de-de.json @@ -267,7 +267,7 @@ "Text Completion": "Textvervollständigung", "Chat Completion": "Chat-Abschluss", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAI Horde", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Vermeide das Senden sensibler Informationen an die Horde.", "Review the Privacy statement": "Überprüfe die Datenschutzerklärung", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "Führen Sie die DrawThings-App mit aktiviertem HTTP-API-Schalter in der Benutzeroberfläche aus! Der Server muss vom SillyTavern-Hostcomputer aus zugänglich sein.", "sd_vlad_url": "Beispiel: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "Der Server muss vom SillyTavern-Hostcomputer aus zugänglich sein.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Tipp: Speichern Sie einen API-Schlüssel in den Horde KoboldAI API-Einstellungen, um ihn hier zu verwenden.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Tipp: Speichern Sie einen API-Schlüssel in den AI Horde API-Einstellungen, um ihn hier zu verwenden.", "Allow NSFW images from Horde": "NSFW-Bilder von Horde zulassen", "Sanitize prompts (recommended)": "Eingabeaufforderungen bereinigen (empfohlen)", "Automatically adjust generation parameters to ensure free image generations.": "Passen Sie die Generierungsparameter automatisch an, um eine freie Bildgenerierung zu gewährleisten.", diff --git a/public/locales/es-es.json b/public/locales/es-es.json index 5ca3e7144..bbe2830fd 100644 --- a/public/locales/es-es.json +++ b/public/locales/es-es.json @@ -267,7 +267,7 @@ "Text Completion": "Completar texto", "Chat Completion": "Finalización del chat", "NovelAI": "NovelAI", - "KoboldAI Horde": "Horde de KoboldAI", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Evite enviar información sensible a Horde.", "Review the Privacy statement": "Revise la declaración de privacidad", @@ -874,7 +874,7 @@ "Bulk_edit_characters": "Editar personajes masivamente", "Bulk select all characters": "Seleccionar de forma masiva todos los personajes", "Bulk delete characters": "Eliminar personajes masivamente", - "popup-button-save": "Ahorrar", + "popup-button-save": "Guardar", "popup-button-yes": "Sí", "popup-button-no": "No", "popup-button-cancel": "Cancelar", @@ -1019,7 +1019,7 @@ "This prompt cannot be overridden by character cards, even if overrides are preferred.": "Este mensaje no puede ser anulado por tarjetas de personaje, incluso si se prefieren las anulaciones.", "prompt_manager_forbid_overrides": "Prohibir anulaciones", "reset": "reiniciar", - "save": "ahorrar", + "save": "guardar", "This message is invisible for the AI": "Este mensaje es invisible para la IA", "Message Actions": "Acciones de mensajes", "Translate message": "Traducir mensaje", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "¡Ejecute la aplicación DrawThings con el interruptor API HTTP habilitado en la interfaz de usuario! Se debe poder acceder al servidor desde la máquina host de SillyTavern.", "sd_vlad_url": "Ejemplo: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "Se debe poder acceder al servidor desde la máquina host de SillyTavern.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Sugerencia: guarde una clave API en la configuración de API de Horde KoboldAI para usarla aquí.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Sugerencia: guarde una clave API en la configuración de API de AI Horde para usarla aquí.", "Allow NSFW images from Horde": "Permitir imágenes NSFW de Horda", "Sanitize prompts (recommended)": "Indicaciones para desinfectar (recomendado)", "Automatically adjust generation parameters to ensure free image generations.": "Ajuste automáticamente los parámetros de generación para garantizar generaciones de imágenes gratuitas.", diff --git a/public/locales/fr-fr.json b/public/locales/fr-fr.json index 3c4280f7b..664f0967c 100644 --- a/public/locales/fr-fr.json +++ b/public/locales/fr-fr.json @@ -267,7 +267,7 @@ "Text Completion": "Achèvement du texte", "Chat Completion": "Achèvement du chat", "NovelAI": "NovelAI", - "KoboldAI Horde": "Horde KoboldAI", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Évitez d'envoyer des informations sensibles à la Horde.", "Review the Privacy statement": "Examiner la déclaration de confidentialité", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "exécutez l'application DrawThings avec le commutateur API HTTP activé dans l'interface utilisateur ! Le serveur doit être accessible depuis la machine hôte de SillyTavern.", "sd_vlad_url": "Exemple : {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "Le serveur doit être accessible depuis la machine hôte de SillyTavern.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Astuce : enregistrez une clé API dans les paramètres de l'API Horde KoboldAI pour l'utiliser ici.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Astuce : enregistrez une clé API dans les paramètres de l'API AI Horde pour l'utiliser ici.", "Allow NSFW images from Horde": "Autoriser les images NSFW de la Horde", "Sanitize prompts (recommended)": "Désinfecter les invites (recommandé)", "Automatically adjust generation parameters to ensure free image generations.": "Ajustez automatiquement les paramètres de génération pour garantir des générations d’images gratuites.", diff --git a/public/locales/is-is.json b/public/locales/is-is.json index e2da3f5a1..5783ee8ee 100644 --- a/public/locales/is-is.json +++ b/public/locales/is-is.json @@ -267,7 +267,7 @@ "Text Completion": "Textaútfylling", "Chat Completion": "Spjalllokun", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAI Hópur", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Forðastu að senda viðkvæm gögn til Hórdans.", "Review the Privacy statement": "Farið yfir Persónuverndarskýrsluna", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "keyrðu DrawThings app með HTTP API rofi virkt í notendaviðmótinu! Miðlarinn verður að vera aðgengilegur frá SillyTavern hýsingarvélinni.", "sd_vlad_url": "Dæmi: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "Miðlarinn verður að vera aðgengilegur frá SillyTavern hýsingarvélinni.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Ábending: Vistaðu API lykil í Horde KoboldAI API stillingum til að nota hann hér.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Ábending: Vistaðu API lykil í AI Horde API stillingum til að nota hann hér.", "Allow NSFW images from Horde": "Leyfa NSFW myndir frá Horde", "Sanitize prompts (recommended)": "Hreinsunarleiðbeiningar (ráðlagt)", "Automatically adjust generation parameters to ensure free image generations.": "Stilltu kynslóðarbreytur sjálfkrafa til að tryggja ókeypis myndmyndun.", diff --git a/public/locales/it-it.json b/public/locales/it-it.json index f6d8a859a..6ce249db9 100644 --- a/public/locales/it-it.json +++ b/public/locales/it-it.json @@ -267,7 +267,7 @@ "Text Completion": "Completamento del testo", "Chat Completion": "Completamento della chat", "NovelAI": "NovelAI", - "KoboldAI Horde": "Orda di KoboldAI", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Evita di inviare informazioni sensibili all'Orda.", "Review the Privacy statement": "Revisione della dichiarazione sulla privacy", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "esegui l'app DrawThings con lo switch API HTTP abilitato nell'interfaccia utente! Il server deve essere accessibile dalla macchina host SillyTavern.", "sd_vlad_url": "Esempio: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "Il server deve essere accessibile dalla macchina host SillyTavern.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Suggerimento: salva una chiave API nelle impostazioni API Horde KoboldAI per usarla qui.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Suggerimento: salva una chiave API nelle impostazioni API AI Horde per usarla qui.", "Allow NSFW images from Horde": "Consenti immagini NSFW da Horde", "Sanitize prompts (recommended)": "Messaggi di disinfezione (consigliato)", "Automatically adjust generation parameters to ensure free image generations.": "Regola automaticamente i parametri di generazione per garantire generazioni di immagini gratuite.", diff --git a/public/locales/ja-jp.json b/public/locales/ja-jp.json index a993b3926..bb552d2fd 100644 --- a/public/locales/ja-jp.json +++ b/public/locales/ja-jp.json @@ -267,7 +267,7 @@ "Text Completion": "テキスト補完", "Chat Completion": "チャット完了", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAI Horde", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Hordeに機密情報を送信しないでください。", "Review the Privacy statement": "プライバシー声明を確認する", @@ -1274,6 +1274,8 @@ "sd_Raw_Last_Message": "生の最後のメッセージ", "sd_Background": "背景", "Image Generation": "画像生成", + "Stop Image Generation": "画像生成を停止", + "Generate Caption": "画像説明を生成", "sd_refine_mode": "プロンプトを生成 API に送信する前に手動で編集できるようにする", "sd_refine_mode_txt": "生成前にプロンプ​​トを編集する", "sd_interactive_mode": "「猫の写真を送ってください」のようなメッセージを送信するときに、画像を自動的に生成します。", @@ -1295,7 +1297,7 @@ "sd_drawthings_auth_txt": "UI で HTTP API スイッチを有効にして DrawThings アプリを実行します。サーバーは SillyTavern ホスト マシンからアクセスできる必要があります。", "sd_vlad_url": "例: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "サーバーは SillyTavern ホスト マシンからアクセスできる必要があります。", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "ヒント: ここで使用するには、Horde KoboldAI API 設定に API キーを保存してください。", + "Hint: Save an API key in AI Horde API settings to use it here.": "ヒント: ここで使用するには、AI Horde API 設定に API キーを保存してください。", "Allow NSFW images from Horde": "HordeからのNSFW画像を許可する", "Sanitize prompts (recommended)": "サニタイズプロンプト(推奨)", "Automatically adjust generation parameters to ensure free image generations.": "生成パラメータを自動的に調整して、自由な画像生成を保証します。", @@ -1439,5 +1441,8 @@ "Still have questions?": "まだ質問がありますか?", "Join the SillyTavern Discord": "SillyTavernのDiscordに参加", "Post a GitHub issue": "GitHubの問題を投稿", - "Contact the developers": "開発者に連絡" + "Contact the developers": "開発者に連絡", + "Stop Inspecting": "検査を停止", + "Inspect Prompts": "プロンプトを検査", + "Toggle prompt inspection": "プロンプト検査の切り替え" } diff --git a/public/locales/ko-kr.json b/public/locales/ko-kr.json index 1aa13299f..636d8cd7f 100644 --- a/public/locales/ko-kr.json +++ b/public/locales/ko-kr.json @@ -269,7 +269,7 @@ "Text Completion": "Text Completion", "Chat Completion": "Chat Completion", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAI Horde", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "민감한 정보를 Horde에 보내지 않도록 합니다.", "Review the Privacy statement": "개인 정보 보호 정책 검토", @@ -1312,7 +1312,7 @@ "sd_drawthings_auth_txt": "UI에서 HTTP API 스위치가 활성화된 상태에서 DrawThings 앱을 실행하세요! SillyTavern 호스트 시스템에서 서버에 액세스할 수 있어야 합니다.", "sd_vlad_url": "예: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "SillyTavern 호스트 시스템에서 서버에 액세스할 수 있어야 합니다.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "힌트: 여기에서 사용하려면 Horde KoboldAI API 설정에 API 키를 저장하세요.", + "Hint: Save an API key in AI Horde API settings to use it here.": "힌트: 여기에서 사용하려면 AI Horde API 설정에 API 키를 저장하세요.", "Allow NSFW images from Horde": "Horde의 NSFW 이미지 허용", "Sanitize prompts (recommended)": "프롬프트 삭제(권장)", "Automatically adjust generation parameters to ensure free image generations.": "무료 이미지 생성을 보장하기 위해 생성 매개변수를 자동으로 조정합니다.", @@ -1623,4 +1623,4 @@ "Master Import": "마스터 불러오기", "Master Export": "마스터 내보내기", "Chat Quick Reply Sets": "채팅 빠른 답장 세트들" -} \ No newline at end of file +} diff --git a/public/locales/nl-nl.json b/public/locales/nl-nl.json index f32802306..9938c3a7f 100644 --- a/public/locales/nl-nl.json +++ b/public/locales/nl-nl.json @@ -267,7 +267,7 @@ "Text Completion": "Tekstvoltooiing", "Chat Completion": "Chat-voltooiing", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAI Horde", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Vermijd het verzenden van gevoelige informatie naar de Horde.", "Review the Privacy statement": "Bekijk de privacyverklaring", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "voer de DrawThings-app uit met HTTP API-switch ingeschakeld in de gebruikersinterface! De server moet toegankelijk zijn vanaf de SillyTavern-hostmachine.", "sd_vlad_url": "Voorbeeld: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "De server moet toegankelijk zijn vanaf de SillyTavern-hostmachine.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Tip: sla een API-sleutel op in de Horde KoboldAI API-instellingen om deze hier te gebruiken.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Tip: sla een API-sleutel op in de AI Horde API-instellingen om deze hier te gebruiken.", "Allow NSFW images from Horde": "Sta NSFW-afbeeldingen van Horde toe", "Sanitize prompts (recommended)": "Ontsmettingsmeldingen (aanbevolen)", "Automatically adjust generation parameters to ensure free image generations.": "Pas de generatieparameters automatisch aan om vrije beeldgeneraties te garanderen.", diff --git a/public/locales/pt-pt.json b/public/locales/pt-pt.json index d7d83a970..98260b8b2 100644 --- a/public/locales/pt-pt.json +++ b/public/locales/pt-pt.json @@ -267,7 +267,7 @@ "Text Completion": "Conclusão de texto", "Chat Completion": "Conclusão do bate-papo", "NovelAI": "NovelAI", - "KoboldAI Horde": "Horda KoboldAI", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Evite enviar informações sensíveis para a Horda.", "Review the Privacy statement": "Reveja a declaração de privacidade", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "execute o aplicativo DrawThings com a opção HTTP API habilitada na IU! O servidor deve estar acessível a partir da máquina host SillyTavern.", "sd_vlad_url": "Exemplo: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "O servidor deve estar acessível a partir da máquina host SillyTavern.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Dica: salve uma chave de API nas configurações da API Horde KoboldAI para usá-la aqui.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Dica: salve uma chave de API nas configurações da API AI Horde para usá-la aqui.", "Allow NSFW images from Horde": "Permitir imagens NSFW da Horda", "Sanitize prompts (recommended)": "Solicitações de higienização (recomendado)", "Automatically adjust generation parameters to ensure free image generations.": "Ajuste automaticamente os parâmetros de geração para garantir gerações de imagens livres.", diff --git a/public/locales/ru-ru.json b/public/locales/ru-ru.json index c61294cd3..5c506a88b 100644 --- a/public/locales/ru-ru.json +++ b/public/locales/ru-ru.json @@ -299,7 +299,7 @@ "Example: http://127.0.0.1:5000/api ": "Пример: http://127.0.0.1:5000/api", "No connection...": "Нет соединения...", "Get your NovelAI API Key": "Получите свой API-ключ для NovelAI", - "KoboldAI Horde": "KoboldAI Horde", + "AI Horde": "AI Horde", "NovelAI": "NovelAI", "OpenAI API key": "Ключ для API OpenAI", "Trim spaces": "Обрезать пробелы", @@ -1461,7 +1461,7 @@ "sd_drawthings_auth_txt": "run DrawThings app with HTTP API switch enabled in the UI! The server must be accessible from the SillyTavern host machine.", "sd_vlad_url": "Example: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "The server must be accessible from the SillyTavern host machine.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Hint: Save an API key in Horde KoboldAI API settings to use it here.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Hint: Save an API key in AI Horde API settings to use it here.", "Allow NSFW images from Horde": "Разрешить NSFW-картинки в Horde", "Sanitize prompts (recommended)": "Sanitize prompts (recommended)", "Automatically adjust generation parameters to ensure free image generations.": "Automatically adjust generation parameters to ensure free image generations.", diff --git a/public/locales/uk-ua.json b/public/locales/uk-ua.json index 2db43a5bc..ccfa17f45 100644 --- a/public/locales/uk-ua.json +++ b/public/locales/uk-ua.json @@ -267,7 +267,7 @@ "Text Completion": "Завершення тексту", "Chat Completion": "Завершення чату", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAI Horde", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Уникайте надсилання чутливої інформації в Horde.", "Review the Privacy statement": "Перегляньте заяву про конфіденційність", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "запустіть програму DrawThings із увімкненим перемикачем HTTP API в інтерфейсі користувача! Сервер має бути доступним із хост-машини SillyTavern.", "sd_vlad_url": "Приклад: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "Сервер має бути доступним із хост-машини SillyTavern.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Підказка: збережіть ключ API в налаштуваннях Horde KoboldAI API, щоб використовувати його тут.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Підказка: збережіть ключ API в налаштуваннях AI Horde API, щоб використовувати його тут.", "Allow NSFW images from Horde": "Дозволити зображення NSFW від Horde", "Sanitize prompts (recommended)": "Очистити підказки (рекомендовано)", "Automatically adjust generation parameters to ensure free image generations.": "Автоматично налаштовуйте параметри генерації, щоб забезпечити вільне створення зображень.", diff --git a/public/locales/vi-vn.json b/public/locales/vi-vn.json index 059303f77..0b7175d01 100644 --- a/public/locales/vi-vn.json +++ b/public/locales/vi-vn.json @@ -267,7 +267,7 @@ "Text Completion": "Text Completion", "Chat Completion": "Chat Completion", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAI Horde", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "Tránh gửi thông tin nhạy cảm cho Horde.", "Review the Privacy statement": "Xem lại Chính sách bảo mật", @@ -1295,7 +1295,7 @@ "sd_drawthings_auth_txt": "chạy ứng dụng DrawThings với tính năng chuyển đổi API HTTP được bật trong giao diện người dùng! Máy chủ phải có thể truy cập được từ máy chủ SillyTavern.", "sd_vlad_url": "Ví dụ: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "Máy chủ phải có thể truy cập được từ máy chủ SillyTavern.", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "Gợi ý: Lưu khóa API trong cài đặt API Horde KoboldAI để sử dụng tại đây.", + "Hint: Save an API key in AI Horde API settings to use it here.": "Gợi ý: Lưu khóa API trong cài đặt API AI Horde để sử dụng tại đây.", "Allow NSFW images from Horde": "Cho phép hình ảnh NSFW từ Horde", "Sanitize prompts (recommended)": "Nhắc nhở vệ sinh (khuyến nghị)", "Automatically adjust generation parameters to ensure free image generations.": "Tự động điều chỉnh các thông số tạo để đảm bảo tạo ra hình ảnh miễn phí.", diff --git a/public/locales/zh-cn.json b/public/locales/zh-cn.json index 0e33087bf..73ae62b8a 100644 --- a/public/locales/zh-cn.json +++ b/public/locales/zh-cn.json @@ -10,39 +10,49 @@ "MAD LAB MODE ON": "疯狂实验室模式开启", "Documentation on sampling parameters": "有关采样参数的文档", "kobldpresets": "Kobold 预设", - "guikoboldaisettings": "KoboldAI 用户界面设置", - "Update current preset": "更新当前预设", - "Save preset as": "另存预设为", "Import preset": "导入预设", "Export preset": "导出预设", - "Restore current preset": "恢复当前预设", "Delete the preset": "删除预设", + "guikoboldaisettings": "KoboldAI 用户界面设置", + "Update current preset": "更新当前预设", + "Rename current preset": "重命名当前预设", + "Save preset as": "另存预设为", + "Restore current preset": "恢复当前预设", "novelaipresets": "NovelAI 预设", "Default": "默认", "openaipresets": "对话补全预设", "Text Completion presets": "文本补全预设", + "response legth(tokens)": "回复长度(以词符数计)", + "Streaming": "流式传输", + "Streaming_desc": "逐位显示生成的回复", + "context size(tokens)": "上下文长度(以词符数计)", + "unlocked": "解锁", + "Only enable this if your model supports context sizes greater than 8192 tokens": "仅在您的模型支持大于8192个词符的上下文长度时启用此选项", + "Max prompt cost:": "最大提示词费用:", "AI Module": "AI 模块", "Changes the style of the generated text.": "更改生成文本的样式。", "No Module": "无模块", "Instruct": "指导", "Prose Augmenter": "散文增强器", "Text Adventure": "文字冒险", - "response legth(tokens)": "回复长度(以词符数计)", - "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.": "当此选项关闭时,回复将在完成后一次性显示。", "Temperature": "温度", "rep.pen": "重复惩罚", "Rep. Pen. Range.": "重复惩罚范围。", "Rep. Pen. Slope": "重复惩罚斜率", "Rep. Pen. Freq.": "频率重复惩罚", "Rep. Pen. Presence": "存在重复惩罚", + "Min P": "Min P", "TFS": "无尾采样", + "Top P": "Top P", + "Top A": "Top A", + "Top K": "Top K", + "Mirostat Tau": "Mirostat τ", + "Mirostat LR": "Mirostat 学习率", + "Typical P": "典型P", + "Linear": "Linear", + "Quad": "Quad", + "Conf": "Conf", + "Min Length": "最小长度", "Phrase Repetition Penalty": "短语重复惩罚", "Off": "关闭", "Very light": "非常轻", @@ -50,27 +60,35 @@ "Medium": "中", "Aggressive": "激进", "Very aggressive": "非常激进", + "Preamble": "序文", + "Restore default prompt": "恢复默认提示词", + "Use style tags to modify the writing style of the output.": "使用样式标签修改输出的写作风格。", + "Banned Tokens": "禁用的词符", + "Sequences you don't want to appear in the output. One per line.": "您不希望出现在输出中的字符串。 每行一个。", + "Logit Bias": "Logit 偏置", + "Add": "添加", + "Helps to ban or reenforce the usage of certain words": "有助于禁止或加强某些单词的使用", "Unlocked Context Size": "解锁上下文长度", "Unrestricted maximum value for the context slider": "AI可见的最大上下文长度", "Context Size (tokens)": "上下文长度(以词符数计)", "Max Response Length (tokens)": "最大回复长度(以词符数计)", "Multiple swipes per generation": "每次生成多个备选回复", + "Middle-out Transform": "Middle-out Transform", + "Auto": "Auto", + "Allow": "Allow", + "Forbid": "Forbid", "Enable OpenAI completion streaming": "启用OpenAI文本补全流式传输", + "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.": "当此选项关闭时,回复将在完成后一次性显示。", "Frequency Penalty": "频率惩罚", "Presence Penalty": "存在惩罚", - "Count Penalty": "计数惩罚", - "Top K": "Top K", - "Top P": "Top P", "Repetition Penalty": "重复惩罚", - "Min P": "Min P", - "Top A": "Top A", "Quick Prompts Edit": "快速提示词编辑", "Main": "主要", "Auxiliary": "辅助的", "Post-History Instructions": "后续历史指令", "Utility Prompts": "实用提示词", "Impersonation prompt": "AI帮答提示词", - "Restore default prompt": "恢复默认提示词", "Prompt that is used for Impersonation function": "用于AI帮答功能的提示词", "World Info Format Template": "世界信息格式模板", "Restore default format": "恢复默认格式", @@ -79,7 +97,7 @@ "scenario_format_template_part_2": "标记插入内容的位置。", "Scenario Format Template": "场景格式模板", "Personality Format Template": "角色设定格式模板", - "Group Nudge Prompt Template": "群组推进提示词模板", + "Group Nudge Prompt Template": "群聊推进提示词模板", "Sent at the end of the group chat history to force reply from a specific character.": "在群聊记录的末尾发送,以强制特定角色回复。", "New Chat": "新聊天", "Restore new chat prompt": "恢复新的聊天提示词", @@ -98,7 +116,6 @@ "Temperature controls the randomness in token selection": "温度控制词符选择中的随机性:\n- 低温(<1.0)导致更可预测的文本,优先选择高概率的词符。\n- 高温(>1.0)鼓励创造性和输出的多样性,更多地选择低概率的词符。\n将值设置为 1.0 以使用原始概率。", "Top_K_desc": "Top K 设定了可以选择的最高概率词符的最大数量。", "Top_P_desc": "Top P(又称核采样)将所有高概率词符聚集在一起,直到达到特定的百分比。\n换句话说,如果前两个词符分别都有 25% 的概率,而 Top-P 为 0.50,那么只有这两个词符会被考虑。\n将这个值设置为 1.0 就相当于关闭了这个功能。", - "Typical P": "典型P", "Typical_P_desc": "典型P采样会根据词符与整体熵的平均差异来优先选择词符。\n那些累积概率接近特定阈值(比如 0.5)的词符会被保留,这样就能区分出那些含有平均信息量的词符。\n将这个值设置为 1.0 就相当于关闭了这个功能。", "Min_P_desc": "Min P 设定了一个基础的最小概率,它会根据最高词符概率来进行优化。\n如果最高词符概率是 80%,而Min P设定为 0.1,那么只有那些概率高于8%的词符会被考虑。\n将这个值设置为 0 就相当于关闭了这个功能。", "Top_A_desc": "Top A 设定了一个阈值,用于根据最高词符概率的平方来选择词符。\n如果 Top A 设定为 0.2,而最高词符概率是 50%,那么概率低于 5% 的词符会被排除(0.2 * 0.5^2)。\n将这个值设置为 0 就相当于关闭了这个功能。", @@ -119,30 +136,17 @@ "Samplers will be applied in a top-down order. Use with caution.": "采样器将按自上而下的顺序应用。请谨慎使用。", "Tail Free Sampling": "无尾采样", "Load koboldcpp order": "加载koboldcpp顺序", - "Preamble": "序文", - "Use style tags to modify the writing style of the output.": "使用样式标签修改输出的写作风格。", - "Banned Tokens": "禁用的词符", - "Sequences you don't want to appear in the output. One per line.": "您不希望出现在输出中的字符串。 每行一个。", - "Logit Bias": "Logit 偏置", - "Add": "添加", - "Helps to ban or reenforce the usage of certain words": "有助于禁止或加强某些单词的使用", - "CFG Scale": "CFG缩放", - "Negative Prompt": "负面提示词", - "Add text here that would make the AI generate things you don't want in your outputs.": "请在此处添加文本,以避免生成您不希望出现在输出中的内容。", - "Used if CFG Scale is unset globally, per chat or character": "如果无分类器指导(CFG)缩放比例未在全局设置,它将作用于每个聊天或每个角色", - "Mirostat Tau": "Mirostat τ", - "Mirostat LR": "Mirostat 学习率", - "Min Length": "最小长度", "Top K Sampling": "Top K 采样", "Nucleus Sampling": "核采样", "Top A Sampling": "Top A 采样", - "CFG": "CFG", + "Unified Sampling": "Unified Sampling", "Neutralize Samplers": "置采样器参数为失效值", "Set all samplers to their neutral/disabled state.": "将所有采样器设置为失效/禁用状态。", "Sampler Select": "采样器选择", "Customize displayed samplers or add custom samplers.": "自定义显示的采样器或添加自定义采样器。", "Epsilon Cutoff": "ε 截断", "Epsilon cutoff sets a probability floor below which tokens are excluded from being sampled": "ε 截断设置了一个概率下限,低于该下限的词符将被排除在采样之外。\n以 1e-4 单位;合适的值为 3。将其设置为 0 以禁用。", + "Top nsigma": "Top nsigma", "Eta Cutoff": "η 截断", "Eta_Cutoff_desc": "η截断是特殊η采样技术的主要参数。 以1e-4为单位;合理的值为3。 设置为0以禁用。 有关详细信息,请参阅Hewitt等人的论文《Truncation Sampling as Language Model Desmoothing》(2022年)。", "rep.pen decay": "重复惩罚衰减", @@ -154,6 +158,9 @@ "Smooth_Sampling_desc": "允许您使用二次/三次变换来调整分布。较低的平滑因子值将更具创造性,通常在 0.2-0.3 之间是最佳点(假设曲线 = 1)。较高的平滑曲线值将使曲线更陡峭,这将更积极地惩罚低概率选择。1.0 曲线相当于仅使用平滑因子。", "Smoothing Factor": "平滑系数", "Smoothing Curve": "平滑曲线", + "Exclude Top Choices (XTC)": "Exclude Top Choices (XTC)", + "Threshold": "阈值", + "Probability": "可能性", "DRY_Repetition_Penalty_desc": "DRY 会对那些会将输入末尾扩展为输入中之前出现过的序列的词符进行惩罚。将乘数设置为 0 以禁用。", "DRY Repetition Penalty": "DRY 重复惩罚", "DRY_Multiplier_desc": "将值设置为 > 0 以启用 DRY。控制最短惩罚序列的惩罚幅度。", @@ -178,10 +185,13 @@ "Mirostat Eta": "Mirostat η", "Learning rate of Mirostat": "Mirostat 的学习率。", "Beam search": "束搜索", - "Helpful tip coming soon.": "有用的提示即将发布。", - "Number of Beams": "束数量", + "A greedy, brute-force algorithm used in LLM sampling to find the most likely sequence of words or tokens. It expands multiple candidate sequences at once, maintaining a fixed number (beam width) of top sequences at each step.": "A greedy, brute-force algorithm used in LLM sampling to find the most likely sequence of words or tokens. It expands multiple candidate sequences at once, maintaining a fixed number (beam width) of top sequences at each step.", + "# of Beams": "# of Beams", + "The number of sequences generated at each step with Beam Search.": "The number of sequences generated at each step with Beam Search.", "Length Penalty": "长度惩罚", + "Penalize sequences based on their length.": "Penalize sequences based on their length.", "Early Stopping": "提前停止", + "Controls the stopping condition for beam search. If checked, the generation stops as soon as there are '# of Beams' sequences. If not checked, a heuristic is applied and the generation is stopped when it's very unlikely to find better candidates.": "Controls the stopping condition for beam search. If checked, the generation stops as soon as there are '# of Beams' sequences. If not checked, a heuristic is applied and the generation is stopped when it's very unlikely to find better candidates.", "Contrastive search": "对比搜索", "Contrastive_search_txt": "通过利用大多数 LLM 表示空间的各向同性以鼓励多样性同时保持一致性的采样器。详见 Su 等人在 2022 年发表的论文 《A Contrastive Framework for Neural Text Generation》。", "Penalty Alpha": "惩罚系数 α", @@ -198,30 +208,34 @@ "Speculative Ngram": "推测性 Ngram", "Use a different speculative decoding method without a draft model": "使用不同的推测解码方法(不采用草稿模型)。最好使用草稿模型。推测性 Ngram 的效果不太好。", "Spaces Between Special Tokens": "特殊词符之间的空格", + "Seed_desc": "A random seed to use for deterministic and reproducable outputs. Set to -1 to use a random seed.", "LLaMA / Mistral / Yi models only": "LLaMA / Mistral / Yi模型专用。首先确保您选择了适当的词符化器。\n这项设置决定了你不想在结果中看到的字符串。\n每行一个字符串。可以是文本或者[词符id]。\n许多词符以空格开头。如果不确定,请使用词符计数器。", "Example: some text [42, 69, 1337]": "例如:\n一些文本\n[42, 69, 1337]", + "CFG": "CFG", "Classifier Free Guidance. More helpful tip coming soon": "无分类器指导(CFG)。更多有用的提示敬请期待。", "Scale": "缩放比例", - "JSON Schema": "JSON 结构", - "Type in the desired JSON schema": "输入所需的 JSON 结构", + "Negative Prompt": "负面提示词", + "Used if CFG Scale is unset globally, per chat or character": "如果无分类器指导(CFG)缩放比例未在全局设置,它将作用于每个聊天或每个角色", + "Add text here that would make the AI generate things you don't want in your outputs.": "请在此处添加文本,以避免生成您不希望出现在输出中的内容。", "Grammar String": "语法字符串", "GBNF or EBNF, depends on the backend in use. If you're using this you should know which.": "GBNF 或 EBNF,取决于使用的后端。如果您使用这个,您应该知道该用哪一个。", + "JSON Schema": "JSON 结构", + "Type in the desired JSON schema": "输入所需的 JSON 结构", "Top P & Min P": "Top P 和 Min P", "Load default order": "加载默认顺序", "Sampler Order": "取样器顺序", "llama.cpp only. Determines the order of samplers. If Mirostat mode is not 0, sampler order is ignored.": "仅限 llama.cpp。确定采样器的顺序。如果 Mirostat 模式不为 0,则忽略采样器顺序。", "Sampler Priority": "采样器优先级", "Ooba only. Determines the order of samplers.": "确定采样器的顺序(仅适用于Ooba)", + "Aphrodite only. Determines the order of samplers. Skew is always applied post-softmax, so it's not included here.": "Aphrodite only. Determines the order of samplers. Skew is always applied post-softmax, so it's not included here.", + "Aphrodite only. Determines the order of samplers.": "Aphrodite only. Determines the order of samplers.", "Character Names Behavior": "角色名称行为", "Helps the model to associate messages with characters.": "有助于模型将消息与角色关联起来。", "None": "无", "character_names_none": "不添加角色名称前缀。在群聊中可能导致错误行为,谨慎勾选。", - "Never add character names.": "不添加角色名称。", "character_names_default": "群聊和过去的角色除外。否则,请确保在提示词中提供了姓名。", - "Don't add character names unless necessary.": "如非必要,否则不添加角色名称。", "Completion Object": "补全对象", "character_names_completion": "适用限制:仅限拉丁字母数字和下划线。不适用于所有补全源,尤其是:Claude、MistralAI、Google。", - "Add character names to completion objects.": "在补全对象中添加角色名称。", "Message Content": "消息内容", "Prepend character names to message contents.": "在消息内容中添加角色名称。", "Continue Postfix": "继续后缀", @@ -249,22 +263,17 @@ "openai_inline_image_quality_auto": "自动", "openai_inline_image_quality_low": "低", "openai_inline_image_quality_high": "高", - "Use AI21 Tokenizer": "使用 AI21 词符化器", - "Use the appropriate tokenizer for Jurassic models, which is more efficient than GPT's.": "对Jurassic模型使用适当的词符化器,它比 GPT 的更高效。", - "Use Google Tokenizer": "使用Google词符化器", - "Use the appropriate tokenizer for Google models via their API. Slower prompt processing, but offers much more accurate token counting.": "通过其API为Google模型使用适当的词符化器。处理速度较慢,但提供更准确的词符计数。", "Use system prompt": "使用系统提示词", - "(Gemini 1.5 Pro/Flash only)": "(仅限 Gemini 1.5 Pro/Flash)", "Merges_all_system_messages_desc_1": "合并所有系统消息,直到第一条具有非系统角色的消息,然后通过", "Merges_all_system_messages_desc_2": "字段发送。", "Assistant Prefill": "AI预填", + "Expand the editor": "展开编辑器", "Start Claude's answer with...": "以如下内容开始Claude的回答...", "Assistant Impersonation Prefill": "AI帮答预填", "Use system prompt (Claude 2.1+ only)": "使用系统提示词(仅适用于Claude 2.1+)", "Send the system prompt for supported models. If disabled, the user message is added to the beginning of the prompt.": "为支持的模型发送系统提示词。如果禁用,则用户消息将添加到提示词的开头。", - "User first message": "用户第一条消息", - "Restore User first message": "恢复用户第一条消息", - "Human message": "人性化消息、说明等。\n为空时不添加任何内容,即需要具有“用户”角色的新提示词。", + "Confirm token parsing with": "Confirm token parsing with", + "Tokenizer": "词符化器", "New preset": "新预设", "Delete preset": "删除预设", "View / Edit bias preset": "查看/编辑偏置预设", @@ -276,13 +285,14 @@ "Text Completion": "文本补全", "Chat Completion": "聊天补全", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAI Horde", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", + "AI Horde Website": "AI Horde 网站", "Avoid sending sensitive information to the Horde.": "避免向 Horde 发送敏感信息。", "Review the Privacy statement": "查看隐私声明", - "Register a Horde account for faster queue times": "注册Horde帐户以加快排队时间", - "Learn how to contribute your idle GPU cycles to the Horde": "了解如何将您的空闲GPU时钟周期共享给Horde", - "Adjust context size to worker capabilities": "根据工作单元能力调整上下文大小", + "Register a Horde account for faster queue times": "注册 Horde 帐户以加快排队时间", + "Learn how to contribute your idle GPU cycles to the Horde": "了解如何将您的空闲GPU时钟周期共享给 Horde", + "Adjust context size to worker capabilities": "根据工作单元能力调整上下文长度", "Adjust response length to worker capabilities": "根据工作单元能力调整响应长度", "Can help with bad responses by queueing only the approved workers. May slowdown the response time.": "可以通过仅排队认证的工作单元来帮助处理不良回复。这可能会减慢回复速度。", "Trusted workers only": "仅信任的工作单元", @@ -308,7 +318,7 @@ "Novel AI Model": "Novel AI 模型", "No connection...": "没有连接...", "API Type": "API 类型", - "Default (completions compatible)": "默认 [OpenAI/completions 兼容:oobabooga、LM Studio 等]", + "Generic (OpenAI-compatible) [LM Studio, LiteLLM, etc.]": "Generic (OpenAI-compatible) [LM Studio, LiteLLM, etc.]", "TogetherAI API Key": "TogetherAI API 密钥", "TogetherAI Model": "TogetherAI 模型", "-- Connect to the API --": "-- 连接到API --", @@ -324,15 +334,26 @@ "DreamGen Model": "DreamGen 模型", "Mancer API key": "Mancer API 密钥", "Mancer Model": "Mancer 模型", - "Make sure you run it with": "确保您在运行时加上", - "flag": "标志", "API key (optional)": "API密钥(可选)", "Server url": "服务器URL", "Example: 127.0.0.1:5000": "示例:127.0.0.1:5000", + "Model ID (optional)": "模型 ID(可选)", + "Make sure you run it with": "确保您在运行时加上", + "flag": "标志", "Custom model (optional)": "自定义模型(可选)", + "Featherless Model Selection": "Featherless Model Selection", + "Search...": "搜索...", + "Search": "搜索", + "category": "分类", + "Top": "Top", + "New": "新建", + "All": "All", + "class": "All Classes", + "Toggle grid view": "切换网格视图", + "No model description": "[No description]", "vllm-project/vllm": "vllm-project/vllm(OpenAI API 包装器模式)", "vLLM API key": "vLLM API 密钥", - "Example: 127.0.0.1:8000": "例如:http://127.0.0.1:8000", + "Example: 127.0.0.1:8000": "示例:http://127.0.0.1:8000", "vLLM Model": "vLLM 模型", "HuggingFace Token": "HuggingFace 代币", "Endpoint URL": "端点 URL", @@ -347,11 +368,14 @@ "Download": "下载", "Tabby API key": "Tabby API 密钥", "Tabby Model": "Tabby 模型", + "must be set in Tabby's config.yml to switch models.": "必须在Tabby的config.yml内设置以切换模型", + "Use an admin API key.": "使用管理员API密钥。", "koboldcpp API key (optional)": "koboldcpp API 密钥(可选)", "Example: 127.0.0.1:5001": "示例:127.0.0.1:5001", + "Bypass status check": "绕过状态检查", + "Derive context size from backend": "从后端获取上下文长度", "Authorize": "授权", "Get your OpenRouter API token using OAuth flow. You will be redirected to openrouter.ai": "使用OAuth流程获取您的OpenRouter API令牌。您将被重定向到openrouter.ai", - "Bypass status check": "绕过状态检查", "Chat Completion Source": "聊天补全来源", "Custom (OpenAI-compatible)": "自定义(兼容 OpenAI)", "Reverse Proxy": "反向代理", @@ -363,7 +387,7 @@ "This will show up as your saved preset.": "这将显示为您保存的预设。", "Proxy Server URL": "代理服务器 URL", "Alternative server URL (leave empty to use the default value).": "备用服务器 URL(留空以使用默认值)。", - "Doesn't work? Try adding": "不起作用?尝试在最后添加", + "Doesn't work? Try adding": "不起作用?尝试添加", "at the end!": "!", "Proxy Password": "代理密码", "Will be used as a password for the proxy instead of API key.": "将用作代理的密码,而不是 API 密钥。", @@ -373,15 +397,16 @@ "Do not proceed if you do not agree to this!": "如果您不同意,请不要继续!", "OpenAI API key": "OpenAI API 密钥", "View API Usage Metrics": "查看API使用情况", - "Follow": "跟随", - "these directions": "这些说明", - "to get your OpenAI API key.": "以获取您的OpenAI API密钥。", + "Follow": "按照", + "these directions": "这些步骤", + "to get your OpenAI API key.": "获取您的OpenAI API密钥。", "Use Proxy password field instead. This input will be ignored.": "请使用“代理密码”字段。此输入将被忽略。", "OpenAI Model": "OpenAI 模型", "Bypass API status check": "绕过API状态检查", "Show External models (provided by API)": "显示外部模型(由API提供)", + "Claude API Key": "Claude API Key", "Get your key from": "从以下位置获取您的密钥", - "Anthropic's developer console": "Anthropic的开发者控制台", + "Anthropic's developer console": "Anthropic 开发者控制台", "Claude Model": "Claude 模型", "Window AI Model": "Window AI 模型", "Allow fallback routes Description": "如果所选模型无法响应您的请求,则自动选择备用模型。", @@ -389,8 +414,8 @@ "Model Order": "OpenRouter 模型顺序", "Alphabetically": "按字母顺序", "Price": "价格(最便宜)", - "Context Size": "上下文大小", - "Group by vendors": "按供应商分组", + "Context Size": "上下文长度", + "Group by vendors": "按厂商分组", "Group by vendors Description": "将 OpenAI 模型放在一组,将 Anthropic 模型放在另一组,等等。可以与排序结合。", "Scale API Key": "Scale API密钥", "Clear your cookie": "清除你的 Cookie", @@ -403,15 +428,19 @@ "MistralAI Model": "MistralAI 模型", "Groq API Key": "Groq API 密钥", "Groq Model": "Groq 模型", + "NanoGPT API Key": "NanoGPT API Key", + "NanoGPT Model": "NanoGPT Model", "Perplexity API Key": "Perplexity API 密钥", "Perplexity Model": "Perplexity 模型", "Cohere API Key": "Cohere API 密钥", "Cohere Model": "Cohere 模型", + "Block Entropy API Key": "Block Entropy API Key", + "Select a Model": "Select a Model", "Custom Endpoint (Base URL)": "自定义端点(基本 URL)", "Example: http://localhost:1234/v1": "例如:http://localhost:1234/v1", - "at the end of the URL!": "在 URL 的末尾!", + "at the end of the URL!": "到 URL 的末尾!", "Custom API Key": "自定义 API 密钥", - "(Optional)": "(可选的)", + "(Optional)": "(可选)", "Enter a Model ID": "输入模型名", "Example: gpt-3.5-turbo": "例如:gpt-3.5-turbo", "Available Models": "可用模型", @@ -419,9 +448,9 @@ "Applies additional processing to the prompt before sending it to the API.": "在将提示词发送到 API 之前对其进行额外处理。", "prompt_post_processing_none": "未选择", "01.AI API Key": "01.AI API密钥", - "01.AI Model": "01.AI模型", + "01.AI Model": "01.AI 模型", "Additional Parameters": "附加参数", - "Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "通过发送简短的测试消息验证您的API连接。请注意,您将因此而消耗额度!", + "Verifies your API connection by sending a short test message. Be aware that you'll be credited for it!": "通过发送简短的测试消息验证您的API连接。请注意,您将因此消耗额度!", "Test Message": "发送测试消息", "Auto-connect to Last Server": "自动连接到上次的服务器", "Missing key": "❌ 缺少密钥", @@ -429,57 +458,74 @@ "View hidden API keys": "查看隐藏的API密钥", "AI Response Formatting": "AI回复格式化", "Advanced Formatting": "高级格式化设置", + "Import Advanced Formatting settings": "导入高级格式化设置\n\n对于指导和上下文模板,你也可以提供旧版文件。", + "Master Import": "Master Import", + "Export Advanced Formatting settings": "导出高级格式化设置", + "Master Export": "Master Export", "Context Template": "上下文模板", - "Auto-select this preset for Instruct Mode": "在指示模式下自动选择此预设", + "context_derived": "若可能,从模型的元数据获取。", + "Select your current Context Template": "选择你当前的上下文模板", + "Update current template": "更新当前模板", + "Rename current template": "重命名当前模板", + "Save template as": "将模板另存为", + "Import template": "导入模板", + "Export template": "导出模板", + "Restore current template": "还原当前模板", + "Delete the template": "删除模板", "Story String": "故事字符串", "Example Separator": "示例分隔符", "Chat Start": "聊天开始", + "Context Formatting": "上下文格式", + "Always add character's name to prompt": "始终将角色名称添加到提示词", + "Generate only one line per request": "每次请求只生成一行", + "Collapse Consecutive Newlines": "折叠连续的换行符", + "Trim spaces": "修剪空格", + "Disabling is not recommended.": "不建议关闭。", + "Trim Incomplete Sentences": "修剪不完整的句子", "Add Chat Start and Example Separator to a list of stopping strings.": "将聊天开始和示例分隔符添加到停止字符串列表中。", "Separators as Stop Strings": "分隔符作为终止字符串", "Add Character and User names to a list of stopping strings.": "将角色和用户名添加到停止字符串列表中。", "Names as Stop Strings": "名称作为终止字符串", "context_allow_post_history_instructions": "如果在角色卡中定义并且启用了“首选角色卡说明”,则在提示末尾包含后历史说明。\n不建议在文本补全模型中使用此功能,否则会导致输出错误。", "Allow Post-History Instructions": "允许后历史说明", - "Context Order": "上下文顺序", - "Summary": "总结", - "Author's Note": "作者注释", - "Example Dialogues": "对话范例", - "Hint": "提示:", - "In-Chat Position not affected": "仅当摘要和作者注释订单未设置聊天内位置时,它们才会受到影响。", - "Instruct Mode": "指示模式", - "Enabled": "已启用", + "Instruct Template": "指导模板", + "instruct_derived": "如果可能,从模型元数据中获取", "instruct_bind_to_context": "如果启用,上下文模板将根据所选的指导模板名称或偏好自动选择。", - "Bind to Context": "绑定到上下文", - "Presets": "预设", - "Auto-select this preset on API connection": "在API连接时自动选择此预设", + "instruct_enabled": "启用指导模板", + "Select your current Instruct Template": "选择你当前的指导模板", + "Delete template": "删除模板", "Activation Regex": "激活正则表达式", + "instruct_template_activation_regex_desc": "当连接到API或选择模型时,若模型名称与给定的正则表达式匹配,自动启用此指导模板。", "Wrap Sequences with Newline": "用换行符包裹序列", "Replace Macro in Sequences": "替换序列中的宏", "Skip Example Dialogues Formatting": "跳过示例对话格式化", "Include Names": "包括名称", - "Force for Groups and Personas": "强制适用于群聊和我的角色", - "System Prompt": "系统提示词", - "Instruct Mode Sequences": "指示模式序列", - "System Prompt Wrapping": "系统提示词换行", + "Never": "永不", + "Groups and Past Personas": "Groups and Past Personas", + "Always": "永远", + "Instruct Sequences": "Instruct Sequences", + "User Message Sequences": "User Message Sequences", + "Inserted before a User message and as a last prompt line when impersonating.": "插入到用户消息之前并作为模拟时的最后一行提示词。", + "User Prefix": "用户消息前缀", + "Inserted after a User message.": "插入到用户消息之后。", + "User Suffix": "用户消息后缀", + "Assistant Message Sequences": "Assistant Message Sequences", + "Inserted before an Assistant message and as a last prompt line when generating an AI reply.": "插入到助手消息之前并作为生成 AI 回复时的最后一行提示词。", + "Assistant Prefix": "助手消息前缀", + "Inserted after an Assistant message.": "插入于助手消息之后。", + "Assistant Suffix": "助手消息后缀", + "System Message Sequences": "System Message Sequences", + "Inserted before a System (added by slash commands or extensions) message.": "插入到系统(由快捷命令或扩展添加)消息之前。", + "System Prefix": "System Message Prefix", + "Inserted after a System message.": "插入到系统消息之后。", + "System Suffix": "System Message Suffix", + "If enabled, System Sequences will be the same as User Sequences.": "如果启用,系统序列将与用户序列相同。", + "System same as User": "系统与用户相同", + "System Prompt Sequences": "System Prompt Sequences", "Inserted before a System prompt.": "插入到系统提示词之前。", "System Prompt Prefix": "系统提示词前缀", "Inserted after a System prompt.": "在系统提示词后插入。", "System Prompt Suffix": "系统提示词后缀", - "Chat Messages Wrapping": "聊天消息换行", - "Inserted before a User message and as a last prompt line when impersonating.": "插入到用户消息之前并作为模拟时的最后一行提示词。", - "User Message Prefix": "用户消息前缀", - "Inserted after a User message.": "插入到用户消息之后。", - "User Message Suffix": "用户消息后缀", - "Inserted before an Assistant message and as a last prompt line when generating an AI reply.": "插入到助手消息之前并作为生成 AI 回复时的最后一行提示词。", - "Assistant Message Prefix": "助理消息前缀", - "Inserted after an Assistant message.": "插入于助手消息之后。", - "Assistant Message Suffix": "助理消息后缀", - "Inserted before a System (added by slash commands or extensions) message.": "插入到系统(由快捷命令或扩展添加)消息之前。", - "System Message Prefix": "系统消息前缀", - "Inserted after a System message.": "插入到系统消息之后。", - "System Message Suffix": "系统消息后缀", - "If enabled, System Sequences will be the same as User Sequences.": "如果启用,系统序列将与用户序列相同。", - "System same as User": "系统与用户相同", "Misc. Sequences": "杂项序列", "Inserted before the first Assistant's message.": "插入到第一个助理的消息之前。", "First Assistant Prefix": "第一个助理前缀", @@ -495,28 +541,24 @@ "Stop Sequence": "停止序列", "Will be inserted at the start of the chat history if it doesn't start with a User message.": "如果聊天记录不是以用户消息开头,则会插入到聊天记录的开头。", "User Filler Message": "用户填写信息", - "Context Formatting": "上下文格式", - "(Saved to Context Template)": "(已保存到上下文模板)", - "Always add character's name to prompt": "始终将角色名称添加到提示词", - "Generate only one line per request": "每次请求只生成一行", - "Trim Incomplete Sentences": "修剪不完整的句子", - "Include Newline": "包括换行符", - "Misc. Settings": "其他设置", - "Collapse Consecutive Newlines": "折叠连续的换行符", - "Trim spaces": "修剪空格", - "Tokenizer": "词符化器", - "Token Padding": "词符填充", - "Start Reply With": "以...开始回复", - "AI reply prefix": "AI回复前缀", - "Show reply prefix in chat": "在聊天中显示回复前缀", - "Non-markdown strings": "非 Markdown 字符串", - "separate with commas w/o space between": "用逗号分隔,不要空格", + "System Prompt": "系统提示词", + "sysprompt_enabled": "启用系统提示词", + "Select your current System Prompt": "选择当前的系统提示词", + "Update current prompt": "更新当前提示词", + "Rename current prompt": "重命名当前提示词", + "Save prompt as": "将提示词另存为", + "Restore current prompt": "还原当前提示词", + "Delete prompt": "删除提示词", + "Prompt Content": "提示词内容", "Custom Stopping Strings": "自定义停止字符串", "JSON serialized array of strings": "JSON序列化的字符串数组", "Replace Macro in Custom Stopping Strings": "替换自定义停止字符串中的宏", - "Auto-Continue": "自动继续", - "Allow for Chat Completion APIs": "允许使用聊天补全API", - "Target length (tokens)": "目标长度(以词符数计)", + "Token Padding": "词符填充", + "Miscellaneous": "杂项", + "Non-markdown strings": "非 Markdown 字符串", + "comma delimited,no spaces between": "comma delimited,no spaces between", + "Start Reply With": "以...开始回复", + "Show reply prefix in chat": "在聊天中显示回复前缀", "World Info": "世界信息", "Locked = World Editor will stay open": "锁定 = 世界编辑器将保持打开状态", "Worlds/Lorebooks": "世界/知识书", @@ -527,11 +569,15 @@ "Scan Depth": "扫描深度", "Context %": "上下文百分比", "Budget Cap": "Token预算上限", - "(0 = disabled)": "(0 = 禁用)", + "(0 = disabled)": "(“0”为禁用)", "Scan chronologically until reached min entries or token budget.": "按时间顺序扫描直到达到最少条目或词符预算。", "Min Activations": "最小激活数", + "(disabled when max recursion steps are used)": "(当使用最大递归步数时禁用)", "Max Depth": "最大深度", - "(0 = unlimited, use budget)": "(0 = 无限制,使用预算)", + "(0 = unlimited, use budget)": "(“0”为无限制,使用预算)", + "Cap the number of entry activation recursions": "限制条目激活递归的次数", + "Max Recursion Steps": "最大递归深度", + "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc\\n(disabled when min activations are used)": "“0”为无限制,“1”为扫描一次且不递归,“2”为扫描一次且递归一次,依此类推\n(当使用最小激活次数时,此功能被禁用)", "Insertion Strategy": "插入策略", "Sorted Evenly": "均匀排序", "Character Lore First": "角色世界书优先", @@ -539,7 +585,7 @@ "Include names with each message into the context for scanning": "将每条消息的名称纳入上下文中以供扫描", "Entries can activate other entries by mentioning their keywords": "条目可以通过提及它们的关键字来激活其他条目", "Recursive Scan": "递归扫描", - "Lookup for the entry keys in the context will respect the case": "在上下文中查找条目键将保持大小写敏感", + "Lookup for the entry keys in the context will respect the case": "在上下文中查找条目键将继续区分大小写", "Case Sensitive": "区分大小写", "If the entry key consists of only one word, it would not be matched as part of other words": "如果条目键只由一个单词组成,则不会作为其他单词的一部分匹配", "Match Whole Words": "匹配整个单词", @@ -547,7 +593,6 @@ "Use Group Scoring": "使用群组评分", "Alert if your world info is greater than the allocated budget.": "如果您的世界信息大于分配的预算,则会发出警报。", "Alert On Overflow": "溢出警报", - "New": "新建", "or": "或", "--- Pick to Edit ---": "--- 选择以编辑 ---", "Rename World Info": "重命名世界书", @@ -560,8 +605,6 @@ "Export World Info": "导出世界书", "Duplicate World Info": "复制世界书", "Delete World Info": "删除世界书", - "Search...": "搜索...", - "Search": "搜索", "Priority": "优先级", "Custom": "自定义", "Title A-Z": "标题 A-Z", @@ -578,12 +621,10 @@ "Trigger% ↘": "触发频率% ↘", "Refresh": "刷新", "User Settings": "用户设置", - "Simple": "简单", - "Advanced": "高级", "UI Language": "语言", "Account": "帐户", "Admin Panel": "管理员面板", - "Logout": "注销", + "Logout": "登出", "Search Settings": "搜索设置", "UI Theme": "UI主题", "Import a theme file": "导入主题文件", @@ -611,7 +652,7 @@ "UI Border": "UI 边框", "User Message Blur Tint": "用户消息模糊色调", "AI Message Blur Tint": "AI 消息模糊色调", - "Chat Width": "聊天宽度", + "Chat Width": "页面宽度", "Width of the main chat window in % of screen width": "主聊天窗口的宽度为屏幕宽度的 %", "Font Scale": "字体比例", "Font size": "字体大小", @@ -633,22 +674,24 @@ "Zen Sliders": "禅意滑块", "Entirely unrestrict all numeric sampling parameters": "完全解除所有数字采样参数的限制", "Mad Lab Mode": "疯狂实验室模式", - "Time the AI's message generation, and show the duration in the chat log": "对 AI 生成消息的时间进行计时,并在聊天记录中显示持续时间。", + "Time the AI's message generation, and show the duration in the chat log": "对 AI 生成消息的时间进行计时,并在聊天记录中显示。", "Message Timer": "AI回复计时器", - "Show a timestamp for each message in the chat log": "在聊天日志中为每条消息显示时间戳", + "Show a timestamp for each message in the chat log": "在聊天日志中显示每条消息的时间戳", "Chat Timestamps": "聊天时间戳", - "Show an icon for the API that generated the message": "为生成消息的API显示图标", + "Show an icon for the API that generated the message": "显示此消息所用API的图标", "Model Icon": "模型图标", - "Show sequential message numbers in the chat log": "在聊天日志中显示连续的消息编号", - "Message IDs": "显示消息编号", - "Hide avatars in chat messages.": "在聊天信息中隐藏头像。", - "Hide Chat Avatars": "隐藏聊天头像", - "Show the number of tokens in each message in the chat log": "在聊天日志中显示每条消息中的词符数", + "Show sequential message numbers in the chat log": "在聊天记录中显示消息楼层", + "Message IDs": "显示消息楼层", + "Hide avatars in chat messages.": "在聊天记录中隐藏头像。", + "Hide Chat Avatars": "隐藏头像", + "Show the number of tokens in each message in the chat log": "在聊天记录中显示每条消息的词符数", "Show Message Token Count": "显示消息词符数", - "Single-row message input area. Mobile only, no effect on PC": "单行消息输入区域。仅适用于移动设备,对PC无影响", + "Single-row message input area. Mobile only, no effect on PC": "将输入框限制为一行。仅适用于移动设备,对PC无影响", "Compact Input Area (Mobile)": "紧凑输入区域(移动端)", + "Display swipe numbers for all messages, not just the last.": "Display swipe numbers for all messages, not just the last.", + "Swipe # for All Messages": "Swipe # for All Messages", "In the Character Management panel, show quick selection buttons for favorited characters": "在角色管理面板中,显示快速选择按钮以选择收藏的角色", - "Characters Hotswap": "角色卡快速热切换", + "Characters Hotswap": "角色卡热切换", "Enable magnification for zoomed avatar display.": "启用放大功能以放大头像显示。", "Avatar Hover Magnification": "头像悬停放大", "Enables a magnification effect on hover when you display the zoomed avatar after clicking an avatar's image in chat.": "启用在聊天中点击头像图像后显示放大的头像时的放大效果。", @@ -668,56 +711,60 @@ "Existing": "现存的", "Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "使用模糊匹配,在列表中通过所有数据字段搜索角色,而不仅仅是名称子字符串", "Advanced Character Search": "高级角色搜索", - "If checked and the character card contains a prompt override (System Prompt), use that instead": "如果角色卡包含提示词,则使用它替代系统提示词", + "If checked and the character card contains a prompt override (System Prompt), use that instead": "开启后,如果角色卡已包含系统提示词,则覆盖当前的系统提示词。", "Prefer Character Card Prompt": "角色卡提示词优先", - "If checked and the character card contains a Post-History Instructions override, use that instead": "如果选中并且角色卡包含后历史指令覆盖,则使用它。", + "If checked and the character card contains a Post-History Instructions override, use that instead": "开启后,如果角色卡包含后历史指令覆盖,则使用它。", "Prefer Character Card Instructions": "首选角色卡说明", "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "避免裁剪和调整导入的角色图像的大小。关闭时,裁剪/调整大小为 512x768。", "Never resize avatars": "永不调整头像大小", "Show actual file names on the disk, in the characters list display only": "在角色列表显示中,显示磁盘上实际的文件名。", "Show avatar filenames": "显示头像文件名", "Hide character definitions from the editor panel behind a spoiler button": "在编辑器面板中,将角色定义隐藏在一个剧透按钮后面。", - "Spoiler Free Mode": "隐藏角色卡信息", - "Miscellaneous": "杂项", + "Spoiler Free Mode": "防剧透模式", "Reload and redraw the currently open chat": "重新加载并重新渲染当前打开的聊天", "Reload Chat": "重新加载聊天", "Debug Menu": "调试菜单", "Smooth Streaming": "平滑流式传输", "Experimental feature. May not work for all backends.": "实验性功能。可能不适用于所有后端。", "Slow": "慢", - "Fast": "快速", - "Play a sound when a message generation finishes": "当消息生成完成时播放声音", + "Fast": "快", + "Play a sound when a message generation finishes": "当消息生成完毕时播放声音", "Message Sound": "消息声音", - "Only play a sound when ST's browser tab is unfocused": "仅在ST的浏览器选项卡未聚焦时播放声音", + "Only play a sound when ST's browser tab is unfocused": "仅在ST的浏览器标签页未被打开时播放声音", "Background Sound Only": "仅背景声音", "Reduce the formatting requirements on API URLs": "减少API URL的格式化要求", "Relaxed API URLS": "宽松的API URL", "Ask to import the World Info/Lorebook for every new character with embedded lorebook. If unchecked, a brief message will be shown instead": "询问是否为每个具有嵌入的知识书的新角色导入世界信息/知识书。如果未选中,则会显示简短的消息", "Lorebook Import Dialog": "知识书导入对话框", + "Enable auto-select of input text in some text fields when clicking/selecting them. Applies to popup input textboxes, and possible other custom input fields.": "启用在某些文本字段中单击/选择时自动选中文本的功能。适用于弹出输入框以及可能的其他自定义输入字段。", + "Auto-select Input Text": "自动选择输入文本", + "markdown_hotkeys_desc": "在特定文本输入框中,启用插入 Markdown 格式的快捷键。详情输入:“/help hotkeys”。", + "Markdown Hotkeys": "Markdown 快捷键", "Restore unsaved user input on page refresh": "在页面刷新时恢复未保存的用户输入", "Restore User Input": "恢复用户输入", "Allow repositioning certain UI elements by dragging them. PC only, no effect on mobile": "允许通过拖动重新定位某些UI元素。仅适用于PC,对移动设备无影响", "Movable UI Panels": "可移动 UI 面板", "Reset MovingUI panel sizes/locations.": "重置 MovingUI 面板大小/位置。", + "mui_reset": "Reset", "MovingUI preset. Predefined/saved draggable positions": "可移动UI预设。预定义/保存的可拖动位置", "MUI Preset": "可移动 UI 预设", "Save movingUI changes to a new file": "将可移动UI更改保存到新文件中", "Apply a custom CSS style to all of the ST GUI": "将自定义CSS样式应用于所有ST GUI", "Custom CSS": "自定义 CSS", - "Expand the editor": "展开编辑器", "Chat/Message Handling": "聊天/消息处理", "# Messages to Load": "# 要加载的消息", "The number of chat history messages to load before pagination.": "分页前要加载的聊天历史消息数。", - "(0 = All)": "(0 = 全部)", + "(0 = All)": "(“0”为全部)", "Streaming FPS": "流式传输帧速率", "Update speed of streamed text.": "文本流的更新速度。", "Example Messages Behavior": "示例消息行为", "Gradual push-out": "逐渐推出", "Always include examples": "始终包含示例", - "Never include examples": "永远不包含示例", + "Never include examples": "永不包含示例", "Send on Enter": "按 Enter 发送", "Disabled": "已禁用", "Automatic (PC)": "自动(PC)", + "Enabled": "已启用", "Press Send to continue": "按发送键以继续", "Show a button in the input area to ask the AI to continue (extend) its last message": "在输入区域中显示一个按钮,要求AI继续(延长)其上一条消息", "Quick 'Continue' button": "快速“继续”按钮", @@ -739,9 +786,9 @@ "Allow {{user}}: in bot messages": "在机器人消息中允许 {{user}}: ", "Skip encoding and characters in message text, allowing a subset of HTML markup as well as Markdown": "跳过消息文本中的编码和字符,允许一部分HTML标记以及Markdown", "Show tags in responses": "在响应中显示标签", - "Allow AI messages in groups to contain lines spoken by other group members": "允许群组中的AI消息包含其他组成员说的话", - "Relax message trim in Groups": "放松群组中的消息修剪", - "Log prompts to console": "将提示词记录到控制台", + "Allow AI messages in groups to contain lines spoken by other group members": "允许群聊中的AI输出群中其他成员说的话", + "Relax message trim in Groups": "减轻群聊中的消息修剪", + "Log prompts to console": "将提示词输出到控制台", "Requests logprobs from the API for the Token Probabilities feature": "从API请求对数概率数据,用于实现词符概率功能。", "Request token probabilities": "请求词符概率", "In group chat, highlight the character(s) that are currently queued to generate responses and the order in which they will respond.": "在群聊中,突出显示当前排队等待生成响应的角色以及他们响应的顺序。", @@ -751,10 +798,13 @@ "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "启用自动滑动功能。仅当启用自动滑动时,本节中的设置才会生效", "Minimum generated message length": "生成的消息的最小长度", "If the generated message is shorter than this, trigger an auto-swipe": "如果生成的消息短于此长度,则触发自动滑动", - "Blacklisted words": "黑名单词语", - "words you dont want generated separated by comma ','": "不想生成的词语,用逗号','分隔", + "Blacklisted words": "屏蔽词", + "words you dont want generated separated by comma ','": "不想生成的词语,用半角逗号“,”分隔", "Blacklisted word count to swipe": "触发滑动的黑名单词语数量", "Minimum number of blacklisted words detected to trigger an auto-swipe": "触发自动滑动刷新回复所需检测到的最少违禁词数量。", + "Auto-Continue": "自动继续", + "Allow for Chat Completion APIs": "允许使用聊天补全API", + "Target length (tokens)": "目标长度(以词符数计)", "AutoComplete Settings": "自动补全设置", "Automatically hide details": "自动隐藏详细信息", "Determines how entries are found for autocomplete.": "确定如何找到自动补全的条目。", @@ -784,7 +834,14 @@ "stscript_parser_flag_replace_getvar_label": "防止 {{getvar::}} {{getglobalvar::}} 宏具有自动评估的文字宏类值。\n例如,“{{newline}}”保留为文字字符串“{{newline}}”\n\n(这是通过在内部用范围变量替换 {{getvar::}} {{getglobalvar::}} 宏来实现的。)", "REPLACE_GETVAR": "替换GETVAR", "Change Background Image": "更改背景图片", + "Background Image": "背景图片", "Filter": "搜索", + "Background Fitting": "Background Fitting", + "Classic": "Classic", + "Cover": "Cover", + "Contain": "Contain", + "Stretch": "Stretch", + "Center": "Center", "Automatically select a background based on the chat context": "根据聊天上下文自动选择背景", "Auto-select": "自动选择", "System Backgrounds": "系统背景", @@ -801,7 +858,6 @@ "Extras API URL": "附加 API URL", "Extras API key (optional)": "扩展API密钥(可选)", "Persona Management": "用户角色管理", - "How do I use this?": "怎样使用?", "Click for stats!": "点击查看统计!", "Usage Stats": "使用统计", "Backup your personas to a file": "将用户角色备份到文件中", @@ -810,17 +866,18 @@ "Restore": "恢复", "Create a dummy persona": "创建空白用户角色", "Create": "创建", - "Toggle grid view": "切换网格视图", "No persona description": "[没有描述]", "Name": "名称", "Enter your name": "输入您的名字", "Click to set a new User Name": "点击设置新的用户名", "Click to lock your selected persona to the current chat. Click again to remove the lock.": "单击以将您选择的用户角色锁定到当前聊天。再次单击以移除锁定。", "Click to set user name for all messages": "点击为所有消息设置用户名", + "Persona Lore Alt+Click to open the lorebook": "Persona Lore\nAlt+Click to open the lorebook", "Persona Description": "用户角色描述", "Example: [{{user}} is a 28-year-old Romanian cat girl.]": "示例:[{{user}}是一个28岁的罗马尼亚猫娘。]", "Tokens persona description": "用户角色描述词符数", "Position:": "位置:", + "None (disabled)": "无(已禁用)", "In Story String / Prompt Manager": "在故事字符串/提示词管理器中", "Top of Author's Note": "作者注的顶部", "Bottom of Author's Note": "作者注的底部", @@ -848,8 +905,8 @@ "Click to select a new avatar for this character": "单击以为此角色选择新的头像", "Add to Favorites": "添加到收藏夹", "Advanced Definition": "高级定义", - "Character Lore": "角色世界书", - "Chat Lore": "聊天知识", + "world_button_title": "Character Lore\n\nClick to load\nShift-click to open 'Link to World Info' popup", + "Chat Lore Alt+Click to open the lorebook": "Chat Lore\nAlt+Click to open the lorebook", "Export and Download": "导出并下载", "Duplicate Character": "复制角色", "Create Character": "创建角色", @@ -877,10 +934,12 @@ "This will be the first message from the character that starts every chat.": "这将是角色在每次聊天开始时发送的第一条消息。", "Group Controls": "群聊控制", "Chat Name (Optional)": "聊天名称(可选)", - "Click to select a new avatar for this group": "单击以为该群组选择新的头像", + "Chat Lore": "聊天知识", + "Click to select a new avatar for this group": "单击选择该群聊的新头像", "Group reply strategy": "群聊回复策略", + "Manual": "手动", "Natural order": "自然顺序", - "List order": "列表顺序", + "List order": "上下顺序", "Group generation handling mode": "群组生成处理模式", "Swap character cards": "交换角色卡", "Join character cards (exclude muted)": "加入角色卡(不包括被禁言的)", @@ -890,7 +949,7 @@ "When 'Join character cards' is selected, all respective fields of the characters are being joined together.This means that in the story string for example all character descriptions will be joined to one big text.If you want those fields to be separated, you can define a prefix or suffix here.This value supports normal macros and will also replace {{char}} with the relevant char's name and with the name of the part (e.g.: description, personality, scenario, etc.)": "当选择“合并角色卡”时,角色的所有相应字段将被合并在一起。这意味着在故事字符串中,例如,所有角色描述都将合并为一个大文本。如果您希望将这些字段分开,可以在此处定义前缀或后缀。此值支持普通宏,还会将 {{char}} 替换为相关角色的名称,将 替换为部分的名称(例如:描述、个性、场景等)", "Inserted after each part of the joined fields.": "插入到加入字段的每个部分之后。", "Join Suffix": "加入后缀", - "Set a group chat scenario": "设置群组聊天场景", + "Set a group chat scenario": "设置群聊背景", "Click to allow/forbid the use of external media for this group.": "单击以允许/禁止该组使用外部媒体。", "Restore collage avatar": "恢复拼贴头像", "Allow self responses": "允许自我回复", @@ -902,7 +961,7 @@ "Create New Character": "新建角色", "Import Character from File": "从文件导入角色", "Import content from external URL": "从外部URL导入内容", - "Create New Chat Group": "创建新的聊天群组", + "Create New Chat Group": "创建新的群聊", "Characters sorting order": "角色排序顺序", "A-Z": "A-Z", "Z-A": "Z-A", @@ -919,6 +978,11 @@ "Bulk_edit_characters": "批量编辑角色", "Bulk select all characters": "批量选择所有角色", "Bulk delete characters": "批量删除角色", + "Bind user name to that avatar": "将用户名称绑定到该头像", + "Change persona image": "更改用户角色头像", + "Select this as default persona for the new chats.": "选择此项作为新聊天的默认用户角色。", + "Duplicate persona": "复制用户角色", + "Delete persona": "删除用户角色", "popup-button-save": "保存", "popup-button-yes": "是", "popup-button-no": "否", @@ -937,26 +1001,26 @@ "Creator's Metadata": "创作者的元数据", "(Not sent with the AI Prompt)": "(不随 AI 提示词发送)", "Everything here is optional": "这里的一切都是可选的", - "(Botmaker's name / Contact Info)": "(角色制作者的姓名/联系信息)", - "(If you want to track character versions)": "(如果您想跟踪角色版本)", - "(Describe the bot, give use tips, or list the chat models it has been tested on. This will be displayed in the character list.)": "(描述角色,提供使用技巧,或列出已经测试过的聊天模型。这将显示在角色列表中。)", + "(Botmaker's name / Contact Info)": "(角色制作者的姓名/联系信息)", + "(If you want to track character versions)": "(如果您想跟踪角色版本)", + "(Describe the bot, give use tips, or list the chat models it has been tested on. This will be displayed in the character list.)": "(描述角色,提供使用技巧,或列出已经测试过的聊天模型。这将显示在角色列表中。)", "Tags to Embed": "嵌入的标签", - "(Write a comma-separated list of tags)": "(编写一个以逗号分隔的标签列表。)", + "(Write a comma-separated list of tags)": "(编写一个以逗号分隔的标签列表。)", "Personality summary": "角色设定摘要", - "(A brief description of the personality)": "(角色设定的简要描述)", + "(A brief description of the personality)": "(角色设定的简要描述)", "Scenario": "情景", - "(Circumstances and context of the interaction)": "(交互的情况和背景)", + "(Circumstances and context of the interaction)": "(交互的情况和背景)", "Character's Note": "角色备注", "(Text to be inserted in-chat @ designated depth and role)": "(文本将插入聊天中指定的深度和角色)", "@ Depth": "@ 深度", "Role": "角色", - "Talkativeness": "健谈度", - "How often the character speaks in group chats!": "角色在群聊中说话的频率!", - "How often the character speaks in": "角色说话的频率", + "Talkativeness": "发言频率", + "How often the character speaks in group chats!": "角色在群聊中发言的频率!", + "How often the character speaks in": "角色发言的频率", "group chats!": "群聊中!", - "Shy": "害羞", - "Normal": "普通", - "Chatty": "话多", + "Shy": "低", + "Normal": "正常", + "Chatty": "高", "Examples of dialogue": "对话示例", "Important to set the character's writing style.": "设置角色的写作风格,很重要!", "(Examples of chat dialog. Begin each example with START on a new line.)": "(聊天对话的示例,每个示例都另起一行以开头。)", @@ -965,12 +1029,9 @@ "Import Chat": "导入聊天", "Copy to system backgrounds": "复制到系统背景", "Rename background": "重命名背景", - "Lock": "加锁", + "Lock": "锁定", "Unlock": "解锁", "Delete background": "删除背景", - "Chat Lorebook": "聊天知识书", - "Chat Lorebook for": "聊天知识书", - "chat_world_template_txt": "选定的世界信息将绑定到此聊天。生成 AI 回复时,\n它将与全球和角色传说书中的条目相结合。", "Select a World Info file for": "选择一个世界书文件给", "Primary Lorebook": "主要知识书", "A selected World Info will be bound to this character as its own Lorebook.": "所选的世界信息将会于该角色绑定,作为该角色自己的知识书", @@ -980,18 +1041,17 @@ "Associate one or more auxillary Lorebooks with this character.": "将一个或多个辅助知识书与此角色关联。", "NOTE: These choices are optional and won't be preserved on character export!": "注意:这些选项是可选的,并且不会在角色导出时被一并导出!", "Rename chat file": "重命名聊天文件", - "Export JSONL chat file": "导出JSONL聊天文件", + "Export JSONL chat file": "导出 .JSONL 聊天文件", "Download chat as plain text document": "将聊天下载为纯文本文档", "Delete chat file": "删除聊天文件", "Drag to reorder tag": "拖动以排序", "Use tag as folder": "标记为文件夹", "Delete tag": "删除标签", "Entry Title/Memo": "条目标题/备忘录", - "WI Entry Status:🔵 Constant🟢 Normal🔗 Vectorized❌ Disabled": "世界信息条目状态:🔵 常量 🟢 正常 🔗 矢量化 ❌ 已禁用", - "WI_Entry_Status_Constant": "常量", - "WI_Entry_Status_Normal": "普通", - "WI_Entry_Status_Vectorized": "矢量化", - "WI_Entry_Status_Disabled": "禁用", + "WI Entry Status:🔵 Constant🟢 Normal🔗 Vectorized": "世界书条目状态:\r🔵 永久\r🟢 关键词\r🔗 向量化", + "WI_Entry_Status_Constant": "永久", + "WI_Entry_Status_Normal": "关键词", + "WI_Entry_Status_Vectorized": "向量化", "T_Position": "↑Char:在角色定义之前\n↓Char:在角色定义之后\n↑AN:在作者注释之前\n↓AN:在作者注释之后\n@D:在深度D处", "Before Char Defs": "角色定义之前", "After Char Defs": "角色定义之后", @@ -1006,7 +1066,6 @@ "Order:": "顺序:", "Order": "顺序", "Trigger %:": "触发 %:", - "Probability": "可能性", "Duplicate world info entry": "重复的世界信息条目", "Delete world info entry": "删除世界信息条目", "Comma separated (required)": "逗号分隔(必填)", @@ -1025,15 +1084,20 @@ "Comma separated list (ignored if empty)": "逗号分隔列表(如果为空则忽略)", "Use global setting": "使用全局设置", "Case-Sensitive": "区分大小写", + "Use global": "使用全局", "Yes": "是", "No": "否", + "Whole Words": "Whole Words", + "Group Scoring": "Group Scoring", "Can be used to automatically activate Quick Replies": "可用于自动激活快速回复", "Automation ID": "自动化ID", - "( None )": "( 没有任何 )", + "( None )": "(没有任何)", + "delay_until_recursion_level": "Defines delay levels for recursive scans.\r\rInitially, only the first level (smallest number) will match.\rOnce no matches are found, the next level becomes eligible for matching.\rThis repeats until all levels are checked.\r\rTied to the \"Delay until recursion\" setting.", + "Recursion Level": "递归等级", "Content": "内容", - "Exclude from recursion": "从递归中排除", + "Non-recursable (will not be activated by another)": "不可递归(不会被其他条目激活)", "Prevent further recursion (this entry will not activate others)": "防止进一步递归(本条目将不会激活其他条目)", - "Delay until recursion (this entry can only be activated on recursive checking)": "延迟到递归(本条目只能在递归检查时激活)", + "Delay until recursion (can only be activated on recursive checking)": "延迟到递归(本条目只能在递归检查时激活)", "What this keyword should mean to the AI, sent verbatim": "这个关键词对AI的含义,逐字发送", "Inclusion Group": "包含组", "Inclusion Groups ensure only one entry from a group is activated at a time, if multiple are triggered.Documentation: World Info - Inclusion Group": "包含组可确保每次仅激活组中的一项(如果触发了多项)。支持多个逗号分隔的组。文档:世界信息 - 包含组", @@ -1048,8 +1112,9 @@ "Cooldown": "冷却", "Entries with a delay can't be activated until there are N messages present in the chat.": "直到聊天中出现 N 条消息时,延迟的条目才能被激活。", "Delay": "延迟", - "Filter to Character(s)": "应用到角色", - "Character Exclusion": "反选角色", + "Filter to Characters or Tags": "应用到角色或标签", + "Switch the Character/Tags filter around to exclude the listed characters and tags from matching for this entry": "切换角色/标签筛选方式,将列出的角色和标签排除在匹配范围之外", + "Exclude": "排除", "-- Characters not found --": "-- 未找到角色 --", "Selective": "选择性", "Use Probability": "使用概率", @@ -1068,6 +1133,7 @@ "prompt_manager_in_chat": "聊天中", "prompt_manager_depth": "深度", "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "注入深度。0 = 在最后一条消息之后,1 = 在最后一条消息之前,等等。", + "The content of this prompt is pulled from elsewhere and cannot be edited here.": "The content of this prompt is pulled from elsewhere and cannot be edited here.", "Prompt": "提示词", "The prompt to be sent.": "要发送的提示词。", "This prompt cannot be overridden by character cards, even if overrides are preferred.": "即使选择覆盖,此提示词也不能被角色卡覆盖。", @@ -1085,7 +1151,7 @@ "Create checkpoint": "创建检查点", "Create Branch": "创建分支", "Copy": "复制", - "Open checkpoint chat": "打开检查点聊天", + "Open checkpoint chat\nShift+Click to replace the existing checkpoint with a new one": "点击打开检查点聊天\nShift+单击来覆盖已有检查点", "Edit": "编辑", "Confirm": "确认", "Copy this message": "复制此消息", @@ -1094,7 +1160,10 @@ "Move message down": "将消息下移", "Enlarge": "放大", "Caption": "标题", + "Swipe left": "Swipe left", + "Swipe right": "Swipe right", "Welcome to SillyTavern!": "欢迎来到 SillyTavern!", + "SillyTavern is aimed at advanced users.": "SillyTavern 面向高级用户。", "welcome_message_part_1": "阅读", "welcome_message_part_2": "官方文档", "welcome_message_part_3": "。", @@ -1103,10 +1172,6 @@ "welcome_message_part_6": "加入", "Discord server": "Discord 服务器", "welcome_message_part_7": "了解信息和公告。", - "SillyTavern is aimed at advanced users.": "SillyTavern 面向高级用户。", - "If you're new to this, enable the simplified UI mode below.": "如果您是新手,请启用下面的简化 UI 模式。", - "Change it later in the 'User Settings' panel.": "稍后在“用户设置”面板中更改它。", - "Enable simple UI mode": "启用简单 UI 模式", "Looking for AI characters?": "正在寻找 AI 角色?", "onboarding_import": "导入", "from supported sources or view": "来自受支持的来源或查看", @@ -1122,16 +1187,17 @@ "Move up": "向上移动", "Move down": "向下移动", "View character card": "查看角色卡片", - "Remove from group": "从群组中移除", - "Add to group": "添加到群组中", + "Remove from group": "踢出群聊", + "Add to group": "拉入群聊", "Alternate Greetings": "额外问候语", - "Alternate_Greetings_desc": "开始新聊天时,这些按钮将显示为第一条消息的滑动选项。\n群组成员可以选择其中之一来发起对话。", + "Alternate_Greetings_desc": "开始新聊天时,这些按钮将显示为第一条消息的滑动选项。\n群成员可以选择其中之一来发起对话。", "alternate_greetings_hint_1": "点击", "alternate_greetings_hint_2": "按钮即可开始!", "Alternate Greeting #": "额外问候语 #", - "(This will be the first message from the character that starts every chat)": "(这将是角色在每次聊天开始时发送的第一条消息)", + "(This will be the first message from the character that starts every chat)": "(这是每次聊天开始时角色的第一条消息)", "View contents": "查看内容", "Remove the file": "删除文件", + "Author's Note": "作者注释", "Unique to this chat": "此聊天独有", "Checkpoints inherit the Note from their parent, and can be changed individually after that.": "检查点从其父级继承注释,之后可以单独更改。", "Include in World Info Scanning": "纳入世界信息扫描", @@ -1139,7 +1205,7 @@ "After Main Prompt / Story String": "主提示词/故事线之后", "as": "作为", "Insertion Frequency": "插入频率", - "(0 = Disable, 1 = Always)": "(0 = 禁用,1 = 始终)", + "(0 = Disable, 1 = Always)": "(“0”为禁用,“1”为始终)", "User inputs until next insertion:": "用户输入直到下一次插入:", "Character Author's Note (Private)": "人物作者注(私有)", "Won't be shared with the character card on export.": "导出时不会与角色卡共享。", @@ -1149,7 +1215,7 @@ "Default Author's Note": "默认作者注", "Will be automatically added as the Author's Note for all new chats.": "将自动添加为所有新聊天的作者注释。", "Chat CFG": "聊天CFG", - "1 = disabled": "1 = 已禁用", + "1 = disabled": "“1”为已禁用", "write short replies, write replies using past tense": "写简短的回复,用过去时写回复", "Positive Prompt": "正面提示词", "Use character CFG scales": "单独为各个角色设置CFG缩放", @@ -1168,6 +1234,7 @@ "Insertion Depth:": "插入深度:", "Token Probabilities": "词符概率", "Select a token to see alternatives considered by the AI.": "选择一个词符来查看 AI 考虑的替代方案。", + "Reroll with the entire prefix": "Reroll with the entire prefix", "Not connected to API!": "未连接到API!", "Type a message, or /? for help": "键入消息,或 /? 获取帮助", "Continue script execution": "继续执行脚本", @@ -1179,6 +1246,7 @@ "Send a message": "发送消息", "Close chat": "关闭聊天", "Toggle Panels": "切换面板", + "CFG Scale": "CFG缩放", "Back to parent chat": "返回到父级聊天", "Save checkpoint": "保存检查点", "Convert to group": "转换为群聊", @@ -1188,11 +1256,6 @@ "Regenerate": "重新生成", "Impersonate": "AI 帮答", "Continue": "继续", - "Bind user name to that avatar": "将用户名称绑定到该头像", - "Change persona image": "更改用户角色头像", - "Select this as default persona for the new chats.": "选择此项作为新聊天的默认用户角色。", - "Duplicate persona": "复制用户角色", - "Delete persona": "删除用户角色", "These characters are the winners of character design contests and have outstandable quality.": "这些角色都是角色设计大赛的获奖者,品质非常出色。", "Contest Winners": "比赛获胜者", "These characters are the finalists of character design contests and have remarkable quality.": "这些角色都是角色设计大赛的入围作品,品质十分出色。", @@ -1261,18 +1324,30 @@ "macro)": "宏指令)", "Automatically caption images": "自动为图像添加标题", "Edit captions before saving": "保存前编辑标题", + "Profile name:": "配置名称:", + "Creating a Connection Profile": "新建API连接配置", + "{{@key}}": "{{@key}}:", + "Enter a name:": "输入名字:", + "Connection Profile": "API连接配置", + "View connection profile details": "查看API连接配置详情", + "Create a new connection profile": "新建一个API连接配置", + "Update a connection profile": "更新API连接配置", + "Edit a connection profile": "编辑API连接配置", + "Reload a connection profile": "重载API连接配置", + "Delete a connection profile": "删除API连接配置", + "Omitted Settings:": "Omitted Settings:", "Character Expressions": "角色表情", "Translate text to English before classification": "分类之前将文本翻译成英文", "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)", "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)", "Classifier API": "分类器 API", "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。", - "LLM": "大语言模型", + "Main API": "主要 API", + "WebLLM Extension": "WebLLM Extension", "LLM Prompt": "大语言模型提示词", "Will be used if the API doesn't support JSON schemas or function calling.": "如果 API 不支持 JSON 模式或函数调用,则会使用它。", "Default / Fallback Expression": "默认/后备表达式", "Set the default and fallback expression being used when no matching expression is found.": "设置在未找到匹配表达式时使用的默认表达式和后备表达式。", - "Fallback Expression": "后备表达式", "Custom Expressions": "自定义表达式", "Can be set manually or with an _space": "可以手动设置或使用", "space_ slash command.": "快捷命令来设置。", @@ -1291,10 +1366,11 @@ "ext_sum_title": "总结", "ext_sum_with": "总结如下:", "ext_sum_main_api": "主要 API", + "ext_sum_webllm": "WebLLM 扩展", "ext_sum_current_summary": "当前摘要:", + "ext_sum_restore_tip": "恢复先前的摘要;重复使用以清除此聊天的摘要状态", "ext_sum_restore_previous": "恢复上一个", "ext_sum_memory_placeholder": "摘要将在这里生成...", - "ext_sum_restore_tip": "恢复先前的摘要;重复使用以清除此聊天的摘要状态", "ext_sum_force_tip": "立即触发摘要更新。", "ext_sum_force_text": "现在总结", "Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API).": "禁用自动摘要更新。暂停时,摘要保持原样。您仍然可以通过按“立即汇总”按钮(仅适用于主 API)强制更新。", @@ -1319,13 +1395,13 @@ "ext_sum_api_response_length_1": "API 响应长度", "ext_sum_api_response_length_2": "(", "ext_sum_api_response_length_3": "个词符)", - "ext_sum_0_default": "0 = 默认", + "ext_sum_0_default": "“0”为默认", "ext_sum_raw_max_msg": "[原始] 每个请求的最大消息数", - "ext_sum_0_unlimited": "0 = 无限制", + "ext_sum_0_unlimited": "“0”为无限制", "Update frequency": "更新频率", "ext_sum_update_every_messages_1": "更新间隔", "ext_sum_update_every_messages_2": "消息", - "ext_sum_0_disable": "0 = 禁用", + "ext_sum_0_disable": "“0”为禁用", "ext_sum_auto_adjust_desc": "尝试根据聊天指标自动调整间隔。", "ext_sum_update_every_words_1": "更新间隔(", "ext_sum_update_every_words_2": "字)", @@ -1346,7 +1422,7 @@ "Message / Command:": "消息/命令:", "Word wrap": "自动换行", "Tab size:": "标签大小:", - "Ctrl+Enter to execute": "Ctrl+Enter 执行", + "Ctrl+Enter to execute": "使用 Ctrl+Enter 来执行", "Context Menu": "上下文菜单", "Chaining:": "链接:", "Auto-Execute": "自动执行", @@ -1356,6 +1432,7 @@ "Execute on user message": "根据用户消息执行", "Execute on AI message": "根据 AI 消息执行", "Execute on chat change": "聊天内容改变时执行", + "Execute on new chat": "在新聊天中执行", "Execute on group member draft": "起草群组成员时执行", "Automation ID:": "自动化标识", "Testing": "测试", @@ -1363,6 +1440,7 @@ "Quick Reply": "快速回复", "Enable Quick Replies": "启用快速回复", "Combine Quick Replies": "合并快速回复", + "Show Popout Button": "(在电脑上)展示弹出式按钮", "Global Quick Reply Sets": "全局快速回复集", "Chat Quick Reply Sets": "聊天快速回复集", "Edit Quick Replies": "编辑快速回复", @@ -1382,6 +1460,7 @@ "ext_regex_global_scripts": "全局正则脚本", "ext_regex_global_scripts_desc": "影响所有角色,保存在本地设定中", "ext_regex_scoped_scripts": "局部正则脚本", + "ext_regex_disallow_scoped": "Disallow using scoped regex", "ext_regex_allow_scoped": "允许使用范围正则表达式", "ext_regex_scoped_scripts_desc": "只影响当前角色,保存在角色卡片中", "Regex Editor": "正则表达式编辑器", @@ -1398,20 +1477,30 @@ "Trim Out": "修剪掉", "ext_regex_trim_placeholder": "在替换之前全局修剪正则表达式匹配中任何不需要的部分。用回车键分隔每个元素。", "ext_regex_affects": "影响", + "ext_regex_user_input_desc": "用户发送的消息", "ext_regex_user_input": "用户输入", + "ext_regex_ai_input_desc": "从生成式API中获取的信息。", "ext_regex_ai_output": "AI输出", + "ext_regex_slash_desc": "通过 STscript 命令发送的消息。", "Slash Commands": "快捷命令", - "ext_regex_min_depth_desc": "当应用于提示或显示时,仅影响深度至少为 N 级的消息。0 = 最后一条消息,1 = 倒数第二条消息等。仅计算 WI 条目 @Depth 和可用消息,即非隐藏或系统消息。", + "ext_regex_wi_desc": "知识书/世界书 条目的内容。需要勾选“仅格式提示词”!", + "ext_regex_min_depth_desc": "当应用于提示或显示时,仅影响深度至少为 N 级的消息。“0”为最后一条消息,“1”为倒数第二条消息等。仅计算 WI 条目 @Depth 和可用消息,即非隐藏或系统消息。", "Min Depth": "最小深度", "ext_regex_min_depth_placeholder": "无限", - "ext_regex_max_depth_desc": "当应用于提示词或显示时,仅影响深度不超过 N 级的消息。0 = 最后一条消息,1 = 倒数第二条消息等。仅计算世界信息条目 @Depth 和可用消息,即非隐藏或系统消息。", + "ext_regex_max_depth_desc": "当应用于提示词或显示时,仅影响深度不超过 N 级的消息。“0”为最后一条消息,“1”为倒数第二条消息等。仅计算世界信息条目 @Depth 和可用消息,即非隐藏或系统消息。", "ext_regex_other_options": "其他选项", + "ext_regex_run_on_edit_desc": "当指定角色的消息被编辑时运行正则脚本。", + "Run On Edit": "在编辑时运行", + "ext_regex_substitute_regex_desc": "在运行正则表达式查找之前,替换 {{macros}}", + "Macro in Find Regex": "正则表达式查找时的宏", + "Don't substitute": "不替换", + "Substitute (raw)": "替换(原始)", + "Substitute (escaped)": "替换(转义)", + "Ephemerality": "短暂", + "ext_regex_only_format_visual_desc": "正则仅在聊天页面生效,聊天文件内的内容不会被改变。", "Only Format Display": "仅格式显示", "ext_regex_only_format_prompt_desc": "聊天记录不会改变,只有在请求发送时(生成时)才会出现提示词。", "Only Format Prompt (?)": "仅格式提示词", - "Run On Edit": "在编辑时运行", - "ext_regex_substitute_regex_desc": "在运行正则表达式查找之前,替换 {{macros}}", - "Substitute Regex": "替代正则表达式", "ext_regex_import_target": "导入至:", "ext_regex_disable_script": "禁用脚本", "ext_regex_enable_script": "启用脚本", @@ -1422,6 +1511,7 @@ "ext_regex_delete_script": "删除脚本", "Trigger Stable Diffusion": "触发Stable Diffusion", "Abort current image generation task": "中止当前图像生成", + "Stop Image Generation": "停止图像生成", "sd_Yourself": "你自己", "sd_Your_Face": "你的脸", "sd_Me": "我", @@ -1432,6 +1522,8 @@ "Image Generation": "图像生成", "sd_refine_mode": "允许在将提示词发送到生成 API 之前手动编辑提示词", "sd_refine_mode_txt": "生成之前编辑提示词", + "sd_function_tool": "Use the function tool to automatically detect intents to generate images.", + "sd_function_tool_txt": "Use function tool", "sd_interactive_mode": "发送消息时自动生成图像,例如“给我发一张猫的照片”。", "sd_interactive_mode_txt": "交互模式", "sd_multimodal_captioning": "使用多模式字幕根据用户和角色的头像生成提示词。", @@ -1439,8 +1531,6 @@ "sd_free_extend": "使用当前选择的 LLM 自动扩展自由模式主题提示(不是肖像或背景)。", "sd_free_extend_txt": "延长自由模式提示", "sd_free_extend_small": "(交互/命令)", - "sd_expand": "使用文本生成模型自动扩展提示词", - "sd_expand_txt": "自动增强提示词", "sd_snap": "快照生成请求具有强制纵横比(肖像、背景)到最接近已知分辨率,同时尝试保留绝对像素数(推荐用于 SDXL)。", "sd_snap_txt": "自动调整分辨率", "sd_auto_url": "例如:{{auto_url}}", @@ -1451,10 +1541,12 @@ "sd_auto_auth_warning_2": "注意!服务器必须可从 SillyTavern 主机访问。", "sd_drawthings_url": "例如:{{drawthings_url}}", "sd_drawthings_auth_txt": "运行 DrawThings 应用程序并在 UI 中启用 HTTP API 开关!必须可以从 SillyTavern 主机访问服务器。", + "Model ID": "Model ID", + "e.g. black-forest-labs/FLUX.1-dev": "例如:black-forest-labs/FLUX.1-dev", "sd_vlad_url": "例如:{{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "必须能够从 SillyTavern 主机访问该服务器。", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "提示:在 Horde KoboldAI API 设置中保存一个 API 密钥以便在此处使用它。", - "Allow NSFW images from Horde": "允许 Horde 的 NSFW 图片", + "Hint: Save an API key in AI Horde API settings to use it here.": "提示:在 Horde AI API 设置中保存一个 API 密钥以便在此处使用它。", + "Allow NSFW images from Horde": "允许来自 Horde 的 NSFW 图片", "Sanitize prompts (recommended)": "净化提示词(推荐)", "Automatically adjust generation parameters to ensure free image generations.": "自动调整生成参数,确保图像生成自由。", "Avoid spending Anlas": "避免花费 Anlas", @@ -1470,18 +1562,18 @@ "Create new workflow": "创建新的工作流", "Delete workflow": "删除工作流", "Enhance": "提高", - "Refine": "优化", "API Key": "API 密钥", "Click to set": "点击设置", "You can find your API key in the Stability AI dashboard.": "您可以在 Stability AI 仪表板中找到您的 API 密钥。", "Style Preset": "风格预设", + "Prompt Upsampling": "Prompt Upsampling", "Sampling method": "采样方法", "Scheduler": "调度器", "Resolution": "分辨率", "Upscaler": "图像扩大器", - "Sampling steps": "采样步数()", - "Width": "宽度()", - "Height": "高度()", + "Sampling steps": "采样步数", + "Width": "宽度", + "Height": "高度", "Swap width and height": "交换宽度和高度", "Upscale by": "扩大倍数", "Denoising strength": "去噪强度", @@ -1491,12 +1583,14 @@ "Hires. Fix": "高清修复", "Karras": "Karras", "Not all samplers supported.": "并非所有采样器都受支持。", + "sd_adetailer_face": "Use ADetailer with face model during the generation. The ADetailer extension must be installed on the backend.", + "Use ADetailer (Face)": "使用 ADetailer (Face)", "SMEA versions of samplers are modified to perform better at high resolution.": "SMEA 版本的采样器经过修改,在高分辨率下性能更佳。", "SMEA": "中小企业协会", "DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions.": "SMEA 采样器的 DYN 变体通常会产生更加多样化的输出,但在非常高的分辨率下可能会失败。", "DYN": "动态", "Decrisper": "去伪器", - "(-1 for random)": "(-1 为随机)", + "(-1 for random)": "(“-1”为随机)", "Preset for prompt prefix and negative prompt": "提示词前缀和负面提示词的预设", "Style": "风格", "Save style": "保存风格", @@ -1515,6 +1609,7 @@ "Extensions Menu": "扩展菜单", "Slash Command": "快捷命令", "Interactive Mode": "交互模式", + "Function Tool": "Function Tool", "Image Prompt Templates": "图像提示模板", "ext_translate_btn_chat": "翻译聊天", "ext_translate_btn_input": "翻译输入", @@ -1529,6 +1624,18 @@ "ext_translate_mode_provider": "提供者", "ext_translate_target_lang": "目标语言", "ext_translate_clear": "清空设置", + "Select TTS Provider": "选择 文本转语音 的服务提供商", + "tts_enabled": "已启用", + "Narrate user messages": "朗读用户消息", + "Auto Generation": "Auto Generation", + "Requires auto generation to be enabled.": "Requires auto generation to be enabled.", + "Narrate by paragraphs (when streaming)": "Narrate by paragraphs (when streaming)", + "Only narrate quotes": "只朗读引号内文本", + "Ignore text, even quotes, inside asterisk": "不朗读所有*星号内文本*,即使其被引号包裹", + "Narrate only the translated text": "只朗读翻译后文本", + "Skip codeblocks": "跳过代码块", + "Skip tagged blocks": "跳过标签化的块()", + "Pass Asterisks to TTS Engine": "将星号传递给文本转语音服务", "Audio Playback Speed": "音频播放速度", "Vector Storage": "向量存储", "Vectorization Source": "向量化源", @@ -1537,7 +1644,6 @@ "Keep model in memory": "将模型保存在内存中", "Hint: Set the URL in the API connection settings.": "提示:在 API 连接设置中设置 URL。", "The server MUST be started with the --embedding flag to use this feature!": "服务器必须使用 --embedding 标志启动才能使用此功能!", - "Vectors Model Warning": "建议在聊天过程中更改模型时清除向量。否则会导致结果不理想。", "NomicAI API Key": "NomicAI API 密钥", "Query messages": "查询消息", "Score threshold": "分数阈值", @@ -1559,9 +1665,9 @@ "Data Bank files": "数据库文件", "Injection Template": "注入模板", "Injection Position": "注射位置", - "Vectorize All": "全部矢量化", + "Vectorize All": "全部向量化", "Purge Vectors": "清除向量", - "Chat vectorization settings": "聊天矢量化设置", + "Chat vectorization settings": "聊天向量化设置", "Enabled for chat messages": "已启用聊天消息", "Retain#": "保持#", "Insert#": "插入#", @@ -1570,15 +1676,10 @@ "Warning: This will slow down vector generation drastically, as all messages have to be summarized first.": "警告:这将大大减慢向量生成速度,因为必须先汇总所有消息。", "Summarize chat messages when sending": "发送时总结聊天消息", "Warning: This might cause your sent messages to take a bit to process and slow down response time.": "警告:这可能会导致您发送的消息需要一点时间来处理并减慢响应时间。", - "Main API": "主要 API", "Extras API": "附加 API", - "Only used when Main API is selected.": "仅在选择主 API 时使用。", - "Old messages are vectorized gradually as you chat. To process all previous messages, click the button below.": "随着您聊天,旧消息会逐渐矢量化。\n要处理所有以前的消息,请单击下面的按钮。", + "Only used when Main API or WebLLM Extension is selected.": "仅在 Main API 或 WebLLM 扩展被选中时使用。", + "Old messages are vectorized gradually as you chat. To process all previous messages, click the button below.": "随着您聊天,旧消息会逐渐向量化。\n要处理所有以前的消息,请单击下面的按钮。", "View Stats": "查看统计数据", - "Title/Memo": "标题/备忘录", - "Status": "状态", - "Position": "位置", - "Trigger %": "触发率 %", "Manager Users": "管理用户", "New User": "新用户", "Status:": "地位:", @@ -1588,6 +1689,8 @@ "Password:": "密码:", "Confirm Password:": "确认密码:", "This will create a new subfolder...": "这将在 /data/ 目录中创建一个新的子文件夹,以用户的句柄作为文件夹名称。", + "Note:": "提示:", + "this chat is temporary and will be deleted as soon as you leave it.": "此聊天会话是临时的,会在你离开时被删除。", "Current Password:": "当前密码:", "New Password:": "新密码:", "Confirm New Password:": "确认新密码:", @@ -1602,6 +1705,13 @@ "Import All": "全部导入", "Import Existing": "导入现有", "Import": "导入", + "Chat Lorebook": "聊天知识书", + "Chat Lorebook for": "聊天知识书", + "chat_world_template_txt": "选定的世界信息将绑定到此聊天。生成 AI 回复时,\n它将与全球和角色传说书中的条目相结合。", + "chat_rename_1": "输入聊天的新名称:", + "chat_rename_2": "注意!!使用已有文件名会导致错误!!", + "chat_rename_3": "此举会将次聊天与标记为“检查点”的聊天解绑。", + "chat_rename_4": "不需要在结尾添加 '.jsonl'", "Include Body Parameters": "包括主体参数", "custom_include_body_desc": "聊天完成请求主体中要包含的参数(YAML 对象)\n\n示例:\n- top_k:20\n- repetition_penalty:1.1", "Exclude Body Parameters": "排除主体参数", @@ -1609,14 +1719,20 @@ "Include Request Headers": "包含请求标头", "custom_include_headers_desc": "聊天完成请求的附加标头(YAML 对象)\n\n示例:\n- CustomHeader:自定义值\n- AnotherHeader:自定义值", "Debug Warning": "此类别中的功能仅供高级用户使用。如果您不确定后果,请不要点击任何内容。", + "THIS IS PERMANENT!": "此操作不可逆!", + "Also delete the chat files": "同时删除聊天文件", "Are you sure you want to delete this user?": "您确定要删除该用户吗?", "Deleting:": "删除:", "Also wipe user data.": "也清空用户数据。", "Warning:": "警告:", "This action is irreversible.": "此操作不可逆。", "Type the user's handle below to confirm:": "在下面输入用户的名称以确认:", - "Forbid Media Override explanation": "当前角色/群组在聊天中使用外部媒体的能力。", + "Are you sure you want to duplicate this character?": "你确定要复制这个角色吗?", + "If you just want to start a new chat with the same character...": "如果你只是想要与此角色开启一个新的聊天,只需点击聊天左下方菜单中的“开始新聊天”按钮。", + "Forbid Media Override explanation": "当前角色/群聊成员使用外部媒体的能力。", "Forbid Media Override subtitle": "媒体:图像、视频、音频。外部:不在本地服务器上托管。", + "forbid_media_global_state_forbidden": "(禁止)", + "forbid_media_global_state_allowed": "(允许)", "Always forbidden": "始终禁止", "Always allowed": "始终允许", "help_format_1": "文本格式化命令:", @@ -1654,10 +1770,6 @@ "help_format_33": "显示为中等标题(注意空格)", "help_format_35": "### 文本", "help_format_36": "显示为小标题(注意空格)", - "help_format_38": "$$ 文本 $$", - "help_format_39": "呈现 LaTeX 公式(如果启用)", - "help_format_40": "$文本$", - "help_format_41": "呈现 AsciiMath 公式(如果启用)", "help_1": "您好!请选择您想要详细了解的帮助主题:", "help_2": "斜线命令", "help_or": "或者", @@ -1668,27 +1780,33 @@ "help_7": "SillyTavern 官方文档网站", "help_8": "有更多信息!", "help_hotkeys_0": "热键/按键绑定", - "help_hotkeys_1": "向上", + "help_hotkeys_1": "上", "help_hotkeys_2": "编辑聊天中的最后一条消息", - "help_hotkeys_3": "Ctrl+向上", + "help_hotkeys_3": "Ctrl+上", "help_hotkeys_4": "编辑聊天中的最后一条用户消息", - "help_hotkeys_5": "左边", + "help_hotkeys_5": "左", "help_hotkeys_6": "向左滑动", - "help_hotkeys_7": "正确的", + "help_hotkeys_7": "右", "help_hotkeys_8": "向右滑动(注意:当聊天栏中输入内容时,滑动热键将被禁用)", - "help_hotkeys_9": "进入", + "help_hotkeys_9": "Enter", "help_hotkeys_10": "(选中聊天栏)", "help_hotkeys_10_1": "向 AI 发送消息", "help_hotkeys_11": "Ctrl+Enter", "help_hotkeys_12": "重新生成最后的 AI 响应", "help_hotkeys_13": "Alt+Enter", "help_hotkeys_14": "继续上一次AI响应", - "help_hotkeys_15": "逃脱", + "help_hotkeys_15": "Esc", "help_hotkeys_16": "停止 AI 响应生成、关闭 UI 面板、取消消息编辑", - "help_hotkeys_17": "Ctrl+Shift+向上", + "help_hotkeys_17": "Ctrl+Shift+上", "help_hotkeys_18": "滚动到上下文行", - "help_hotkeys_19": "Ctrl+Shift+向下", + "help_hotkeys_19": "Ctrl+Shift+下", "help_hotkeys_20": "滚动聊天到底部", + "help_hotkeys_21": "在输入框中生效,由这个图标标记:", + "help_hotkeys_22": "**加粗**", + "help_hotkeys_23": "*斜体*", + "help_hotkeys_24": "__下划线__", + "help_hotkeys_25": "`行内代码`", + "help_hotkeys_26": "~~删除线~~", "Import Characters": "导入角色", "Enter the URL of the content to import": "输入要导入的内容的URL", "Supported sources:": "支持的来源:", @@ -1726,7 +1844,8 @@ "help_macros_15": "您当前的 Persona 用户名", "help_macros_16": "角色的名字", "help_macros_17": "角色的版本号", - "help_macros_18": "以逗号分隔的群组成员名称列表或单人聊天中的角色名称。别名:{{charIfNotGroup}}", + "help_macros_18": "以逗号分隔的群成员名称列表或单人聊天中的角色名称。别名:{{charIfNotGroup}}", + "help_groupNotMuted": "与 {{group}} 相同,但排除被禁言的成员", "help_macros_19": "当前选定的 API 的文本生成模型名称。", "Can be inaccurate!": "可能不准确!", "help_macros_20": "最新聊天消息的文本。", @@ -1755,9 +1874,10 @@ "help_macros_38": "随机的替代语法允许在列表项中使用逗号。", "help_macros_39": "从列表中随机挑选一项。工作原理与 {{random}} 相同,具有相同的语法选项,但一旦挑选,挑选将一直持续到本次聊天,不会在连续消息和提示处理中重新滚动。", "help_macros_40": "如果使用文本生成 WebUI 后端,则动态地将引号中的文本添加到禁用单词序列中。对其他后端不执行任何操作。可以在任何地方使用(角色描述、WI、AN 等)。文本周围的引号很重要。", + "help_macros_isMobile": "当为移动端时为\"true\",反之为\"false\"", "Instruct Mode and Context Template Macros:": "指导模式和上下文模板宏:", "(enabled in the Advanced Formatting settings)": "(在高级格式设置中启用)", - "help_macros_41": "令牌中允许的最大提示长度 = (上下文大小 - 响应长度)", + "help_macros_41": "令牌中允许的最大提示长度 = (上下文长度 - 响应长度)", "help_macros_42": "上下文模板示例对话分隔符", "help_macros_43": "上下文模板聊天开始行", "help_macros_44": "主系统提示(如果选择,则覆盖字符提示,或 instructSystemPrompt)", @@ -1793,12 +1913,16 @@ "help_macros_68": "替换为全局变量“name”的值减 1 的结果", "help_macros_69": "替换为范围变量“name”的值", "help_macros_70": "用范围变量“name”的索引处的项目值(对于数组/列表或对象/字典)替换", + "{{name}}": "{{name}}", + "If necessary, you can later restore this chat file from the /backups folder": "若需要,您可稍后在 /backups 文件夹中恢复此聊天文件。", + "Also delete the current chat file": "同时删除当前聊天文件", + "PErsona Lorebook for": "Persona Lorebook for", + "persona_world_template_txt": "A selected World Info will be bound to this persona. When generating an AI reply,\n it will be combined with the entries from global, character and chat lorebooks.", "Export for character": "导出角色", "Export prompts for this character, including their order.": "导出此角色的提示词,包括其顺序。", "Export all": "全部导出", "Export all your prompts to a file": "将所有提示词导出到文件", "Insert prompt": "插入提示词", - "Delete prompt": "删除提示词", "Import a prompt list": "导入提示词列表", "Export this prompt list": "导出此提示词列表", "Reset current character": "重置当前角色", @@ -1806,7 +1930,13 @@ "Prompts": "提示词", "Total Tokens:": "总词符数:", "prompt_manager_tokens": "词符", - "Are you sure you want to reset your settings to factory defaults?": "您确定要将设置重置为出厂默认设置吗?", + "Are you sure you want to connect to the following proxy URL?": "你确定要连接到下面的代理URL吗?", + "Encountered an error while processing your request.": "处理请求时遇到了问题。", + "Check you have credits available on your": "检查您的 ", + "OpenAI account quora_error": "OpenAI 账号余额是否充足", + "dot quota_error": "。", + "If you have sufficient credits, please try again later.": "若您有足够的余额,请稍后再试。", + "Are you sure you want to reset your settings to factory defaults?": "您确定要将您的设置重置为出厂默认设置吗?", "Don't forget to save a snapshot of your settings before proceeding.": "在继续之前,不要忘记保存您的设置快照。", "Chat Scenario Override": "聊天场景覆盖", "Remove": "移除", @@ -1824,7 +1954,15 @@ "Glob patterns of files to include in the download.": "要包含在下载中的文件的全局模式。每个模式用换行符分隔。", "Exclude Patterns": "排除模式", "Glob patterns of files to exclude in the download.": "下载中要排除的文件的 Glob 模式。每个模式用换行符分隔。", - "Hi,": "嘿,", + "Tag Management": "标签管理", + "Save your tags to a file": "将标签保存为文件", + "Restore tags from a file": "从文件中恢复标签", + "Create a new tag": "新建一个标签", + "Drag handle to reorder. Click name to rename. Click color to change display.": "拖拽左侧三条横线以排序,点击名字以重命名,点击调色盘以切换颜色。", + "Click on the folder icon to use this tag as a folder.": "点击文件夹图标来将此标签作为一个文件夹。", + "Use alphabetical sorting": "按字母顺序排列", + "tags_sorting_desc": "启用后,标签在创建或重命名时会自动按字母顺序排序。\n禁用后,新标签会追加到末尾。\n\n如果通过拖动手动重新排列标签,则自动排序将被禁用。", + "Hi,": "嗨,", "To enable multi-account features, restart the SillyTavern server with": "要启用多帐户功能,请使用以下命令重新启动 SillyTavern 服务器", "set to true in the config.yaml file.": "在 config.yaml 文件中设置为 true。", "Account Info": "帐户信息", @@ -1848,18 +1986,25 @@ "Want to update?": "获取最新版本", "How to start chatting?": "如何快速开始聊天?", "Click _space": "点击", - "and select a": "并选择一个", - "Chat API": "聊天API", + "and connect to an": "并连接一个", "and pick a character.": "并选择一个角色。", - "You can browse a list of bundled characters in the": "您可以在", - "menu within": "中浏览角色包列表", - "Confused or lost?": "获取更多帮助?", - "click these icons!": "点击这些图标!", - "in the chat bar": "到聊天框里", - "SillyTavern Documentation Site": "SillyTavern帮助文档", - "Extras Installation Guide": "扩展安装指南", + "You can add more": "你可以点击右侧添加更多", + "or_welcome": "或从其他网站中", + "from other websites": "。", + "Go to the": "您可前往此处", + "menu within": "(在这里:", + "to install additional features.": ")以安装拓展功能。", + "Confused or lost?": "遇到了不懂的内容?", + "click these icons!": "点击左侧这种图标!", + "in the chat bar": "至聊天框", + "SillyTavern Documentation Site": "访问 SillyTavern 帮助文档", "Still have questions?": "仍有疑问?", - "Join the SillyTavern Discord": "加入SillyTavern Discord", - "Post a GitHub issue": "发布GitHub问题", - "Contact the developers": "联系开发人员" + "Join the SillyTavern Discord": "加入 SillyTavern Discord群组", + "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 %": "触发率 %" } diff --git a/public/locales/zh-tw.json b/public/locales/zh-tw.json index 66d2d06b6..9e86fdc0c 100644 --- a/public/locales/zh-tw.json +++ b/public/locales/zh-tw.json @@ -2,9 +2,9 @@ "Favorite": "我的最愛", "Tag": "標籤", "Duplicate": "複製", - "Persona": "玩家角色人物", + "Persona": "使用者角色", "Delete": "刪除", - "AI Response Configuration": "AI 回應配置", + "AI Response Configuration": "設定 AI 回應", "AI Configuration panel will stay open": "上鎖 = AI 設定面板將保持開啟", "clickslidertips": "點選滑桿下的數字可手動輸入。", "MAD LAB MODE ON": "瘋狂實驗室模式", @@ -27,13 +27,13 @@ "Instruct": "指示", "Prose Augmenter": "散文增強器", "Text Adventure": "文字冒險", - "response legth(tokens)": "回應長度(符記數)", + "response legth(tokens)": "回應長度(符元數)", "Streaming": "串流", "Streaming_desc": "生成時逐位顯示回應。當此功能關閉時,回應將在完成後一次顯示。", - "context size(tokens)": "上下文大小(符記數)", + "context size(tokens)": "上下文長度(符元數)", "unlocked": "解鎖", - "Only enable this if your model supports context sizes greater than 8192 tokens": "僅在您的模型支援超過8192個符記的上下文大小時啟用此功能", - "Max prompt cost:": "最多提示詞費用", + "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.": "關閉時,回應將在完成後一次性顯示。", "Temperature": "溫度", @@ -50,10 +50,10 @@ "Medium": "中等", "Aggressive": "積極", "Very aggressive": "非常積極", - "Unlocked Context Size": "解鎖的上下文大小", + "Unlocked Context Size": "解鎖上下文長度", "Unrestricted maximum value for the context slider": "上下文滑桿的無限制最大值", - "Context Size (tokens)": "上下文大小(符記數)", - "Max Response Length (tokens)": "最大回應長度(符記數)", + "Context Size (tokens)": "上下文長度(符元數)", + "Max Response Length (tokens)": "最大回應長度(符元數)", "Multiple swipes per generation": "每次生成多次滑動", "Enable OpenAI completion streaming": "啟用 OpenAI 補充串流", "Frequency Penalty": "頻率懲罰", @@ -65,22 +65,22 @@ "Min P": "Min P", "Top A": "Top A", "Quick Prompts Edit": "快速提示詞編輯", - "Main": "主要", + "Main": "主要提示詞", "NSFW": "NSFW", "Jailbreak": "越獄", "Utility Prompts": "實用提示詞", "Impersonation prompt": "AI 扮演提示詞", "Restore default prompt": "還原預設提示詞", "Prompt that is used for Impersonation function": "用於 AI 模仿功能的提示詞", - "World Info Format Template": "世界資訊格式範本", + "World Info Format Template": "世界資訊格式", "Restore default format": "還原預設格式", "Wraps activated World Info entries before inserting into the prompt.": "在插入提示詞前包裝已啟用的世界資訊條目。", "scenario_format_template_part_1": "使用", "scenario_format_template_part_2": "來標示要插入內容的位置。", - "Scenario Format Template": "情境格式範本", - "Personality Format Template": "個性格式範本", + "Scenario Format Template": "場景格式", + "Personality Format Template": "個性格式", "Group Nudge Prompt Template": "群組推動提示詞範本", - "Sent at the end of the group chat history to force reply from a specific character.": "在群組聊天歷史結束時發送以強制特定角色人物回覆", + "Sent at the end of the group chat history to force reply from a specific character.": "在群組聊天歷史結束時發送以強制特定角色回覆", "New Chat": "新聊天", "Restore new chat prompt": "還原新聊天的提示詞", "Set at the beginning of the chat history to indicate that a new chat is about to start.": "設定在聊天歷史的開頭以表明即將開始新的聊天", @@ -91,18 +91,18 @@ "Set at the beginning of Dialogue examples to indicate that a new example chat is about to start.": "設定在對話範例的開頭以表明即將開始新的範例聊天", "Continue nudge": "繼續輔助提示詞", "Set at the end of the chat history when the continue button is pressed.": "按下繼續按鈕時設定在聊天歷史的末尾", - "Replace empty message": "取代空訊息", + "Replace empty message": "取代空白訊息", "Send this text instead of nothing when the text box is empty.": "當文字方塊為空時,發送此字串而不是空白。", "Seed": "種子", - "Set to get deterministic results. Use -1 for random seed.": "設置以獲取確定性結果。使用 -1 作為隨機種子", - "Temperature controls the randomness in token selection": "溫度控制符記選擇中的隨機性", - "Top_K_desc": "Top K 設定可以選擇的最高符記數量。\n例如,Top K 為 20,這意味著只保留排名前 20 的符記(無論它們的機率是多樣還是有限的)。\n設定為 0 以停用。", - "Top_P_desc": "Top P(又名核心取樣)會將所有頂級符記加總,直到達到目標百分比。\n例如,如果前兩個符記都是 25%,而 Top P 設為 0.5,那麼只有前兩個符記會被考慮。\n設定為 1.0 以停用。", + "Set to get deterministic results. Use -1 for random seed.": "設定以獲取確定性結果。使用 -1 作為隨機種子", + "Temperature controls the randomness in token selection": "溫度控制符元選擇中的隨機性", + "Top_K_desc": "Top K 設定可以選擇的最高符元數量。\n例如,Top K 為 20,這意味著只保留排名前 20 的符元(無論它們的機率是多樣還是有限的)。\n設定為 0 以停用。", + "Top_P_desc": "Top P(又名核心取樣)會將所有頂級符元加總,直到達到目標百分比。\n例如,如果前兩個符元都是 25%,而 Top P 設為 0.5,那麼只有前兩個符元會被考慮。\n設定為 1.0 以停用。", "Typical P": "Typical P", - "Typical_P_desc": "Typical P 取樣根據符記偏離集合平均熵的程度進行優先排序。\n它會保留累積機率接近預設閾值(例如0.5)的符記,強調那些具有平均信息量的符記。\n設定為 1.0 以停用。", - "Min_P_desc": "Min P 設定基本最小機率。\n這個值會根據最高符記的機率進行調整。例如,如果最高符記機率為 80%,而 Min P 設為 0.1 ,那麼只有機率高於 8% 的符記會被考慮。\n設定為 0 以停用。", - "Top_A_desc": "Top A 根據最高符記機率的平方設定符記選擇的門檻。\n例如,如果 Top A 值為 0.2,而最高符記機率為 50%,那麼低於 5%(0.2 * 0.5^2) 的符記機率就會被排除。\n設定為 0 以停用。", - "Tail_Free_Sampling_desc": "無尾取樣(Tail-Free Sampling, TFS)會透過分析符記機率的變化率(使用導數)來尋找分佈中的低機率符記尾部。\n它會根據標準化的二階導數,保留直到某個閾值(例如0.3)的符記。\n數值越接近 0 ,表示會棄去越多符記。設定為 1.0 以停用。", + "Typical_P_desc": "Typical P 取樣根據符元偏離集合平均熵的程度進行優先排序。\n它會保留累積機率接近預設閾值(例如0.5)的符元,強調那些具有平均信息量的符元。\n設定為 1.0 以停用。", + "Min_P_desc": "Min P 設定基本最小機率。\n這個值會根據最高符元的機率進行調整。例如,如果最高符元機率為 80%,而 Min P 設為 0.1 ,那麼只有機率高於 8% 的符元會被考慮。\n設定為 0 以停用。", + "Top_A_desc": "Top A 根據最高符元機率的平方設定符元選擇的門檻。\n例如,如果 Top A 值為 0.2,而最高符元機率為 50%,那麼低於 5%(0.2 * 0.5^2) 的符元機率就會被排除。\n設定為 0 以停用。", + "Tail_Free_Sampling_desc": "無尾取樣(Tail-Free Sampling, TFS)會透過分析符元機率的變化率(使用導數)來尋找分佈中的低機率符元尾部。\n它會根據標準化的二階導數,保留直到某個閾值(例如0.3)的符元。\n數值越接近 0 ,表示會棄去越多符元。設定為 1.0 以停用。", "rep.pen range": "重複懲罰範圍", "Mirostat": "Mirostat", "Mode": "模式", @@ -111,8 +111,8 @@ "Mirostat_Tau_desc": "這個設定控制了 Mirostat 輸出的可變性。", "Eta": "Eta", "Mirostat_Eta_desc": "這個設定控制 Mirostat 的學習率。", - "Ban EOS Token": "禁止 EOS 符記", - "Ban_EOS_Token_desc": "對於 KoboldCpp (以及可能也適用於 KoboldAI 的其他符記),禁止使用序列結束 (End-of-Sequence, EOS) 符記。這個設定對於故事創作很有幫助,但不應該在聊天和指令模式中使用。", + "Ban EOS Token": "禁止 EOS 符元", + "Ban_EOS_Token_desc": "對於 KoboldCpp(以及可能也適用於 KoboldAI 的其他符元),禁止使用序列結束 (End-of-Sequence, EOS) 符元。這個設定對於故事創作很有幫助,但不應該在聊天和指令模式中使用。", "GBNF Grammar": "GBNF 語法", "Type in the desired custom grammar": "輸入所需的自定義語法", "Samplers Order": "取樣器順序", @@ -121,15 +121,15 @@ "Load koboldcpp order": "載入 koboldcpp 順序", "Preamble": "前言", "Use style tags to modify the writing style of the output.": "使用樣式標籤修改輸出的寫作樣式。", - "Banned Tokens": "禁止的符記", - "Sequences you don't want to appear in the output. One per line.": "您不希望在輸出中出現的序列。每行一個,使用文字或符記 ID。", + "Banned Tokens": "禁止的符元", + "Sequences you don't want to appear in the output. One per line.": "您不希望在輸出中出現的序列。每行一個,使用文字或符元 ID。", "Logit Bias": "Logit 偏差", "Add": "新增", - "Helps to ban or reenforce the usage of certain words": "有助於禁止或強化某些符記的使用", + "Helps to ban or reenforce the usage of certain words": "有助於禁止或強化某些符元的使用", "CFG Scale": "CFG 比例", "Negative Prompt": "負面提示詞", "Add text here that would make the AI generate things you don't want in your outputs.": "在這裡新增文字,使 AI 生成您不希望在輸出中出現的內容。", - "Used if CFG Scale is unset globally, per chat or character": "如果CFG Scale未在全域、每個聊天或角色人物中設定,則使用", + "Used if CFG Scale is unset globally, per chat or character": "如果CFG Scale未在全域、每個聊天或角色中設定,則使用", "Mirostat Tau": "Tau", "Mirostat LR": "Mirostat 學習率", "Min Length": "最小長度", @@ -138,23 +138,23 @@ "Top A Sampling": "Top A 取樣", "CFG": "CFG", "Neutralize Samplers": "中和取樣器", - "Set all samplers to their neutral/disabled state.": "將所有取樣器設定為中性/停用狀態。", + "Set all samplers to their neutral/disabled state.": "將所有取樣器設定為中性/停用狀態。", "Sampler Select": "選擇取樣器", "Customize displayed samplers or add custom samplers.": "自訂顯示的取樣器或新增自訂取樣器。", "Epsilon Cutoff": "Epsilon 截斷", - "Epsilon cutoff sets a probability floor below which tokens are excluded from being sampled": "Epsilon 截斷設定排除符記的機率下限", + "Epsilon cutoff sets a probability floor below which tokens are excluded from being sampled": "Epsilon 截斷設定排除符元的機率下限", "Eta Cutoff": "Eta 截斷", - "Eta_Cutoff_desc": "Eta 截斷是特殊 Eta 取樣技術的主要參數。\n單位為 1e-4;合理值為 3。\n設為 0 以停用。\n詳情請參見 Hewitt 等人(2022)撰寫的論文《Truncation Sampling as Language Model Desmoothing》。", + "Eta_Cutoff_desc": "Eta 截斷是特殊 Eta 取樣技術的主要參數。\n單位為 1e-4;合理值為 3。\n設為 0 以停用。\n詳情請參見 Hewitt 等人於 2022 年撰寫的論文《Truncation Sampling as Language Model Desmoothing》。", "rep.pen decay": "重複懲罰衰減", "Encoder Rep. Pen.": "編碼器重複懲罰", - "No Repeat Ngram Size": "無重複Ngram大小", + "No Repeat Ngram Size": "無重複 Ngram 大小", "Skew": "Skew", - "Max Tokens Second": "最大符記/秒", + "Max Tokens Second": "最大符元/秒", "Smooth Sampling": "平滑取樣", - "Smooth_Sampling_desc": "允許您使用二次/三次變換來調整分佈。較低的平滑因子值將更具創造性,通常在 0.2-0.3 之間是最佳點(假設曲線=1)。較高的平滑曲線值會使曲線更陡峭,這將更加激烈地懲罰低概率選擇。1.0 的曲線值相當於僅使用平滑因子。", + "Smooth_Sampling_desc": "允許您使用二次/三次變換來調整分佈。較低的平滑因子值將更具創造性,通常在 0.2-0.3 之間是最佳點(假設曲線=1)。較高的平滑曲線值會使曲線更陡峭,這將更加激烈地懲罰低概率選擇。1.0 的曲線值相當於僅使用平滑因子。", "Smoothing Factor": "平滑因子", "Smoothing Curve": "平滑曲線", - "DRY_Repetition_Penalty_desc": "DRY 會懲罰那些將輸入的結尾擴充為已在先前輸入中出現過序列的符記。將乘法器設為 0 以停用。", + "DRY_Repetition_Penalty_desc": "DRY 會懲罰那些將輸入的結尾擴充為已在先前輸入中出現過序列的符元。將乘法器設為 0 以停用。", "DRY Repetition Penalty": "DRY 重複懲罰", "DRY_Multiplier_desc": "將值設為大於 0 來啟用 DRY。控制對最短受懲罰序列的懲罰幅度。", "Multiplier": "乘法器", @@ -163,42 +163,42 @@ "DRY_Allowed_Length_desc": "可以重複而不受懲罰的最長序列長度。", "Allowed Length": "允許長度", "Penalty Range": "懲罰範圍", - "DRY_Sequence_Breakers_desc": "序列停止繼續配對的符記,使用引號包裹字串並以逗號分隔清單。", + "DRY_Sequence_Breakers_desc": "序列停止繼續配對的符元,使用引號包裹字串並以逗號分隔清單。", "Sequence Breakers": "序列中斷器", - "JSON-serialized array of strings.": "序列化JSON的字串陣列。", + "JSON-serialized array of strings.": "序列化 JSON 的字串陣列。", "Dynamic Temperature": "動態溫度", - "Scale Temperature dynamically per token, based on the variation of probabilities": "根據機率變化,動態調整每個符記的溫度", + "Scale Temperature dynamically per token, based on the variation of probabilities": "根據機率變化,動態調整每個符元的溫度", "Minimum Temp": "最低溫度", "Maximum Temp": "最高溫度", "Exponent": "指數", - "Mirostat (mode=1 is only for llama.cpp)": "Mirostat(mode=1僅適用於llama.cpp)", + "Mirostat (mode=1 is only for llama.cpp)": "Mirostat(mode=1 僅適用於 llama.cpp)", "Mirostat_desc": "Mirostat 是輸出困惑度的恆溫器。\nMirostat 會將輸出的困惑度與輸入的困惑度相配對,\n從而避免了重複陷阱(在這個陷阱中,隨著自回歸推理產生字串,輸出的困惑度趨於零)和混亂陷阱(困惑度發散)。\n有關詳細資訊,請參閱 Basu 等人於 2020 年發表的論文《A Neural Text Decoding Algorithm that Directly Controls Perplexity》。\n模式選擇 Mirostat 版本。0=停用,1=Mirostat 1.0(僅限 llama.cpp),2=Mirostat 2.0。", "Mirostat Mode": "Mode", "Variability parameter for Mirostat outputs": "Mirostat 輸出的變異性參數", "Mirostat Eta": "Eta", "Learning rate of Mirostat": "Mirostat 的學習率", - "Beam search": "波束搜尋", + "Beam search": "Beam search(波束搜尋)", "Helpful tip coming soon.": "更多有用提示訊息即將推出。", "Number of Beams": "波束數量", "Length Penalty": "長度懲罰", "Early Stopping": "提前停止", "Contrastive search": "對比搜尋", - "Contrastive_search_txt": "一種取樣器,通過利用大多數 LLM 的表示空間的等向性,鼓勵多樣性的同時保持一致性。詳情請參閱 Su 等人於2022年發表的論文《A Contrastive Framework for Neural Text Generation》。", + "Contrastive_search_txt": "一種取樣器,通過利用大多數 LLM 的表示空間的等向性,鼓勵多樣性的同時保持一致性。詳情請參閱 Su 等人於 2022 年發表的論文《A Contrastive Framework for Neural Text Generation》。", "Penalty Alpha": "懲罰 Alpha", - "Strength of the Contrastive Search regularization term. Set to 0 to disable CS": "對比搜尋正則化項的強度。設定為0以停用CS", + "Strength of the Contrastive Search regularization term. Set to 0 to disable CS": "對比搜尋正則化項的強度。設定為 0 以停用 CS", "Do Sample": "進行取樣", - "Add BOS Token": "新增 BOS 符記", + "Add BOS Token": "新增 BOS 符元", "Add the bos_token to the beginning of prompts. Disabling this can make the replies more creative": "在提示詞的開頭新增 bos_token。停用此功能可以使回應更具創造性", "Ban the eos_token. This forces the model to never end the generation prematurely": "禁止 eos_token。這迫使模型不會提前結束生成", - "Ignore EOS Token": "忽略 EOS 符記", - "Ignore the EOS Token even if it generates.": "即使生成也忽略 EOS 符記", - "Skip Special Tokens": "跳過特殊符記", + "Ignore EOS Token": "忽略 EOS 符元", + "Ignore the EOS Token even if it generates.": "即使生成也忽略 EOS 符元", + "Skip Special Tokens": "跳過特殊符元", "Temperature Last": "最後的溫度", - "Temperature_Last_desc": "使用最後應用溫度取樣器。這幾乎總是明智的做法。\n啟用時:首先取樣一組合理的符記,然後應用溫度來調整它們的相對機率(技術上講,是 logits)。\n停用時:首先應用溫度調整所有符記的相對機率,然後從中取樣合理的符記。\n停用「最後應用溫度取樣」會增加分佈尾部的概率,這傾向於放大獲得不連貫回應的機會。", + "Temperature_Last_desc": "使用最後應用溫度取樣器。這幾乎總是明智的做法。\n啟用時:首先取樣一組合理的符元,然後應用溫度來調整它們的相對機率(技術上講,是 logits)。\n停用時:首先應用溫度調整所有符元的相對機率,然後從中取樣合理的符元。\n停用「最後應用溫度取樣」會增加分佈尾部的概率,這傾向於放大獲得不連貫回應的機會。", "Speculative Ngram": "推測性 Ngram", "Use a different speculative decoding method without a draft model": "使用不含草稿模型的不同推測性解碼方法。", - "Spaces Between Special Tokens": "特殊符記之間的空格", - "LLaMA / Mistral / Yi models only": "僅限 LLaMA / Mistral / Yi 模型", + "Spaces Between Special Tokens": "特殊符元之間的空格", + "LLaMA / Mistral / Yi models only": "僅限 LLaMA/Mistral/Yi 模型", "Example: some text [42, 69, 1337]": "範例:\n一些文字 [42, 69, 1337]", "Classifier Free Guidance. More helpful tip coming soon": "無分類器導引。更多有用提示訊息即將推出", "Scale": "比例", @@ -211,16 +211,16 @@ "llama.cpp only. Determines the order of samplers. If Mirostat mode is not 0, sampler order is ignored.": "僅適用於 llama.cpp。決定取樣器的順序。如果 Mirostat 模式不為 0,則忽略取樣器順序。", "Sampler Priority": "取樣器優先順序", "Ooba only. Determines the order of samplers.": "僅適用於 Ooba。決定取樣器的順序。", - "Character Names Behavior": "角色人物名稱行為", - "Helps the model to associate messages with characters.": "幫助模型將訊息與角色人物關聯起來。", + "Character Names Behavior": "角色名稱行為", + "Helps the model to associate messages with characters.": "幫助模型將訊息與角色關聯起來。", "None": "無", - "character_names_default": "除了團體和過去的玩家角色人物外。否則,請確保在提示中提供名字。", - "Don't add character names.": "不要新增角色人物名稱", + "character_names_default": "除了團體和過去的使用者角色外。否則,請確保在提示中提供名字。", + "Don't add character names.": "不要新增角色名稱", "Completion": "補充", "character_names_completion": "字元限制:僅限拉丁字母數字和底線。不適用於所有來源,特別是:Claude、MistralAI、Google。", - "Add character names to completion objects.": "新增角色人物名稱來補充物件", + "Add character names to completion objects.": "新增角色名稱來補充物件", "Message Content": "訊息內容", - "Prepend character names to message contents.": "在訊息內容前新增角色人物名稱", + "Prepend character names to message contents.": "在訊息內容前新增角色名稱", "Continue Postfix": "繼續後綴", "The next chunk of the continued message will be appended using this as a separator.": "繼續訊息的下一塊將使用此作為分隔符附加", "Space": "空格", @@ -233,55 +233,55 @@ "Continue prefill": "繼續預先填充", "Continue sends the last message as assistant role instead of system message with instruction.": "繼續將最後的訊息作為助理角色發送,而不是帶有指令的系統訊息。", "Squash system messages": "合併系統訊息", - "Combines consecutive system messages into one (excluding example dialogues). May improve coherence for some models.": "將連續的系統訊息合併為一個(不包括範例對話)。可能會提高某些模型的一致性。", + "Combines consecutive system messages into one (excluding example dialogues). May improve coherence for some models.": "將連續的系統訊息合併為一個(不包括對話範例)。可能會提高某些模型的一致性。", "Enable function calling": "啟用函數調用", "Send inline images": "發送內嵌圖片", - "image_inlining_hint_1": "如果模型支援(例如: GPT-4V、Claude 3 或 Llava 13B),則在提示詞中發送圖片。\n使用任何訊息上的", + "image_inlining_hint_1": "如果模型支援(例如:GPT-4V、Claude 3 或 Llava 13B),則在提示詞中發送圖片。\n使用任何訊息上的", "image_inlining_hint_2": "動作或", "image_inlining_hint_3": "選單來附加圖片文件到聊天中。", "Inline Image Quality": "內嵌圖片品質", "openai_inline_image_quality_auto": "自動", "openai_inline_image_quality_low": "低", "openai_inline_image_quality_high": "高", - "Use AI21 Tokenizer": "使用 AI21 符記器", - "Use the appropriate tokenizer for Jurassic models, which is more efficient than GPT's.": "對於 Jurassic 模型使用適當的符記器,比 GPT 的更高效", - "Use Google Tokenizer": "使用 Google 符記器", - "Use the appropriate tokenizer for Google models via their API. Slower prompt processing, but offers much more accurate token counting.": "通過 Google 模型的 API 使用適當的符記器。提示詞處理速度較慢,但提供更準確的符記計數。", + "Use AI21 Tokenizer": "使用 AI21 分詞器", + "Use the appropriate tokenizer for Jurassic models, which is more efficient than GPT's.": "對於 Jurassic 模型使用適當的分詞器,比 GPT 的更高效", + "Use Google Tokenizer": "使用 Google 分詞器", + "Use the appropriate tokenizer for Google models via their API. Slower prompt processing, but offers much more accurate token counting.": "通過 Google 模型的 API 使用適當的分詞器。提示詞處理速度較慢,但提供更準確的符元計數。", "Use system prompt": "使用系統提示詞", - "(Gemini 1.5 Pro/Flash only)": "(僅限於 Gemini 1.5 Pro/Flash)", - "Merges_all_system_messages_desc_1": "合併所有系統訊息,直到第一條非系統角色的訊息,並通過 google 的", + "(Gemini 1.5 Pro/Flash only)": "(僅限於 Gemini 1.5 Pro/Flash)", + "Merges_all_system_messages_desc_1": "合併所有系統訊息,直到第一則非系統角色的訊息,並通過 google 的", "Merges_all_system_messages_desc_2": "字段發送,而不是與其餘提示詞內容一起發送。", "Assistant Prefill": "預先填充助理訊息", - "Start Claude's answer with...": "開始 Claude 的回答...", + "Start Claude's answer with...": "開始 Claude 的回答⋯", "Assistant Impersonation Prefill": "助理扮演時的預先填充", - "Use system prompt (Claude 2.1+ only)": "使用系統提示詞(僅限 Claude 2.1+)", + "Use system prompt (Claude 2.1+ only)": "使用系統提示詞(僅限 Claude 2.1+)", "Send the system prompt for supported models. If disabled, the user message is added to the beginning of the prompt.": "為支援的模型發送系統提示詞。停用時,使用者訊息將新增到提示詞的開頭。", - "User first message": "使用者第一條訊息", - "Restore User first message": "還原使用者第一個訊息", + "User first message": "使用者第一則訊息", + "Restore User first message": "還原使用者第一則訊息", "Human message": "人類訊息、指令等。\n當空白時不加入任何內容,也就是需要一個帶有使用者角色的新提示詞。", "New preset": "新預設", "Delete preset": "刪除預設", - "View / Edit bias preset": "查看/編輯偏見預設", - "Add bias entry": "新增偏見條目", - "Most tokens have a leading space.": "大多數符記有前導空格", + "View / Edit bias preset": "查看/編輯 Bias 預設", + "Add bias entry": "新增 Bias 條目", + "Most tokens have a leading space.": "大多數符元有前導空格", "API Connections": "API 連線", "Text Completion": "文字補充", "Chat Completion": "聊天補充", "NovelAI": "NovelAI", - "KoboldAI Horde": "KoboldAI Horde", + "AI Horde": "AI Horde", "KoboldAI": "KoboldAI", "Avoid sending sensitive information to the Horde.": "避免發送敏感資訊到 Horde。", "Review the Privacy statement": "檢視隱私聲明", "Register a Horde account for faster queue times": "註冊Horde帳號以縮短排隊時間", "Learn how to contribute your idle GPU cycles to the Horde": "了解如何將閒置的 GPU 週期貢獻給 Horde", - "Adjust context size to worker capabilities": "根據 worker 的能力調整上下文大小", + "Adjust context size to worker capabilities": "根據 worker 的能力調整上下文長度", "Adjust response length to worker capabilities": "根據 worker 的能力調整回應長度", "Can help with bad responses by queueing only the approved workers. May slowdown the response time.": "僅將已批准的 worker 排隊,可以幫助處理不良回應。可能會延長回應時間。", "Trusted workers only": "僅限受信任的 worker", "API key": "API 金鑰", "Get it here:": "在這裡獲取:", - "Register": "登記", - "View my Kudos": "查看我的稱讚", + "Register": "註冊", + "View my Kudos": "瀏覽我的讚賞記錄", "Enter": "輸入", "to use anonymous mode.": "以使用匿名模式。", "Clear your API key": "清除您的 API 金鑰", @@ -289,7 +289,7 @@ "Models": "模型", "Refresh models": "重新整理模型", "-- Horde models not loaded --": "-- Horde 模型未載入 --", - "Not connected...": "尚未連線...", + "Not connected...": "尚未連線⋯", "API url": "API URL", "Example: http://127.0.0.1:5000/api ": "範例:http://127.0.0.1:5000/api ", "Connect": "連線", @@ -298,9 +298,9 @@ "Get your NovelAI API Key": "取得您的 NovelAI API 金鑰", "Enter it in the box below": "請在下面的框中輸入", "Novel AI Model": "NovelAI 模型", - "No connection...": "沒有連線...", + "No connection...": "沒有連線⋯", "API Type": "API 類型", - "Default (completions compatible)": "預設(相容補充)", + "Default (completions compatible)": "預設(相容補充)", "TogetherAI API Key": "TogetherAI API 金鑰", "TogetherAI Model": "TogetherAI 模型", "-- Connect to the API --": "-- 連線到 API --", @@ -317,10 +317,10 @@ "Mancer Model": "Mancer 模型", "Make sure you run it with": "確保您使用以下方式執行它", "flag": "旗標", - "API key (optional)": "API 金鑰(可選)", + "API key (optional)": "API 金鑰(可選)", "Server url": "伺服器 URL", "Example: 127.0.0.1:5000": "範例:127.0.0.1:5000", - "Custom model (optional)": "自訂模型(選填)", + "Custom model (optional)": "自訂模型(選填)", "vllm-project/vllm": "vllm-project/vllm", "vLLM API key": "vLLM API 金鑰", "Example: 127.0.0.1:8000": "範例:127.0.0.1:8000", @@ -334,10 +334,10 @@ "Ollama Model": "Ollama 模型", "Download": "下載", "Tabby API key": "Tabby API 金鑰", - "koboldcpp API key (optional)": "KoboldCpp API 金鑰(可選)", + "koboldcpp API key (optional)": "KoboldCpp API 金鑰(可選)", "Example: 127.0.0.1:5001": "範例:127.0.0.1:5001", "Authorize": "授權", - "Get your OpenRouter API token using OAuth flow. You will be redirected to openrouter.ai": "使用 OAuth 流程取得您的 OpenRouter API 符記。您將被重新導向到 openrouter.ai", + "Get your OpenRouter API token using OAuth flow. You will be redirected to openrouter.ai": "使用 OAuth 流程取得您的 OpenRouter API 符元。您將被重新導向到 openrouter.ai", "Bypass status check": "繞過狀態檢查", "Chat Completion Source": "聊天補充來源", "Reverse Proxy": "反向代理伺服器", @@ -348,11 +348,11 @@ "Proxy Name": "代理伺服器名稱", "This will show up as your saved preset.": "這將顯示為您儲存的預設", "Proxy Server URL": "代理伺服器 URL", - "Alternative server URL (leave empty to use the default value).": "替代伺服器 URL(留空以使用預設值)。", - "Remove your real OAI API Key from the API panel BEFORE typing anything into this box": "在此框中輸入任何內容之前,從API面板中刪除您的實際OAI API金鑰", + "Alternative server URL (leave empty to use the default value).": "替代伺服器 URL(留空以使用預設值)。", + "Remove your real OAI API Key from the API panel BEFORE typing anything into this box": "在此框中輸入任何內容之前,從 API 面板中刪除您的實際 OAI API 金鑰", "We cannot provide support for problems encountered while using an unofficial OpenAI proxy": "我們無法為使用非官方 OpenAI 代理伺服器時遇到的問題提供支援", "Doesn't work? Try adding": "不起作用?嘗試新增", - "at the end!": "在最後!", + "at the end!": "在最後!", "Proxy Password": "代理伺服器密碼", "Will be used as a password for the proxy instead of API key.": "將用作代理的密碼,而不是 API 金鑰", "Peek a password": "顯示密碼", @@ -364,17 +364,17 @@ "Use Proxy password field instead. This input will be ignored.": "請改用代理密碼欄位。此輸入將被忽略", "OpenAI Model": "OpenAI 模型", "Bypass API status check": "繞過 API 狀態檢查", - "Show External models (provided by API)": "顯示外部模型(由 API 提供)", + "Show External models (provided by API)": "顯示外部模型(由 API 提供)", "Get your key from": "取得您的鑰匙", "Anthropic's developer console": "Anthropic 的開發者控制台", "Claude Model": "Claude 模型", "Window AI Model": "Window AI 模型", "Model Order": "模型順序", "Alphabetically": "按字母順序", - "Price": "價格(最便宜的)", - "Context Size": "上下文大小", + "Price": "價格(最便宜的)", + "Context Size": "上下文長度", "Group by vendors": "按供應商分組", - "Group by vendors Description": "將 OpenAI 、 Anthropic 等等的模型放各自供應商的群組中。可以與排序功能結合使用。", + "Group by vendors Description": "將 OpenAI、Anthropic 等模型放各自供應商的群組中。可以與排序功能結合使用。", "Allow fallback routes": "允許備援路徑", "Allow fallback routes Description": "如果選擇的模型無法滿足要求,會自動選擇替代模型。", "Scale API Key": "Scale API 金鑰", @@ -392,7 +392,7 @@ "Perplexity Model": "Perplexity 模型", "Cohere API Key": "Cohere API 金鑰", "Cohere Model": "Cohere 模型", - "Custom Endpoint (Base URL)": "自訂端點(Base URL)", + "Custom Endpoint (Base URL)": "自訂端點(Base URL)", "Custom API Key": "自訂 API 金鑰", "Available Models": "可用模型", "Prompt Post-Processing": "提示詞後處理", @@ -412,7 +412,7 @@ "Chat Start": "聊天開始符號", "Add Chat Start and Example Separator to a list of stopping strings.": "將聊天開始和範例分隔符號加入終止字串中。", "Use as Stop Strings": "用作停止字串", - "context_allow_jailbreak": "如果在角色人物卡中定義了越獄,且啟用了「偏好角色人物卡越獄」,則會在提示詞的結尾加入越獄內容。\n這不建議用於文字完成模型,因為可能導致不良的輸出結果。", + "context_allow_jailbreak": "如果在角色卡中定義了越獄,且啟用了「角色卡越獄優先」,則會在提示詞的結尾加入越獄內容。\n這不建議用於文字完成模型,因為可能導致不良的輸出結果。", "Allow Jailbreak": "允許越獄", "Context Order": "上下文順序", "Summary": "摘要", @@ -429,9 +429,9 @@ "Activation Regex": "啟用正規表示式", "Wrap Sequences with Newline": "用換行符包裹序列", "Replace Macro in Sequences": "取代序列中的巨集", - "Skip Example Dialogues Formatting": "跳過範例對話的格式設定", + "Skip Example Dialogues Formatting": "跳過對話範例的格式設定", "Include Names": "包含名稱", - "Force for Groups and Personas": "強制用於群組和玩家角色人物", + "Force for Groups and Personas": "強制用於群組和使用者角色", "System Prompt": "系統提示詞", "Instruct Mode Sequences": "指示模式序列", "System Prompt Wrapping": "系統提示詞換行", @@ -448,34 +448,34 @@ "Assistant Message Prefix": "助理訊息前綴", "Inserted after an Assistant message.": "插入在助理訊息之後。", "Assistant Message Suffix": "助理訊息後綴", - "Inserted before a System (added by slash commands or extensions) message.": "插入在系統(透過斜線命令或擴充套件增加)訊息之前。", + "Inserted before a System (added by slash commands or extensions) message.": "插入在系統(透過斜線命令或擴充功能增加)訊息之前。", "System Message Prefix": "系統訊息前綴", "Inserted after a System message.": "插入在系統訊息之後", "System Message Suffix": "系統訊息後綴", "If enabled, System Sequences will be the same as User Sequences.": "如果啟用,系統序列將與使用者序列相同。", "System same as User": "系統與使用者相同", "Misc. Sequences": "其他序列", - "Inserted before the first Assistant's message.": "插入在第一個助理的訊息之前。", - "First Assistant Prefix": "首個助理前綴", - "instruct_last_output_sequence": "插入在最後一個助理的訊息之前,或在生成 AI 回覆時作為最後一行提示詞(除了中立/系統角色)。", + "Inserted before the first Assistant's message.": "插入在第一則助理訊息之前。", + "First Assistant Prefix": "開頭助理前綴", + "instruct_last_output_sequence": "插入在最後一則助理訊息之前,或在生成 AI 回覆時作為最後一行提示詞(除了中立/系統角色)。", "Last Assistant Prefix": "末尾助理前綴", - "Will be inserted as a last prompt line when using system/neutral generation.": "在使用系統/中立生成時作為最後一行提示詞插入。", + "Will be inserted as a last prompt line when using system/neutral generation.": "在使用系統/中立生成時作為最後一行提示詞插入。", "System Instruction Prefix": "系統指令前綴", "If a stop sequence is generated, everything past it will be removed from the output (inclusive).": "如果生成了停止序列,包括該序列以及之後的所有內容將從輸出中刪除。", "Stop Sequence": "停止序列", "Will be inserted at the start of the chat history if it doesn't start with a User message.": "如果聊天歷史不是以使用者訊息開始,將在聊天歷史的開頭插入。", "User Filler Message": "使用者填充訊息", "Context Formatting": "上下文格式", - "(Saved to Context Template)": "(已儲存到上下文範本)", - "Always add character's name to prompt": "總是將角色人物名稱新增到提示詞中", + "(Saved to Context Template)": "(已儲存到上下文範本)", + "Always add character's name to prompt": "總是將角色名稱新增到提示詞中", "Generate only one line per request": "每次請求僅生成一行", "Trim Incomplete Sentences": "修剪不完整的句子", "Include Newline": "包含換行符號", "Misc. Settings": "其他設定", "Collapse Consecutive Newlines": "折疊連續的換行符號", "Trim spaces": "修剪空格", - "Tokenizer": "符記化工具", - "Token Padding": "符記填充", + "Tokenizer": "分詞器 Tokenizer", + "Token Padding": "符元填充", "Start Reply With": "開始回覆", "AI reply prefix": "AI 回覆前綴", "Show reply prefix in chat": "在聊天中顯示回覆前綴", @@ -486,25 +486,25 @@ "Replace Macro in Custom Stopping Strings": "取代自訂停止字串中的巨集", "Auto-Continue": "自動繼續", "Allow for Chat Completion APIs": "允許聊天補充 API", - "Target length (tokens)": "目標長度(符記)", + "Target length (tokens)": "目標長度(符元)", "World Info": "世界資訊", "Locked = World Editor will stay open": "上鎖 = 世界編輯器將保持開啟", - "Worlds/Lorebooks": "世界/知識書", + "Worlds/Lorebooks": "世界/知識書", "Active World(s) for all chats": "所有聊天啟用中的世界書", "-- World Info not found --": "-- 未找到世界資訊 --", - "Global World Info/Lorebook activation settings": "全球世界資訊/傳說書啟動設置", + "Global World Info/Lorebook activation settings": "全域世界資訊/知識書啟動設定", "Click to expand": "點擊展開", "Scan Depth": "掃描深度", "Context %": "上下文百分比", "Budget Cap": "預算上限", - "(0 = disabled)": "(0 = 停用)", - "Scan chronologically until reached min entries or token budget.": "按時間順序掃描直到達到最小條目或符記預算", + "(0 = disabled)": "(0 = 停用)", + "Scan chronologically until reached min entries or token budget.": "按時間順序掃描直到達到最小條目或符元預算", "Min Activations": "最小啟動次數", "Max Depth": "最大深度", - "(0 = unlimited, use budget)": "(0 = 無限制, 使用預算)", + "(0 = unlimited, use budget)": "(0 = 無限制,使用預算)", "Insertion Strategy": "插入策略", "Sorted Evenly": "均等排序", - "Character Lore First": "角色人物知識書優先", + "Character Lore First": "角色知識書優先", "Global Lore First": "全域知識書優先", "Entries can activate other entries by mentioning their keywords": "條目可以通過提及其關鍵字來啟用其他條目", "Recursive Scan": "遞迴掃描", @@ -523,19 +523,19 @@ "Open all Entries": "開啟所有條目", "Close all Entries": "關閉所有條目", "New Entry": "新條目", - "Fill empty Memo/Titles with Keywords": "用關鍵字填充空的備忘錄/標題", + "Fill empty Memo/Titles with Keywords": "用關鍵字填充空的備忘錄/標題", "Import World Info": "匯入世界資訊", "Export World Info": "匯出世界資訊", "Duplicate World Info": "複製世界資訊", "Delete World Info": "刪除世界資訊", - "Search...": "搜尋...", + "Search...": "搜尋⋯", "Search": "搜尋", "Priority": "優先順序", "Custom": "自訂", "Title A-Z": "標題 A-Z", "Title Z-A": "標題 Z-A", - "Tokens ↗": "符記 ↗", - "Tokens ↘": "符記 ↘", + "Tokens ↗": "符元 ↗", + "Tokens ↘": "符元 ↘", "Depth ↗": "深度 ↗", "Depth ↘": "深度 ↘", "Order ↗": "順序 ↗", @@ -553,12 +553,12 @@ "Admin Panel": "管理面板", "Logout": "登出", "Search Settings": "搜尋設定", - "UI Theme": "介面佈景主題", - "Import a theme file": "匯入布景主題檔案", - "Export a theme file": "匯出布景主題檔案", + "UI Theme": "介面主題", + "Import a theme file": "匯入主題檔案", + "Export a theme file": "匯出主題檔案", "Delete a theme": "刪除主題", "Update a theme file": "更新主題檔案", - "Save as a new theme": "儲存成新的布景主題", + "Save as a new theme": "儲存成新的主題", "Avatar Style": "頭像樣式", "Circle": "圓形", "Square": "方形", @@ -568,7 +568,7 @@ "Bubbles": "氣泡", "Document": "文件", "Specify colors for your theme.": "為您的主題指定顏色", - "Theme Colors": "佈景主題顏色", + "Theme Colors": "介面主題顏色", "Main Text": "主要文字", "Italics Text": "斜體文字", "Underlined Text": "帶底線的文字", @@ -581,22 +581,22 @@ "AI Message Blur Tint": "AI 訊息模糊色調", "Chat Width": "對話框寬度", "Width of the main chat window in % of screen width": "主聊天視窗寬度占螢幕寬度的百分比", - "Font Scale": "字型比例", + "Font Scale": "字體比例", "Font size": "字體大小", "Blur Strength": "模糊強度", "Blur strength on UI panels.": "UI 面板上的模糊強度", "Text Shadow Width": "文字陰影寬度", "Strength of the text shadows": "文字陰影的強度", "Disables animations and transitions": "停用動畫和過渡效果", - "Reduced Motion": "減少動作", + "Reduced Motion": "停用動畫過渡效果", "removes blur from window backgrounds": "從視窗背景中移除模糊效果,以加快算繪速度。", "No Blur Effect": "無模糊效果", "Remove text shadow effect": "移除文字陰影效果。", "No Text Shadows": "無文字陰影", "Reduce chat height, and put a static sprite behind the chat window": "減少聊天高度,並在聊天視窗後放置一個靜態 Sprite。", - "Waifu Mode": "Waifu 模式", - "Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "始終顯示聊天訊息的訊息動作上下文項目的完整列表,而不是將它們隱藏在「...」後面。", - "Auto-Expand Message Actions": "自動展開訊息動作", + "Waifu Mode": "視覺小說模式", + "Always show the full list of the Message Actions context items for chat messages, instead of hiding them behind '...'": "始終顯示聊天訊息的訊息動作上下文項目的完整列表,而不是將它們隱藏在「⋯」後面。", + "Auto-Expand Message Actions": "展開訊息快速編輯選單", "Alternative UI for numeric sampling parameters with fewer steps": "數值取樣參數的替代 UI,步驟更少", "Zen Sliders": "Zen 滑桿", "Entirely unrestrict all numeric sampling parameters": "完全解除所有數值取樣參數的限制。", @@ -608,39 +608,39 @@ "Show an icon for the API that generated the message": "顯示生成訊息的 API 圖示。", "Model Icon": "模型圖示", "Show sequential message numbers in the chat log": "在聊天記錄中顯示連續的訊息編號。", - "Message IDs": "訊息 ID", + "Message IDs": "顯示訊息 ID", "Hide avatars in chat messages.": "在聊天訊息中隱藏頭像", "Hide Chat Avatars": "隱藏聊天頭像", - "Show the number of tokens in each message in the chat log": "在聊天記錄中顯示每條訊息中的符記數量", - "Show Message Token Count": "顯示訊息符記數量", + "Show the number of tokens in each message in the chat log": "在聊天記錄中顯示每條訊息中的符元數量", + "Show Message Token Count": "顯示訊息符元數", "Single-row message input area. Mobile only, no effect on PC": "單行訊息輸入區域。僅適用於行動裝置版。", - "Compact Input Area (Mobile)": "緊湊的輸入區域(行動版)", - "In the Character Management panel, show quick selection buttons for favorited characters": "在角色人物管理面板中,顯示加入到最愛角色人物的快速選擇按鈕。", - "Characters Hotswap": "角色人物快速選擇", + "Compact Input Area (Mobile)": "緊湊的輸入區域(行動版)", + "In the Character Management panel, show quick selection buttons for favorited characters": "在角色管理面板中,顯示加入到最愛角色的快速選擇按鈕。", + "Characters Hotswap": "角色卡快捷選單", "Enable magnification for zoomed avatar display.": "啟用放大顯示頭像", "Avatar Hover Magnification": "頭像懸停時的放大倍數", "Enables a magnification effect on hover when you display the zoomed avatar after clicking an avatar's image in chat.": "當你在聊天中點選頭像的圖片後,這會啟用滑鼠懸停時的放大效果。", - "Show tagged character folders in the character list": "在角色人物列表中顯示標籤角色人物資料夾。", + "Show tagged character folders in the character list": "在角色列表中顯示標籤角色資料夾。", "Tags as Folders": "標籤作為資料夾", "Tags_as_Folders_desc": "最近更改:標籤必須在標籤管理選單中標記為資料夾才能顯示為此類。點選這裡打開。", - "Character Handling": "角色人物處理", - "If set in the advanced character definitions, this field will be displayed in the characters list.": "如果在進階角色人物定義中設定,這個欄位將顯示在角色人物清單中。", - "Char List Subheader": "角色人物列表子標題", - "Character Version": "角色人物版本", - "Created by": "建立者", - "Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "使用模糊配對,並通過所有資料欄位在列表中搜尋角色人物,而不僅僅是通過名稱子字串。", - "Advanced Character Search": "進階角色人物搜尋", - "If checked and the character card contains a prompt override (System Prompt), use that instead": "如果選中並且角色人物卡包含提示詞覆寫(系統提示詞),則使用該提示詞。", - "Prefer Character Card Prompt": "偏好角色人物卡提示詞", - "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "如果選中並且角色人物卡包含越獄覆寫(歷史指示後),則使用該提示詞。", - "Prefer Character Card Jailbreak": "偏好角色人物卡越獄", - "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "避免裁剪和調整匯入的角色人物圖像大小。未勾選時將會裁剪/調整大小到 512x768。", + "Character Handling": "角色處理", + "If set in the advanced character definitions, this field will be displayed in the characters list.": "如果在進階角色定義中設定,這個欄位將顯示在角色清單中。", + "Char List Subheader": "角色列表子標題", + "Character Version": "角色版本", + "Created by": "創作者", + "Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "使用模糊配對,並通過所有資料欄位在列表中搜尋角色,而不僅僅是通過名稱子字串。", + "Advanced Character Search": "進階角色搜尋", + "If checked and the character card contains a prompt override (System Prompt), use that instead": "如果選中並且角色卡包含提示詞覆寫(系統提示詞),則使用該提示詞。", + "Prefer Character Card Prompt": "角色卡主要提示詞優先", + "If checked and the character card contains a jailbreak override (Post History Instruction), use that instead": "如果選中並且角色卡包含越獄覆寫(聊天歷史後指示),則使用該提示詞。", + "Prefer Character Card Jailbreak": "角色卡越獄優先", + "Avoid cropping and resizing imported character images. When off, crop/resize to 512x768": "避免裁剪和調整匯入的角色圖像大小。未勾選時將會裁剪/調整大小到 512x768。", "Never resize avatars": "永不調整頭像大小", - "Show actual file names on the disk, in the characters list display only": "僅在角色人物列表顯示實際檔案名稱。", + "Show actual file names on the disk, in the characters list display only": "僅在角色列表顯示實際檔案名稱。", "Show avatar filenames": "顯示頭像檔案名", - "Prompt to import embedded card tags on character import. Otherwise embedded tags are ignored": "在角色人物匯入時提示詞匯入嵌入的卡片標籤。否則,嵌入的標籤將被忽略。", + "Prompt to import embedded card tags on character import. Otherwise embedded tags are ignored": "在角色匯入時提示詞匯入嵌入的卡片標籤。否則,嵌入的標籤將被忽略。", "Import Card Tags": "匯入卡片中的標籤", - "Hide character definitions from the editor panel behind a spoiler button": "在編輯器面板中將角色人物定義隱藏在劇透按鈕後面。", + "Hide character definitions from the editor panel behind a spoiler button": "在編輯器面板中將角色定義隱藏在劇透按鈕後面。", "Spoiler Free Mode": "無劇透模式", "Miscellaneous": "其他", "Reload and redraw the currently open chat": "重新載入並重繪目前開啟的聊天", @@ -653,26 +653,26 @@ "Play a sound when a message generation finishes": "訊息生成完成時播放音效。", "Message Sound": "訊息音效", "Only play a sound when ST's browser tab is unfocused": "僅在 ST 的瀏覽器分頁未聚焦時播放音效。", - "Background Sound Only": "僅背景音效", + "Background Sound Only": "僅作為背景音效", "Reduce the formatting requirements on API URLs": "降低 API URL 的格式要求。", "Relaxed API URLS": "寬鬆的 API URL 格式", - "Ask to import the World Info/Lorebook for every new character with embedded lorebook. If unchecked, a brief message will be shown instead": "當新角色人物含有知識書時,詢問是否要匯入嵌入的世界資訊/知識書。如果未選中,則會顯示簡短的訊息。", + "Ask to import the World Info/Lorebook for every new character with embedded lorebook. If unchecked, a brief message will be shown instead": "當新角色含有知識書時,詢問是否要匯入嵌入的世界資訊/知識書。如果未選中,則會顯示簡短的訊息。", "Lorebook Import Dialog": "匯入知識書對話框", "Restore unsaved user input on page refresh": "在頁面刷新時還原未儲存的使用者輸入。", "Restore User Input": "還原使用者輸入", "Allow repositioning certain UI elements by dragging them. PC only, no effect on mobile": "允許通過拖動重新定位某些 UI 元素。僅適用於 PC 版。", "Movable UI Panels": "可移動的 UI 面板", - "MovingUI preset. Predefined/saved draggable positions": "MovingUI 預設。預先定義/儲存可拖動位置。", + "MovingUI preset. Predefined/saved draggable positions": "MovingUI 預設。預先定義/儲存可拖動位置。", "MUI Preset": "MUI 預設", - "Save movingUI changes to a new file": "將移動UI變更儲存到新檔案", - "Reset MovingUI panel sizes/locations.": "重置 MovingUI 面板大小/位置", + "Save movingUI changes to a new file": "將移動 UI 變更儲存到新檔案", + "Reset MovingUI panel sizes/locations.": "重置 MovingUI 面板大小/位置", "Apply a custom CSS style to all of the ST GUI": "將自訂 CSS 樣式應用於所有 ST GUI", "Custom CSS": "自訂 CSS", "Expand the editor": "展開編輯器", - "Chat/Message Handling": "聊天/訊息處理", + "Chat/Message Handling": "聊天/訊息處理", "# Messages to Load": "每頁載入的訊息數", "The number of chat history messages to load before pagination.": "每頁載入的聊天歷史訊息數", - "(0 = All)": "(0 = 全部)", + "(0 = All)": "(0 = 全部)", "Streaming FPS": "串流 FPS", "Update speed of streamed text.": "更新串流文字的速度", "Example Messages Behavior": "訊息行為範例:", @@ -681,10 +681,10 @@ "Never include examples": "永不包含範例", "Send on Enter": "按下 Enter 鍵發送:", "Disabled": "停用", - "Automatic (PC)": "自動(PC)", + "Automatic (PC)": "自動(PC)", "Press Send to continue": "按下傳送繼續", - "Show a button in the input area to ask the AI to continue (extend) its last message": "在輸入區域顯示一個按鈕,請求 AI 繼續(擴充)其最後一條訊息", - "Quick 'Continue' button": "快速「繼續」按鈕", + "Show a button in the input area to ask the AI to continue (extend) its last message": "在輸入區域顯示一個按鈕,請求 AI 繼續(擴充)其最後一則訊息", + "Quick 'Continue' button": "快速「繼續回應」按鈕", "Show arrow buttons on the last in-chat message to generate alternative AI responses. Both PC and mobile": "在最後的聊天訊息中顯示箭頭按鈕,以生成替代的 AI 回應。適用於 PC 和行動裝置版", "Swipes": "滑動", "Allow using swiping gestures on the last in-chat message to trigger swipe generation. Mobile only, no effect on PC": "允許在最後的聊天訊息上使用滑動手勢以觸發滑動生成。僅適用於行動裝置版。", @@ -704,15 +704,15 @@ "Allow AI messages in groups to contain lines spoken by other group members": "允許群組中的 AI 訊息包含其他群組成員說的話", "Relax message trim in Groups": "放寬群組中的訊息修剪", "Log prompts to console": "將提示詞記錄到控制台", - "Requests logprobs from the API for the Token Probabilities feature": "從 API 請求 logprobs 用於符記機率功能。", - "Request token probabilities": "請求符記機率", + "Requests logprobs from the API for the Token Probabilities feature": "從 API 請求 logprobs 用於符元機率功能。", + "Request token probabilities": "請求符元機率", "Automatically reject and re-generate AI message based on configurable criteria": "根據可配置標準自動拒絕並重新生成 AI 訊息。", "Auto-swipe": "自動滑動", "Enable the auto-swipe function. Settings in this section only have an effect when auto-swipe is enabled": "啟用自動滑動功能。此部分的設定僅在啟用自動滑動時有效。", - "Minimum generated message length": "生成的訊息最小長度", + "Minimum generated message length": "生成訊息的最小長度", "If the generated message is shorter than this, trigger an auto-swipe": "如果生成的訊息比這個短,將觸發自動滑動。", "Blacklisted words": "黑名單詞語", - "words you dont want generated separated by comma ','": "您不想生成的文字,使用逗號分隔", + "words you dont want generated separated by comma ','": "您不想生成的文字,使用逗號分隔", "Blacklisted word count to swipe": "滑動的黑名單詞語數量", "Minimum number of blacklisted words detected to trigger an auto-swipe": "檢測到的黑名單詞語數量觸發自動滑動的最小值。", "AutoComplete Settings": "自動完成設定", @@ -722,62 +722,62 @@ "Starts with": "開始於", "Includes": "包含", "Fuzzy": "模糊", - "Sets the style of the autocomplete.": "設置自動完成的樣式", + "Sets the style of the autocomplete.": "設定自動完成的樣式", "Autocomplete Style": "自動完成樣式", - "Follow Theme": "參照佈景主題", + "Follow Theme": "沿用介面主題", "Dark": "深色", - "Sets the font size of the autocomplete.": "設置自動完成的字體大小", - "Sets the width of the autocomplete.": "設置自動完成的寬度", + "Sets the font size of the autocomplete.": "設定自動完成的字體大小", + "Sets the width of the autocomplete.": "設定自動完成的寬度", "Autocomplete Width": "自動完成寬度", "chat input box": "聊天輸入框", "entire chat width": "整個聊天寬度", "full window width": "全視窗寬度", "STscript Settings": "STscript 設定", - "Sets default flags for the STscript parser.": "設置 STscript 解析器的預設標誌", + "Sets default flags for the STscript parser.": "設定 STscript 解析器的預設標誌", "Parser Flags": "解析器標誌", "Switch to stricter escaping, allowing all delimiting characters to be escaped with a backslash, and backslashes to be escaped as well.": "切換到更嚴格的字元跳脫,允許所有分隔符號使用反斜線跳脫,反斜線自己也可以跳脫。", "STRICT_ESCAPING": "STRICT_ESCAPING", "Replace all {{getvar::}} and {{getglobalvar::}} macros with scoped variables to avoid double macro substitution.": "將所有 {{getvar::}} 和 {{getglobalvar::}} 巨集取代為作用域變量以避免雙重巨集取代", "REPLACE_GETVAR": "REPLACE_GETVAR", - "Change Background Image": "更換背景圖片", + "Change Background Image": "變更背景圖片", "Filter": "篩選", "Automatically select a background based on the chat context": "根據聊天上下文自動選擇背景", "Auto-select": "自動選擇", "System Backgrounds": "系統背景圖片", "Chat Backgrounds": "聊天背景圖片", - "bg_chat_hint_1": "使用擴充套件", - "bg_chat_hint_2": "所產生的背景圖片將會出現在這裡。", - "Extensions": "擴充套件", - "Notify on extension updates": "擴充套件更新時通知", - "Manage extensions": "管理擴充套件", - "Import Extension From Git Repo": "從 Git 倉庫匯入擴充套件", - "Install extension": "安裝擴充套件", - "Extras API:": "Extras API:", + "bg_chat_hint_1": "使用擴充功能", + "bg_chat_hint_2": "所產生的背景圖片將在此顯示。", + "Extensions": "擴充功能", + "Notify on extension updates": "擴充功能更新時通知", + "Manage extensions": "管理擴充功能", + "Import Extension From Git Repo": "從 Git 倉庫匯入擴充功能", + "Install extension": "安裝擴充功能", + "Extras API:": "擴充功能 API:", "Auto-connect": "自動連線", - "Extras API URL": "Extras API URL", - "Extras API key (optional)": "Extras API 金鑰(選填)", - "Persona Management": "玩家角色人物管理", + "Extras API URL": "擴充功能 API URL", + "Extras API key (optional)": "擴充功能 API 金鑰(選填)", + "Persona Management": "使用者角色管理", "How do I use this?": "我該如何使用這個?", "Click for stats!": "點選查看統計!", "Usage Stats": "使用統計", - "Backup your personas to a file": "將您的玩家角色人物備份到檔案中", + "Backup your personas to a file": "將您的使用者角色備份到檔案中", "Backup": "備份", - "Restore your personas from a file": "從檔案還原您的角色人物", + "Restore your personas from a file": "從檔案還原您的角色", "Restore": "還原", - "Create a dummy persona": "建立一個假玩家角色人物", + "Create a dummy persona": "建立一個虛構使用者角色", "Create": "建立", - "Toggle grid view": "切換網格視圖", - "No persona description": "無玩家角色人物描述", + "Toggle grid view": "切換為網格視圖", + "No persona description": "無使用者角色描述", "Name": "名稱", "Enter your name": "輸入您的名字", - "Click to set a new User Name": "點選以設置新使用者名稱", - "Click to lock your selected persona to the current chat. Click again to remove the lock.": "點選以將所選玩家角色人物上鎖到目前聊天。再次點選以移除上鎖。", - "Click to set user name for all messages": "點選以設置所有訊息的使用者名稱", - "Persona Description": "玩家角色人物描述", + "Click to set a new User Name": "點選以設定新使用者名稱", + "Click to lock your selected persona to the current chat. Click again to remove the lock.": "點選以綁定目前所選的使用者角色至本次聊天。再次點選則可移除綁定。", + "Click to set user name for all messages": "點選以設定所有訊息的使用者名稱", + "Persona Description": "使用者角色描述", "Example: [{{user}} is a 28-year-old Romanian cat girl.]": "範例:[{{user}} 是一個 28 歲的羅馬尼亞貓娘。]", - "Tokens persona description": "符記角色人物描述", - "Position:": "位置:", - "In Story String / Prompt Manager": "在故事字串 / 提示詞管理器", + "Tokens persona description": "角色描述符元數", + "Position:": "插入位置:", + "In Story String / Prompt Manager": "提示詞管理器/故事字串中", "Top of Author's Note": "作者備註的頂部", "Bottom of Author's Note": "作者備註的底部", "In-chat @ Depth": "聊天中 @ 深度", @@ -786,80 +786,80 @@ "System": "系統", "User": "使用者", "Assistant": "助理", - "Show notifications on switching personas": "切換角色人物時顯示通知", - "Character Management": "角色人物管理", - "Locked = Character Management panel will stay open": "上鎖 = 角色人物管理面板將保持開啟", - "Select/Create Characters": "選擇/建立角色人物", - "Favorite characters to add them to HotSwaps": "將角色人物加入到最愛來新增到快速切換", - "Token counts may be inaccurate and provided just for reference.": "符記計數可能不準確,僅供參考。", - "Total tokens": "符記總計", - "Calculating...": "計算中...", - "Tokens": "符記", - "Permanent tokens": "永久符記", + "Show notifications on switching personas": "切換角色時顯示通知", + "Character Management": "角色管理", + "Locked = Character Management panel will stay open": "上鎖 = 角色管理面板將保持開啟", + "Select/Create Characters": "選擇/建立角色", + "Favorite characters to add them to HotSwaps": "將角色加入到最愛來新增到快速切換", + "Token counts may be inaccurate and provided just for reference.": "符元計數可能不準確,僅供參考。", + "Total tokens": "符元總計", + "Calculating...": "計算中⋯", + "Tokens": "符元", + "Permanent tokens": "永久符元", "Permanent": "永久", - "About Token 'Limits'": "關於符記數「限制」", - "Toggle character info panel": "切換角色人物資訊面板", - "Name this character": "為此角色人物命名", - "extension_token_counter": "符記:", - "Click to select a new avatar for this character": "點選以選擇此角色人物的新頭像", + "About Token 'Limits'": "關於符元數「限制」", + "Toggle character info panel": "切換角色資訊面板", + "Name this character": "為此角色命名", + "extension_token_counter": "符元:", + "Click to select a new avatar for this character": "點選以選擇此角色的新頭像", "Add to Favorites": "新增至我的最愛", "Advanced Definition": "進階定義", - "Character Lore": "角色人物知識書", - "Chat Lore": "聊天知識", + "Character Lore": "角色知識書", + "Chat Lore": "聊天知識書", "Export and Download": "匯出和下載", - "Duplicate Character": "複製角色人物", - "Create Character": "建立角色人物", - "Delete Character": "刪除角色人物", - "More...": "更多...", + "Duplicate Character": "複製角色", + "Create Character": "建立角色", + "Delete Character": "刪除角色", + "More...": "更多⋯", "Link to World Info": "連結到世界資訊", "Import Card Lore": "匯入卡片中的知識書", - "Scenario Override": "情境覆寫", - "Convert to Persona": "轉換為玩家角色人物", + "Scenario Override": "場景覆寫", + "Convert to Persona": "轉換為使用者角色", "Rename": "重新命名", "Link to Source": "連結到來源", - "Replace / Update": "取代 / 更新", + "Replace / Update": "取代/更新", "Import Tags": "匯入標籤", - "Search / Create Tags": "搜尋/建立標籤", + "Search / Create Tags": "搜尋/建立標籤", "View all tags": "查看所有標籤", - "Creator's Notes": "建立者備註", - "Show / Hide Description and First Message": "顯示/隱藏描述和第一條訊息", - "Character Description": "角色人物描述", - "Click to allow/forbid the use of external media for this character.": "點選以允許/禁止此角色人物使用外部媒體", + "Creator's Notes": "創作者備註", + "Show / Hide Description and First Message": "顯示/隱藏描述和第一則訊息", + "Character Description": "角色描述", + "Click to allow/forbid the use of external media for this character.": "點選以允許/禁止此角色使用外部媒體", "Ext. Media": "外部媒體權限", - "Describe your character's physical and mental traits here.": "在此描述角色人物的身體和心理特徵。", + "Describe your character's physical and mental traits here.": "在此描述角色的身體和心理特徵。", "First message": "初始訊息", - "Click to set additional greeting messages": "點選以設置額外的問候訊息", + "Click to set additional greeting messages": "點選以設定額外的問候訊息", "Alt. Greetings": "額外問候語", - "This will be the first message from the character that starts every chat.": "這將是每次聊天開始時角色人物發送的第一條訊息。", + "This will be the first message from the character that starts every chat.": "這將是每次聊天開始時角色發送的第一則訊息。", "Group Controls": "群組控制", - "Chat Name (Optional)": "聊天名稱(選填)", + "Chat Name (Optional)": "聊天名稱(選填)", "Click to select a new avatar for this group": "點選以選擇此群組的新頭像", "Group reply strategy": "群組回應策略", "Natural order": "自然順序", "List order": "清單順序", "Group generation handling mode": "群組生成處理模式", - "Swap character cards": "交換角色人物卡", - "Join character cards (exclude muted)": "加入角色人物卡(排除靜音)", - "Join character cards (include muted)": "加入角色人物卡(包括靜音)", + "Swap character cards": "交換角色卡", + "Join character cards (exclude muted)": "合併角色卡欄位(排除靜音)", + "Join character cards (include muted)": "合併角色卡欄位(包括靜音)", "Inserted before each part of the joined fields.": "插入在合併欄位的每一部分之前。", "Join Prefix": "加入前綴", - "When 'Join character cards' is selected, all respective fields of the characters are being joined together.This means that in the story string for example all character descriptions will be joined to one big text.If you want those fields to be separated, you can define a prefix or suffix here.This value supports normal macros and will also replace {{char}} with the relevant char's name and with the name of the part (e.g.: description, personality, scenario, etc.)": "當選擇“加入角色卡”時,角色的所有對應欄位將連接在一起。\r這意味著,例如在故事字串中,所有角色描述都將連接到一個大文字中。\r如果您希望分隔這些字段,可以在此處定義前綴或後綴。\r\r該值支援普通宏,並且還將 {{char}} 替換為相關字元的名稱,並將 替換為部分的名稱(例如:描述、個性、場景等)", + "When 'Join character cards' is selected, all respective fields of the characters are being joined together.This means that in the story string for example all character descriptions will be joined to one big text.If you want those fields to be separated, you can define a prefix or suffix here.This value supports normal macros and will also replace {{char}} with the relevant char's name and with the name of the part (e.g.: description, personality, scenario, etc.)": "選擇「合併角色卡欄位」時,所有角色的相關欄位將被合併。\r例如,在故事字串中,所有角色的描述將合併為一段大文本。\r若您希望這些欄位保持分隔,您可以在此定義前綴或後綴。\r\r此值支持常規巨集 {{macros}},並會將 {{char}} 替換為相關角色的名稱,可將 替換為欄位名稱(例如:角色描述、個性、場景等)。", "Inserted after each part of the joined fields.": "插入在合併欄位的每一部分之後。", "Join Suffix": "加入後綴", - "Set a group chat scenario": "設定群組聊天情境", - "Click to allow/forbid the use of external media for this group.": "點選以允許/禁止此群組使用外部媒體", + "Set a group chat scenario": "設定群組聊天場景", + "Click to allow/forbid the use of external media for this group.": "點選以允許/禁止此群組使用外部媒體", "Restore collage avatar": "還原預設頭像", "Allow self responses": "允許自我回應", "Auto Mode": "自動模式", "Auto Mode delay": "自動模式延遲", - "Hide Muted Member Sprites": "隱藏靜音成員精靈", + "Hide Muted Member Sprites": "於群組拼貼頭像中隱藏靜音成員", "Current Members": "目前成員", "Add Members": "新增成員", - "Create New Character": "建立新角色人物", - "Import Character from File": "從檔案匯入角色人物", - "Import content from external URL": "從外部 URL 匯入內容", - "Create New Chat Group": "建立新聊天群組", - "Characters sorting order": "角色人物排序依據", + "Create New Character": "創建角色", + "Import Character from File": "由本地檔案匯入角色", + "Import content from external URL": "由外部 URL 匯入內容", + "Create New Chat Group": "創建聊天群組", + "Characters sorting order": "角色排序依據", "A-Z": "A-Z", "Z-A": "Z-A", "Newest": "最新", @@ -868,13 +868,13 @@ "Recent": "最近", "Most chats": "最多聊天", "Least chats": "最少聊天", - "Most tokens": "最多符記", - "Least tokens": "最少符記", + "Most tokens": "最多符元", + "Least tokens": "最少符元", "Random": "隨機", - "Toggle character grid view": "切換角色人物網格視圖", - "Bulk_edit_characters": "批量編輯角色人物\n\n點選以切換角色人物\nShift + 點選以選擇/取消選擇一範圍的角色人物\n右鍵以查看動作", - "Bulk select all characters": "全選所有角色人物", - "Bulk delete characters": "批量刪除角色人物", + "Toggle character grid view": "切換為角色網格視圖", + "Bulk_edit_characters": "批次編輯角色\n\n點選以切換角色\nShift + 點選以選擇/取消選擇一範圍的角色\n右鍵以查看動作", + "Bulk select all characters": "全選所有角色", + "Bulk delete characters": "批次刪除角色", "popup-button-save": "儲存", "popup-button-yes": "是", "popup-button-no": "否", @@ -882,60 +882,59 @@ "popup-button-import": "匯入", "Advanced Defininitions": "- 進階定義", "Prompt Overrides": "提示詞覆寫", - "(For Chat Completion and Instruct Mode)": "(用於聊天補充和指令模式)", + "(For Chat Completion and Instruct Mode)": "(用於聊天補充和指令模式)", "Insert {{original}} into either box to include the respective default prompt from system settings.": "在任一框中插入 {{original}} 以包含系統設定中的預設提示詞。", "Main Prompt": "主要提示詞", - "Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)": "此處的任何內容將取代此角色人物使用的預設主要提示詞。(v2 規範:system_prompt)", - "Any contents here will replace the default Jailbreak Prompt used for this character. (v2 spec: post_history_instructions)": "此處的任何內容將取代此角色人物使用的預設越獄提示詞。(v2 規範:post_history_instructions)", - "Creator's Metadata (Not sent with the AI prompt)": "建立者的中繼資料(不會與 AI 提示詞一起發送)", - "Creator's Metadata": "建立者的中繼資料", - "(Not sent with the AI Prompt)": "(不與 AI 提示詞一起發送)", + "Any contents here will replace the default Main Prompt used for this character. (v2 spec: system_prompt)": "此處的任何內容將取代此角色使用的預設主要提示詞。(v2 規範:system_prompt)", + "Any contents here will replace the default Jailbreak Prompt used for this character. (v2 spec: post_history_instructions)": "此處的任何內容將取代此角色使用的預設越獄提示詞。(v2 規範:post_history_instructions)", + "Creator's Metadata (Not sent with the AI prompt)": "創作者的中繼資料(不會與 AI 提示詞一起發送)", + "Creator's Metadata": "創作者的中繼資料", + "(Not sent with the AI Prompt)": "(不與 AI 提示詞一起發送)", "Everything here is optional": "此處所有內容均為選填", - "(Botmaker's name / Contact Info)": "(機器人製作者的名字 / 聯繫資訊)", - "(If you want to track character versions)": "(如果您想追蹤角色人物版本)", - "(Describe the bot, give use tips, or list the chat models it has been tested on. This will be displayed in the character list.)": "(描述機器人,提供使用技巧,或列出已測試的聊天模型。這將顯示在角色人物列表中。)", + "(Botmaker's name / Contact Info)": "(機器人創作者的名字/聯絡資訊)", + "(If you want to track character versions)": "(若您想追蹤角色版本)", + "(Describe the bot, give use tips, or list the chat models it has been tested on. This will be displayed in the character list.)": "(描述機器人,提供使用技巧,或列出已測試的聊天模型。這將顯示在角色列表中。)", "Tags to Embed": "嵌入標籤", - "(Write a comma-separated list of tags)": "(使用逗號分隔每個標籤)", + "(Write a comma-separated list of tags)": "(使用逗號分隔每個標籤)", "Personality summary": "個性摘要", - "(A brief description of the personality)": "(個性的簡短描述)", - "Scenario": "情境", - "(Circumstances and context of the interaction)": "(互動的情況和上下文)", - "Character's Note": "角色人物筆記", - "(Text to be inserted in-chat @ designated depth and role)": "(要在聊天中插入的文字 @ 指定深度和角色)", + "(A brief description of the personality)": "(關於角色性格的簡要描述)", + "Scenario": "場景設想", + "(Circumstances and context of the interaction)": "(互動情形與聊天背景)", + "Character's Note": "角色備註", + "(Text to be inserted in-chat @ designated depth and role)": "(在聊天中以指定角色於 @ 深度位置插入文本)", "@ Depth": "@ 深度", "Role": "角色", "Talkativeness": "健談度", - "How often the character speaks in group chats!": "角色人物在群組聊天中說話的頻率!", + "How often the character speaks in group chats!": "角色在群組聊天中說話的頻率!", "How often the character speaks in": "角色說話的頻率", "group chats!": "群組聊天!", - "Shy": "害羞", + "Shy": "寡言", "Normal": "正常", "Chatty": "健談", "Examples of dialogue": "對話範例", - "Important to set the character's writing style.": "設定角色人物的寫作樣式很重要。", - "(Examples of chat dialog. Begin each example with START on a new line.)": "(聊天對話範例。每個範例以新的行並以「START」開始。)", - "Save": "儲存", - "Chat History": "聊天紀錄", + "Important to set the character's writing style.": "設定角色的寫作樣式很重要。", + "(Examples of chat dialog. Begin each example with START on a new line.)": "(聊天對話範例。每個範例以新的行並以「START」開始。)", + "Chat History": "聊天記錄", "Import Chat": "匯入聊天", "Copy to system backgrounds": "複製到系統背景圖片", "Rename background": "重新命名背景圖片", "Lock": "上鎖", "Unlock": "解鎖", "Delete background": "刪除背景圖片", - "Chat Scenario Override": "聊天情境覆寫", + "Chat Scenario Override": "聊天場景覆寫", "Remove": "刪除", - "Type here...": "在此輸入...", + "Type here...": "在此輸入⋯", "Chat Lorebook": "聊天知識書", "Chat Lorebook for": "聊天知識書", "chat_world_template_txt": "選定的世界資訊將附加到此聊天。\n在生成 AI 回覆時,它將與全域和角色知識書中的條目結合。", "Select a World Info file for": "選擇世界資訊檔案", "Primary Lorebook": "主要知識書", - "A selected World Info will be bound to this character as its own Lorebook.": "選定的世界資訊將作為此角色人物的知識書附加到此角色人物。", + "A selected World Info will be bound to this character as its own Lorebook.": "選定的世界資訊將作為此角色的知識書附加到此角色。", "When generating an AI reply, it will be combined with the entries from a global World Info selector.": "生成 AI 回覆時,將與全域世界資訊選擇器中的條目結合。", - "Exporting a character would also export the selected Lorebook file embedded in the JSON data.": "匯出角色人物時,也會匯出嵌入 JSON 資料中的選定知識書檔案。", + "Exporting a character would also export the selected Lorebook file embedded in the JSON data.": "匯出角色時,也會匯出嵌入 JSON 資料中的選定知識書檔案。", "Additional Lorebooks": "額外的知識書", - "Associate one or more auxillary Lorebooks with this character.": "將一個或多個輔助知識書與此角色人物關聯。", - "NOTE: These choices are optional and won't be preserved on character export!": "注意:這些選項是選填的,在角色人物匯出時不會保留!", + "Associate one or more auxillary Lorebooks with this character.": "將一個或多個輔助知識書與此角色關聯。", + "NOTE: These choices are optional and won't be preserved on character export!": "請注意:此為額外選項,不會隨角色卡資料一同匯出!", "Rename chat file": "重新命名聊天檔案", "Export JSONL chat file": "匯出 JSONL 聊天檔案", "Download chat as plain text document": "將聊天下載為純文字檔案", @@ -943,15 +942,15 @@ "Drag to reorder tag": "拖動以重新排序標籤", "Use tag as folder": "將標籤用作資料夾", "Delete tag": "刪除標籤", - "Entry Title/Memo": "條目標題/備註", - "WI Entry Status:🔵 Constant🟢 Normal🔗 Vectorized❌ Disabled": "WI條目狀態:🔵常數🟢正常🔗向量❌停用", + "Entry Title/Memo": "條目標題/備註", + "WI Entry Status:🔵 Constant🟢 Normal🔗 Vectorized❌ Disabled": "世界資訊條目狀態:🔵常數 🟢正常 🔗向量 ❌停用", "WI_Entry_Status_Constant": "🔵", "WI_Entry_Status_Normal": "🟢", "WI_Entry_Status_Vectorized": "🔗", "WI_Entry_Status_Disabled": "❌", - "T_Position": "↑角色人物:角色人物定義之前\n↓角色人物:角色人物定義之後\n↑備註:作者備註之前\n↓備註:作者備註之後\n@深度", - "Before Char Defs": "角色人物定義之前", - "After Char Defs": "角色人物定義之後", + "T_Position": "↑角色:角色定義之前\n↓角色:角色定義之後\n↑備註:作者備註之前\n↓備註:作者備註之後\n@深度", + "Before Char Defs": "角色定義之前", + "After Char Defs": "角色定義之後", "Before EM": "範例訊息之前", "After EM": "範例訊息之後", "Before AN": "作者備註之前", @@ -962,11 +961,11 @@ "Depth": "深度", "Order:": "順序:", "Order": "順序", - "Trigger %:": "觸發%", + "Trigger %:": "觸發%:", "Probability": "機率", "Duplicate world info entry": "複製世界資訊物件", "Delete world info entry": "刪除世界資訊物件", - "Comma separated (required)": "逗號分隔(必填)", + "Comma separated (required)": "逗號分隔(必填)", "Primary Keywords": "主要關鍵字", "Keywords or Regexes": "關鍵字或正規表示式", "Comma separated list": "逗號分隔列表", @@ -976,27 +975,27 @@ "AND ALL": "AND 所有", "NOT ALL": "NOT 所有", "NOT ANY": "NOT 任何", - "(ignored if empty)": "(如果為空則忽略)", + "(ignored if empty)": "(如果為空則忽略)", "Optional Filter": "選填過濾器", - "Keywords or Regexes (ignored if empty)": "關鍵字或正規表示式(如果為空則忽略)", - "Comma separated list (ignored if empty)": "逗號分隔列表(如果為空則忽略)", + "Keywords or Regexes (ignored if empty)": "關鍵字或正規表示式(如果為空則忽略)", + "Comma separated list (ignored if empty)": "逗號分隔列表(如果為空則忽略)", "Use global setting": "使用全域設定", "Case-Sensitive": "區分大小寫", "Yes": "是", "No": "否", "Can be used to automatically activate Quick Replies": "可用於自動啟用快速回覆", "Automation ID": "自動化 ID", - "( None )": "( 無 )", + "( None )": "(無)", "Content": "內容", "Exclude from recursion": "不可遞迴(此條目不會被其他條目啟用)", - "Prevent further recursion (this entry will not activate others)": "防止進一步遞迴(此條目不會啟用其他條目)", - "Delay until recursion (this entry can only be activated on recursive checking)": "延遲遞迴(此條目只能在遞迴檢查時啟用)", + "Prevent further recursion (this entry will not activate others)": "防止進一步遞迴(此條目不會啟用其他條目)", + "Delay until recursion (this entry can only be activated on recursive checking)": "延遲遞迴(此條目只能在遞迴檢查時啟用)", "What this keyword should mean to the AI, sent verbatim": "這個關鍵字對 AI 應意味著什麼,逐字發送", - "Filter to Character(s)": "角色人物篩選", - "Character Exclusion": "角色人物排除", - "-- Characters not found --": "-- 未找到角色人物 --", + "Filter to Character(s)": "角色篩選", + "Character Exclusion": "角色排除", + "-- Characters not found --": "-- 未找到角色 --", "Inclusion Group": "包含的群組", - "Inclusion Groups ensure only one entry from a group is activated at a time, if multiple are triggered.Documentation: World Info - Inclusion Group": "如果觸發多個條目,包含群組可確保一次僅啟動一組中的一個條目。\r支援多個以逗號分隔的群組。\r\r文件:世界資訊 - 包容性集團", + "Inclusion Groups ensure only one entry from a group is activated at a time, if multiple are triggered.Documentation: World Info - Inclusion Group": "如果觸發多個條目,包含群組可確保一次僅啟動一組中的一個條目。\r支援多個以逗號分隔的群組。\r\r文件:世界資訊——包容性集團", "Prioritize this entry: When checked, this entry is prioritized out of all selections.If multiple are prioritized, the one with the highest 'Order' is chosen.": "優先考慮此條目:選取後,此條目將在所有選擇中優先。\r如果有多個優先級,則選擇「順序」最高的一個。", "Only one entry with the same label will be activated": "僅會啟用具有相同標籤的一個條目", "A relative likelihood of entry activation within the group": "群組內條目啟用的相對可能性", @@ -1004,7 +1003,7 @@ "Selective": "選擇性", "Use Probability": "使用機率", "Add Memo": "新增備忘錄", - "Text or token ids": "文字或符記 ID", + "Text or token ids": "文字或符元 ID", "close": "關閉", "prompt_manager_edit": "編輯", "prompt_manager_name": "名稱", @@ -1015,18 +1014,19 @@ "Injection position. Next to other prompts (relative) or in-chat (absolute).": "注入位置。與其他提示詞相鄰(相對位置)或在聊天中(絕對位置)。", "prompt_manager_relative": "相對位置", "prompt_manager_depth": "深度", - "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "注入深度。0 = 在最後一條訊息之後,1 = 在最後一條訊息之前,以此類推。", + "Injection depth. 0 = after the last message, 1 = before the last message, etc.": "注入深度。0 = 在最後一則訊息之後,1 = 在最後一則訊息之前,以此類推。", "Prompt": "提示詞", "The prompt to be sent.": "要發送的提示詞。", - "This prompt cannot be overridden by character cards, even if overrides are preferred.": "即使啟用偏好覆寫,此提示詞也不能被角色卡片覆寫。", + "This prompt cannot be overridden by character cards, even if overrides are preferred.": "即使啟用優先覆寫,此提示詞也不能被角色卡片覆寫。", "prompt_manager_forbid_overrides": "禁止覆寫", "reset": "重設", - "save": "節省", + "save": "儲存", "This message is invisible for the AI": "此訊息對 AI 不可見", "Message Actions": "訊息動作", "Translate message": "翻譯訊息", "Generate Image": "生成圖片", - "Narrate": "敘述", + "Stop Image Generation": "終止圖片生成", + "Narrate": "朗讀訊息", "Exclude message from prompts": "從提示詞中排除訊息", "Include message in prompts": "在提示詞中包含訊息", "Embed file or image": "嵌入檔案或圖片", @@ -1050,32 +1050,32 @@ "welcome_message_part_6": "加入", "Discord server": "不和諧伺服器", "welcome_message_part_7": "取得公告和資訊。", - "SillyTavern is aimed at advanced users.": "SillyTavern 旨在進階使用者", - "If you're new to this, enable the simplified UI mode below.": "如果您是新手,請啟用下方的簡化 UI 模式", + "SillyTavern is aimed at advanced users.": "SillyTavern 專為進階使用者設計", + "If you're new to this, enable the simplified UI mode below.": "如果您是新手,請啟用下方的簡易 UI 模式", "Change it later in the 'User Settings' panel.": "稍後可在「使用者設定」面板中變更", - "Enable simple UI mode": "啟用簡單 UI 模式", - "Looking for AI characters?": "正在尋找 AI 角色人物?", + "Enable simple UI mode": "啟用簡易 UI 模式", + "Looking for AI characters?": "正在尋找 AI 角色?", "onboarding_import": "匯入", "from supported sources or view": "從支援的來源或檢視", - "Sample characters": "範例角色人物", - "Your Persona": "您的玩家角色人物", - "Before you get started, you must select a persona name.": "在開始之前,您必須選擇一個玩家角色人物名稱", + "Sample characters": "範例角色", + "Your Persona": "您的使用者角色", + "Before you get started, you must select a persona name.": "在開始之前,您必須選擇一個使用者角色名稱", "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": "觸發此角色人物的訊息", + "Persona Name:": "使用者角色名稱", + "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": "查看角色人物卡", + "View character card": "查看角色卡", "Remove from group": "從群組中移除", "Add to group": "新增到群組", "Alternate Greetings": "額外問候語", - "Alternate_Greetings_desc": "這些將在開始新聊天時顯示為第一條訊息的滑動選項。\n群組成員可以選擇其中之一來開始對話。", + "Alternate_Greetings_desc": "這些將在開始新聊天時顯示為第一則訊息的滑動選項。\n群組成員可以選擇其中之一來開始對話。", "Alternate Greetings Hint": "額外問候語的提示訊息", - "(This will be the first message from the character that starts every chat)": "(這將是每次聊天開始時角色人物發送的第一條訊息)", - "Forbid Media Override explanation": "當前角色/群組在聊天中使用外部媒體的能力。", + "(This will be the first message from the character that starts every chat)": "(這將是每次聊天開始時角色發送的第一則訊息)", + "Forbid Media Override explanation": "此角色/群組在聊天中使用外部媒體的能力。", "Forbid Media Override subtitle": "禁止媒體覆寫副標題", "Always forbidden": "總是禁止", "Always allowed": "總是允許", @@ -1083,17 +1083,17 @@ "Remove the file": "刪除檔案", "Unique to this chat": "此聊天獨有", "Checkpoints inherit the Note from their parent, and can be changed individually after that.": "檢查點繼承其上級的註釋,之後可以單獨更改", - "Include in World Info Scanning": "包括在世界資訊掃描中", - "Before Main Prompt / Story String": "在主要提示詞 / 故事字串之前", - "After Main Prompt / Story String": "在主要提示詞 / 故事字串之後", + "Include in World Info Scanning": "納入世界資訊掃描", + "Before Main Prompt / Story String": "在主要提示詞/故事字串之前", + "After Main Prompt / Story String": "在主要提示詞/故事字串之後", "as": "作為", "Insertion Frequency": "插入頻率", - "(0 = Disable, 1 = Always)": "(0 = 停用, 1 = 永久)", + "(0 = Disable, 1 = Always)": "(0 = 停用, 1 = 永久)", "User inputs until next insertion:": "使用者輸入直到下一次插入:", - "Character Author's Note (Private)": "角色人物作者備註(私人)", - "Won't be shared with the character card on export.": "不會在匯出時與角色人物卡共享", - "Will be automatically added as the author's note for this character. Will be used in groups, but can't be modified when a group chat is open.": "將自動新增為此角色人物的作者備註。將在群組中使用,但在群組聊天開啟時無法修改", - "Use character author's note": "使用角色人物作者備註", + "Character Author's Note (Private)": "角色作者備註(私人)", + "Won't be shared with the character card on export.": "不會在匯出時與角色卡共享", + "Will be automatically added as the author's note for this character. Will be used in groups, but can't be modified when a group chat is open.": "將自動新增為此角色的作者備註。將在群組中使用,但在群組聊天開啟時無法修改", + "Use character author's note": "使用角色作者備註", "Replace Author's Note": "取代作者註釋", "Default Author's Note": "預設作者註釋", "Will be automatically added as the Author's Note for all new chats.": "將自動新增為所有新聊天的作者備註", @@ -1101,24 +1101,24 @@ "1 = disabled": "1 = 停用", "write short replies, write replies using past tense": "撰寫簡短回覆,使用過去式撰寫回覆", "Positive Prompt": "正面提示詞", - "Use character CFG scales": "使用角色人物 CFG 比例", - "Character CFG": "角色人物 CFG", - "Will be automatically added as the CFG for this character.": "將自動新增為此角色人物的 CFG", + "Use character CFG scales": "使用角色 CFG 比例", + "Character CFG": "角色 CFG", + "Will be automatically added as the CFG for this character.": "將自動新增為此角色的 CFG", "Global CFG": "全域 CFG", "Will be used as the default CFG options for every chat unless overridden.": "將作為每次聊天的預設 CFG 選項,除非被覆寫", "CFG Prompt Cascading": "CFG 提示詞級聯", - "Combine positive/negative prompts from other boxes.": "結合其他文字方塊中的正/負提示詞", - "For example, ticking the chat, global, and character boxes combine all negative prompts into a comma-separated string.": "例如,勾選聊天、全域和角色人物框將所有負面提示詞合併為一個逗號分隔的字串", + "Combine positive/negative prompts from other boxes.": "結合其他文字方塊中的正/負提示詞", + "For example, ticking the chat, global, and character boxes combine all negative prompts into a comma-separated string.": "例如:勾選聊天、全域和角色框將所有負面提示詞合併為一個逗號分隔的字串", "Always Include": "總是包含", "Chat Negatives": "聊天負面提示詞", - "Character Negatives": "角色人物負面提示詞", + "Character Negatives": "角色負面提示詞", "Global Negatives": "全域負面提示詞", "Custom Separator:": "自訂分隔符:", "Insertion Depth:": "插入深度:", - "Token Probabilities": "符記機率", - "Select a token to see alternatives considered by the AI.": "選擇一個符記以檢視 AI 考慮的替代方案", + "Token Probabilities": "符元機率", + "Select a token to see alternatives considered by the AI.": "選擇一個符元以檢視 AI 考慮的替代方案", "Not connected to API!": "未連線到 API!", - "Type a message, or /? for help": "輸入一個訊息,或輸入 /? 取得支援", + "Type a message, or /? for help": "輸入一則訊息,或輸入 /? 取得支援", "Continue script execution": "繼續腳本執行", "Pause script execution": "暫停腳本執行", "Abort script execution": "中止腳本執行", @@ -1138,15 +1138,15 @@ "Impersonate": "AI 扮演使用者", "Continue": "繼續", "Bind user name to that avatar": "將使用者名稱附加到該頭像", - "Change persona image": "更改玩家角色人物圖片", - "Select this as default persona for the new chats.": "選擇此作為新聊天的預設玩家角色人物。", - "Delete persona": "刪除玩家角色人物", + "Change persona image": "更改使用者角色圖片", + "Select this as default persona for the new chats.": "選擇此作為新聊天的預設使用者角色。", + "Delete persona": "刪除使用者角色", "These characters are the winners of character design contests and have outstandable quality.": "這些角色都是角色設計比賽的優勝者,品質卓越。", "Contest Winners": "比賽獲勝者", "These characters are the finalists of character design contests and have remarkable quality.": "這些角色都是角色設計比賽的入圍作品,品質卓越。", - "Featured Characters": "特色角色人物", - "Attach a File": "附加檔案", - "Open Data Bank": "打開資料儲藏庫", + "Featured Characters": "特色角色", + "Attach a File": "上傳附件檔案", + "Open Data Bank": "開啟資料庫", "Enter a URL or the ID of a Fandom wiki page to scrape:": "輸入 URL 或 Fandom 維基頁面的 ID 來抓取:", "Examples:": "範例:", "Example:": "範例:", @@ -1154,89 +1154,90 @@ "All articles will be concatenated into a single file.": "所有文章將連接成一個檔案。", "File per article": "每篇文章一個檔案", "Each article will be saved as a separate file.": "每篇文章將另存為一個檔案。", - "Data Bank": "資料儲藏庫", - "These files will be available for extensions that support attachments (e.g. Vector Storage).": "這些檔案將可用於支援附件的擴充套件(例如向量存儲)。", + "Data Bank": "資料庫", + "These files will be available for extensions that support attachments (e.g. Vector Storage).": "這些檔案將可用於支援附件的擴充功能(例如向量存儲)。", "Supported file types: Plain Text, PDF, Markdown, HTML, EPUB.": "支援的檔案類型:純文字,PDF,Markdown,HTML,EPUB。", - "Drag and drop files here to upload.": "拖放檔案到這裡以上傳。", - "Date (Newest First)": "日期(最新優先)", - "Date (Oldest First)": "日期(最舊優先)", - "Name (A-Z)": "名稱(A-Z)", - "Name (Z-A)": "名稱(Z-A)", - "Size (Smallest First)": "尺寸(由小到大)", - "Size (Largest First)": "尺寸(由大到小)", + "Drag and drop files here to upload.": "拖放檔案至此即可上傳。", + "Date (Newest First)": "日期(最新優先)", + "Date (Oldest First)": "日期(最舊優先)", + "Name (A-Z)": "名稱(A-Z)", + "Name (Z-A)": "名稱(Z-A)", + "Size (Smallest First)": "尺寸(由小到大)", + "Size (Largest First)": "尺寸(由大到小)", "Bulk Edit": "批次編輯", "Select All": "全選", "Select None": "選擇無", "Global Attachments": "全域附件", - "These files are available for all characters in all chats.": "這些檔案在所有聊天中的所有角色人物中可用。", - "Character Attachments": "角色人物附件", - "These files are available the current character in all chats they are in.": "這些檔案在當前角色人物參與的所有聊天中可用。", - "Saved locally. Not exported.": "本機儲存。不匯出。", + "These files are available for all characters in all chats.": "適用於所有聊天、所有角色。", + "Character Attachments": "角色附件", + "These files are available the current character in all chats they are in.": "適用於該角色參與的所有聊天。", + "Saved locally. Not exported.": "僅本地保存,不匯出。", "Chat Attachments": "聊天附件", - "These files are available to all characters in the current chat.": "這些檔案在當前聊天中的所有角色人物中可用。", + "These files are available to all characters in the current chat.": "適用於本次聊天中的所有角色。", "Enter a base URL of the MediaWiki to scrape.": "輸入要抓取的 MediaWiki 的基礎 URL。", "Don't include the page name!": "不要包括頁面名稱!", - "Enter web URLs to scrape (one per line):": "輸入要抓取的網頁 URL(每行一個):", + "Enter web URLs to scrape (one per line):": "輸入要抓取的網頁 URL(每行一個):", "Enter a video URL to download its transcript.": "輸入影片 URL 來下載其文字記錄。", "Expression API": "表達 API", "Fallback Expression": "回退表達式", - "ext_sum_with": "總結一下:", - "ext_sum_main_api": "主要API", - "ext_sum_current_summary": "目前摘要:", - "ext_sum_restore_previous": "恢復上一個", - "ext_sum_memory_placeholder": "摘要將在此處產生...", - "Trigger a summary update right now.": "立即觸發摘要更新。", - "ext_sum_force_text": "現在總結一下", - "Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API).": "停用自動摘要更新。暫停時,摘要保持原樣。您仍然可以透過按下「立即匯總」按鈕強制更新(僅適用於 Main API)。", + "ext_sum_title": "聊天摘要", + "ext_sum_with": "摘要來源", + "ext_sum_main_api": "主要 API", + "ext_sum_current_summary": "摘要內容:", + "ext_sum_restore_previous": "還原為上一則", + "ext_sum_memory_placeholder": "將在此生成摘要⋯", + "Trigger a summary update right now.": "立即更新摘要內容。", + "ext_sum_force_text": "重新摘要", + "Disable automatic summary updates. While paused, the summary remains as-is. You can still force an update by pressing the Summarize now button (which is only available with the Main API).": "停用自動摘要更新。暫停時,摘要保持原樣。您仍可以透過點擊「重新摘要」按鈕強制更新(僅適用於使用「主要 API」)。", "ext_sum_pause": "暫停", - "Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN.": "從要總結的文本中省略世界資訊和作者備註。僅在使用主要 API 時有效。額外 API 總是省略 WI/AN。", - "ext_sum_no_wi_an": "無無線網路/無線網路", + "Omit World Info and Author's Note from text to be summarized. Only has an effect when using the Main API. The Extras API always omits WI/AN.": "摘要時將省略世界資訊和作者備註。此選項僅適用於使用主要 API,擴充功能 API 始終自動省略世界資訊與作者備註。", + "ext_sum_no_wi_an": "排除世界資訊及作者備註", "ext_sum_settings_tip": "編輯摘要提示、插入位置等", "ext_sum_settings": "摘要設定", - "ext_sum_prompt_builder": "提示產生器", - "ext_sum_prompt_builder_1_desc": "擴充功能將使用尚未匯總的訊息建立自己的提示。阻止聊天,直到產生摘要。", + "ext_sum_prompt_builder": "摘要提示詞產生器", + "ext_sum_prompt_builder_1_desc": "將使用尚未摘要的訊息以建立摘要提示詞,在完成摘要前將暫停聊天功能。", "ext_sum_prompt_builder_1": "原始、阻塞", - "ext_sum_prompt_builder_2_desc": "擴充功能將使用尚未匯總的訊息建立自己的提示。產生摘要時不會阻止聊天。並非所有後端都支援此模式。", + "ext_sum_prompt_builder_2_desc": "將使用尚未摘要的訊息自動建立摘要提示詞,在完成摘要前仍可正常進行聊天。並非所有後端都支援此模式。", "ext_sum_prompt_builder_2": "原始、非阻塞", - "ext_sum_prompt_builder_3_desc": "擴充功能將使用常規主提示產生器並將摘要請求新增至其中作為最後一個系統訊息。", - "ext_sum_prompt_builder_3": "經典,阻塞", + "ext_sum_prompt_builder_3_desc": "將使用常規主提示產生器,並將摘要請求新增至其中作為最後一則系統訊息。", + "ext_sum_prompt_builder_3": "經典、阻塞", "Summary Prompt": "摘要提示詞", - "ext_sum_restore_default_prompt_tip": "恢復預設提示", - "ext_sum_prompt_placeholder": "這個提示詞將發送給 AI 以請求生成摘要。{{words}} 將解析為「字數」參數。", + "ext_sum_restore_default_prompt_tip": "還原為預設提示詞", + "ext_sum_prompt_placeholder": "此提示詞將發送給 AI 以請求生成摘要。{{words}} 將解析為「字數」參數。", "ext_sum_target_length_1": "目標摘要長度", "ext_sum_target_length_2": "(", "ext_sum_target_length_3": "字)", - "ext_sum_api_response_length_1": "API響應長度", + "ext_sum_api_response_length_1": "API 響應長度", "ext_sum_api_response_length_2": "(", - "ext_sum_api_response_length_3": "代幣)", + "ext_sum_api_response_length_3": "個符元)", "ext_sum_0_default": "0 = 預設", - "ext_sum_raw_max_msg": "[原始] 每個請求的最大訊息數", + "ext_sum_raw_max_msg": "[原始] 每則請求的最大訊息數", "ext_sum_0_unlimited": "0 = 無限制", "Update frequency": "更新頻率", "ext_sum_update_every_messages_1": "更新每", - "ext_sum_update_every_messages_2": "訊息", + "ext_sum_update_every_messages_2": " 訊息", "ext_sum_0_disable": "0 = 停用", - "ext_sum_auto_adjust_desc": "嘗試根據聊天指標自動調整間隔。", + "ext_sum_auto_adjust_desc": "嘗試根據聊天指標自動調整更新間隔。", "ext_sum_update_every_words_1": "更新每", - "ext_sum_update_every_words_2": "字", - "ext_sum_both_sliders": "如果兩個滑桿都非零,則兩者都將按各自的時間間隔觸發摘要更新。", - "ext_sum_injection_template": "注射模板", + "ext_sum_update_every_words_2": " 字", + "ext_sum_both_sliders": "若兩個滑桿數值皆不為零,則將各自按照其時間間隔更新摘要。", + "ext_sum_injection_template": "插入模板", "ext_sum_memory_template_placeholder": "{{summary}} 會解析為目前的摘要內容。", - "ext_sum_injection_position": "注射位置", - "How many messages before the current end of the chat.": "在聊天目前結束前有多少訊息。", + "ext_sum_injection_position": "插入位置", + "How many messages before the current end of the chat.": "距離本次聊天結尾前的訊息數量。", "ext_regex_title": "正規表示式", - "ext_regex_new_global_script": "+ 全球", - "ext_regex_new_scoped_script": "+ 範圍", + "ext_regex_new_global_script": "+ 全域", + "ext_regex_new_scoped_script": "+ 局部", "ext_regex_import_script": "匯入腳本", "ext_regex_global_scripts": "全域腳本", - "ext_regex_global_scripts_desc": "適用於所有角色。儲存到本地設定。", - "ext_regex_scoped_scripts": "作用域腳本", - "ext_regex_scoped_scripts_desc": "僅適用於此角色。儲存到卡片資料中。", + "ext_regex_global_scripts_desc": "適用於所有角色,資料將儲存到本地。", + "ext_regex_scoped_scripts": "局部腳本", + "ext_regex_scoped_scripts_desc": "僅適用於目前角色,資料將儲存到該角色卡中。", "Regex Editor": "正規表示式編輯器", "Test Mode": "測試模式", - "ext_regex_desc": "正規表示式(Regex)是一種使用正規表示式尋找/取代字串的工具。如果您想了解更多,請點選標題旁邊的(?)", + "ext_regex_desc": "正規表示式(Regex)是一種使用正規表示式尋找/取代字串的工具。如果您想了解更多,請點選標題旁邊的「?」", "Input": "輸入", - "ext_regex_test_input_placeholder": "在此輸入...", + "ext_regex_test_input_placeholder": "在此輸入⋯", "Output": "輸出", "ext_regex_output_placeholder": "空的", "Script Name": "腳本名稱", @@ -1254,42 +1255,42 @@ "ext_regex_min_depth_placeholder": "無限制", "ext_regex_max_depth_desc": "當應用於提示或顯示時,僅影響不超過 N 層深度的訊息。 0 = 最後一則訊息,1 = 倒數第二個訊息等。", "ext_regex_other_options": "其他選項", - "Only Format Display": "僅格式化顯示", - "ext_regex_only_format_prompt_desc": "聊天記錄不會更改,只會更改發送請求時的提示(生成時)。", - "Only Format Prompt (?)": "僅格式化提示詞(?)", + "Only Format Display": "僅修改聊天顯示", + "ext_regex_only_format_prompt_desc": "不修改聊天記錄,僅修改發送訊息(請求文本生成時)時的系統提示詞。", + "Only Format Prompt (?)": "僅修改系統提示詞", "Run On Edit": "編輯時執行", - "ext_regex_substitute_regex_desc": "在執行「尋找正規表示式」前取代 {{macros}}", + "ext_regex_substitute_regex_desc": "在執行「尋找正規表達式」前,將 {{巨集}} 替換為對應內容", "Substitute Regex": "取代正規表示式", - "ext_regex_import_target": "導入至:", + "ext_regex_import_target": "匯入至:", "ext_regex_disable_script": "停用腳本", "ext_regex_enable_script": "啟用腳本", "ext_regex_edit_script": "編輯腳本", "ext_regex_move_to_global": "移至全域腳本", "ext_regex_move_to_scoped": "移至作用域腳本", - "ext_regex_export_script": "導出腳本", + "ext_regex_export_script": "匯出腳本", "ext_regex_delete_script": "刪除腳本", "Trigger Stable Diffusion": "觸發 Stable Diffusion", - "sd_Yourself": "角色人物", - "sd_Your_Face": "角色人物的臉", - "sd_Me": "玩家", + "sd_Yourself": "角色", + "sd_Your_Face": "角色的臉", + "sd_Me": "使用者", "sd_The_Whole_Story": "整篇故事", - "sd_The_Last_Message": "最後一個訊息", - "sd_Raw_Last_Message": "最後一個原始訊息", + "sd_The_Last_Message": "最後一則訊息", + "sd_Raw_Last_Message": "最後一則原始訊息", "sd_Background": "背景", - "Image Generation": "圖片生成", + "Image Generation": "圖片生成設定", "sd_refine_mode": "允許在傳送至生成 API 前,手動編輯提示詞字串", "sd_refine_mode_txt": "生成前編輯提示詞", "sd_interactive_mode": "當發送「給我一張貓的圖片」這類訊息時,自動生成圖片。", "sd_interactive_mode_txt": "互動模式", - "sd_multimodal_captioning": "根據使用者和角色人物的頭像,使用多模態模型描述生成肖像提示詞。", + "sd_multimodal_captioning": "根據使用者和角色的頭像,使用多模態模型描述生成肖像提示詞。", "sd_multimodal_captioning_txt": "對肖像使用多模態模型描述", - "sd_expand": "使用文字生成模型自動擴充提示詞。", - "sd_expand_txt": "自動擴充提示詞", - "sd_snap": "對於具有特定長寬比的生成請求(如肖像、背景),將其調整至最接近的已知解析度,同時儘量保持絕對像素數(建議用於SDXL)。", + "sd_expand": "使用文本生成模型自動擴寫提示詞。", + "sd_expand_txt": "自動潤色提示詞", + "sd_snap": "對於具有特定長寬比的生成請求(如肖像、背景),將其調整至最接近的已知解析度,同時儘量保持絕對像素數(建議用於 SDXL)。", "sd_snap_txt": "自動調整解析度", "Source": "來源", "sd_auto_url": "範例: {{auto_url}}", - "Authentication (optional)": "授權驗證(選填)", + "Authentication (optional)": "授權驗證(選填)", "Example: username:password": "範例:帳號:密碼", "Important:": "重要:", "sd_auto_auth_warning_1": "使用", @@ -1298,9 +1299,9 @@ "sd_drawthings_auth_txt": "執行 DrawThings 應用程式並在介面中啟用 HTTP API 開關!伺服器必須能夠被 SillyTavern 主機存取。", "sd_vlad_url": "範例: {{vlad_url}}", "The server must be accessible from the SillyTavern host machine.": "伺服器必須能夠被 SillyTavern 主機存取。", - "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "提示訊息:在 Horde KoboldAI API 設定中儲存一個 API 金鑰,以便在此使用。", + "Hint: Save an API key in AI Horde API settings to use it here.": "提示訊息:在 AI Horde API 設定中儲存一個 API 金鑰,以便在此使用。", "Allow NSFW images from Horde": "允許來自 Horde 的 NSFW 圖片", - "Sanitize prompts (recommended)": "清理提示詞(建議)", + "Sanitize prompts (recommended)": "清理提示詞(建議)", "Automatically adjust generation parameters to ensure free image generations.": "自動調整生成參數以確保生成免費的圖片。", "Avoid spending Anlas": "避免消耗 Anlas 點數", "Opus tier": "Opus 級別", @@ -1318,12 +1319,12 @@ "Refine": "精煉", "Decrisper": "德克里斯珀", "Sampling steps": "取樣步數", - "Width": "寬度(W)", - "Height": "高度(H)", + "Width": "寬度(Width)", + "Height": "高度(Height)", "Resolution": "解析度", "Model": "模型", "Sampling method": "取樣方法", - "Karras (not all samplers supported)": "Karras(並非所有取樣器都支援)", + "Karras (not all samplers supported)": "Karras(並非所有取樣器均受支援)", "SMEA versions of samplers are modified to perform better at high resolution.": "SMEA 版本的取樣器經過修改,能在高解析度下表現更佳。", "SMEA": "SMEA", "DYN variants of SMEA samplers often lead to more varied output, but may fail at very high resolutions.": "SMEA 取樣器的 DYN 變體通常會產生更多樣化的輸出,但在非常高的解析度下可能會失敗。", @@ -1334,21 +1335,21 @@ "Upscaler": "放大演算法", "Upscale by": "放大倍率", "Denoising strength": "重繪幅度", - "Hires steps (2nd pass)": "高解析步驟 (2nd pass)", + "Hires steps (2nd pass)": "高解析步驟(2nd pass)", "Preset for prompt prefix and negative prompt": "提示詞前綴和負面提示詞的預設設定", "Style": "樣式", "Save style": "儲存樣式", "Delete style": "刪除樣式", "Common prompt prefix": "通用提示詞前綴", "sd_prompt_prefix_placeholder": "使用 {prompt} 指定生成的提示詞將被插入的位置。", - "Negative common prompt prefix": "負面通用提示詞前綴", - "Character-specific prompt prefix": "角色人物特定提示詞前綴", - "Won't be used in groups.": "不會用在群組", - "sd_character_prompt_placeholder": "描述當前選擇角色人物的特徵。這些特徵將增加在通用提示詞前綴之後。\n例如:女性、綠色眼睛、棕色頭髮、粉紅色襯衫。", - "Character-specific negative prompt prefix": "角色人物特定負面提示詞前綴", - "sd_character_negative_prompt_placeholder": "不應出現在選定角色人物上的任何特徵。這些特徵將增加在負面通用提示詞前綴之後。例如:珠寶、鞋子、眼鏡。", - "Shareable": "可分享", - "Image Prompt Templates": "圖片提示詞範本", + "Negative common prompt prefix": "通用負面提示詞前綴", + "Character-specific prompt prefix": "角色提示詞前綴", + "Won't be used in groups.": "群聊中無效", + "sd_character_prompt_placeholder": "描述該角色的特徵。這些特徵將添加在通用提示詞前綴之後。例如:女性、綠色眼睛、棕色頭髮、粉紅色襯衫。", + "Character-specific negative prompt prefix": "角色負面提示詞前綴", + "sd_character_negative_prompt_placeholder": "不應出現在該角色上的任何特徵。這些特徵將添加在負面通用提示詞前綴之後。例如:珠寶、鞋子、眼鏡。", + "Shareable": "分享至角色卡", + "Image Prompt Templates": "圖片生成提示詞", "Vectors Model Warning": "向量模型警告", "Translate files into English before processing": "處理前將檔案翻譯成英文", "Manager Users": "管理使用者", @@ -1359,7 +1360,7 @@ "User Handle:": "使用者控制代碼:", "Password:": "密碼:", "Confirm Password:": "確認密碼:", - "This will create a new subfolder...": "這將建立一個新的子資料夾...", + "This will create a new subfolder...": "這將建立一個新的子資料夾⋯", "Current Password:": "目前密碼:", "New Password:": "新密碼:", "Confirm New Password:": "確認新密碼:", @@ -1371,27 +1372,27 @@ "Warning:": "警告:", "This action is irreversible.": "此動作不可逆轉。", "Type the user's handle below to confirm:": "在下方輸入使用者的控制代碼以確認:", - "Import Characters": "導入角色", + "Import Characters": "匯入角色", "Enter the URL of the content to import": "輸入要匯入的內容的 URL", "Supported sources:": "支持的來源:", - "char_import_1": "Chub角色(直接連結或ID)", + "char_import_1": "Chub角色(直接連結或 ID)", "char_import_example": "例子:", - "char_import_2": "Chub Lorebook(直接連結或ID)", - "char_import_3": "JanitorAI 角色(直接連結或 UUID)", - "char_import_4": "Pygmalion.chat 角色(直接連結或 UUID)", + "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_6": "直接 PNG 連結(請參閱", "char_import_7": "對於允許的主機)", "char_import_8": "RisuRealm角色(直接連結)", - "Supports importing multiple characters.": "支援導入多個字元。", + "Supports importing multiple characters.": "支援匯入多個字元。", "Write each URL or ID into a new line.": "將每個 URL 或 ID 寫入新行。", "Export for character": "匯出字符", "Export prompts for this character, including their order.": "匯出該角色的提示,包括其順序。", - "Export all": "全部導出", + "Export all": "全部匯出", "Export all your prompts to a file": "將所有提示匯出到文件", "Insert prompt": "插入提示", "Delete prompt": "刪除提示", - "Import a prompt list": "導入提示列表", + "Import a prompt list": "匯入提示列表", "Export this prompt list": "匯出此提示列表", "Reset current character": "重置目前字符", "New prompt": "新提示", @@ -1403,12 +1404,12 @@ "Settings Snapshots": "設定值快照", "Record a snapshot of your current settings.": "記錄目前設定的快照。", "Make a Snapshot": "建立快照", - "Restore this snapshot": "恢復此快照", + "Restore this snapshot": "還原此快照", "Hi,": "嗨,", "To enable multi-account features, restart the SillyTavern server with": "要啟用多帳號功能,請在 config.yaml 文件中將", - "set to true in the config.yaml file.": "設為 true,然後重啟 SillyTavern 伺服器。", + "set to true in the config.yaml file.": "設為 true 後重啟 SillyTavern 伺服器。", "Account Info": "帳號資訊", - "To change your user avatar, use the buttons below or select a default persona in the Persona Management menu.": "要更改您的使用者頭像,請使用下方按鈕或在玩家角色管理選單中選擇一個預設人物。", + "To change your user avatar, use the buttons below or select a default persona in the Persona Management menu.": "要更改您的使用者頭像,請使用下方按鈕或在使用者角色管理選單中選擇一個預設人物。", "Set your custom avatar.": "設定您的頭像。", "Remove your custom avatar.": "移除您的頭像。", "Handle:": "使用者名稱:", @@ -1425,15 +1426,15 @@ "Wipe all user data and reset your account to factory settings.": "刪除您所有使用者設定以及帳號,還原成原廠預設值。", "Reset Everything": "重設全部", "Reset Code:": "重設驗證碼:", - "Want to update?": "想要更新嗎?", + "Want to update?": "想更新到最新版嗎?", "How to start chatting?": "如何開始聊天?", "Click _space": "點選", - "and select a": "並選擇一個", + "and select a": "並擇一", "Chat API": "聊天 API", - "and pick a character.": "並選擇一個角色人物。", + "and pick a character.": "並選擇一個角色。", "You can browse a list of bundled characters in the": "您可以在", - "Download Extensions & Assets": "下載擴充套件和資產", - "menu within": "選單中瀏覽內建角色人物列表", + "Download Extensions & Assets": "下載擴充功能&額外資源", + "menu within": "選單中瀏覽內建角色列表", "Confused or lost?": "困惑或迷路了嗎?", "click these icons!": "點選這些圖示!", "in the chat bar": "到聊天欄中", @@ -1442,5 +1443,717 @@ "Still have questions?": "還有問題嗎?", "Join the SillyTavern Discord": "加入 SillyTavern Discord", "Post a GitHub issue": "發布 GitHub 問題", - "Contact the developers": "聯繫開發者" + "Contact the developers": "聯繫開發者", + "(-1 for random)": "(-1 表示隨機)", + "(Optional)": "(可選)", + "(use _space": "(使用", + "api_no_connection": "未連線⋯", + "No model description": "[無描述]", + "openai_logit_bias_no_items": "無項目", + "Any contents here will replace the default Post-History Instructions used for this character. (v2 specpost_history_instructions)": "此處填入的內容將取代該角色的默認聊天歷史後指示(Post-History Instructions)。\n(v2 specpost_history_instructions)", + "comma delimited,no spaces between": "逗號分割,無需空格", + "e.g. black-forest-labs/FLUX.1-dev": "例如:black-forest-labs/FLUX.1-dev", + "Example: gpt-3.5-turbo": "例如:gpt-3.5-turbo", + "Example: http://localhost:1234/v1": "例如:http://localhost:1234/v1", + "popup-button-crop": "裁剪", + "(disabled when max recursion steps are used)": "(當最大遞歸步驟數使用時將停用)", + "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc\n(disabled when min activations are used)": "0 = 無限制,1 = 掃描一次且不遞歸,2 = 掃描一次並遞歸一次,以此類推\n(使用最小啟動設定時將停用)", + "A greedy, brute-force algorithm used in LLM sampling to find the most likely sequence of words or tokens. It expands multiple candidate sequences at once, maintaining a fixed number (beam width) of top sequences at each step.": "一種用於 LLM 抽樣的貪婪演算法,用於尋找最可能的單詞或標記序列。該方法會同時展開多個候選序列,並在每一步中保持固定數量的頂級序列(beam width)。", + "A multiplicative factor to expand the overall area that the nodes take up.": "節點佔用該擴充功能區域的倍數。", + "Abort current image generation task": "終止目前的圖片生成任務", + "Add Character and User names to a list of stopping strings.": "將角色和使用者角色名稱添加至停止字符列表。", + "Alignment for rank nodes.": "對排名節點的對齊方式。", + "Always show the node full info panel at the bottom left of the timeline view. When off, show it near the node.": "始終將節點的完整資訊面板顯示在時間軸視圖的左下角。關閉時,將顯示在節點附近。", + "Always show the node tooltip at the bottom left of the timeline view. When off, show it near the node.": "始終將節點的工具提示欄顯示在時間軸視圖的左下角。關閉時,將顯示在節點附近。", + "Apply current sorting as Order": "應用此排序為順序", + "Cap the number of entry activation recursions": "限制入口啟動的遞歸次數", + "Caption": "標題", + "Close popup": "關閉彈出視窗", + "Color configuration for Timelines when 'Use UI Theme' in Style Settings is off.": "關閉「使用介面主題」的時間線顏色。", + "context_allow_post_history_instructions": "在文本完成模式中包含聊天歷史後指示(Post-History Instructions),但可能導致不良輸出。", + "Create a new connection profile": "建立新的連線設定檔", + "Defines on importing cards which action should be chosen for importing its listed tags. 'Ask' will always display the dialog.": "定義匯入角色卡時應採取的動作。選擇「詢問」將始終顯示對話框。", + "delay_until_recursion_level": "定義遞迴掃描的延遲層級。\r最初僅匹配第一層(數字最小的層級)。\r未找到匹配時,下一層將成為可匹配的層級。\r此過程會重複,直到所有層級都被檢查完畢。\r與「延遲至遞歸」設定相關聯。", + "Delete a connection profile": "刪除連線設定檔", + "Delete template": "刪除模板", + "Delete the template": "刪除此模板", + "Disabling is not recommended.": "不建議禁用。", + "Display swipe numbers for all messages, not just the last.": "顯示所有訊息的滑動編號,而不僅是最後一條訊息。", + "Duplicate persona": "複製使用者角色", + "Edit a connection profile": "編輯連線設定檔", + "Enable auto-select of input text in some text fields when clicking/selecting them. Applies to popup input textboxes, and possible other custom input fields.": "啟用自動選擇輸入框中的文本,適用於彈出輸入框及其他自定義輸入框。", + "Entries with a cooldown can't be activated N messages after being triggered.": "設有冷卻時間的條目於觸發後的 N 條訊息內無法再次啟用。", + "Entries with a delay can't be activated until there are N messages present in the chat.": "有延遲的條目需等待聊天中出現 N 條訊息後才能啟用。", + "Expand swipe nodes when the timeline view first opens, and whenever the graph is refreshed. When off, you can expand them by long-pressing a node, or by pressing the Toggle Swipes button.": "時間線視圖首次打開或刷新時展開滑動節點。關閉時可通過長按節點或點選「切換滑動」按鈕展開。", + "Export Advanced Formatting settings": "匯出高級格式設定", + "Export template": "匯出模板", + "Find similar characters": "尋找相似角色", + "Height of a node, in pixels at zoom level 1.0.": "縮放等級為 1.0 時的節點像素高度。", + "How the automatic graph builder assigns a rank (layout depth) to graph nodes.": "自動圖表生成器分配圖節點等級(佈局深度)的方式。", + "If checked and the character card contains a Post-History Instructions override, use that instead": "勾選後,將使用角色卡中的聊天歷史後指示(Post-History Instructions)覆蓋。", + "Import Advanced Formatting settings": "匯入進階格式設定\n\n您也可以提供舊版檔案作為提示詞和上下文範本使用。", + "Import template": "匯入模板", + "In group chat, highlight the character(s) that are currently queued to generate responses and the order in which they will respond.": "在群組聊天中,突出顯示該生成回應的角色及順序。", + "Include names with each message into the context for scanning": "將每條訊息的名稱納入掃描上下文", + "Inserted before the first User's message": "插入到第一條使用者訊息前。", + "instruct_enabled": "啟用指令模式(Instruct Mode)", + "instruct_last_input_sequence": "插入到最後一條使用者訊息之前。", + "instruct_template_activation_regex_desc": "連線 API 或選擇模型時,若模型名稱符合所提供的正規表達式,則自動啟動該指令模板(Instruct Template)。", + "Load Asset List": "載入資源列表", + "load_asset_list_desc": "根據資源列表文件載入擴充功能及資源。\n\n該字段中的默認資源 URL 指向官方擴充功能及資源列表。\n可在此插入您的自定義資源列表。\n\n若需安裝單個第三方擴充功能,請使用右上角的「安裝擴充功能」按鈕。", + "markdown_hotkeys_desc": "啟用快捷鍵以在某些文本輸入框中插入 Markdown 格式字符。詳見「/help hotkeys」。", + "Not all samplers supported.": "並非所有採樣器均受支援。", + "Open the timeline view. Same as the slash command '/tl'.": "打開時間線視圖,與斜線指令「/tl」相同。", + "Penalize sequences based on their length.": "根據序列長度進行懲罰。", + "Reload a connection profile": "重新載入連線設定檔", + "Rename current preset": "重新命名此預設", + "Rename current prompt": "重新命名此提示詞", + "Rename current template": "重新密名此模板", + "Reset all Timelines settings to their default values.": "將所有時間軸設定重置為預設值。", + "Restore current prompt": "還原目前的提示詞", + "Restore current template": "還原目前的模板", + "Save prompt as": "另存提示詞為", + "Save template as": "另存模板為", + "sd_adetailer_face": "在生成過程中使用 ADetailer 臉部模型。需在後端安裝 ADetailer 擴充功能。", + "sd_free_extend": "自動使用目前選定的 LLM 擴充功能的「自由模式」提示詞(不包括肖像或背景)。", + "sd_function_tool": "使用功能工具自動檢測意圖以生成圖片。", + "Seed_desc": "用於生成確定性和可重現輸出的隨機種子。設定為 -1 時將使用隨機種子。", + "Select your current Context Template": "選擇您目前的上下文模板", + "Select your current Instruct Template": "選擇您目前的指令模板", + "Select your current System Prompt": "選擇您目前的系統提示詞", + "Separation between adjacent edges in the same rank.": "同一層級中相鄰邊之間的間距。", + "Separation between adjacent nodes in the same rank.": "同一層級中相鄰節點之間的間距。", + "Separation between each rank in the layout.": "佈局中各層級之間的間距。", + "Settings for the visual appearance of the Timelines graph.": "時間線圖形的視覺外觀設置。", + "Show a button in the input area to ask the AI to impersonate your character for a single message": "於輸入框中添加按鈕,讓 AI 模仿您的角色發送一則訊息。", + "Show a legend for colors corresponding to different characters and chat checkpoints.": "顯示一個圖例,標註不同角色和對話檢查點對應的顏色。", + "Show the AI character's avatar as the graph root node. When off, the root node is blank.": "將 AI 角色的頭像作為圖形的根節點;關閉時,根節點為空。", + "Sticky entries will stay active for N messages after being triggered.": "觸發後,置頂條目將在接下來的 N 條訊息中保持活躍。", + "stscript_parser_flag_replace_getvar_label": "防止 {{getvar::}} 和 {{getglobalvar::}} 巨集的字面巨集樣值被自動解析。\n例如,{{newline}} 將保持為字面字串 {{newline}}。\n\n(此功能通過內部將 {{getvar::}} 和 {{getglobalvar::}} 巨集替換為局部變數來實現。)", + "Style and routing of graph edges.": "圖形邊的樣式和路徑。", + "Swap width and height": "交換寬度與高度", + "Swipe left": "向左滑動", + "Swipe right": "向右滑動", + "Switch the Character/Tags filter around to exclude the listed characters and tags from matching for this entry": "切換角色/標籤篩選,將列出的角色和標籤從此條目的匹配中排除", + "sysprompt_enabled": "啟用系統提示詞", + "The number of sequences generated at each step with Beam Search.": "在 Beam Search 中每一步生成的序列數量。", + "The visual appearance of a node in the graph.": "圖形中節點的視覺外觀。", + "Update a connection profile": "更新連線設定檔", + "Update current prompt": "更新此提示詞", + "Update current template": "更新此模板", + "Use GPU acceleration for positioning the full info panel that appears when you click a node. If the tooltip arrow tends to disappear, turning this off may help.": "啟用 GPU 加速來定位點擊節點時出現的完整資訊面板。若發現工具提示箭頭經常消失,可考慮關閉此功能。", + "Use the colors of the ST GUI theme, instead of the colors configured in Color Settings specifically for this extension.": "使用使用者設定中的介面主題顏色,取代下方「顏色設定」中額外設定的顏色。", + "View connection profile details": "查看連線設定檔詳情", + "When enabled, nodes that have swipes splitting off of them will appear subtly larger, in addition to having the double border.": "啟用後,具分支滑動的節點將顯示雙重邊框,還會略微放大。", + "WI Entry Status:🔵 Constant🟢 Normal🔗 Vectorized": "世界資訊條目狀態:\\r🔵 恆定\\r🟢 正常\\r🔗 向量化", + "Width of a node, in pixels at zoom level 1.0.": "縮放等級為 1.0 時,節點的像素寬度。", + "world_button_title": "角色背景設定\n點擊以載入\n「Shift+點擊」可開啟「連結至世界資訊」彈窗", + "# of Beams": "# of Beams", + "01.AI API Key": "01.AI API 金鑰", + "01.AI Model": "01.AI 模型", + "取消": "取消", + "Additional Parameters": "其他參數", + "All": "全部", + "Allow fallback models": "允許回退模型", + "Allow fallback providers": "允許回退供應商", + "Allow Post-History Instructions": "允許聊天歷史後指示", + "Allow reverse proxy": "允許反向代理", + "Alternate Greeting #": "備選問候語 #", + "alternate_greetings_hint_1": "點擊", + "alternate_greetings_hint_2": "按鈕開始!", + "Always": "總是", + "ANY support requests will be REFUSED if you are using a proxy.": "使用代理時,所有支援請求均不予受理。", + "API": "API", + "API Key": "API 金鑰", + "Ask": "詢問", + "Ask every time": "每次都詢問", + "Assets URL": "資源 URL", + "Assistant Message Sequences": "助理訊息序列", + "Assistant Prefix": "助理訊息前綴", + "Assistant Suffix": "助理訊息後綴", + "at the end of the URL!": "在 URL 末尾!", + "Audio Playback Speed": "音檔播放速度", + "Auto-select Input Text": "自動選擇輸入文本", + "Automatically caption images": "自動為圖片添加註解", + "Auxiliary": "輔助提示詞", + "Background Image": "背景圖片", + "Block Entropy API Key": "Block Entropy API 金鑰", + "Can be set manually or with an _space": "可以手動設置或使用 _space", + "Caption Prompt": "註解功能提示詞", + "category": "類別", + "Character Expressions": "角色情緒立繪", + "Character Node Color": "角色節點顏色", + "character_names_none": "避免使用角色名稱作為前綴。此功能在群組中可能表現不理想,需謹慎考慮。", + "Characters": "角色", + "Chat Message Visibility (by source)": "聊天訊息可見性(按來源)", + "Chat vectorization settings": "聊天向量化設定", + "Checked: all entries except ❌ status can be activated.": "勾選:除 ❌ 狀態外的所有條目均可啟動。", + "Checkpoint Color": "檢查點節點邊框顏色", + "Chunk boundary": "Chunk 邊界", + "Chunk overlap (%)": "Chunk 重疊(%)", + "Chunk size (chars)": "Chunk 大小(字符數)", + "class": "所有類別", + "Classifier API": "分類器 API", + "Click to set": "點擊以設定", + "CLIP Skip": "CLIP 跳過", + "Completion Object": "完成對象", + "Conf": "設定檔", + "Connection Profile": "連線設定檔", + "Cooldown": "冷卻時間", + "Create new folder in the _space": "請在「SillyTavern > data > 使用者資料夾 > ", + "currently_loaded": "[目前已載入]", + "currently_selected": "[目前已選取]", + "Custom (OpenAI-compatible)": "自定義(相容 OpenAI)", + "Custom Expressions": "自定義情緒圖像", + "Data Bank files": "數據庫文件", + "Default / Fallback Expression": "默認/回退表情", + "Delay": "延遲", + "Delay until recursion (can only be activated on recursive checking)": "遞迴掃描延遲(僅在啟用遞迴掃描時可用)", + "Do not proceed if you do not agree to this!": "若不同意此條款,請勿繼續!", + "Edge Color": "邊緣顏色", + "Edit captions before saving": "在保存前編輯註解", + "Enable for files": "啟用文件檔案向量化", + "Enable for World Info": "啟用世界資訊向量化", + "enable_functions_desc_1": "允許使用", + "enable_functions_desc_2": "功能工具", + "enable_functions_desc_3": "可供多種擴充功能利用,實現更多功能。", + "Enabled for all entries": "對所有條目啟用", + "Enabled for chat messages": "啟用聊天訊息向量化", + "Endpoint URL": "端點 URL", + "Enter a Model ID": "輸入模型 ID", + "Example: https://****.endpoints.huggingface.cloud": "例如:https://****.endpoints.huggingface.cloud", + "Exclude": "排除", + "Exclude Top Choices (XTC)": "排除頂部選項(XTC)", + "Existing": "現有項目", + "expression_label_pattern": "[情緒名稱].[圖檔格式](例如:neutral.png)。", + "ext_translate_auto_mode": "自動翻譯模式", + "ext_translate_btn_chat": "翻譯聊天內容", + "ext_translate_btn_input": "翻譯輸入內容", + "ext_translate_clear": "清除翻譯", + "ext_translate_mode_both": "翻譯輸入和回應", + "ext_translate_mode_inputs": "僅翻譯輸入", + "ext_translate_mode_none": "無翻譯", + "ext_translate_mode_provider": "翻譯提供者", + "ext_translate_mode_responses": "僅翻譯回應", + "ext_translate_target_lang": "目標語言", + "ext_translate_title": "聊天翻譯", + "Extensions Menu": "擴充功能選單", + "Extras": "擴充功能", + "Extras API": "擴充功能 API", + "Featherless Model Selection": "Featherless 模型選擇", + "File vectorization settings": "檔案向量化設定", + "Filter to Characters or Tags": "角色或標籤篩選", + "First User Prefix": "第一使用者前綴", + "folder of your user data directory and name it as the name of the character.": "」中新建資料夾,並將該資料夾命名為角色名稱(名稱需與使用者資料夾中的角色名稱一致)。", + "Group Scoring": "群組評分", + "Groups and Past Personas": "群組與過去的使用者角色設定", + "Hint:": "提示:", + "Hint: Set the URL in the API connection settings.": "提示:在 API 連線設置中設定 URL。", + "Horde": "Horde", + "HuggingFace Token": "HuggingFace 符元", + "Image Captioning": "圖片註解", + "Generate Caption": "生成圖片註解", + "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)", + "Injection Position": "插入位置", + "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。", + "Injection Template": "插入範本", + "Insert#": "插入#", + "Instruct Sequences": "指令序列", + "Instruct Template": "指令範本", + "Interactive Mode": "互動模式", + "Karras": "Karras", + "Keep model in memory": "將模型保存在記憶體中", + "Keyboard": "鍵盤:", + "AI Horde Website": "AI Horde 網站", + "Last User Prefix": "最後用戶前綴", + "Linear": "線性", + "LLM": "LLM", + "LLM Prompt": "LLM 提示詞", + "Load a custom asset list or select": "載入或選擇自定義資源列表", + "Load an asset list": "載入資源列表", + "Local": "本地", + "Local (Transformers)": "本地(Transformers)", + "macro)": "巨集)", + "Main API": "主要 API", + "Markdown Hotkeys": "Markdown 快捷鍵", + "Master Export": "主匯出", + "Master Import": "主匯入", + "Max Entries": "最大條目數", + "Max Recursion Steps": "最大遞迴步數", + "Message attachments": "訊息附件", + "Message Template": "註解訊息模板", + "Model ID": "模型 ID", + "mui_reset": "重置", + "Multimodal (OpenAI / Anthropic / llama / Google)": "多模態(OpenAI/Anthropic/llama/Google)", + "must be set in Tabby's config.yml to switch models.": "須在 Tabby's config.yml 中設置以切換模型。", + "Names as Stop Strings": "將名稱用作停止字串", + "Never": "從不", + "NomicAI API Key": "NomicAI API 金鑰", + "Non-recursable (will not be activated by another)": "不可遞迴(不會被其他條目啟動)", + "None (disabled)": "無(已禁用)", + "OK": "確定", + "Old messages are vectorized gradually as you chat. To process all previous messages, click the button below.": "舊訊息會在聊天時逐步向量化。\n若要處理所有先前訊息,請點擊下方按鈕。", + "Only used when Main API or WebLLM Extension is selected.": "僅在選擇主要 API 或 WebLLM 擴充功能時使用。", + "Open a chat to see the character expressions.": "開啟聊天以查看角色表情。", + "Post-History Instructions": "聊天歷史後指示", + "Prefer Character Card Instructions": "角色卡聊天歷史後指示優先", + "Prioritize": "優先處理", + "Prompt Content": "提示詞內容", + "prompt_manager_in_chat": "聊天中的提示詞管理", + "prompt_post_processing_none": "無", + "Purge Vectors": "清除向量", + "Put images with expressions there. File names should follow the pattern:": "將表情立繪放置於此,檔案名稱應遵循以下格式:", + "Quad": "四元數", + "Query messages": "查詢訊息", + "Quick Impersonate button": "快速模擬按鈕", + "Recursion Level": "遞迴層級", + "Remove all image overrides": "移除所有圖片覆蓋", + "Restore default": "", + "Retain#": "保留#", + "Retrieve chunks": "檢索 Chunks", + "Sampler Order": "取樣順序", + "Score threshold": "分數閾值", + "sd_free_extend_small": "(互動/指令)", + "sd_free_extend_txt": "使用「自由模式」。由 LLM 自動擴寫圖片生成提示", + "sd_function_tool_txt": "使用功能工具", + "sd_prompt_-1": "聊天訊息模板", + "sd_prompt_-2": "功能工具提示詞", + "sd_prompt_0": "角色(第二人稱,你)", + "sd_prompt_1": "使用者(第一人稱,我)", + "sd_prompt_10": "肖像(多模態模式)", + "sd_prompt_11": "自由模式(由 LLM 自動擴寫)", + "sd_prompt_2": "場景(擷取場景資訊)", + "sd_prompt_3": "原始最後訊息", + "sd_prompt_4": "最後訊息", + "sd_prompt_5": "肖像(第二人稱,你)", + "sd_prompt_7": "背景", + "sd_prompt_8": "角色(多模態模式)", + "sd_prompt_9": "使用者(多模態模式)", + "Select a Model": "選擇模型", + "Select the API for classifying expressions.": "選擇分類表情的 API。", + "Select with Enter": "按 Enter 選擇", + "Select with Tab": "按 Tab 選擇", + "Select with Tab or Enter": "按 Tab 或 Enter 選擇", + "Separators as Stop Strings": "以分隔符作為停止字串", + "Set the default and fallback expression being used when no matching expression is found.": "設定在無法配對表情時所使用的預設表情和備用圖片。", + "Set your API keys and endpoints in the API Connections tab first.": "請先在「API 連線」頁面中設定您的 API 金鑰和端點。", + "Show default images (emojis) if sprite missing": "無對應圖片時,顯示為預設表情符號(emoji)", + "Show group chat queue": "顯示群組聊天隊列", + "Size threshold (KB)": "大小閾值(KB)", + "Slash Command": "斜線命令", + "space_ slash command.": "斜線命令。", + "Sprite Folder Override": "表情立繪資料夾覆蓋", + "Sprite set:": "立繪組:", + "Show Gallery": "查看畫廊", + "Sticky": "固定", + "Style Preset": "預設樣式", + "Summarize chat messages for vector generation": "摘要聊天訊息以進行向量化處理", + "Summarize chat messages when sending": "傳送時摘要聊天內容", + "Swipe # for All Messages": "為所有訊息分配滑動編號 #", + "System Message Sequences": "系統訊息序列", + "System Prefix": "系統訊息前綴", + "System Prompt Sequences": "系統提示詞序列", + "System Suffix": "系統訊息後綴", + "Tabby Model": "Tabby 模型", + "tag_import_all": "全部匯入", + "tag_import_none": "不匯入", + "Text Generation WebUI (oobabooga)": "文字生成 WebUI (oobabooga)", + "The server MUST be started with the --embedding flag to use this feature!": "若要使用此功能,伺服器必須啟動時加上 --embedding 標誌。", + "Threshold": "閾值", + "to install 3rd party extensions.": "用於安裝第三方擴充功能。", + "Top": "頂部", + "Translate text to English before classification": "在分類前將文本翻譯為英文。", + "Uncheck to hide the extensions messages in chat prompts.": "不勾選即可隱藏聊天提示詞中的擴充功能訊息。", + "Unchecked: only entries with ❌ status can be activated.": "未勾選時:僅允許啟用狀態為 ❌ 的條目。", + "Unified Sampling": "統一取樣(Unified Sampling)", + "Upload sprite pack (ZIP)": "批次上傳立繪包(.ZIP)", + "Use a forward slash to specify a subfolder. Example: _space": "使用「/」來設置子目錄,例如:_space", + "Use ADetailer (Face)": "使用 ADetailer 進行臉部處理。", + "Use an admin API key.": "使用管理員的 API 金鑰。", + "Use global": "啟用全域設定", + "User Message Sequences": "使用者訊息序列", + "User Node Color": "使用者節點顏色", + "User Prefix": "使用者訊息前綴", + "User Suffix": "使用者訊息後綴", + "Using a proxy that youre not running yourself is a risk to your data privacy.": "使用非自行管理的代理服務存在數據隱私洩漏風險。", + "Vector Storage": "向量存儲", + "Vector Summarization": "向量摘要", + "Vectorization Model": "向量生成模型", + "Vectorization Source": "向量化來源", + "Vectorize All": "向量化全部數據", + "View Stats": "查看統計資料", + "Warning: This might cause your sent messages to take a bit to process and slow down response time.": "警告:這可能會導致訊息處理速度變慢,並延長回應時間。", + "WarningThis might cause your sent messages to take a bit to process and slow down response time.": "警告:這將顯著減緩向量生成速度,因為所有消息都需先進行摘要。", + "WebLLM Extension": "WebLLM 擴充功能", + "Whole Words": "匹配完整單字", + "Will be used if the API doesnt support JSON schemas or function calling.": "若 API 不支持 JSON 模式或函數調用,將使用此設定。", + "World Info settings": "世界資訊設定", + "You are in offline mode. Click on the image below to set the expression.": "您目前為離線狀態,請點擊下方圖片進行表情設定。", + "You can find your API key in the Stability AI dashboard.": "API 金鑰可在 Stability AI 儀表板中查看。", + "Stop Inspecting": "停止檢查", + "Inspect Prompts": "檢查提示詞", + "Toggle prompt inspection": "切換提示詞檢查", + "Top nsigma": "Top nsigma", + "Controls the stopping condition for beam search. If checked, the generation stops as soon as there are '# of Beams' sequences. If not checked, a heuristic is applied and the generation is stopped when it's very unlikely to find better candidates.": "Controls the stopping condition for beam search. If checked, the generation stops as soon as there are '# of Beams' sequences. If not checked, a heuristic is applied and the generation is stopped when it's very unlikely to find better candidates.", + "Confirm token parsing with": "確認符元(Token)解析方式", + "KoboldAI Horde": "KoboldAI Horde", + "KoboldAI Horde Website": "KoboldAI Horde 網站", + "Derive context size from backend": "從後端推導上下文大小", + "Using a proxy that you're not running yourself is a risk to your data privacy.": "使用非自行管理的代理服務可能導致您的數據隱私外洩。", + "Claude API Key": "Claude API 金鑰", + "NanoGPT API Key": "NanoGPT API 金鑰", + "NanoGPT Model": "NanoGPT 模型", + "context_derived": "若可能,根據模型元數據推導。", + "instruct_derived": "若可能,根據模型元數據推導。", + "Inserted before the first User's message.": "插入於第一則使用者訊息之前。", + "0 = unlimited, 1 = scans once and doesn't recurse, 2 = scans once and recurses once, etc\\n(disabled when min activations are used)": "0 = 無限制,1 = 掃描一次不遞歸,2 = 掃描一次後遞歸一次 ⋯以此類推\n(啟用最小啟動次數時無效)", + "Quick 'Impersonate' button": "快速「AI 扮演使用者」按鈕", + "Manual": "手動", + "Any contents here will replace the default Post-History Instructions used for this character. (v2 spec: post_history_instructions)": "此處填入的內容將取代該角色的默認聊天歷史後指示(Post-History Instructions)。\n(v2 specpost_history_instructions)", + "The content of this prompt is pulled from elsewhere and cannot be edited here.": "此提示內容由其他地方提取,無法在此進行編輯。", + "Open checkpoint chat\nShift+Click to replace the existing checkpoint with a new one": "開啟檢查點聊天\n使用「Shift+點擊」將以新檢查點替換現有的。", + "Reroll with the entire prefix": "使用完整前綴重新生成", + "Disable": "停用", + "Enable": "啟用", + "These files are available for the current character in all chats they are in.": "這些檔案適用於該角色所在的所有聊天。", + "These files are available for all characters in the current chat.": "這些檔案適用於本次聊天中的所有角色。", + "Set your API keys and endpoints in the 'API Connections' tab first.": "請先於「API 連線」選單中設定 API 金鑰和端點。", + "Profile name:": "設定檔名稱:", + "Creating a Connection Profile": "建立連線設定檔", + "{{@key}}": "{{@key}}:", + "Enter a name:": "輸入名稱:", + "Omitted Settings:": "忽略的設定:", + "Will be used if the API doesn't support JSON schemas or function calling.": "將於 API 不支援 JSON 結構或函數調用時使用。", + "ext_sum_webllm": "WebLLM 擴充功能", + "ext_sum_restore_tip": "恢復先前的摘要;重複使用以清除此聊天的摘要狀態。", + "ext_sum_force_tip": "將立即更新摘要。", + "ext_sum_include_wi_scan_desc": "於掃描世界資訊時包含最新摘要。", + "ext_sum_include_wi_scan": "包含世界資訊掃描", + "None (not injected)": "無(不插入)", + "ext_sum_injection_position_none": "此摘要將不會插入提示詞中,但可通過 {{summary}} 巨集訪問。", + "Labels and Message": "標籤與訊息", + "Label": "標籤", + "(label of the button, if no icon is chosen) ": "(若未選擇圖標,則為按鈕的標籤)", + "Title": "名稱", + "(tooltip, leave empty to show message or /command)": "(工具提示,留空以顯示訊息或 /command)", + "Message / Command:": "訊息/指令:", + "Word wrap": "自動換行", + "Tab size:": "標籤大小:", + "Ctrl+Enter to execute": "以 Ctrl+Enter 執行", + "Context Menu": "上下文選單", + "Auto-Execute": "自動執行", + "Don't trigger auto-execute": "不觸發自動執行", + "Invisible (auto-execute only)": "隱藏(僅自動執行)", + "Execute on startup": "啟動時執行", + "Execute on user message": "根據使用者訊息執行", + "Execute on AI message": "根據 AI 訊息執行", + "Execute on chat change": "聊天內容變更時執行", + "Execute on new chat": "新建聊天時執行", + "Execute on group member draft": "欲改動群組成員時執行", + "Automation ID:": "自動化 ID:", + "Testing": "測試", + "Quick Reply": "快速回覆", + "Enable Quick Replies": "啟用快速回覆", + "Combine Quick Replies": "合併快速回覆", + "Show Popout Button": "顯示彈出按鈕(桌面版)", + "Global Quick Reply Sets": "全域快速回覆", + "Chat Quick Reply Sets": "僅限目前聊天", + "Edit Quick Replies": "編輯快速回覆", + "Disable Send (Insert Into Input Field)": "停用發送(插入到輸入字段)", + "Place Quick Reply Before Input": "在輸入前插入快速回覆", + "Inject user input automatically": "自動插入使用者輸入", + "(if disabled, use ": "(若停用,請使用", + "macro for manual injection)": "巨集進行手動插入)", + "Color": "顏色", + "Only apply color as accent": "僅使用顏色作為強調", + "ext_regex_new_global_script_desc": "新建「全域」正規表達式", + "ext_regex_new_scoped_script_desc": "新建「作用域」正規表達式", + "ext_regex_disallow_scoped": "不使用作用域正規表達式", + "ext_regex_allow_scoped": "使用作用域正規表達式", + "ext_regex_user_input_desc": "使用者發送的訊息。", + "ext_regex_ai_input_desc": "從生成式 API 接收到的訊息。", + "ext_regex_slash_desc": "使用 STscript 指令發送的訊息。", + "ext_regex_wi_desc": "世界資訊/知識書條目內容。需要勾選「僅格式化提示詞」!", + "ext_regex_run_on_edit_desc": "當指定角色的訊息被編輯時執行正規腳本。", + "Macro in Find Regex": "巨集替換模式", + "Don't substitute": "不替換(純文字匹配)", + "Substitute (raw)": "原始替換(不處理 *、. 等特殊字符)", + "Substitute (escaped)": "轉義替換(將特殊字符 *、. 等當作普通文字處理)", + "Ephemerality": "暫時性", + "ext_regex_only_format_visual_desc": "僅改變聊天界面顯示的訊息,不修改聊天記錄檔案內容。", + "Hint: Save an API key in Horde KoboldAI API settings to use it here.": "提示:請於 Horde KoboldAI API 設定中保存 API 金鑰以進行使用。", + "Prompt Upsampling": "提示提升(Upsampling)", + "Uncheck to hide the extension's messages in chat prompts.": "取消選取可在聊天提示詞中隱藏擴充功能的訊息。", + "ext_translate_delete_confirm_1": "確定要刪除嗎?", + "ext_translate_delete_confirm_2": "這將「永久刪除」本次聊天中所有訊息的翻譯文本,且無法復原。", + "Select TTS Provider": "選擇 TTS 提供者", + "tts_enabled": "啟用", + "Narrate user messages": "朗讀使用者訊息", + "Auto Generation": "自動生成", + "Requires auto generation to be enabled.": "需要啟用自動生成功能。", + "Narrate by paragraphs (when streaming)": "按段落朗讀(使用「串流」傳輸時)", + "Only narrate quotes": "僅朗讀「引號」中的文字", + "Ignore text, even quotes, inside asterisk": "忽略 *(星號)內的文字(包括「引號」)", + "Narrate only the translated text": "僅朗讀翻譯後的文本", + "Skip codeblocks": "跳過代碼塊", + "Skip tagged blocks": "跳過 <標記> 塊", + "Pass Asterisks to TTS Engine": "將 *(星號)視為普通文字傳送至 TTS 引擎(否則忽略)", + "Warning: This will slow down vector generation drastically, as all messages have to be summarized first.": "警告:操作後將顯著降低向量生成速度,因為所有訊息都必須先進行摘要。", + "Note:": "注意:", + "this chat is temporary and will be deleted as soon as you leave it.": "此聊天為臨時聊天,離開後將被刪除。", + "Import Tags For _begin": "為", + "Import Tags For _end": "匯入標籤", + "Click remove on any tag to remove it from this import.
Select one of the import options to finish importing the tags.": "點擊任意標籤上的「移除」可將其於本次匯入中刪除。\n選擇一個匯入選項以完成標籤匯入。", + "Existing Tags": "現有標籤", + "New Tags": "新標籤", + "Folder Tags": "資料夾標籤", + "The following tags will be auto-imported based on the currently selected folders": "以下標籤將根據目前選擇的文件夾自動匯入", + "Import None": "不匯入", + "Import All": "全部匯入", + "Import Existing": "匯入現有標籤", + "Import": "匯入", + "chat_rename_1": "輸入此聊天檔案的新名稱:", + "chat_rename_2": "!! 使用已存在的檔案名將導致錯誤 !!", + "chat_rename_3": "這將斷開各檢查點間的連結。", + "chat_rename_4": "無需在末尾加上 `.jsonl`。", + "Include Body Parameters": "包含請求主體參數", + "custom_include_body_desc": "包含在 Chat Completion 請求體中的參數(YAML 格式)\n\n範例:\n- top_k: 20\n- repetition_penalty: 1.1", + "Exclude Body Parameters": "排除請求主體參數", + "custom_exclude_body_desc": "排除於 Chat Completion 請求體中的參數(YAML 格式)\n\n範例:\n- frequency_penalty\n- presence_penalty", + "Include Request Headers": "包含請求頭(Request Headers)", + "custom_include_headers_desc": "添加於 Chat Completion 請求的自定義標頭(YAML 格式)\n\n範例:\n- CustomHeader: custom-value\n- AnotherHeader: custom-value", + "THIS IS PERMANENT!": "這是「永久性」的!", + "Also delete the chat files": "同時刪除此聊天檔案", + "Are you sure you want to duplicate this character?": "您確定要複製該角色嗎?", + "If you just want to start a new chat with the same character...": "若您只是想與該角色開始新聊天,請使用左下角選單中的「開始新聊天」。", + "forbid_media_global_state_forbidden": "(禁止)", + "forbid_media_global_state_allowed": "(允許)", + "help_format_1": "文本格式化命令:", + "help_format_2": "*文本*", + "help_format_3": "顯示為", + "help_format_4": "斜體", + "help_format_5": "**文本**", + "help_format_6": "顯示為", + "help_format_7": "粗體", + "help_format_8": "***text***", + "help_format_9": "顯示為", + "help_format_10": "粗斜體", + "help_format_11": "__文本__", + "help_format_12": "顯示為", + "help_format_13": "底線", + "help_format_14": "~~text~~", + "help_format_15": "顯示為", + "help_format_16": "刪除線", + "help_format_17": "[text](url)", + "help_format_18": "顯示為", + "help_format_19": "超連結", + "help_format_20": "![text](url)", + "help_format_21": "顯示為圖片", + "help_format_22": "```text```", + "help_format_23": "顯示為程式碼區塊(反引號內允許換行)", + "help_format_like_this": "像這樣", + "help_format_24": "`text`", + "help_format_25": "顯示為", + "help_format_26": "單行程式碼", + "help_format_27": "> text", + "help_format_28": "顯示為塊引用(注意 > 後的空格)", + "help_format_29": "# text", + "help_format_30": "顯示為一級標題(注意空格)", + "help_format_32": "## text", + "help_format_33": "顯示為二級標題(注意空格)", + "help_format_35": "### text", + "help_format_36": "顯示為三級標題(注意空格)", + "help_1": "您好!請選擇您想了解的幫助主題:", + "help_2": "斜線命令", + "help_or": "或", + "help_3": "格式化", + "help_4": "快捷鍵", + "help_5": "{{巨集}}", + "help_6": "還有問題嗎?請查看", + "help_7": "SillyTavern 官方文檔網站", + "help_8": " 了解更多資訊!", + "help_hotkeys_0": "聊天快捷鍵", + "help_hotkeys_1": "↑(方向鍵)", + "help_hotkeys_2": "編輯聊天中的最後一則訊息", + "help_hotkeys_3": "Ctrl+↑", + "help_hotkeys_4": "編輯聊天中的最後一則使用者訊息", + "help_hotkeys_5": "←(方向鍵)", + "help_hotkeys_6": "向左滑動", + "help_hotkeys_7": "→(方向鍵)", + "help_hotkeys_8": "向右滑動(注意:若聊天框中已有輸入,滑動快捷鍵將被禁用)", + "help_hotkeys_9": "Enter", + "help_hotkeys_10": "(選中聊天框時)", + "help_hotkeys_10_1": "向 AI 發送您的訊息", + "help_hotkeys_11": "Ctrl+Enter", + "help_hotkeys_12": "重新生成最後一則 AI 回應", + "help_hotkeys_13": "Alt+Enter", + "help_hotkeys_14": "繼續生成最後一則 AI 回應", + "help_hotkeys_15": "Esc 鍵", + "help_hotkeys_16": "停止 AI 回應生成,關閉使用者界面,取消訊息編輯", + "help_hotkeys_17": "Ctrl+Shift+↑", + "help_hotkeys_18": "滾動到上下文行", + "help_hotkeys_19": "Ctrl+Shift+↓", + "help_hotkeys_20": "Markdown 快捷鍵", + "help_hotkeys_21": "適用於聊天框和帶有此圖標的文本區域:", + "help_hotkeys_22": "**粗體**", + "help_hotkeys_23": "*斜體*", + "help_hotkeys_24": "__底線__", + "help_hotkeys_25": "`單行程式碼`", + "help_hotkeys_26": "~~刪除線~~", + "Show Raw Prompt": "顯示原始提示詞", + "Copy Prompt": "複製提示詞", + "Show Prompt Differences": "顯示提示詞差異", + "System-wide Replacement Macros (in order of evaluation):": "系統範圍替換巨集(按評估順序):", + "help_macros_1": "僅用於斜線命令批次處理。替換為前一條命令的返回結果。", + "help_macros_2": "插入一個換行符。", + "help_macros_3": "修剪巨集指令周圍的換行符。", + "help_macros_4": "無操作,僅返回空字串。", + "help_macros_5": "在 API 設定中定義的全域提示詞。僅在高級定義提示詞覆蓋中有效。", + "help_macros_6": "使用者輸入", + "help_macros_7": "角色的主要提示詞覆蓋", + "help_macros_8": "角色的聊天歷史後指示覆蓋", + "help_macros_9": "角色描述", + "help_macros_10": "角色的性格特徵", + "help_macros_11": "角色場景", + "help_macros_12": "您目前的使用者描述", + "help_macros_13": "角色對話範例", + "help_macros_14": "未格式化的對話範例", + "(only for Story String)": "(僅適用於故事字串)", + "help_macros_summary": "由「訊息摘要」擴充功能生成的最新聊天摘要(如果可用)。", + "help_macros_15": "您目前的使用者名稱", + "help_macros_16": "角色名稱", + "help_macros_17": "角色版本", + "help_macros_18": "以逗號分隔的群組成員名稱列表(包含靜音成員)或單人聊天中的角色名稱。別名:{{charIfNotGroup}}", + "help_groupNotMuted": "與 {{group}} 相同,但不包含靜音成員", + "help_macros_19": "目前所選之 API 的文本生成模型名稱。", + "Can be inaccurate!": "可能不準確!", + "help_macros_20": "最新聊天訊息的文本內容。", + "help_macros_lastUser": "最新使用者聊天訊息的文本內容。", + "help_macros_lastChar": "最新角色聊天訊息的文本內容。", + "help_macros_21": "最新聊天訊息的索引 # 編號。適用於斜線命令批次處理。", + "help_macros_22": "包含在上下文中的第一條訊息的 ID。需在目前對話中至少進行一次生成。", + "help_macros_23": "最新聊天訊息中所滑動的 ID(以 1 起始)。若最新訊息為使用者訊息或提示為隱藏,則為空字串。", + "help_macros_24": "最新聊天訊息中的滑動次數。如果最新訊息為使用者訊息或提示為隱藏,則為空字串。", + "help_macros_reverse": "反轉巨集的內容。", + "help_macros_25": "您可以在此留下備註,巨集將被替換為空白內容。對 AI 不可見。", + "help_macros_26": "目前時間", + "help_macros_27": "目前日期", + "help_macros_28": "目前週幾", + "help_macros_29": "目前 ISO 時間(24 小時制)", + "help_macros_30": "目前 ISO 日期(YYYY-MM-DD)", + "help_macros_31": "指定格式的目前日期/時間,例如,德國日期/時間:", + "help_macros_32": "指定 UTC 時區偏移量的目前時間,例如 UTC-4 或 UTC+2", + "help_macros_33": "計算 time1 和 time2 之間的時間差。接受時間和日期巨集。(例如:{{timeDiff::{{isodate}} {{time}}::2024/5/11 12:30:00}})", + "help_macros_34": "上次使用者訊息發送後的時間", + "help_macros_35": "設定 AI 的行為偏好,直到下一次使用者輸入。引號中的文本很重要。", + "help_macros_36": "擲骰子。(例如:", + "space_ will roll a 6-sided dice and return a number between 1 and 6)": "將擲一個六面骰並回傳 1 到 6 間的數字)", + "help_macros_37": "從列表中返回隨機一項。(例如:", + "space_ will return 1 of the 4 numbers at random. Works with text lists too.": "將隨機返回 4 個數字中的 1 個。也適用於文本列表。)", + "help_macros_38": "用於隨機的替代語法,允許在列表中使用逗號。", + "help_macros_39": "從列表中選擇隨機一項。工作原理與 {{random}} 相同,但選擇結果將在本次聊天中保持一致,不會在後續消息或提示處理時重新滾動。", + "help_macros_40": "若使用 Text Generation WebUI 後端,動態將引號中的文本添加到禁用單詞序列中。對其他後端無效。可在任何地方使用(角色描述、世界資訊、作者備註等)。引號內容很重要。", + "Instruct Mode and Context Template Macros:": "指令模式與上下文模板巨集:", + "(enabled in the Advanced Formatting settings)": "(在高級格式化設定中啟用)", + "help_macros_41": "允許的最大提示詞長度(以符元為單位)=(上下文大小 - 回應長度)", + "help_macros_42": "上下文模板對話範例分隔符", + "help_macros_43": "上下文模板聊天開始行", + "help_macros_44": "主要提示詞(啟用後,將覆蓋角色提示詞或預設系統提示)", + "help_macros_45": "主要提示詞", + "help_macros_46": "指令系統提示詞前綴序列", + "help_macros_47": "指令系統提示詞後綴序列", + "help_macros_48": "指令使用者前綴序列", + "help_macros_49": "指令使用者後綴序列", + "help_macros_50": "指令助理前綴序列", + "help_macros_51": "指令助理後綴序列", + "help_macros_52": "指令助理的開頭輸出序列", + "help_macros_53": "指令助理的結尾輸出序列", + "help_macros_54": "指令系統訊息前綴序列", + "help_macros_55": "指令系統訊息後綴序列", + "help_macros_56": "指令系統指令前綴", + "help_macros_57": "指令第一則使用者訊息補充", + "help_macros_58": "指令停止序列", + "help_macros_first_user": "指令使用者開頭輸入序列", + "help_macros_last_user": "指令使用者結尾輸入序列", + "Chat variables Macros:": "聊天變數巨集:", + "Local variables = unique to the current chat": "局部變數 = 僅作用於本次聊天", + "Global variables = works in any chat for any character": "全域變數 = 作用於所有聊天中的所有角色", + "Scoped variables = works in STscript": "局部變數 = 適用於 STscript", + "help_macros_59": "替換為局部變數 \"name\" 的值", + "help_macros_60": "替換為空字串,並將局部變數 \"name\" 設定為 \"value\"", + "help_macros_61": "替換為空字串,並將 \"increment\" 數值添加到局部變數 \"name\"", + "help_macros_62": "替換為局部變數 \"name\" 的值增加 1 後的結果", + "help_macros_63": "替換為局部變數 \"name\" 的值減少 1 後的結果", + "help_macros_64": "替換為全域變數 \"name\" 的值", + "help_macros_65": "替換為空字串,並將全域變數 \"name\" 設定為 \"value\"", + "help_macros_66": "替換為空字串,並將 \"increment\" 數值添加到全域變數 \"name\"", + "help_macros_67": "替換為全域變數 \"name\" 的值增加 1 後的結果", + "help_macros_68": "替換為全域變數 \"name\" 的值減少 1 後的結果", + "help_macros_69": "替換為局部變數 \"name\" 的值", + "help_macros_70": "替換為局部變數 \"name\" 中指定索引(適用於陣列/列表或對象/字典)的值", + "{{name}}": "{{name}}", + "If necessary, you can later restore this chat file from the /backups folder": "若需要,您可以稍後從 /backups 資料夾恢復此聊天檔案", + "Also delete the current chat file": "同時刪除目前的聊天歷史", + "Are you sure you want to connect to the following proxy URL?": "您確定要連線到以下代理 URL 嗎?", + "Encountered an error while processing your request.": "處理您的請求時遇到錯誤。", + "Check you have credits available on your": "請檢查您的帳號中是否有可用餘額", + "OpenAI account quora_error": "OpenAI 帳號", + "dot quota_error": "。", + "If you have sufficient credits, please try again later.": "若餘額充足,請稍後重試。", + "Download Model": "下載模型", + "Downloader Options": "下載選項", + "Extra parameters for downloading/HuggingFace API": "下載/使用 HuggingFace API 的額外參數。\r若不確定,請留空。", + "Revision": "修訂", + "Folder Name": "輸出資料夾名稱", + "HF Token": "HF 符元", + "Include Patterns": "包含模式", + "Glob patterns of files to include in the download.": "要包含於下載中文件的全域模式。\r每行輸入一個模式。", + "Exclude Patterns": "排除模式", + "Glob patterns of files to exclude in the download.": "要排除於下載中的文件的全域模式。\r每行輸入一個模式。", + "Tag Management": "管理標籤", + "Save your tags to a file": "將標籤保存到文件", + "Restore tags from a file": "從文件中恢復標籤", + "Create a new tag": "創建新標籤", + "Drag handle to reorder. Click name to rename. Click color to change display.": "拖動以重新排序。點擊名稱重新命名。點擊顏色更改顯示。", + "Click on the folder icon to use this tag as a folder.": "點擊資料夾圖示以將此標籤作為資料夾。", + "Use alphabetical sorting": "使用字母順序排序", + "tags_sorting_desc": "啟用後,標籤將在創建或重命名時將自動按字母排序。\n禁用時,新標籤將附加到末尾。\n若標籤被手動拖動重新排序,則自動排序將被禁用。", + "and connect to an": "並連接到", + "You can add more": "您可以添加更多", + "or_welcome": "或", + "from other websites": "從其他網站取得。", + "Go to the": "前往", + "to install additional features.": "以安裝更多功能。", + "If you're connected to an API, try asking me something!": "若您已連接 API,嘗試問我一些問題吧!", + "Title/Memo": "標題/備註", + "Strategy": "插入策略", + "Position": "位置", + "Trigger %": "觸發%", + "Dialogue Colorizer": "對話著色器", + "Global Dialogue Settings": "全域對話設定", + "Chat Bubble Lightness": "聊天氣泡亮度", + "The lightness to use for the chat bubble color.": "設定聊天氣泡的亮度。", + "Character Dialogue Settings": "角色對話設定", + "Dialogue settings for characters.": "設定角色對話顏色。", + "The static color to use for character dialog if 'Color Source' is set to 'Static Color'.": "若將「顏色來源」設定為「靜態顏色」,請先設定該角色對話的靜態顏色。", + "Persona Dialogue Settings": "使用者對話設定", + "Persona Dialogue Settings Info": "設定使用者對話顏色。", + "Dialogue Color": "對話顏色", + "The color to use for this character's dialogue (quoted text). Overrides the global setting.": "該角色對話中引號文字的顏色。覆蓋全域設定。", + "Avatar Vibrant": "頭像顏色", + "Use a vibrant color dynamically calculated from the character's avatar.": "使用從角色頭像動態提取出的色彩。", + "Static Color": "靜態顏色", + "Use a specified static color.": "使用指定的靜態顏色。", + "Per-Character Only": "僅限特定角色", + "Use the default quote color except for characters with a specified override color.": "除非角色有指定的覆蓋顏色,否則使用預設的引號文字顏色。", + "Chat Bubbles": "聊天氣泡", + "Color the chat bubbles. Only works with the 'Bubbles' chat style.": "為聊天氣泡上色,僅適用於「氣泡」聊天樣式。", + "Quoted Text": "引號文字", + "Color quoted text.": "為引號文字上色。", + "Color both chat bubbles and quoted text.": "同時為聊天氣泡和引號文字上色。", + "Color Source": "顏色來源", + "The source to use for dialogue color.": "設定對話顏色的來源。", + "Color Targets": "顏色目標", + "Which elements to color.": "設定需要上色的元素。", + "Open Chat History": "開啟聊天記錄" } diff --git a/public/script.js b/public/script.js index 99f673f21..c554efcbd 100644 --- a/public/script.js +++ b/public/script.js @@ -10,6 +10,7 @@ import { SVGInject, Popper, initLibraryShims, + slideToggle, default as libs, } from './lib.js'; @@ -234,7 +235,7 @@ import { import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js'; import { hideLoader, showLoader } from './scripts/loader.js'; import { BulkEditOverlay, CharacterContextMenu } from './scripts/BulkEditOverlay.js'; -import { loadFeatherlessModels, loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermaticAIModels, loadOpenRouterModels, loadVllmModels, loadAphroditeModels, loadDreamGenModels, initTextGenModels, loadTabbyModels } from './scripts/textgen-models.js'; +import { loadFeatherlessModels, loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermaticAIModels, loadOpenRouterModels, loadVllmModels, loadAphroditeModels, loadDreamGenModels, initTextGenModels, loadTabbyModels, loadGenericModels } from './scripts/textgen-models.js'; import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId, preserveNeutralChat, restoreNeutralChat } from './scripts/chats.js'; import { getPresetManager, initPresetManager } from './scripts/preset-manager.js'; import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js'; @@ -549,6 +550,7 @@ let optionsPopper = Popper.createPopper(document.getElementById('options_button' let exportPopper = Popper.createPopper(document.getElementById('export_button'), document.getElementById('export_format_popup'), { placement: 'left', }); +let isExportPopupOpen = false; // Saved here for performance reasons const messageTemplate = $('#message_template .mes'); @@ -847,11 +849,9 @@ export let is_send_press = false; //Send generation let this_del_mes = -1; -//message editing and chat scroll position persistence +//message editing var this_edit_mes_chname = ''; var this_edit_mes_id; -var scroll_holder = 0; -var is_use_scroll_holder = false; //settings export let settings; @@ -895,6 +895,13 @@ export function getRequestHeaders() { }; } +export function getSlideToggleOptions() { + return { + miliseconds: animation_duration * 1.5, + transitionFunction: animation_duration > 0 ? 'ease-in-out' : 'step-start', + }; +} + $.ajaxPrefilter((options, originalOptions, xhr) => { xhr.setRequestHeader('X-CSRF-Token', token); }); @@ -1173,7 +1180,7 @@ async function getStatusTextgen() { return resultCheckStatus(); } - if (textgen_settings.type == textgen_types.OOBA && textgen_settings.bypass_status_check) { + if ([textgen_types.GENERIC, textgen_types.OOBA].includes(textgen_settings.type) && textgen_settings.bypass_status_check) { setOnlineStatus('Status check bypassed'); return resultCheckStatus(); } @@ -1221,6 +1228,9 @@ async function getStatusTextgen() { } else if (textgen_settings.type === textgen_types.TABBY) { loadTabbyModels(data?.data); setOnlineStatus(textgen_settings.tabby_model || data?.result); + } else if (textgen_settings.type === textgen_types.GENERIC) { + loadGenericModels(data?.data); + setOnlineStatus(textgen_settings.generic_model || data?.result || 'Connected'); } else { setOnlineStatus(data?.result); } @@ -2873,23 +2883,54 @@ function addPersonaDescriptionExtensionPrompt() { } } -function getAllExtensionPrompts() { - const value = Object - .values(extension_prompts) - .filter(x => x.value) - .map(x => x.value.trim()) - .join('\n'); +/** + * Returns all extension prompts combined. + * @returns {Promise} Combined extension prompts + */ +async function getAllExtensionPrompts() { + const values = []; - return value.length ? substituteParams(value) : ''; + for (const prompt of Object.values(extension_prompts)) { + const value = prompt?.value?.trim(); + + if (!value) { + continue; + } + + const hasFilter = typeof prompt.filter === 'function'; + if (hasFilter && !await prompt.filter()) { + continue; + } + + values.push(value); + } + + return substituteParams(values.join('\n')); } -// Wrapper to fetch extension prompts by module name -export function getExtensionPromptByName(moduleName) { - if (moduleName) { - return substituteParams(extension_prompts[moduleName]?.value); - } else { - return; +/** + * Wrapper to fetch extension prompts by module name + * @param {string} moduleName Module name + * @returns {Promise} Extension prompt + */ +export async function getExtensionPromptByName(moduleName) { + if (!moduleName) { + return ''; } + + const prompt = extension_prompts[moduleName]; + + if (!prompt) { + return ''; + } + + const hasFilter = typeof prompt.filter === 'function'; + + if (hasFilter && !await prompt.filter()) { + return ''; + } + + return substituteParams(prompt.value); } /** @@ -2900,27 +2941,36 @@ export function getExtensionPromptByName(moduleName) { * @param {string} [separator] Separator for joining multiple prompts * @param {number} [role] Role of the prompt * @param {boolean} [wrap] Wrap start and end with a separator - * @returns {string} Extension prompt + * @returns {Promise} Extension prompt */ -export function getExtensionPrompt(position = extension_prompt_types.IN_PROMPT, depth = undefined, separator = '\n', role = undefined, wrap = true) { - let extension_prompt = Object.keys(extension_prompts) +export async function getExtensionPrompt(position = extension_prompt_types.IN_PROMPT, depth = undefined, separator = '\n', role = undefined, wrap = true) { + const filterByFunction = async (prompt) => { + const hasFilter = typeof prompt.filter === 'function'; + if (hasFilter && !await prompt.filter()) { + return false; + } + return true; + }; + const promptPromises = Object.keys(extension_prompts) .sort() .map((x) => extension_prompts[x]) .filter(x => x.position == position && x.value) .filter(x => depth === undefined || x.depth === undefined || x.depth === depth) .filter(x => role === undefined || x.role === undefined || x.role === role) - .map(x => x.value.trim()) - .join(separator); - if (wrap && extension_prompt.length && !extension_prompt.startsWith(separator)) { - extension_prompt = separator + extension_prompt; + .filter(filterByFunction); + const prompts = await Promise.all(promptPromises); + + let values = prompts.map(x => x.value.trim()).join(separator); + if (wrap && values.length && !values.startsWith(separator)) { + values = separator + values; } - if (wrap && extension_prompt.length && !extension_prompt.endsWith(separator)) { - extension_prompt = extension_prompt + separator; + if (wrap && values.length && !values.endsWith(separator)) { + values = values + separator; } - if (extension_prompt.length) { - extension_prompt = substituteParams(extension_prompt); + if (values.length) { + values = substituteParams(values); } - return extension_prompt; + return values; } export function baseChatReplace(value, name1, name2) { @@ -3742,6 +3792,23 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro } } + // Fetches the combined prompt for both negative and positive prompts + const cfgGuidanceScale = getGuidanceScale(); + const useCfgPrompt = cfgGuidanceScale && cfgGuidanceScale.value !== 1; + + // Adjust max context based on CFG prompt to prevent overfitting + if (useCfgPrompt) { + const negativePrompt = getCfgPrompt(cfgGuidanceScale, true, true)?.value || ''; + const positivePrompt = getCfgPrompt(cfgGuidanceScale, false, true)?.value || ''; + if (negativePrompt || positivePrompt) { + const previousMaxContext = this_max_context; + const [negativePromptTokenCount, positivePromptTokenCount] = await Promise.all([getTokenCountAsync(negativePrompt), getTokenCountAsync(positivePrompt)]); + const decrement = Math.max(negativePromptTokenCount, positivePromptTokenCount); + this_max_context -= decrement; + console.log(`Max context reduced by ${decrement} tokens of CFG prompt (${previousMaxContext} -> ${this_max_context})`); + } + } + console.log(`Core/all messages: ${coreChat.length}/${chat.length}`); // kingbri MARK: - Make sure the prompt bias isn't the same as the user bias @@ -3834,7 +3901,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro // Inject all Depth prompts. Chat Completion does it separately let injectedIndices = []; if (main_api !== 'openai') { - injectedIndices = doChatInject(coreChat, isContinue); + injectedIndices = await doChatInject(coreChat, isContinue); } // Insert character jailbreak as the last user message (if exists, allowed, preferred, and not using Chat Completion) @@ -3907,8 +3974,8 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro } // Call combined AN into Generate - const beforeScenarioAnchor = getExtensionPrompt(extension_prompt_types.BEFORE_PROMPT).trimStart(); - const afterScenarioAnchor = getExtensionPrompt(extension_prompt_types.IN_PROMPT); + const beforeScenarioAnchor = (await getExtensionPrompt(extension_prompt_types.BEFORE_PROMPT)).trimStart(); + const afterScenarioAnchor = await getExtensionPrompt(extension_prompt_types.IN_PROMPT); const storyStringParams = { description: description, @@ -4249,10 +4316,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro setPromptString(); } - // Fetches the combined prompt for both negative and positive prompts - const cfgGuidanceScale = getGuidanceScale(); - const useCfgPrompt = cfgGuidanceScale && cfgGuidanceScale.value !== 1; - // For prompt bit itemization let mesSendString = ''; @@ -4471,7 +4534,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro ...thisPromptBits[currentArrayEntry], rawPrompt: generate_data.prompt || generate_data.input, mesId: getNextMessageId(type), - allAnchors: getAllExtensionPrompts(), + allAnchors: await getAllExtensionPrompts(), chatInjects: injectedIndices?.map(index => arrMes[arrMes.length - index - 1])?.join('') || '', summarizeString: (extension_prompts['1_memory']?.value || ''), authorsNoteString: (extension_prompts['2_floating_prompt']?.value || ''), @@ -4536,9 +4599,12 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro const shouldDeleteMessage = type !== 'swipe' && ['', '...'].includes(lastMessage?.mes) && ['', '...'].includes(streamingProcessor?.result); hasToolCalls && shouldDeleteMessage && await deleteLastMessage(); const invocationResult = await ToolManager.invokeFunctionTools(streamingProcessor.toolCalls); + const shouldStopGeneration = (!invocationResult.invocations.length && shouldDeleteMessage) || invocationResult.stealthCalls.length; if (hasToolCalls) { - if (!invocationResult.invocations.length && shouldDeleteMessage) { - ToolManager.showToolCallError(invocationResult.errors); + if (shouldStopGeneration) { + if (Array.isArray(invocationResult.errors) && invocationResult.errors.length) { + ToolManager.showToolCallError(invocationResult.errors); + } unblockGeneration(type); generatedPromptCache = ''; streamingProcessor = null; @@ -4638,9 +4704,12 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro const shouldDeleteMessage = type !== 'swipe' && ['', '...'].includes(getMessage); hasToolCalls && shouldDeleteMessage && await deleteLastMessage(); const invocationResult = await ToolManager.invokeFunctionTools(data); + const shouldStopGeneration = (!invocationResult.invocations.length && shouldDeleteMessage) || invocationResult.stealthCalls.length; if (hasToolCalls) { - if (!invocationResult.invocations.length && shouldDeleteMessage) { - ToolManager.showToolCallError(invocationResult.errors); + if (shouldStopGeneration) { + if (Array.isArray(invocationResult.errors) && invocationResult.errors.length) { + ToolManager.showToolCallError(invocationResult.errors); + } unblockGeneration(type); generatedPromptCache = ''; return; @@ -4740,9 +4809,9 @@ export function stopGeneration() { * Injects extension prompts into chat messages. * @param {object[]} messages Array of chat messages * @param {boolean} isContinue Whether the generation is a continuation. If true, the extension prompts of depth 0 are injected at position 1. - * @returns {number[]} Array of indices where the extension prompts were injected + * @returns {Promise} Array of indices where the extension prompts were injected */ -function doChatInject(messages, isContinue) { +async function doChatInject(messages, isContinue) { const injectedIndices = []; let totalInsertedMessages = 0; messages.reverse(); @@ -4760,7 +4829,7 @@ function doChatInject(messages, isContinue) { const wrap = false; for (const role of roles) { - const extensionPrompt = String(getExtensionPrompt(extension_prompt_types.IN_CHAT, i, separator, role, wrap)).trimStart(); + const extensionPrompt = String(await getExtensionPrompt(extension_prompt_types.IN_CHAT, i, separator, role, wrap)).trimStart(); const isNarrator = role === extension_prompt_roles.SYSTEM; const isUser = role === extension_prompt_roles.USER; const name = names[role]; @@ -7453,14 +7522,16 @@ function select_rm_characters() { * @param {number} depth Insertion depth. 0 represets the last message in context. Expected values up to MAX_INJECTION_DEPTH. * @param {number} role Extension prompt role. Defaults to SYSTEM. * @param {boolean} scan Should the prompt be included in the world info scan. + * @param {(function(): Promise|boolean)} filter Filter function to determine if the prompt should be injected. */ -export function setExtensionPrompt(key, value, position, depth, scan = false, role = extension_prompt_roles.SYSTEM) { +export function setExtensionPrompt(key, value, position, depth, scan = false, role = extension_prompt_roles.SYSTEM, filter = null) { extension_prompts[key] = { value: String(value), position: Number(position), depth: Number(depth), scan: !!scan, role: Number(role ?? extension_prompt_roles.SYSTEM), + filter: filter, }; } @@ -9158,40 +9229,48 @@ function doDrawerOpenClick() { * @returns {void} */ function doNavbarIconClick() { - var icon = $(this).find('.drawer-icon'); - var drawer = $(this).parent().find('.drawer-content'); + const icon = $(this).find('.drawer-icon'); + const drawer = $(this).parent().find('.drawer-content'); if (drawer.hasClass('resizing')) { return; } - var drawerWasOpenAlready = $(this).parent().find('.drawer-content').hasClass('openDrawer'); - let targetDrawerID = $(this).parent().find('.drawer-content').attr('id'); + const drawerWasOpenAlready = $(this).parent().find('.drawer-content').hasClass('openDrawer'); + const targetDrawerID = $(this).parent().find('.drawer-content').attr('id'); const pinnedDrawerClicked = drawer.hasClass('pinnedOpen'); if (!drawerWasOpenAlready) { //to open the drawer - $('.openDrawer').not('.pinnedOpen').addClass('resizing').slideToggle(200, 'swing', async function () { - await delay(50); $(this).closest('.drawer-content').removeClass('resizing'); + $('.openDrawer').not('.pinnedOpen').addClass('resizing').each((_, el) => { + slideToggle(el, { + ...getSlideToggleOptions(), + onAnimationEnd: function (el) { + el.closest('.drawer-content').classList.remove('resizing'); + }, + }); }); - $('.openIcon').toggleClass('closedIcon openIcon'); + $('.openIcon').not('.drawerPinnedOpen').toggleClass('closedIcon openIcon'); $('.openDrawer').not('.pinnedOpen').toggleClass('closedDrawer openDrawer'); icon.toggleClass('openIcon closedIcon'); drawer.toggleClass('openDrawer closedDrawer'); //console.log(targetDrawerID); if (targetDrawerID === 'right-nav-panel') { - $(this).closest('.drawer').find('.drawer-content').addClass('resizing').slideToggle({ - duration: 200, - easing: 'swing', - start: function () { - jQuery(this).css('display', 'flex'); //flex needed to make charlist scroll - }, - complete: async function () { - favsToHotswap(); - await delay(50); - $(this).closest('.drawer-content').removeClass('resizing'); - $('#rm_print_characters_block').trigger('scroll'); - }, + $(this).closest('.drawer').find('.drawer-content').addClass('resizing').each((_, el) => { + slideToggle(el, { + ...getSlideToggleOptions(), + elementDisplayStyle: 'flex', + onAnimationEnd: function (el) { + el.closest('.drawer-content').classList.remove('resizing'); + favsToHotswap(); + $('#rm_print_characters_block').trigger('scroll'); + }, + }); }); } else { - $(this).closest('.drawer').find('.drawer-content').addClass('resizing').slideToggle(200, 'swing', async function () { - await delay(50); $(this).closest('.drawer-content').removeClass('resizing'); + $(this).closest('.drawer').find('.drawer-content').addClass('resizing').each((_, el) => { + slideToggle(el, { + ...getSlideToggleOptions(), + onAnimationEnd: function (el) { + el.closest('.drawer-content').classList.remove('resizing'); + }, + }); }); } @@ -9207,13 +9286,23 @@ function doNavbarIconClick() { icon.toggleClass('closedIcon openIcon'); if (pinnedDrawerClicked) { - $(drawer).addClass('resizing').slideToggle(200, 'swing', async function () { - await delay(50); $(this).removeClass('resizing'); + $(drawer).addClass('resizing').each((_, el) => { + slideToggle(el, { + ...getSlideToggleOptions(), + onAnimationEnd: function (el) { + el.classList.remove('resizing'); + }, + }); }); } else { - $('.openDrawer').not('.pinnedOpen').addClass('resizing').slideToggle(200, 'swing', async function () { - await delay(50); $(this).closest('.drawer-content').removeClass('resizing'); + $('.openDrawer').not('.pinnedOpen').addClass('resizing').each((_, el) => { + slideToggle(el, { + ...getSlideToggleOptions(), + onAnimationEnd: function (el) { + el.closest('.drawer-content').classList.remove('resizing'); + }, + }); }); } @@ -9649,25 +9738,29 @@ jQuery(async function () { chooseBogusFolder($(this), tagId); }); - /** - * Sets the scroll height of the edit textarea to fit the content. - * @param {HTMLTextAreaElement} e Textarea element to auto-fit - */ - function autoFitEditTextArea(e) { - scroll_holder = chatElement[0].scrollTop; - e.style.height = '0px'; - const newHeight = e.scrollHeight + 4; - e.style.height = `${newHeight}px`; - is_use_scroll_holder = true; - } - const autoFitEditTextAreaDebounced = debounce(autoFitEditTextArea, debounce_timeout.short); - document.addEventListener('input', e => { - if (e.target instanceof HTMLTextAreaElement && e.target.classList.contains('edit_textarea')) { - const scrollbarShown = e.target.clientWidth < e.target.offsetWidth && e.target.offsetHeight >= window.innerHeight * 0.75; - const immediately = (e.target.scrollHeight > e.target.offsetHeight && !scrollbarShown) || e.target.value === ''; - immediately ? autoFitEditTextArea(e.target) : autoFitEditTextAreaDebounced(e.target); + const cssAutofit = CSS.supports('field-sizing', 'content'); + if (!cssAutofit) { + /** + * Sets the scroll height of the edit textarea to fit the content. + * @param {HTMLTextAreaElement} e Textarea element to auto-fit + */ + function autoFitEditTextArea(e) { + const scrollTop = chatElement.scrollTop(); + e.style.height = '0px'; + const newHeight = e.scrollHeight + 4; + e.style.height = `${newHeight}px`; + chatElement.scrollTop(scrollTop); } - }); + const autoFitEditTextAreaDebounced = debounce(autoFitEditTextArea, debounce_timeout.short); + document.addEventListener('input', e => { + if (e.target instanceof HTMLTextAreaElement && e.target.classList.contains('edit_textarea')) { + const scrollbarShown = e.target.clientWidth < e.target.offsetWidth && e.target.offsetHeight >= window.innerHeight * 0.75; + const immediately = (e.target.scrollHeight > e.target.offsetHeight && !scrollbarShown) || e.target.value === ''; + immediately ? autoFitEditTextArea(e.target) : autoFitEditTextAreaDebounced(e.target); + } + }); + } + const chatElementScroll = document.getElementById('chat'); const chatScrollHandler = function () { if (power_user.waifuMode) { @@ -9689,12 +9782,6 @@ jQuery(async function () { }; chatElementScroll.addEventListener('wheel', chatScrollHandler, { passive: true }); chatElementScroll.addEventListener('touchmove', chatScrollHandler, { passive: true }); - chatElementScroll.addEventListener('scroll', function () { - if (is_use_scroll_holder) { - this.scrollTop = scroll_holder; - is_use_scroll_holder = false; - } - }, { passive: true }); $(document).on('click', '.mes', function () { //when a 'delete message' parent div is clicked @@ -10000,6 +10087,7 @@ jQuery(async function () { { id: 'api_key_llamacpp', secret: SECRET_KEYS.LLAMACPP }, { id: 'api_key_featherless', secret: SECRET_KEYS.FEATHERLESS }, { id: 'api_key_huggingface', secret: SECRET_KEYS.HUGGINGFACE }, + { id: 'api_key_generic', secret: SECRET_KEYS.GENERIC }, ]; for (const key of keys) { @@ -10034,20 +10122,21 @@ jQuery(async function () { await getStatusNovel(); }); - var button = $('#options_button'); - var menu = $('#options'); + const button = $('#options_button'); + const menu = $('#options'); + let isOptionsMenuVisible = false; function showMenu() { showBookmarksButtons(); - // menu.stop() menu.fadeIn(animation_duration); optionsPopper.update(); + isOptionsMenuVisible = true; } function hideMenu() { - // menu.stop(); menu.fadeOut(animation_duration); optionsPopper.update(); + isOptionsMenuVisible = false; } function isMouseOverButtonOrMenu() { @@ -10055,26 +10144,15 @@ jQuery(async function () { } button.on('click', function () { - if (menu.is(':visible')) { + if (isOptionsMenuVisible) { hideMenu(); } else { showMenu(); } }); - button.on('blur', function () { - //delay to prevent menu hiding when mouse leaves button into menu - setTimeout(() => { - if (!isMouseOverButtonOrMenu()) { hideMenu(); } - }, 100); - }); - menu.on('blur', function () { - //delay to prevent menu hide when mouseleaves menu into button - setTimeout(() => { - if (!isMouseOverButtonOrMenu()) { hideMenu(); } - }, 100); - }); $(document).on('click', function () { - if (!isMouseOverButtonOrMenu() && menu.is(':visible')) { hideMenu(); } + if (!isOptionsMenuVisible) return; + if (!isMouseOverButtonOrMenu()) { hideMenu(); } }); /* $('#set_chat_scenario').on('click', setScenarioOverride); */ @@ -10442,14 +10520,16 @@ jQuery(async function () { .closest('.mes_block') .find('.mes_text') .append( - '', + '', ); $('#curEditTextarea').val(text); let edit_textarea = $(this) .closest('.mes_block') .find('.edit_textarea'); - edit_textarea.height(0); - edit_textarea.height(edit_textarea[0].scrollHeight); + if (!cssAutofit) { + edit_textarea.height(0); + edit_textarea.height(edit_textarea[0].scrollHeight); + } edit_textarea.focus(); edit_textarea[0].setSelectionRange( //this sets the cursor at the end of the text String(edit_textarea.val()).length, @@ -10470,22 +10550,28 @@ jQuery(async function () { }); $(document).on('click', '.extraMesButtonsHint', function (e) { - const elmnt = e.target; - $(elmnt).transition({ + const $hint = $(e.target); + const $buttons = $hint.siblings('.extraMesButtons'); + + $hint.transition({ opacity: 0, duration: animation_duration, - easing: 'ease-in-out', + easing: animation_easing, + complete: function () { + $hint.hide(); + $buttons + .addClass('visible') + .css({ + opacity: 0, + display: 'flex', + }) + .transition({ + opacity: 1, + duration: animation_duration, + easing: animation_easing, + }); + }, }); - setTimeout(function () { - $(elmnt).hide(); - $(elmnt).siblings('.extraMesButtons').css('opcacity', '0'); - $(elmnt).siblings('.extraMesButtons').css('display', 'flex'); - $(elmnt).siblings('.extraMesButtons').transition({ - opacity: 1, - duration: animation_duration, - easing: 'ease-in-out', - }); - }, animation_duration); }); $(document).on('click', function (e) { @@ -10496,23 +10582,36 @@ jQuery(async function () { // Check if the click was outside the relevant elements if (!$(e.target).closest('.extraMesButtons, .extraMesButtonsHint').length) { + const $visibleButtons = $('.extraMesButtons.visible'); + + if (!$visibleButtons.length) { + return; + } + + const $hiddenHints = $('.extraMesButtonsHint:hidden'); + // Transition out the .extraMesButtons first - $('.extraMesButtons:visible').transition({ + $visibleButtons.transition({ opacity: 0, duration: animation_duration, - easing: 'ease-in-out', + easing: animation_easing, complete: function () { - $(this).hide(); // Hide the .extraMesButtons after the transition + // Hide the .extraMesButtons after the transition + $(this) + .hide() + .removeClass('visible'); // Transition the .extraMesButtonsHint back in - $('.extraMesButtonsHint:not(:visible)').show().transition({ - opacity: .3, - duration: animation_duration, - easing: 'ease-in-out', - complete: function () { - $(this).css('opacity', ''); - }, - }); + $hiddenHints + .show() + .transition({ + opacity: 0.3, + duration: animation_duration, + easing: animation_easing, + complete: function () { + $(this).css('opacity', ''); + }, + }); }, }); } @@ -10696,8 +10795,9 @@ jQuery(async function () { } }); - $('#export_button').on('click', function (e) { - $('#export_format_popup').toggle(); + $('#export_button').on('click', function () { + isExportPopupOpen = !isExportPopupOpen; + $('#export_format_popup').toggle(isExportPopupOpen); exportPopper.update(); }); @@ -10708,6 +10808,10 @@ jQuery(async function () { return; } + $('#export_format_popup').hide(); + isExportPopupOpen = false; + exportPopper.update(); + // Save before exporting await createOrEditCharacter(); const body = { format, avatar_url: characters[this_chid].avatar }; @@ -10729,9 +10833,6 @@ jQuery(async function () { URL.revokeObjectURL(a.href); document.body.removeChild(a); } - - - $('#export_format_popup').hide(); }); //**************************CHAT IMPORT EXPORT*************************// $('#chat_import_button').click(function () { @@ -10803,15 +10904,18 @@ jQuery(async function () { }); $(document).on('click', '.drawer-opener', doDrawerOpenClick); + $('.drawer-toggle').on('click', doNavbarIconClick); $('html').on('touchstart mousedown', function (e) { var clickTarget = $(e.target); - if ($('#export_format_popup').is(':visible') + if (isExportPopupOpen && clickTarget.closest('#export_button').length == 0 && clickTarget.closest('#export_format_popup').length == 0) { $('#export_format_popup').hide(); + isExportPopupOpen = false; + exportPopper.update(); } const forbiddenTargets = [ @@ -10836,12 +10940,16 @@ jQuery(async function () { if ($('.openDrawer').length !== 0) { if (targetParentHasOpenDrawer === 0) { //console.log($('.openDrawer').not('.pinnedOpen').length); - $('.openDrawer').not('.pinnedOpen').addClass('resizing').slideToggle(200, 'swing', function () { - $(this).closest('.drawer-content').removeClass('resizing'); + $('.openDrawer').not('.pinnedOpen').addClass('resizing').each((_, el) => { + slideToggle(el, { + ...getSlideToggleOptions(), + onAnimationEnd: (el) => { + el.closest('.drawer-content').classList.remove('resizing'); + }, + }); }); $('.openIcon').not('.drawerPinnedOpen').toggleClass('closedIcon openIcon'); $('.openDrawer').not('.pinnedOpen').toggleClass('closedDrawer openDrawer'); - } } } @@ -11007,14 +11115,6 @@ jQuery(async function () { case 'renameCharButton': renameCharacter(); break; - /*case 'dupe_button': - DupeChar(); - break; - case 'export_button': - $('#export_format_popup').toggle(); - exportPopper.update(); - break; - */ case 'import_character_info': await importEmbeddedWorldInfo(); saveCharacterDebounced(); diff --git a/public/scripts/RossAscends-mods.js b/public/scripts/RossAscends-mods.js index fb55e42dd..40f87ddf1 100644 --- a/public/scripts/RossAscends-mods.js +++ b/public/scripts/RossAscends-mods.js @@ -1,4 +1,4 @@ -import { DOMPurify, Bowser } from '../lib.js'; +import { DOMPurify, Bowser, slideToggle } from '../lib.js'; import { characters, @@ -19,6 +19,7 @@ import { menu_type, substituteParams, sendTextareaMessage, + getSlideToggleOptions, } from '../script.js'; import { @@ -315,7 +316,7 @@ function RA_checkOnlineStatus() { if (online_status == 'no_connection') { const send_textarea = $('#send_textarea'); send_textarea.attr('placeholder', send_textarea.attr('no_connection_text')); //Input bar placeholder tells users they are not connected - //$('#send_form').addClass('no-connection'); //entire input form area is red when not connected + $('#send_form').addClass('no-connection'); $('#send_but').addClass('displayNone'); //send button is hidden when not connected; $('#mes_continue').addClass('displayNone'); //continue button is hidden when not connected; $('#mes_impersonate').addClass('displayNone'); //continue button is hidden when not connected; @@ -326,7 +327,7 @@ function RA_checkOnlineStatus() { if (online_status !== undefined && online_status !== 'no_connection') { const send_textarea = $('#send_textarea'); send_textarea.attr('placeholder', send_textarea.attr('connected_text')); //on connect, placeholder tells user to type message - //$('#send_form').removeClass('no-connection'); + $('#send_form').removeClass('no-connection'); $('#API-status-top').removeClass('fa-plug-circle-exclamation redOverlayGlow'); $('#API-status-top').addClass('fa-plug'); connection_made = true; @@ -748,8 +749,8 @@ export function initRossMods() { $(RightNavDrawerIcon).removeClass('drawerPinnedOpen'); if ($(RightNavPanel).hasClass('openDrawer') && $('.openDrawer').length > 1) { - $(RightNavPanel).slideToggle(200, 'swing'); - $(RightNavDrawerIcon).toggleClass('openIcon closedIcon'); + slideToggle(RightNavPanel, getSlideToggleOptions()); + $(RightNavDrawerIcon).toggleClass('closedIcon openIcon'); $(RightNavPanel).toggleClass('openDrawer closedDrawer'); } } @@ -766,8 +767,8 @@ export function initRossMods() { $(LeftNavDrawerIcon).removeClass('drawerPinnedOpen'); if ($(LeftNavPanel).hasClass('openDrawer') && $('.openDrawer').length > 1) { - $(LeftNavPanel).slideToggle(200, 'swing'); - $(LeftNavDrawerIcon).toggleClass('openIcon closedIcon'); + slideToggle(LeftNavPanel, getSlideToggleOptions()); + $(LeftNavDrawerIcon).toggleClass('closedIcon openIcon'); $(LeftNavPanel).toggleClass('openDrawer closedDrawer'); } } @@ -786,8 +787,8 @@ export function initRossMods() { if ($(WorldInfo).hasClass('openDrawer') && $('.openDrawer').length > 1) { console.debug('closing WI after lock removal'); - $(WorldInfo).slideToggle(200, 'swing'); - $(WIDrawerIcon).toggleClass('openIcon closedIcon'); + slideToggle(WorldInfo, getSlideToggleOptions()); + $(WIDrawerIcon).toggleClass('closedIcon openIcon'); $(WorldInfo).toggleClass('openDrawer closedDrawer'); } } @@ -886,7 +887,40 @@ export function initRossMods() { saveSettingsDebounced(); }); + const cssAutofit = CSS.supports('field-sizing', 'content'); + + if (cssAutofit) { + let lastHeight = chatBlock.offsetHeight; + const chatBlockResizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.target !== chatBlock) { + continue; + } + + const threshold = 1; + const newHeight = chatBlock.offsetHeight; + const deltaHeight = newHeight - lastHeight; + const isScrollAtBottom = Math.abs(chatBlock.scrollHeight - chatBlock.scrollTop - newHeight) <= threshold; + + if (!isScrollAtBottom && Math.abs(deltaHeight) > threshold) { + chatBlock.scrollTop -= deltaHeight; + } + lastHeight = newHeight; + } + }); + + chatBlockResizeObserver.observe(chatBlock); + } + sendTextArea.addEventListener('input', () => { + saveUserInputDebounced(); + + if (cssAutofit) { + // Unset modifications made with a manual resize + sendTextArea.style.height = 'auto'; + return; + } + const hasContent = sendTextArea.value !== ''; const fitsCurrentSize = sendTextArea.scrollHeight <= sendTextArea.offsetHeight; const isScrollbarShown = sendTextArea.clientWidth < sendTextArea.offsetWidth; @@ -894,7 +928,6 @@ export function initRossMods() { const needsDebounce = hasContent && (fitsCurrentSize || (isScrollbarShown && isHalfScreenHeight)); if (needsDebounce) autoFitSendTextAreaDebounced(); else autoFitSendTextArea(); - saveUserInputDebounced(); }); restoreUserInput(); diff --git a/public/scripts/backgrounds.js b/public/scripts/backgrounds.js index 7b25728ef..f83d4e044 100644 --- a/public/scripts/backgrounds.js +++ b/public/scripts/backgrounds.js @@ -12,6 +12,7 @@ const LIST_METADATA_KEY = 'chat_backgrounds'; export let background_settings = { name: '__transparent.png', url: generateUrlParameter('__transparent.png', false), + fitting: 'classic', }; export function loadBackgroundSettings(settings) { @@ -19,7 +20,12 @@ export function loadBackgroundSettings(settings) { if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) { backgroundSettings = background_settings; } + if (!backgroundSettings.fitting) { + backgroundSettings.fitting = 'classic'; + } setBackground(backgroundSettings.name, backgroundSettings.url); + setFittingClass(backgroundSettings.fitting); + $('#background_fitting').val(backgroundSettings.fitting); } /** @@ -333,6 +339,14 @@ async function autoBackgroundCommand() { const bestMatch = fuse.search(reply, { limit: 1 }); if (bestMatch.length == 0) { + for (const option of options) { + if (String(reply).toLowerCase().includes(option.text.toLowerCase())) { + console.debug('Fallback choosing background:', option); + option.element.click(); + return ''; + } + } + toastr.warning('No match found. Please try again.'); return ''; } @@ -462,6 +476,18 @@ function highlightNewBackground(bg) { flashHighlight(newBg); } +/** + * Sets the fitting class for the background element + * @param {string} fitting Fitting type + */ +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'); +} + function onBackgroundFilterInput() { const filterValue = String($(this).val()).toLowerCase(); $('#bg_menu_content > div').each(function () { @@ -502,4 +528,9 @@ export function initBackgrounds() { helpString: 'Automatically changes the background based on the chat context using the AI request prompt', })); + $('#background_fitting').on('input', function () { + background_settings.fitting = String($(this).val()); + setFittingClass(background_settings.fitting); + saveSettingsDebounced(); + }); } diff --git a/public/scripts/cfg-scale.js b/public/scripts/cfg-scale.js index 85c91a95f..e49e5bcef 100644 --- a/public/scripts/cfg-scale.js +++ b/public/scripts/cfg-scale.js @@ -451,8 +451,14 @@ function getCustomSeparator() { } } -// Gets the CFG prompt -export function getCfgPrompt(guidanceScale, isNegative) { +/** + * Gets the CFG prompt based on the guidance scale. + * @param {{type: number, value: number}} guidanceScale The CFG guidance scale + * @param {boolean} isNegative Whether to get the negative prompt + * @param {boolean} quiet Whether to suppress console output + * @returns {{value: string, depth: number}} The CFG prompt and insertion depth + */ +export function getCfgPrompt(guidanceScale, isNegative, quiet = false) { let splitCfgPrompt = []; const cfgPromptCombine = chat_metadata[metadataKeys.prompt_combine] ?? []; @@ -484,7 +490,7 @@ export function getCfgPrompt(guidanceScale, isNegative) { const customSeparator = getCustomSeparator(); const combinedCfgPrompt = splitCfgPrompt.filter((e) => e.length > 0).join(customSeparator); const insertionDepth = chat_metadata[metadataKeys.prompt_insertion_depth] ?? 1; - console.log(`Setting CFG with guidance scale: ${guidanceScale.value}, negatives: ${combinedCfgPrompt}`); + !quiet && console.log(`Setting CFG with guidance scale: ${guidanceScale.value}, negatives: ${combinedCfgPrompt}`); return { value: combinedCfgPrompt, diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index a57aa2de9..2729eda4c 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -4,30 +4,57 @@ import { eventSource, event_types, saveSettings, saveSettingsDebounced, getReque import { showLoader } from './loader.js'; import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; import { renderTemplate, renderTemplateAsync } from './templates.js'; -import { isSubsetOf, setValueByPath } from './utils.js'; +import { delay, isSubsetOf, setValueByPath } from './utils.js'; import { getContext } from './st-context.js'; -import { translate } from "./i18n.js"; +import { isAdmin } from './user.js'; +import { t,translate } from './i18n.js'; +import { debounce_timeout } from './constants.js'; + export { getContext, getApiUrl, - loadExtensionSettings, - runGenerationInterceptors, - doExtrasFetch, - modules, - extension_settings, - ModuleWorkerWrapper, }; /** @type {string[]} */ export let extensionNames = []; -let manifests = {}; -const defaultUrl = 'http://localhost:5100'; +/** + * Holds the type of each extension. + * Don't use this directly, use getExtensionType instead! + * @type {Record} + */ +export let extensionTypes = {}; -let saveMetadataTimeout = null; +/** + * A list of active modules provided by the Extras API. + * @type {string[]} + */ +export let modules = []; + +/** + * A set of active extensions. + * @type {Set} + */ +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)); +let connectedToApi = false; + +/** + * Holds manifest data for each extension. + * @type {Record} + */ +let manifests = {}; + +/** + * Default URL for the Extras API. + */ +const defaultUrl = 'http://localhost:5100'; let requiresReload = false; let stateChanged = false; +let saveMetadataTimeout = null; export function saveMetadataDebounced() { const context = getContext(); @@ -52,9 +79,9 @@ export function saveMetadataDebounced() { } console.debug('Saving metadata...'); - newContext.saveMetadata(); + await newContext.saveMetadata(); console.debug('Saved metadata...'); - }, 1000); + }, debounce_timeout.relaxed); } /** @@ -84,7 +111,7 @@ export function renderExtensionTemplateAsync(extensionName, templateId, template } // Disables parallel updates -class ModuleWorkerWrapper { +export class ModuleWorkerWrapper { constructor(callback) { this.isBusy = false; this.callback = callback; @@ -108,7 +135,7 @@ class ModuleWorkerWrapper { } } -const extension_settings = { +export const extension_settings = { apiUrl: defaultUrl, apiKey: '', autoConnect: false, @@ -173,12 +200,6 @@ const extension_settings = { disabled_attachments: [], }; -let modules = []; -let activeExtensions = new Set(); - -const getApiUrl = () => extension_settings.apiUrl; -let connectedToApi = false; - function showHideExtensionsMenu() { // Get the number of menu items that are not hidden const hasMenuItems = $('#extensionsMenu').children().filter((_, child) => $(child).css('display') !== 'none').length > 0; @@ -195,7 +216,23 @@ function showHideExtensionsMenu() { // Periodically check for new extensions const menuInterval = setInterval(showHideExtensionsMenu, 1000); -async function doExtrasFetch(endpoint, args) { +/** + * Gets the type of an extension based on its external ID. + * @param {string} externalId External ID of the extension (excluding or including the leading 'third-party/') + * @returns {string} Type of the extension (global, local, system, or empty string if not found) + */ +function getExtensionType(externalId) { + const id = Object.keys(extensionTypes).find(id => id === externalId || (id.startsWith('third-party') && id.endsWith(externalId))); + return id ? extensionTypes[id] : ''; +} + +/** + * Performs a fetch of the Extras API. + * @param {string|URL} endpoint Extras API endpoint + * @param {RequestInit} args Request arguments + * @returns {Promise} Response from the fetch + */ +export async function doExtrasFetch(endpoint, args = {}) { if (!args) { args = {}; } @@ -214,10 +251,13 @@ async function doExtrasFetch(endpoint, args) { }); } - const response = await fetch(endpoint, args); - return response; + return await fetch(endpoint, args); } +/** + * Discovers extensions from the API. + * @returns {Promise<{name: string, type: string}[]>} + */ async function discoverExtensions() { try { const response = await fetch('/api/extensions/discover'); @@ -246,6 +286,11 @@ function onEnableExtensionClick() { enableExtension(name, false); } +/** + * Enables an extension by name. + * @param {string} name Extension name + * @param {boolean} [reload=true] If true, reload the page after enabling the extension + */ export async function enableExtension(name, reload = true) { extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name); stateChanged = true; @@ -257,6 +302,11 @@ export async function enableExtension(name, reload = true) { } } +/** + * Disables an extension by name. + * @param {string} name Extension name + * @param {boolean} [reload=true] If true, reload the page after disabling the extension + */ export async function disableExtension(name, reload = true) { extension_settings.disabledExtensions.push(name); stateChanged = true; @@ -268,6 +318,11 @@ export async function disableExtension(name, reload = true) { } } +/** + * Loads manifest.json files for extensions. + * @param {string[]} names Array of extension names + * @returns {Promise>} Object with extension names as keys and their manifests as values + */ async function getManifests(names) { const obj = {}; const promises = []; @@ -295,43 +350,36 @@ async function getManifests(names) { return obj; } +/** + * Tries to activate all available extensions that are not already active. + * @returns {Promise} + */ async function activateExtensions() { - const extensions = Object.entries(manifests).sort((a, b) => a[1].loading_order - b[1].loading_order); + const extensions = Object.entries(manifests).sort((a, b) => sortManifests(a[1], b[1])); const promises = []; for (let entry of extensions) { const name = entry[0]; const manifest = entry[1]; - const elementExists = document.getElementById(name) !== null; - if (elementExists || activeExtensions.has(name)) { + if (activeExtensions.has(name)) { continue; } - // all required modules are active (offline extensions require none) - if (isSubsetOf(modules, manifest.requires)) { + const meetsModuleRequirements = !Array.isArray(manifest.requires) || isSubsetOf(modules, manifest.requires); + const isDisabled = extension_settings.disabledExtensions.includes(name); + + if (meetsModuleRequirements && !isDisabled) { try { - const isDisabled = extension_settings.disabledExtensions.includes(name); - const li = document.createElement('li'); - - if (!isDisabled) { - const promise = Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]); - await promise - .then(() => activeExtensions.add(name)) - .catch(err => console.log('Could not activate extension: ' + name, err)); - promises.push(promise); - } - else { - li.classList.add('disabled'); - } - - li.id = name; - li.innerText = manifest.display_name; - - $('#extensions_list').append(li); + console.debug('Activating extension', name); + const promise = Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]); + await promise + .then(() => activeExtensions.add(name)) + .catch(err => console.log('Could not activate extension', name, err)); + promises.push(promise); } catch (error) { - console.error(`Could not activate extension: ${name}`); + console.error('Could not activate extension', name); console.error(error); } } @@ -341,8 +389,8 @@ async function activateExtensions() { } async function connectClickHandler() { - const baseUrl = $('#extensions_url').val(); - extension_settings.apiUrl = String(baseUrl); + const baseUrl = String($('#extensions_url').val()); + extension_settings.apiUrl = baseUrl; const testApiKey = $('#extensions_api_key').val(); extension_settings.apiKey = String(testApiKey); saveSettingsDebounced(); @@ -371,26 +419,30 @@ async function addExtensionsButtonAndMenu() { const title = translate(button.attr('title')); button.attr('title', title); const dropdown = $('#extensionsMenu'); - //dropdown.hide(); + let isDropdownVisible = false; let popper = Popper.createPopper(button.get(0), dropdown.get(0), { placement: 'top-start', }); $(button).on('click', function () { - if (dropdown.is(':visible')) { + if (isDropdownVisible) { dropdown.fadeOut(animation_duration); + isDropdownVisible = false; } else { dropdown.fadeIn(animation_duration); + isDropdownVisible = true; } popper.update(); }); $('html').on('click', function (e) { + if (!isDropdownVisible) return; const clickTarget = $(e.target); const noCloseTargets = ['#sd_gen', '#extensionsMenuButton', '#roll_dice']; - if (dropdown.is(':visible') && !noCloseTargets.some(id => clickTarget.closest(id).length > 0)) { - $(dropdown).fadeOut(animation_duration); + if (!noCloseTargets.some(id => clickTarget.closest(id).length > 0)) { + dropdown.fadeOut(animation_duration); + isDropdownVisible = false; } }); } @@ -404,21 +456,11 @@ function notifyUpdatesInputHandler() { } } -/* $(document).on('click', function (e) { - const target = $(e.target); - if (target.is(dropdown)) return; - if (target.is(button) && dropdown.is(':hidden')) { - dropdown.toggle(200); - popper.update(); - } - if (target !== dropdown && - target !== button && - dropdown.is(":visible")) { - dropdown.hide(200); - } - }); -} */ - +/** + * Connects to the Extras API. + * @param {string} baseUrl Extras API base URL + * @returns {Promise} + */ async function connectToApi(baseUrl) { if (!baseUrl) { return; @@ -434,7 +476,7 @@ async function connectToApi(baseUrl) { const data = await getExtensionsResult.json(); modules = data.modules; await activateExtensions(); - eventSource.emit(event_types.EXTRAS_CONNECTED, modules); + await eventSource.emit(event_types.EXTRAS_CONNECTED, modules); } updateStatus(getExtensionsResult.ok); @@ -444,71 +486,84 @@ async function connectToApi(baseUrl) { } } +/** + * Updates the status of Extras API connection. + * @param {boolean} success Whether the connection was successful + */ function updateStatus(success) { connectedToApi = success; - const _text = success ? 'Connected to API' : 'Could not connect to API'; + const _text = success ? t`Connected to API` : t`Could not connect to API`; const _class = success ? 'success' : 'failure'; $('#extensions_status').text(_text); $('#extensions_status').attr('class', _class); } +/** + * Adds a CSS file for an extension. + * @param {string} name Extension name + * @param {object} manifest Extension manifest + * @returns {Promise} When the CSS is loaded + */ function addExtensionStyle(name, manifest) { - if (manifest.css) { - return new Promise((resolve, reject) => { - const url = `/scripts/extensions/${name}/${manifest.css}`; - - if ($(`link[id="${name}"]`).length === 0) { - const link = document.createElement('link'); - link.id = name; - link.rel = 'stylesheet'; - link.type = 'text/css'; - link.href = url; - link.onload = function () { - resolve(); - }; - link.onerror = function (e) { - reject(e); - }; - document.head.appendChild(link); - } - }); + if (!manifest.css) { + return Promise.resolve(); } - return Promise.resolve(); + return new Promise((resolve, reject) => { + const url = `/scripts/extensions/${name}/${manifest.css}`; + + if ($(`link[id="${name}"]`).length === 0) { + const link = document.createElement('link'); + link.id = name; + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = url; + link.onload = function () { + resolve(); + }; + link.onerror = function (e) { + reject(e); + }; + document.head.appendChild(link); + } + }); } +/** + * Loads a JS file for an extension. + * @param {string} name Extension name + * @param {object} manifest Extension manifest + * @returns {Promise} When the script is loaded + */ function addExtensionScript(name, manifest) { - if (manifest.js) { - return new Promise((resolve, reject) => { - const url = `/scripts/extensions/${name}/${manifest.js}`; - let ready = false; - - if ($(`script[id="${name}"]`).length === 0) { - const script = document.createElement('script'); - script.id = name; - script.type = 'module'; - script.src = url; - script.async = true; - script.onerror = function (err) { - reject(err, script); - }; - script.onload = script.onreadystatechange = function () { - // console.log(this.readyState); // uncomment this line to see which ready states are called. - if (!ready && (!this.readyState || this.readyState == 'complete')) { - ready = true; - resolve(); - } - }; - document.body.appendChild(script); - } - }); + if (!manifest.js) { + return Promise.resolve(); } - return Promise.resolve(); + return new Promise((resolve, reject) => { + const url = `/scripts/extensions/${name}/${manifest.js}`; + let ready = false; + + if ($(`script[id="${name}"]`).length === 0) { + const script = document.createElement('script'); + script.id = name; + script.type = 'module'; + script.src = url; + script.async = true; + script.onerror = function (err) { + reject(err); + }; + script.onload = function () { + if (!ready) { + ready = true; + resolve(); + } + }; + document.body.appendChild(script); + } + }); } - - /** * Generates HTML string for displaying an extension in the UI. * @@ -518,64 +573,85 @@ function addExtensionScript(name, manifest) { * @param {boolean} isDisabled - Whether the extension is disabled or not. * @param {boolean} isExternal - Whether the extension is external or not. * @param {string} checkboxClass - The class for the checkbox HTML element. - * @return {Promise} - The HTML string that represents the extension. + * @return {string} - The HTML string that represents the extension. */ -async function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { +function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { + function getExtensionIcon() { + const type = getExtensionType(name); + switch (type) { + case 'global': + return ''; + case 'local': + return ''; + case 'system': + return ''; + default: + return ''; + } + } + + const isUserAdmin = isAdmin(); + const extensionIcon = getExtensionIcon(); const displayName = manifest.display_name; - let displayVersion = manifest.version ? ` v${manifest.version}` : ''; - let isUpToDate = true; - let updateButton = ''; + const displayVersion = manifest.version || ''; + const externalId = name.replace('third-party', ''); let originHtml = ''; if (isExternal) { - let data = await getExtensionVersion(name.replace('third-party', '')); - let branch = data.currentBranchName; - let commitHash = data.currentCommitHash; - let origin = data.remoteUrl; - isUpToDate = data.isUpToDate; - displayVersion = ` (${branch}-${commitHash.substring(0, 7)})`; - updateButton = isUpToDate ? - `` : - ``; - originHtml = `
`; + originHtml = ''; } let toggleElement = isActive || isDisabled ? `` : ``; - let deleteButton = isExternal ? `` : ''; - - // if external, wrap the name in a link to the repo - - let extensionHtml = `
-

- ${updateButton} - ${deleteButton} - ${originHtml} - - ${DOMPurify.sanitize(displayName)}${displayVersion} - - ${isExternal ? '' : ''} - - ${toggleElement} -

`; + let deleteButton = isExternal ? `` : ''; + let updateButton = isExternal ? `` : ''; + let moveButton = isExternal && isUserAdmin ? `` : ''; + let modulesInfo = ''; if (isActive && Array.isArray(manifest.optional)) { const optional = new Set(manifest.optional); modules.forEach(x => optional.delete(x)); if (optional.size > 0) { const optionalString = DOMPurify.sanitize([...optional].join(', ')); - extensionHtml += `

Optional modules: ${optionalString}

`; + modulesInfo = `
Optional modules: ${optionalString}
`; } } else if (!isDisabled) { // Neither active nor disabled const requirements = new Set(manifest.requires); modules.forEach(x => requirements.delete(x)); if (requirements.size > 0) { const requirementsString = DOMPurify.sanitize([...requirements].join(', ')); - extensionHtml += `

Missing modules: ${requirementsString}

`; + modulesInfo = `
Missing modules: ${requirementsString}
`; } } + // if external, wrap the name in a link to the repo + + let extensionHtml = ` +
+
+ ${toggleElement} +
+
+ ${extensionIcon} +
+
+ ${originHtml} + + ${DOMPurify.sanitize(displayName)} + ${DOMPurify.sanitize(displayVersion)} + ${modulesInfo} + + ${isExternal ? '' : ''} +
+ +
+ ${updateButton} + ${moveButton} + ${deleteButton} +
+
`; + return extensionHtml; } @@ -583,9 +659,9 @@ async function generateExtensionHtml(name, manifest, isActive, isDisabled, isExt * Gets extension data and generates the corresponding HTML for displaying the extension. * * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. - * @return {Promise} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string. + * @return {object} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string. */ -async function getExtensionData(extension) { +function getExtensionData(extension) { const name = extension[0]; const manifest = extension[1]; const isActive = activeExtensions.has(name); @@ -594,7 +670,7 @@ async function getExtensionData(extension) { const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; - const extensionHtml = await generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); + const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); return { isExternal, extensionHtml }; } @@ -617,42 +693,38 @@ function getModuleInformation() { * Generates the HTML strings for all extensions and displays them in a popup. */ async function showExtensionsDetails() { + const abortController = new AbortController(); let popupPromise; try { - const htmlDefault = $('

Built-in Extensions:

'); - const htmlExternal = $('

Installed Extensions:

').addClass('opacity50p'); - const htmlLoading = $(`

+ // If we are updating an extension, the "old" popup is still active. We should close that. + let initialScrollTop = 0; + const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info')); + if (oldPopup) { + initialScrollTop = oldPopup.content.scrollTop; + await oldPopup.completeCancelled(); + } + const htmlDefault = $('

Built-in Extensions:

'); + const htmlExternal = $('

Installed Extensions:

'); + const htmlLoading = $(`
Loading third-party extensions... Please wait... -

`); + `); - /** @type {Promise[]} */ - const promises = []; - const extensions = Object.entries(manifests).sort((a, b) => a[1].loading_order - b[1].loading_order); + htmlExternal.append(htmlLoading); - for (const extension of extensions) { - promises.push(getExtensionData(extension)); - } + const extensions = Object.entries(manifests).sort((a, b) => sortManifests(a[1], b[1])).map(getExtensionData); - promises.forEach(promise => { - promise.then(value => { - const { isExternal, extensionHtml } = value; - const container = isExternal ? htmlExternal : htmlDefault; - container.append(extensionHtml); - }); - }); - - Promise.allSettled(promises).then(() => { - htmlLoading.remove(); - htmlExternal.removeClass('opacity50p'); + extensions.forEach(value => { + const { isExternal, extensionHtml } = value; + const container = isExternal ? htmlExternal : htmlDefault; + container.append(extensionHtml); }); const html = $('
') .addClass('extensions_info') - .append(getModuleInformation()) .append(htmlDefault) - .append(htmlLoading) - .append(htmlExternal); + .append(htmlExternal) + .append(getModuleInformation()); /** @type {import('./popup.js').CustomPopupButton} */ const updateAllButton = { @@ -665,12 +737,6 @@ async function showExtensionsDetails() { }, }; - // If we are updating an extension, the "old" popup is still active. We should close that. - const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info')); - if (oldPopup) { - await oldPopup.complete(POPUP_RESULT.CANCELLED); - } - let waitingForSave = false; const popup = new Popup(html, POPUP_TYPE.TEXT, '', { @@ -685,7 +751,7 @@ async function showExtensionsDetails() { } if (stateChanged) { waitingForSave = true; - const toast = toastr.info('The page will be reloaded shortly...', 'Extensions state changed'); + const toast = toastr.info(t`The page will be reloaded shortly...`, t`Extensions state changed`); await saveSettings(); toastr.clear(toast); waitingForSave = false; @@ -695,12 +761,15 @@ async function showExtensionsDetails() { }, }); popupPromise = popup.show(); + popup.content.scrollTop = initialScrollTop; + checkForUpdatesManual(abortController.signal).finally(() => htmlLoading.remove()); } catch (error) { - toastr.error('Error loading extensions. See browser console for details.'); + toastr.error(t`Error loading extensions. See browser console for details.`); console.error(error); } if (popupPromise) { await popupPromise; + abortController.abort(); } if (requiresReload) { showLoader(); @@ -708,17 +777,26 @@ async function showExtensionsDetails() { } } - /** * Handles the click event for the update button of an extension. - * This function makes a POST request to '/update_extension' with the extension's name. + * This function makes a POST request to '/api/extensions/update' with the extension's name. * If the extension is already up to date, it displays a success message. * If the extension is not up to date, it updates the extension and displays a success message with the new commit hash. */ async function onUpdateClick() { + const isCurrentUserAdmin = isAdmin(); const extensionName = $(this).data('name'); - $(this).find('i').addClass('fa-spin'); + const isGlobal = getExtensionType(extensionName) === 'global'; + if (isGlobal && !isCurrentUserAdmin) { + toastr.error(t`You don't have permission to update global extensions.`); + return; + } + + const icon = $(this).find('i'); + icon.addClass('fa-spin'); await updateExtension(extensionName, false); + // updateExtension eats the error, but we can at least stop the spinner + icon.removeClass('fa-spin'); } /** @@ -731,13 +809,23 @@ async function updateExtension(extensionName, quiet) { const response = await fetch('/api/extensions/update', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ extensionName }), + body: JSON.stringify({ + extensionName, + global: getExtensionType(extensionName) === 'global', + }), }); + if (!response.ok) { + const text = await response.text(); + toastr.error(text || response.statusText, t`Extension update failed`, { timeOut: 5000 }); + console.error('Extension update failed', response.status, response.statusText, text); + return; + } + const data = await response.json(); if (!quiet) { - showExtensionsDetails(); + void showExtensionsDetails(); } if (data.isUpToDate) { @@ -760,44 +848,122 @@ async function updateExtension(extensionName, quiet) { */ async function onDeleteClick() { const extensionName = $(this).data('name'); + const isCurrentUserAdmin = isAdmin(); + const isGlobal = getExtensionType(extensionName) === 'global'; + if (isGlobal && !isCurrentUserAdmin) { + toastr.error(t`You don't have permission to delete global extensions.`); + return; + } + // use callPopup to create a popup for the user to confirm before delete - const confirmation = await callGenericPopup(`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {}); + const confirmation = await callGenericPopup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {}); if (confirmation === POPUP_RESULT.AFFIRMATIVE) { await deleteExtension(extensionName); } } +async function onMoveClick() { + const extensionName = $(this).data('name'); + const isCurrentUserAdmin = isAdmin(); + const isGlobal = getExtensionType(extensionName) === 'global'; + if (isGlobal && !isCurrentUserAdmin) { + toastr.error(t`You don't have permission to move extensions.`); + return; + } + + const source = getExtensionType(extensionName); + const destination = source === 'global' ? 'local' : 'global'; + + const confirmationHeader = t`Move extension`; + const confirmationText = source == 'global' + ? t`Are you sure you want to move ${extensionName} to your local extensions? This will make it available only for you.` + : t`Are you sure you want to move ${extensionName} to the global extensions? This will make it available for all users.`; + + const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText); + + if (!confirmation) { + return; + } + + $(this).find('i').addClass('fa-spin'); + await moveExtension(extensionName, source, destination); +} + +/** + * Moves an extension via the API. + * @param {string} extensionName Extension name + * @param {string} source Source type + * @param {string} destination Destination type + * @returns {Promise} + */ +async function moveExtension(extensionName, source, destination) { + try { + const result = await fetch('/api/extensions/move', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify({ + extensionName, + source, + destination, + }), + }); + + if (!result.ok) { + const text = await result.text(); + toastr.error(text || result.statusText, t`Extension move failed`, { timeOut: 5000 }); + console.error('Extension move failed', result.status, result.statusText, text); + return; + } + + toastr.success(t`Extension ${extensionName} moved.`); + await loadExtensionSettings({}, false, false); + void showExtensionsDetails(); + } catch (error) { + console.error('Error:', error); + } +} + +/** + * Deletes an extension via the API. + * @param {string} extensionName Extension name to delete + */ export async function deleteExtension(extensionName) { try { await fetch('/api/extensions/delete', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ extensionName }), + body: JSON.stringify({ + extensionName, + global: getExtensionType(extensionName) === 'global', + }), }); } catch (error) { console.error('Error:', error); } - toastr.success(`Extension ${extensionName} deleted`); - showExtensionsDetails(); - // reload the page to remove the extension from the list - location.reload(); + toastr.success(t`Extension ${extensionName} deleted`); + delay(1000).then(() => location.reload()); } /** * Fetches the version details of a specific extension. * * @param {string} extensionName - The name of the extension. + * @param {AbortSignal} [abortSignal] - The signal to abort the operation. * @return {Promise} - An object containing the extension's version details. * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl. * @throws {error} - If there is an error during the fetch operation, it logs the error to the console. */ -async function getExtensionVersion(extensionName) { +async function getExtensionVersion(extensionName, abortSignal) { try { const response = await fetch('/api/extensions/version', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ extensionName }), + body: JSON.stringify({ + extensionName, + global: getExtensionType(extensionName) === 'global', + }), + signal: abortSignal, }); const data = await response.json(); @@ -810,22 +976,26 @@ async function getExtensionVersion(extensionName) { /** * Installs a third-party extension via the API. * @param {string} url Extension repository URL + * @param {boolean} global Is the extension global? * @returns {Promise} */ -export async function installExtension(url) { +export async function installExtension(url, global) { console.debug('Extension installation started', url); - toastr.info('Please wait...', 'Installing extension'); + toastr.info(t`Please wait...`, t`Installing extension`); const request = await fetch('/api/extensions/install', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ url }), + body: JSON.stringify({ + url, + global, + }), }); if (!request.ok) { const text = await request.text(); - toastr.warning(text || request.statusText, 'Extension installation failed', { timeOut: 5000 }); + toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 }); console.error('Extension installation failed', request.status, request.statusText, text); return; } @@ -843,7 +1013,7 @@ export async function installExtension(url) { * @param {boolean} versionChanged Is this a version change? * @param {boolean} enableAutoUpdate Enable auto-update */ -async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) { +export async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) { if (settings.extension_settings) { Object.assign(extension_settings, settings.extension_settings); } @@ -855,7 +1025,9 @@ async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) // Activate offline extensions await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD); - extensionNames = await discoverExtensions(); + const extensions = await discoverExtensions(); + extensionNames = extensions.map(x => x.name); + extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type])); manifests = await getManifests(extensionNames); if (versionChanged && enableAutoUpdate) { @@ -876,6 +1048,86 @@ export function doDailyExtensionUpdatesCheck() { }, 1); } +const concurrencyLimit = 5; +let activeRequestsCount = 0; +const versionCheckQueue = []; + +function enqueueVersionCheck(fn) { + return new Promise((resolve, reject) => { + versionCheckQueue.push(() => fn().then(resolve).catch(reject)); + processVersionCheckQueue(); + }); +} + +function processVersionCheckQueue() { + if (activeRequestsCount >= concurrencyLimit || versionCheckQueue.length === 0) { + return; + } + activeRequestsCount++; + const fn = versionCheckQueue.shift(); + fn().finally(() => { + activeRequestsCount--; + processVersionCheckQueue(); + }); +} + +/** + * Performs a manual check for updates on all 3rd-party extensions. + * @param {AbortSignal} abortSignal Signal to abort the operation + * @returns {Promise} + */ +async function checkForUpdatesManual(abortSignal) { + const promises = []; + for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party')).sort((a, b) => sortManifests(manifests[a], manifests[b]))) { + const externalId = id.replace('third-party', ''); + const promise = enqueueVersionCheck(async () => { + try { + const data = await getExtensionVersion(externalId, abortSignal); + const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`); + if (extensionBlock && data) { + if (data.isUpToDate === false) { + const buttonElement = extensionBlock.querySelector('.btn_update'); + if (buttonElement) { + buttonElement.classList.remove('displayNone'); + } + const nameElement = extensionBlock.querySelector('.extension_name'); + if (nameElement) { + nameElement.classList.add('update_available'); + } + } + let branch = data.currentBranchName; + let commitHash = data.currentCommitHash; + let origin = data.remoteUrl; + + const originLink = extensionBlock.querySelector('a'); + if (originLink) { + try { + const url = new URL(origin); + if (!['https:', 'http:'].includes(url.protocol)) { + throw new Error('Invalid protocol'); + } + originLink.href = url.href; + originLink.target = '_blank'; + originLink.rel = 'noopener noreferrer'; + } catch (error) { + console.log('Error setting origin link', originLink, error); + } + } + + const versionElement = extensionBlock.querySelector('.extension_version'); + if (versionElement) { + versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`; + } + } + } catch (error) { + console.error('Error checking for extension updates', error); + } + }); + promises.push(promise); + } + return Promise.allSettled(promises); +} + /** * Checks if there are updates available for 3rd-party extensions. * @param {boolean} force Skip nag check @@ -894,21 +1146,26 @@ async function checkForExtensionUpdates(force) { localStorage.setItem(STORAGE_NAG_KEY, currentDate); } + const isCurrentUserAdmin = isAdmin(); const updatesAvailable = []; const promises = []; for (const [id, manifest] of Object.entries(manifests)) { + const isGlobal = getExtensionType(id) === 'global'; + if (isGlobal && !isCurrentUserAdmin) { + console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); + continue; + } + if (manifest.auto_update && id.startsWith('third-party')) { - const promise = new Promise(async (resolve, reject) => { + const promise = enqueueVersionCheck(async () => { try { const data = await getExtensionVersion(id.replace('third-party', '')); - if (data.isUpToDate === false) { + if (!data.isUpToDate) { updatesAvailable.push(manifest.display_name); } - resolve(); } catch (error) { console.error('Error checking for extension updates', error); - reject(); } }); promises.push(promise); @@ -933,8 +1190,14 @@ async function autoUpdateExtensions(forceAll) { } const banner = toastr.info('Auto-updating extensions. This may take several minutes.', 'Please wait...', { timeOut: 10000, extendedTimeOut: 10000 }); + const isCurrentUserAdmin = isAdmin(); const promises = []; for (const [id, manifest] of Object.entries(manifests)) { + const isGlobal = getExtensionType(id) === 'global'; + if (isGlobal && !isCurrentUserAdmin) { + console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); + continue; + } if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) { console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`); promises.push(updateExtension(id.replace('third-party', ''), true)); @@ -950,7 +1213,7 @@ async function autoUpdateExtensions(forceAll) { * @param {number} contextSize Context size * @returns {Promise} True if generation should be aborted */ -async function runGenerationInterceptors(chat, contextSize) { +export async function runGenerationInterceptors(chat, contextSize) { let aborted = false; let exitImmediately = false; @@ -959,11 +1222,11 @@ async function runGenerationInterceptors(chat, contextSize) { exitImmediately = immediately; }; - for (const manifest of Object.values(manifests).sort((a, b) => a.loading_order - b.loading_order)) { + for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifests(a, b))) { const interceptorKey = manifest.generate_interceptor; - if (typeof window[interceptorKey] === 'function') { + if (typeof globalThis[interceptorKey] === 'function') { try { - await window[interceptorKey](chat, contextSize, abort); + await globalThis[interceptorKey](chat, contextSize, abort); } catch (e) { console.error(`Failed running interceptor for ${manifest.display_name}`, e); } @@ -1036,8 +1299,23 @@ export async function writeExtensionField(characterId, key, value) { * @returns {Promise} */ export async function openThirdPartyExtensionMenu(suggestUrl = '') { - const html = await renderTemplateAsync('installExtension'); - const input = await callGenericPopup(html, POPUP_TYPE.INPUT, suggestUrl ?? ''); + const isCurrentUserAdmin = isAdmin(); + const html = await renderTemplateAsync('installExtension', { isCurrentUserAdmin }); + const okButton = isCurrentUserAdmin ? t`Install just for me` : t`Install`; + + let global = false; + const installForAllButton = { + text: t`Install for all users`, + appendAtEnd: false, + action: async () => { + global = true; + await popup.complete(POPUP_RESULT.AFFIRMATIVE); + }, + }; + + const customButtons = isCurrentUserAdmin ? [installForAllButton] : []; + const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons }); + const input = await popup.show(); if (!input) { console.debug('Extension install cancelled'); @@ -1045,11 +1323,9 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') { } const url = String(input).trim(); - await installExtension(url); + await installExtension(url, global); } - - export async function initExtensions() { await addExtensionsButtonAndMenu(); $('#extensionsMenuButton').css('display', 'flex'); @@ -1058,10 +1334,11 @@ export async function initExtensions() { $('#extensions_autoconnect').on('input', autoConnectInputHandler); $('#extensions_details').on('click', showExtensionsDetails); $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler); - $(document).on('click', '.toggle_disable', onDisableExtensionClick); - $(document).on('click', '.toggle_enable', onEnableExtensionClick); - $(document).on('click', '.btn_update', onUpdateClick); - $(document).on('click', '.btn_delete', onDeleteClick); + $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick); + $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); + $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); + $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); + $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); /** * Handles the click event for the third-party extension import button. diff --git a/public/scripts/extensions/caption/index.js b/public/scripts/extensions/caption/index.js index 19c35fe71..7b7421c8b 100644 --- a/public/scripts/extensions/caption/index.js +++ b/public/scripts/extensions/caption/index.js @@ -393,7 +393,7 @@ jQuery(async function () { const sendButton = $(`
- Generate Caption + Generate Caption
`); $('#caption_wand_container').append(sendButton); diff --git a/public/scripts/extensions/caption/settings.html b/public/scripts/extensions/caption/settings.html index 79692d54a..187e47876 100644 --- a/public/scripts/extensions/caption/settings.html +++ b/public/scripts/extensions/caption/settings.html @@ -53,6 +53,8 @@ + + @@ -69,7 +71,6 @@ - diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 4130a5b05..e5ba1ed21 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -14,7 +14,7 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from ' import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; -import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js'; +import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; export { MODULE_NAME }; const MODULE_NAME = 'expressions'; @@ -59,6 +59,7 @@ const EXPRESSION_API = { local: 0, extras: 1, llm: 2, + webllm: 3, }; let expressionsList = null; @@ -697,6 +698,11 @@ async function moduleWorker() { return; } + // If using LLM api then check if streamingProcessor is finished to avoid sending multiple requests to the API + if (extension_settings.expressions.api === EXPRESSION_API.llm && context.streamingProcessor && !context.streamingProcessor.isFinished) { + return; + } + // API is busy if (inApiCall) { console.debug('Classification API is busy'); @@ -847,7 +853,7 @@ function setTalkingHeadState(newState) { extension_settings.expressions.talkinghead = newState; // Store setting saveSettingsDebounced(); - if (extension_settings.expressions.api == EXPRESSION_API.local || extension_settings.expressions.api == EXPRESSION_API.llm) { + if ([EXPRESSION_API.local, EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) { return; } @@ -979,6 +985,71 @@ async function setSpriteSlashCommand(_, spriteId) { return label; } +/** + * Returns the sprite folder name (including override) for a character. + * @param {object} char Character object + * @param {string} char.avatar Avatar filename with extension + * @returns {string} Sprite folder name + * @throws {Error} If character not found or avatar not set + */ +function spriteFolderNameFromCharacter(char) { + const avatarFileName = char.avatar.replace(/\.[^/.]+$/, ''); + const expressionOverride = extension_settings.expressionOverrides.find(e => e.name === avatarFileName); + return expressionOverride?.path ? expressionOverride.path : avatarFileName; +} + +/** + * Slash command callback for /uploadsprite + * + * label= is required + * if name= is provided, it will be used as a findChar lookup + * if name= is not provided, the last character's name will be used + * if folder= is a full path, it will be used as the folder + * if folder= is a partial path, it will be appended to the character's name + * if folder= is not provided, the character's override folder will be used, if set + * + * @param {object} args + * @param {string} args.name Character name or avatar key, passed through findChar + * @param {string} args.label Expression label + * @param {string} args.folder Sprite folder path, processed using backslash rules + * @param {string} imageUrl Image URI to fetch and upload + * @returns {Promise} + */ +async function uploadSpriteCommand({ name, label, folder }, imageUrl) { + if (!imageUrl) throw new Error('Image URL is required'); + if (!label || typeof label !== 'string') throw new Error('Expression label is required'); + + label = label.replace(/[^a-z]/gi, '').toLowerCase().trim(); + if (!label) throw new Error('Expression label must contain at least one letter'); + + name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; + const char = findChar({ name }); + + if (!folder) { + folder = spriteFolderNameFromCharacter(char); + } else if (folder.startsWith('/') || folder.startsWith('\\')) { + const subfolder = folder.slice(1); + folder = `${char.name}/${subfolder}`; + } + + try { + const response = await fetch(imageUrl); + const blob = await response.blob(); + const file = new File([blob], 'image.png', { type: 'image/png' }); + + const formData = new FormData(); + formData.append('name', folder); // this is the folder or character name + formData.append('label', label); // this is the expression label + formData.append('avatar', file); // this is the image file + + await handleFileUpload('/api/sprites/upload', formData); + console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`); + } catch (error) { + console.error(`[${MODULE_NAME}] Error uploading file:`, error); + throw error; + } +} + /** * Processes the classification text to reduce the amount of text sent to the API. * Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is. @@ -995,6 +1066,11 @@ function sampleClassifyText(text) { // Replace macros, remove asterisks and quotes let result = substituteParams(text).replace(/[*"]/g, ''); + // If using LLM api there is no need to check length of characters + if (extension_settings.expressions.api === EXPRESSION_API.llm) { + return result.trim(); + } + const SAMPLE_THRESHOLD = 500; const HALF_SAMPLE_THRESHOLD = SAMPLE_THRESHOLD / 2; @@ -1047,11 +1123,39 @@ function parseLlmResponse(emotionResponse, labels) { console.debug(`fuzzy search found: ${result[0].item} as closest for the LLM response:`, emotionResponse); return result[0].item; } + const lowerCaseResponse = String(emotionResponse || '').toLowerCase(); + for (const label of labels) { + if (lowerCaseResponse.includes(label.toLowerCase())) { + console.debug(`Found label ${label} in the LLM response:`, emotionResponse); + return label; + } + } } throw new Error('Could not parse emotion response ' + emotionResponse); } +/** + * Gets the JSON schema for the LLM API. + * @param {string[]} emotions A list of emotions to search for. + * @returns {object} The JSON schema for the LLM API. + */ +function getJsonSchema(emotions) { + return { + $schema: 'http://json-schema.org/draft-04/schema#', + type: 'object', + properties: { + emotion: { + type: 'string', + enum: emotions, + }, + }, + required: [ + 'emotion', + ], + }; +} + function onTextGenSettingsReady(args) { // Only call if inside an API call if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) { @@ -1061,19 +1165,7 @@ function onTextGenSettingsReady(args) { stop: [], stopping_strings: [], custom_token_bans: [], - json_schema: { - $schema: 'http://json-schema.org/draft-04/schema#', - type: 'object', - properties: { - emotion: { - type: 'string', - enum: emotions, - }, - }, - required: [ - 'emotion', - ], - }, + json_schema: getJsonSchema(emotions), }); } } @@ -1129,6 +1221,22 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin const emotionResponse = await generateRaw(text, main_api, false, false, prompt); return parseLlmResponse(emotionResponse, expressionsList); } + // Using WebLLM + case EXPRESSION_API.webllm: { + if (!isWebLlmSupported()) { + console.warn('WebLLM is not supported. Using fallback expression'); + return getFallbackExpression(); + } + + const expressionsList = await getExpressionsList(); + const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList); + const messages = [ + { role: 'user', content: text + '\n\n' + prompt }, + ]; + + const emotionResponse = await generateWebLlmChatPrompt(messages); + return parseLlmResponse(emotionResponse, expressionsList); + } // Extras default: { const url = new URL(getApiUrl()); @@ -1239,8 +1347,6 @@ async function drawSpritesList(character, labels, sprites) { * @returns {Promise} Rendered list item template */ async function getListItem(item, imageSrc, textClass, isCustom) { - const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; - imageSrc = isFirefox ? `${imageSrc}?t=${Date.now()}` : imageSrc; return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); } @@ -1593,7 +1699,7 @@ function onExpressionApiChanged() { const tempApi = this.value; if (tempApi) { extension_settings.expressions.api = Number(tempApi); - $('.expression_llm_prompt_block').toggle(extension_settings.expressions.api === EXPRESSION_API.llm); + $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); expressionsList = null; spriteCache = {}; moduleWorker(); @@ -1930,7 +2036,7 @@ function migrateSettings() { await renderAdditionalExpressionSettings(); $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras); - $('.expression_llm_prompt_block').toggle(extension_settings.expressions.api === EXPRESSION_API.llm); + $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? ''); $('#expression_llm_prompt').on('input', function () { extension_settings.expressions.llmPrompt = $(this).val(); @@ -2173,4 +2279,43 @@ function migrateSettings() { `, })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'uploadsprite', + callback: async (args, url) => { + await uploadSpriteCommand(args, url); + return ''; + }, + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'URL of the image to upload', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + }), + ], + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'Character name or avatar key (default is current character)', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + acceptsMultiple: false, + }), + SlashCommandNamedArgument.fromProps({ + name: 'label', + description: 'Sprite label/expression name', + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: localEnumProviders.expressions, + isRequired: true, + acceptsMultiple: false, + }), + SlashCommandNamedArgument.fromProps({ + name: 'folder', + description: 'Override folder to upload into', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + acceptsMultiple: false, + }), + ], + helpString: '
Upload a sprite from a URL.
Example:
/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png
', + })); })(); diff --git a/public/scripts/extensions/expressions/settings.html b/public/scripts/extensions/expressions/settings.html index f2b7b79ac..dc22debbd 100644 --- a/public/scripts/extensions/expressions/settings.html +++ b/public/scripts/extensions/expressions/settings.html @@ -24,7 +24,8 @@
diff --git a/public/scripts/extensions/stable-diffusion/button.html b/public/scripts/extensions/stable-diffusion/button.html index 578fa8276..2962ff4f4 100644 --- a/public/scripts/extensions/stable-diffusion/button.html +++ b/public/scripts/extensions/stable-diffusion/button.html @@ -1,8 +1,8 @@
- Generate Image + Generate Image
- Stop Image Generation + Stop Image Generation
diff --git a/public/scripts/extensions/stable-diffusion/index.js b/public/scripts/extensions/stable-diffusion/index.js index 7a302f392..89ecc5de2 100644 --- a/public/scripts/extensions/stable-diffusion/index.js +++ b/public/scripts/extensions/stable-diffusion/index.js @@ -218,7 +218,7 @@ const defaultSettings = { // CFG Scale scale_min: 1, scale_max: 30, - scale_step: 0.5, + scale_step: 0.1, scale: 7, // Sampler steps @@ -319,6 +319,7 @@ const defaultSettings = { wand_visible: false, command_visible: false, interactive_visible: false, + tool_visible: false, // Stability AI settings stability_style_preset: 'anime', @@ -488,6 +489,7 @@ async function loadSettings() { $('#sd_wand_visible').prop('checked', extension_settings.sd.wand_visible); $('#sd_command_visible').prop('checked', extension_settings.sd.command_visible); $('#sd_interactive_visible').prop('checked', extension_settings.sd.interactive_visible); + $('#sd_tool_visible').prop('checked', extension_settings.sd.tool_visible); $('#sd_stability_style_preset').val(extension_settings.sd.stability_style_preset); $('#sd_huggingface_model_id').val(extension_settings.sd.huggingface_model_id); $('#sd_function_tool').prop('checked', extension_settings.sd.function_tool); @@ -844,6 +846,11 @@ function onInteractiveVisibleInput() { saveSettingsDebounced(); } +function onToolVisibleInput() { + extension_settings.sd.tool_visible = !!$('#sd_tool_visible').prop('checked'); + saveSettingsDebounced(); +} + function onClipSkipInput() { extension_settings.sd.clip_skip = Number($('#sd_clip_skip').val()); $('#sd_clip_skip_value').val(extension_settings.sd.clip_skip); @@ -1104,7 +1111,8 @@ function onHrSecondPassStepsInput() { } function onComfyUrlInput() { - extension_settings.sd.comfy_url = $('#sd_comfy_url').val(); + // Remove trailing slashes + extension_settings.sd.comfy_url = String($('#sd_comfy_url').val()); saveSettingsDebounced(); } @@ -1605,17 +1613,12 @@ async function loadVladSamplers() { } async function loadNovelSamplers() { - if (!secret_state[SECRET_KEYS.NOVEL]) { - console.debug('NovelAI API key is not set.'); - return []; - } - return [ + 'k_euler_ancestral', + 'k_euler', 'k_dpmpp_2m', 'k_dpmpp_sde', 'k_dpmpp_2s_ancestral', - 'k_euler', - 'k_euler_ancestral', 'k_dpm_fast', 'ddim', ]; @@ -1971,12 +1974,11 @@ async function loadVladModels() { } async function loadNovelModels() { - if (!secret_state[SECRET_KEYS.NOVEL]) { - console.debug('NovelAI API key is not set.'); - return []; - } - return [ + { + value: 'nai-diffusion-4-curated-preview', + text: 'NAI Diffusion Anime V4 (Curated Preview)', + }, { value: 'nai-diffusion-3', text: 'NAI Diffusion Anime V3', @@ -1985,22 +1987,10 @@ async function loadNovelModels() { value: 'nai-diffusion-2', text: 'NAI Diffusion Anime V2', }, - { - value: 'nai-diffusion', - text: 'NAI Diffusion Anime V1 (Full)', - }, - { - value: 'safe-diffusion', - text: 'NAI Diffusion Anime V1 (Curated)', - }, { value: 'nai-diffusion-furry-3', text: 'NAI Diffusion Furry V3', }, - { - value: 'nai-diffusion-furry', - text: 'NAI Diffusion Furry', - }, ]; } @@ -2041,7 +2031,7 @@ async function loadSchedulers() { schedulers = await getAutoRemoteSchedulers(); break; case sources.novel: - schedulers = ['N/A']; + schedulers = ['karras', 'native', 'exponential', 'polyexponential']; break; case sources.vlad: schedulers = ['N/A']; @@ -3150,6 +3140,7 @@ async function generateNovelImage(prompt, negativePrompt, signal) { prompt: prompt, model: extension_settings.sd.model, sampler: extension_settings.sd.sampler, + scheduler: extension_settings.sd.scheduler, steps: steps, scale: extension_settings.sd.scale, width: width, @@ -3177,13 +3168,18 @@ async function generateNovelImage(prompt, negativePrompt, signal) { * @returns {{steps: number, width: number, height: number, sm: boolean, sm_dyn: boolean}} - A tuple of parameters for NovelAI API. */ function getNovelParams() { - let steps = extension_settings.sd.steps; + let steps = Math.min(extension_settings.sd.steps, 50); let width = extension_settings.sd.width; let height = extension_settings.sd.height; let sm = extension_settings.sd.novel_sm; let sm_dyn = extension_settings.sd.novel_sm_dyn; - if (extension_settings.sd.sampler === 'ddim') { + // If a source was never changed after the scheduler setting was added, we need to set it to 'karras' for compatibility. + if (!extension_settings.sd.scheduler || extension_settings.sd.scheduler === 'normal') { + extension_settings.sd.scheduler = 'karras'; + } + + if (extension_settings.sd.sampler === 'ddim' || extension_settings.sd.model === 'nai-diffusion-4-curated-preview') { sm = false; sm_dyn = false; } @@ -3314,7 +3310,6 @@ async function generateComfyImage(prompt, negativePrompt, signal) { 'scale', 'width', 'height', - 'clip_skip', ]; const workflowResponse = await fetch('/api/sd/comfy/workflow', { @@ -3337,6 +3332,9 @@ async function generateComfyImage(prompt, negativePrompt, signal) { const denoising_strength = extension_settings.sd.denoising_strength === undefined ? 1.0 : extension_settings.sd.denoising_strength; workflow = workflow.replaceAll('"%denoise%"', JSON.stringify(denoising_strength)); + const clip_skip = isNaN(extension_settings.sd.clip_skip) ? -1 : -extension_settings.sd.clip_skip; + workflow = workflow.replaceAll('"%clip_skip%"', JSON.stringify(clip_skip)); + placeholders.forEach(ph => { workflow = workflow.replaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph])); }); @@ -3670,6 +3668,8 @@ function getVisibilityByInitiator(initiator) { return !!extension_settings.sd.wand_visible; case initiators.command: return !!extension_settings.sd.command_visible; + case initiators.tool: + return !!extension_settings.sd.tool_visible; default: return false; } @@ -4417,6 +4417,7 @@ jQuery(async () => { $('#sd_wand_visible').on('input', onWandVisibleInput); $('#sd_command_visible').on('input', onCommandVisibleInput); $('#sd_interactive_visible').on('input', onInteractiveVisibleInput); + $('#sd_tool_visible').on('input', onToolVisibleInput); $('#sd_swap_dimensions').on('click', onSwapDimensionsClick); $('#sd_stability_key').on('click', onStabilityKeyClick); $('#sd_stability_style_preset').on('change', onStabilityStylePresetChange); diff --git a/public/scripts/extensions/stable-diffusion/settings.html b/public/scripts/extensions/stable-diffusion/settings.html index be7c3dd40..e1ae810c6 100644 --- a/public/scripts/extensions/stable-diffusion/settings.html +++ b/public/scripts/extensions/stable-diffusion/settings.html @@ -109,7 +109,7 @@ The server must be accessible from the SillyTavern host machine.
- Hint: Save an API key in Horde KoboldAI API settings to use it here. + Hint: Save an API key in AI Horde API settings to use it here.
-
+
@@ -459,25 +459,32 @@
+
diff --git a/public/scripts/extensions/third-party/.gitkeep b/public/scripts/extensions/third-party/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/public/scripts/extensions/translate/index.html b/public/scripts/extensions/translate/index.html index 794252c83..949903ca9 100644 --- a/public/scripts/extensions/translate/index.html +++ b/public/scripts/extensions/translate/index.html @@ -14,16 +14,20 @@
- + - + + + +
@@ -35,4 +39,4 @@ - \ No newline at end of file + diff --git a/public/scripts/extensions/translate/index.js b/public/scripts/extensions/translate/index.js index c42a34f38..1f01f1913 100644 --- a/public/scripts/extensions/translate/index.js +++ b/public/scripts/extensions/translate/index.js @@ -14,6 +14,8 @@ import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from '../../popup.js'; import { findSecret, secret_state, writeSecret } from '../../secrets.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; +import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; +import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; import { splitRecursive } from '../../utils.js'; @@ -32,6 +34,7 @@ const defaultSettings = { internal_language: 'en', provider: 'google', auto_mode: autoModeOptions.NONE, + deepl_endpoint: 'free', }; const languageCodes = { @@ -106,7 +109,8 @@ const languageCodes = { 'Pashto': 'ps', 'Persian': 'fa', 'Polish': 'pl', - 'Portuguese (Portugal, Brazil)': 'pt', + 'Portuguese (Portugal)': 'pt-PT', + 'Portuguese (Brazil)': 'pt-BR', 'Punjabi': 'pa', 'Romanian': 'ro', 'Russian': 'ru', @@ -151,6 +155,7 @@ function showKeysButton() { $('#translate_key_button').toggleClass('success', Boolean(secret_state[extension_settings.translate.provider])); $('#translate_url_button').toggle(providerOptionalUrl); $('#translate_url_button').toggleClass('success', Boolean(secret_state[extension_settings.translate.provider + '_url'])); + $('#deepl_api_endpoint').toggle(extension_settings.translate.provider === 'deepl'); } function loadSettings() { @@ -160,9 +165,10 @@ function loadSettings() { } } - $(`#translation_provider option[value="${extension_settings.translate.provider}"]`).attr('selected', true); - $(`#translation_target_language option[value="${extension_settings.translate.target_language}"]`).attr('selected', true); - $(`#translation_auto_mode option[value="${extension_settings.translate.auto_mode}"]`).attr('selected', true); + $(`#translation_provider option[value="${extension_settings.translate.provider}"]`).attr('selected', 'true'); + $(`#translation_target_language option[value="${extension_settings.translate.target_language}"]`).attr('selected', 'true'); + $(`#translation_auto_mode option[value="${extension_settings.translate.auto_mode}"]`).attr('selected', 'true'); + $('#deepl_api_endpoint').val(extension_settings.translate.deepl_endpoint).toggle(extension_settings.translate.provider === 'deepl'); showKeysButton(); } @@ -284,10 +290,11 @@ async function translateProviderDeepl(text, lang) { throw new Error('No DeepL API key'); } + const endpoint = extension_settings.translate.deepl_endpoint || 'free'; const response = await fetch('/api/translate/deepl', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ text: text, lang: lang }), + body: JSON.stringify({ text: text, lang: lang, endpoint: endpoint }), }); if (response.ok) { @@ -394,9 +401,10 @@ async function chunkedTranslate(text, lang, translateFn, chunkSize = 5000) { * Translates text using the selected translation provider * @param {string} text Text to translate * @param {string} lang Target language code + * @param {string} provider Translation provider to use * @returns {Promise} Translated text */ -async function translate(text, lang) { +async function translate(text, lang, provider = null) { try { if (text == '') { return ''; @@ -406,13 +414,17 @@ async function translate(text, lang) { lang = extension_settings.translate.target_language; } + if (!provider) { + provider = extension_settings.translate.provider; + } + // split text by embedded images links const chunks = text.split(/!\[.*?]\([^)]*\)/); const links = [...text.matchAll(/!\[.*?]\([^)]*\)/g)]; let result = ''; for (let i = 0; i < chunks.length; i++) { - result += await translateInner(chunks[i], lang); + result += await translateInner(chunks[i], lang, provider); if (i < links.length) result += links[i][0]; } @@ -423,11 +435,21 @@ async function translate(text, lang) { } } -async function translateInner(text, lang) { +/** + * Common translation function that handles the translation logic + * @param {string} text Text to translate + * @param {string} lang Target language code + * @param {string} provider Translation provider to use + * @returns {Promise} Translated text + */ +async function translateInner(text, lang, provider) { if (text == '') { return ''; } - switch (extension_settings.translate.provider) { + if (!provider) { + provider = extension_settings.translate.provider; + } + switch (provider) { case 'libre': return await translateProviderLibre(text, lang); case 'google': @@ -445,7 +467,7 @@ async function translateInner(text, lang) { case 'yandex': return await translateProviderYandex(text, lang); default: - console.error('Unknown translation provider', extension_settings.translate.provider); + console.error('Unknown translation provider', provider); return text; } } @@ -600,18 +622,34 @@ jQuery(async () => { } $('#translation_auto_mode').on('change', (event) => { + if (!(event.target instanceof HTMLSelectElement)) { + return; + } extension_settings.translate.auto_mode = event.target.value; saveSettingsDebounced(); }); $('#translation_provider').on('change', (event) => { + if (!(event.target instanceof HTMLSelectElement)) { + return; + } extension_settings.translate.provider = event.target.value; showKeysButton(); saveSettingsDebounced(); }); $('#translation_target_language').on('change', (event) => { + if (!(event.target instanceof HTMLSelectElement)) { + return; + } extension_settings.translate.target_language = event.target.value; saveSettingsDebounced(); }); + $('#deepl_api_endpoint').on('change', (event) => { + if (!(event.target instanceof HTMLSelectElement)) { + return; + } + extension_settings.translate.deepl_endpoint = event.target.value; + saveSettingsDebounced(); + }); $(document).on('click', '.mes_translate', onMessageTranslateClick); $('#translate_key_button').on('click', async () => { const optionText = $('#translation_provider option:selected').text(); @@ -687,6 +725,14 @@ jQuery(async () => { helpString: 'Translate text to a target language. If target language is not provided, the value from the extension settings will be used.', namedArgumentList: [ new SlashCommandNamedArgument('target', 'The target language code to translate to', ARGUMENT_TYPE.STRING, false, false, '', Object.values(languageCodes)), + SlashCommandNamedArgument.fromProps({ + name: 'provider', + description: 'The translation provider to use. If not provided, the value from the extension settings will be used.', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + acceptsMultiple: false, + enumProvider: () => Array.from(document.getElementById('translation_provider').querySelectorAll('option')).map((option) => new SlashCommandEnumValue(option.value, option.text, enumTypes.name, enumIcons.server)), + }), ], unnamedArgumentList: [ new SlashCommandArgument('The text to translate', ARGUMENT_TYPE.STRING, true, false, ''), @@ -695,7 +741,8 @@ jQuery(async () => { const target = args?.target && Object.values(languageCodes).includes(String(args.target)) ? String(args.target) : extension_settings.translate.target_language; - return await translate(String(value), target); + const provider = args?.provider || extension_settings.translate.provider; + return await translate(String(value), target, provider); }, returns: ARGUMENT_TYPE.STRING, })); diff --git a/public/scripts/filters.js b/public/scripts/filters.js index f07b4e33f..eb5329d8a 100644 --- a/public/scripts/filters.js +++ b/public/scripts/filters.js @@ -448,6 +448,5 @@ export class FilterHelper { for (const cache of Object.values(this.fuzzySearchCaches)) { cache.resultMap.clear(); } - console.log('All fuzzy search caches cleared'); } } diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js index 9784218ee..635343f33 100644 --- a/public/scripts/group-chats.js +++ b/public/scripts/group-chats.js @@ -275,6 +275,20 @@ export function getGroupMembers(groupId = selected_group) { return group?.members.map(member => characters.find(x => x.avatar === member)) ?? []; } +/** + * Retrieves the member names of a group. If the group is not selected, an empty array is returned. + * @returns {string[]} An array of character names representing the members of the group. + */ +export function getGroupNames() { + if (!selected_group) { + return []; + } + const groupMembers = groups.find(x => x.id == selected_group)?.members; + return Array.isArray(groupMembers) + ? groupMembers.map(x => characters.find(y => y.avatar === x)?.name).filter(x => x) + : []; +} + /** * Finds the character ID for a group member. * @param {string} arg 0-based member index or character name @@ -423,14 +437,20 @@ export function getGroupCharacterCards(groupId, characterId) { * @param {string} value Value to replace * @param {string} characterName Name of the character * @param {string} fieldName Name of the field + * @param {function(string): string} [preprocess] Preprocess function * @returns {string} Prepared text * */ - function replaceAndPrepareForJoin(value, characterName, fieldName) { + function replaceAndPrepareForJoin(value, characterName, fieldName, preprocess = null) { value = value.trim(); if (!value) { return ''; } + // Run preprocess function + if (typeof preprocess === 'function') { + value = preprocess(value); + } + // Prepare and replace prefixes const prefix = customBaseChatReplace(group.generation_mode_join_prefix, fieldName, characterName); const suffix = customBaseChatReplace(group.generation_mode_join_suffix, fieldName, characterName); @@ -465,7 +485,7 @@ export function getGroupCharacterCards(groupId, characterId) { descriptions.push(replaceAndPrepareForJoin(character.description, character.name, 'Description')); personalities.push(replaceAndPrepareForJoin(character.personality, character.name, 'Personality')); scenarios.push(replaceAndPrepareForJoin(character.scenario, character.name, 'Scenario')); - mesExamplesArray.push(replaceAndPrepareForJoin(character.mes_example, character.name, 'Example Messages')); + mesExamplesArray.push(replaceAndPrepareForJoin(character.mes_example, character.name, 'Example Messages', (x) => !x.startsWith('') ? `\n${x}` : x)); } const description = descriptions.filter(x => x.length).join('\n'); diff --git a/public/scripts/instruct-mode.js b/public/scripts/instruct-mode.js index 8d8556041..746682859 100644 --- a/public/scripts/instruct-mode.js +++ b/public/scripts/instruct-mode.js @@ -431,7 +431,8 @@ export function formatInstructModeExamples(mesExamplesArray, name1, name2) { return mesExamplesArray.map(x => x.replace(/\n/i, blockHeading)); } - const includeNames = power_user.instruct.names_behavior === names_behavior_types.ALWAYS || (!!selected_group && power_user.instruct.names_behavior === names_behavior_types.FORCE); + const includeNames = power_user.instruct.names_behavior === names_behavior_types.ALWAYS; + const includeGroupNames = selected_group && [names_behavior_types.ALWAYS, names_behavior_types.FORCE].includes(power_user.instruct.names_behavior); let inputPrefix = power_user.instruct.input_sequence || ''; let outputPrefix = power_user.instruct.output_sequence || ''; @@ -463,7 +464,7 @@ export function formatInstructModeExamples(mesExamplesArray, name1, name2) { for (const item of mesExamplesArray) { const cleanedItem = item.replace(//i, '{Example Dialogue:}').replace(/\r/gm, ''); - const blockExamples = parseExampleIntoIndividual(cleanedItem); + const blockExamples = parseExampleIntoIndividual(cleanedItem, includeGroupNames); if (blockExamples.length === 0) { continue; @@ -474,8 +475,9 @@ export function formatInstructModeExamples(mesExamplesArray, name1, name2) { } for (const example of blockExamples) { - // If force group/persona names is set, we should override the include names for the user placeholder - const includeThisName = includeNames || (power_user.instruct.names_behavior === names_behavior_types.FORCE && example.name == 'example_user'); + // If group names were included, we don't want to add any additional prefix as it already was applied. + // Otherwise, if force group/persona names is set, we should override the include names for the user placeholder + const includeThisName = !includeGroupNames && (includeNames || (power_user.instruct.names_behavior === names_behavior_types.FORCE && example.name == 'example_user')); const prefix = example.name == 'example_user' ? inputPrefix : outputPrefix; const suffix = example.name == 'example_user' ? inputSuffix : outputSuffix; @@ -489,7 +491,6 @@ export function formatInstructModeExamples(mesExamplesArray, name1, name2) { if (formattedExamples.length === 0) { return mesExamplesArray.map(x => x.replace(/\n/i, blockHeading)); } - return formattedExamples; } diff --git a/public/scripts/logit-bias.js b/public/scripts/logit-bias.js index 2b9d67bdc..f1061def4 100644 --- a/public/scripts/logit-bias.js +++ b/public/scripts/logit-bias.js @@ -1,6 +1,6 @@ import { saveSettingsDebounced } from '../script.js'; import { getTextTokens } from './tokenizers.js'; -import { uuidv4 } from './utils.js'; +import { getSortableDelay, uuidv4 } from './utils.js'; export const BIAS_CACHE = new Map(); @@ -16,7 +16,8 @@ export function displayLogitBias(logitBias, containerSelector) { return; } - $(containerSelector).find('.logit_bias_list').empty(); + const list = $(containerSelector).find('.logit_bias_list'); + list.empty(); for (const entry of logitBias) { if (entry) { @@ -24,6 +25,27 @@ export function displayLogitBias(logitBias, containerSelector) { } } + // Check if a sortable instance exists + if (list.sortable('instance') !== undefined) { + // Destroy the instance + list.sortable('destroy'); + } + + // Make the list sortable + list.sortable({ + delay: getSortableDelay(), + handle: '.drag-handle', + stop: function () { + const order = []; + list.children().each(function () { + order.unshift($(this).data('id')); + }); + logitBias.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); + console.log('Logit bias reordered:', logitBias); + saveSettingsDebounced(); + }, + }); + BIAS_CACHE.delete(containerSelector); } diff --git a/public/scripts/macros.js b/public/scripts/macros.js index 0e4d7a8da..cbc38ded4 100644 --- a/public/scripts/macros.js +++ b/public/scripts/macros.js @@ -4,6 +4,7 @@ import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } f import { textgenerationwebui_banned_in_macros } from './textgen-settings.js'; import { getInstructMacros } from './instruct-mode.js'; import { getVariableMacros } from './variables.js'; +import { isMobile } from './RossAscends-mods.js'; /** * @typedef Macro @@ -516,7 +517,11 @@ export function evaluateMacros(content, env, postProcessFn) { break; } - content = content.replace(macro.regex, (...args) => postProcessFn(macro.replace(...args))); + try { + content = content.replace(macro.regex, (...args) => postProcessFn(macro.replace(...args))); + } catch (e) { + console.warn(`Macro content can't be replaced: ${macro.regex} in ${content}`, e); + } } return content; @@ -538,5 +543,6 @@ export function initMacros() { }); } + MacrosParser.registerMacro('isMobile', () => String(isMobile())); initLastGenerationType(); } diff --git a/public/scripts/openai.js b/public/scripts/openai.js index 76232563b..f803da07b 100644 --- a/public/scripts/openai.js +++ b/public/scripts/openai.js @@ -33,7 +33,7 @@ import { system_message_types, this_chid, } from '../script.js'; -import { selected_group } from './group-chats.js'; +import { getGroupNames, selected_group } from './group-chats.js'; import { chatCompletionDefaultPrompts, @@ -60,6 +60,7 @@ import { parseJsonFile, resetScrollHeight, stringFormat, + uuidv4, } from './utils.js'; import { countTokensOpenAIAsync, getTokenizerModel } from './tokenizers.js'; import { isMobile } from './RossAscends-mods.js'; @@ -101,16 +102,16 @@ const default_new_group_chat_prompt = '[Start a new group chat. Group members: { const default_new_example_chat_prompt = '[Example Chat]'; const default_continue_nudge_prompt = '[Continue the following message. Do not include ANY parts of the original message. Use capitalization and punctuation as if your reply is a part of the original message: {{lastChatMessage}}]'; const default_bias = 'Default (none)'; -const default_personality_format = '[{{char}}\'s personality: {{personality}}]'; -const default_scenario_format = '[Circumstances and context of the dialogue: {{scenario}}]'; +const default_personality_format = '{{personality}}'; +const default_scenario_format = '{{scenario}}'; const default_group_nudge_prompt = '[Write the next reply only as {{char}}.]'; const default_bias_presets = { [default_bias]: [], 'Anti-bond': [ - { text: ' bond', value: -50 }, - { text: ' future', value: -50 }, - { text: ' bonding', value: -50 }, - { text: ' connection', value: -25 }, + { id: '22154f79-dd98-41bc-8e34-87015d6a0eaf', text: ' bond', value: -50 }, + { id: '8ad2d5c4-d8ef-49e4-bc5e-13e7f4690e0f', text: ' future', value: -50 }, + { id: '52a4b280-0956-4940-ac52-4111f83e4046', text: ' bonding', value: -50 }, + { id: 'e63037c7-c9d1-4724-ab2d-7756008b433b', text: ' connection', value: -25 }, ], }; @@ -206,6 +207,12 @@ const custom_prompt_post_processing_types = { STRICT: 'strict', }; +const openrouter_middleout_types = { + AUTO: 'auto', + ON: 'on', + OFF: 'off', +}; + const sensitiveFields = [ 'reverse_proxy', 'proxy_password', @@ -266,6 +273,7 @@ const default_settings = { openrouter_sort_models: 'alphabetically', openrouter_providers: [], openrouter_allow_fallbacks: true, + openrouter_middleout: openrouter_middleout_types.ON, jailbreak_system: false, reverse_proxy: '', chat_completion_source: chat_completion_sources.OPENAI, @@ -342,6 +350,7 @@ const oai_settings = { openrouter_sort_models: 'alphabetically', openrouter_providers: [], openrouter_allow_fallbacks: true, + openrouter_middleout: openrouter_middleout_types.ON, jailbreak_system: false, reverse_proxy: '', chat_completion_source: chat_completion_sources.OPENAI, @@ -543,11 +552,15 @@ function setupChatCompletionPromptManager(openAiSettings) { * @returns {Message[]} Array of message objects */ export function parseExampleIntoIndividual(messageExampleString, appendNamesForGroup = true) { + const groupBotNames = getGroupNames().map(name => `${name}:`); + let result = []; // array of msgs let tmp = messageExampleString.split('\n'); let cur_msg_lines = []; let in_user = false; let in_bot = false; + let botName = name2; + // DRY my cock and balls :) function add_msg(name, role, system_name) { // join different newlines (we split them by \n and join by \n) @@ -571,10 +584,14 @@ export function parseExampleIntoIndividual(messageExampleString, appendNamesForG in_user = true; // we were in the bot mode previously, add the message if (in_bot) { - add_msg(name2, 'system', 'example_assistant'); + add_msg(botName, 'system', 'example_assistant'); } in_bot = false; - } else if (cur_str.startsWith(name2 + ':')) { + } else if (cur_str.startsWith(name2 + ':') || groupBotNames.some(n => cur_str.startsWith(n))) { + if (!cur_str.startsWith(name2 + ':') && groupBotNames.length) { + botName = cur_str.split(':')[0]; + } + in_bot = true; // we were in the user mode previously, add the message if (in_user) { @@ -589,7 +606,7 @@ export function parseExampleIntoIndividual(messageExampleString, appendNamesForG if (in_user) { add_msg(name1, 'system', 'example_user'); } else if (in_bot) { - add_msg(name2, 'system', 'example_assistant'); + add_msg(botName, 'system', 'example_assistant'); } return result; } @@ -611,8 +628,9 @@ function formatWorldInfo(value) { * * @param {Prompt[]} prompts - Array containing injection prompts. * @param {Object[]} messages - Array containing all messages. + * @returns {Promise} - Array containing all messages with injections. */ -function populationInjectionPrompts(prompts, messages) { +async function populationInjectionPrompts(prompts, messages) { let totalInsertedMessages = 0; const roleTypes = { @@ -635,7 +653,7 @@ function populationInjectionPrompts(prompts, messages) { // Get prompts for current role const rolePrompts = depthPrompts.filter(prompt => prompt.role === role).map(x => x.content).join(separator); // Get extension prompt - const extensionPrompt = getExtensionPrompt(extension_prompt_types.IN_CHAT, i, separator, roleTypes[role], wrap); + const extensionPrompt = await getExtensionPrompt(extension_prompt_types.IN_CHAT, i, separator, roleTypes[role], wrap); const jointPrompt = [rolePrompts, extensionPrompt].filter(x => x).map(x => x.trim()).join(separator); @@ -1020,7 +1038,7 @@ async function populateChatCompletion(prompts, chatCompletion, { bias, quietProm } // Add in-chat injections - messages = populationInjectionPrompts(absolutePrompts, messages); + messages = await populationInjectionPrompts(absolutePrompts, messages); // Decide whether dialogue examples should always be added if (power_user.pin_examples) { @@ -1051,9 +1069,9 @@ async function populateChatCompletion(prompts, chatCompletion, { bias, quietProm * @param {string} options.systemPromptOverride * @param {string} options.jailbreakPromptOverride * @param {string} options.personaDescription - * @returns {Object} prompts - The prepared and merged system and user-defined prompts. + * @returns {Promise} prompts - The prepared and merged system and user-defined prompts. */ -function preparePromptsForChatCompletion({ Scenario, charPersonality, name2, worldInfoBefore, worldInfoAfter, charDescription, quietPrompt, bias, extensionPrompts, systemPromptOverride, jailbreakPromptOverride, personaDescription }) { +async function preparePromptsForChatCompletion({ Scenario, charPersonality, name2, worldInfoBefore, worldInfoAfter, charDescription, quietPrompt, bias, extensionPrompts, systemPromptOverride, jailbreakPromptOverride, personaDescription }) { const scenarioText = Scenario && oai_settings.scenario_format ? substituteParams(oai_settings.scenario_format) : ''; const charPersonalityText = charPersonality && oai_settings.personality_format ? substituteParams(oai_settings.personality_format) : ''; const groupNudge = substituteParams(oai_settings.group_nudge_prompt); @@ -1142,6 +1160,9 @@ function preparePromptsForChatCompletion({ Scenario, charPersonality, name2, wor if (!extensionPrompts[key].value) continue; if (![extension_prompt_types.BEFORE_PROMPT, extension_prompt_types.IN_PROMPT].includes(prompt.position)) continue; + const hasFilter = typeof prompt.filter === 'function'; + if (hasFilter && !await prompt.filter()) continue; + systemPrompts.push({ identifier: key.replace(/\W/g, '_'), position: getPromptPosition(prompt.position), @@ -1178,7 +1199,8 @@ function preparePromptsForChatCompletion({ Scenario, charPersonality, name2, wor // Apply character-specific main prompt const systemPrompt = prompts.get('main') ?? null; - if (systemPromptOverride && systemPrompt && systemPrompt.forbid_overrides !== true) { + const isSystemPromptDisabled = promptManager.isPromptDisabledForActiveCharacter('main'); + if (systemPromptOverride && systemPrompt && systemPrompt.forbid_overrides !== true && !isSystemPromptDisabled) { const mainOriginalContent = systemPrompt.content; systemPrompt.content = systemPromptOverride; const mainReplacement = promptManager.preparePrompt(systemPrompt, mainOriginalContent); @@ -1187,7 +1209,8 @@ function preparePromptsForChatCompletion({ Scenario, charPersonality, name2, wor // Apply character-specific jailbreak const jailbreakPrompt = prompts.get('jailbreak') ?? null; - if (jailbreakPromptOverride && jailbreakPrompt && jailbreakPrompt.forbid_overrides !== true) { + const isJailbreakPromptDisabled = promptManager.isPromptDisabledForActiveCharacter('jailbreak'); + if (jailbreakPromptOverride && jailbreakPrompt && jailbreakPrompt.forbid_overrides !== true && !isJailbreakPromptDisabled) { const jbOriginalContent = jailbreakPrompt.content; jailbreakPrompt.content = jailbreakPromptOverride; const jbReplacement = promptManager.preparePrompt(jailbreakPrompt, jbOriginalContent); @@ -1252,7 +1275,7 @@ export async function prepareOpenAIMessages({ try { // Merge markers and ordered user prompts with system prompts - const prompts = preparePromptsForChatCompletion({ + const prompts = await preparePromptsForChatCompletion({ Scenario, charPersonality, name2, @@ -1860,6 +1883,7 @@ async function sendOpenAIRequest(type, messages, signal) { 'n': canMultiSwipe ? oai_settings.n : undefined, 'user_name': name1, 'char_name': name2, + 'group_names': getGroupNames(), }; // Empty array will produce a validation error @@ -1904,6 +1928,7 @@ async function sendOpenAIRequest(type, messages, signal) { generate_data['use_fallback'] = oai_settings.openrouter_use_fallback; generate_data['provider'] = oai_settings.openrouter_providers; generate_data['allow_fallbacks'] = oai_settings.openrouter_allow_fallbacks; + generate_data['middleout'] = oai_settings.openrouter_middleout; if (isTextCompletion) { generate_data['stop'] = getStoppingStrings(isImpersonate, isContinue); @@ -2073,7 +2098,7 @@ function getStreamingReply(data) { 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?.[0]?.text || ''; + return data?.candidates?.[0]?.content?.parts?.map(x => x.text)?.join('\n\n') || ''; } else if (oai_settings.chat_completion_source === chat_completion_sources.COHERE) { return data?.delta?.message?.content?.text || data?.delta?.message?.tool_plan || ''; } else { @@ -2085,7 +2110,7 @@ function getStreamingReply(data) { * parseChatCompletionLogprobs converts the response data returned from a chat * completions-like source into an array of TokenLogprobs found in the response. * @param {Object} data - response data from a chat completions-like source - * @returns {import('logprobs.js').TokenLogprobs[] | null} converted logprobs + * @returns {import('./logprobs.js').TokenLogprobs[] | null} converted logprobs */ function parseChatCompletionLogprobs(data) { if (!data) { @@ -2114,7 +2139,7 @@ function parseChatCompletionLogprobs(data) { * completion API and converts into the structure used by the Token Probabilities * view. * @param {{content: { token: string, logprob: number, top_logprobs: { token: string, logprob: number }[] }[]}} logprobs - * @returns {import('logprobs.js').TokenLogprobs[] | null} converted logprobs + * @returns {import('./logprobs.js').TokenLogprobs[] | null} converted logprobs */ function parseOpenAIChatLogprobs(logprobs) { const { content } = logprobs ?? {}; @@ -2142,7 +2167,7 @@ function parseOpenAIChatLogprobs(logprobs) { * completion API and converts into the structure used by the Token Probabilities * view. * @param {{tokens: string[], token_logprobs: number[], top_logprobs: { token: string, logprob: number }[][]}} logprobs - * @returns {import('logprobs.js').TokenLogprobs[] | null} converted logprobs + * @returns {import('./logprobs.js').TokenLogprobs[] | null} converted logprobs */ function parseOpenAITextLogprobs(logprobs) { const { tokens, token_logprobs, top_logprobs } = logprobs ?? {}; @@ -3006,6 +3031,7 @@ function loadOpenAISettings(data, settings) { oai_settings.openrouter_sort_models = settings.openrouter_sort_models ?? default_settings.openrouter_sort_models; oai_settings.openrouter_use_fallback = settings.openrouter_use_fallback ?? default_settings.openrouter_use_fallback; oai_settings.openrouter_allow_fallbacks = settings.openrouter_allow_fallbacks ?? default_settings.openrouter_allow_fallbacks; + oai_settings.openrouter_middleout = settings.openrouter_middleout ?? default_settings.openrouter_middleout; oai_settings.ai21_model = settings.ai21_model ?? default_settings.ai21_model; oai_settings.mistralai_model = settings.mistralai_model ?? default_settings.mistralai_model; oai_settings.cohere_model = settings.cohere_model ?? default_settings.cohere_model; @@ -3114,6 +3140,7 @@ function loadOpenAISettings(data, settings) { $('#openrouter_group_models').prop('checked', oai_settings.openrouter_group_models); $('#openrouter_allow_fallbacks').prop('checked', oai_settings.openrouter_allow_fallbacks); $('#openrouter_providers_chat').val(oai_settings.openrouter_providers).trigger('change'); + $('#openrouter_middleout').val(oai_settings.openrouter_middleout); $('#squash_system_messages').prop('checked', oai_settings.squash_system_messages); $('#continue_prefill').prop('checked', oai_settings.continue_prefill); $('#openai_function_calling').prop('checked', oai_settings.function_calling); @@ -3162,6 +3189,14 @@ function loadOpenAISettings(data, settings) { $('#openai_logit_bias_preset').empty(); for (const preset of Object.keys(oai_settings.bias_presets)) { + // Backfill missing IDs + if (Array.isArray(oai_settings.bias_presets[preset])) { + oai_settings.bias_presets[preset].forEach((bias) => { + if (bias && !bias.id) { + bias.id = uuidv4(); + } + }); + } const option = document.createElement('option'); option.innerText = preset; option.value = preset; @@ -3346,6 +3381,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) { openrouter_sort_models: settings.openrouter_sort_models, openrouter_providers: settings.openrouter_providers, openrouter_allow_fallbacks: settings.openrouter_allow_fallbacks, + openrouter_middleout: settings.openrouter_middleout, ai21_model: settings.ai21_model, mistralai_model: settings.mistralai_model, cohere_model: settings.cohere_model, @@ -3450,7 +3486,8 @@ function onLogitBiasPresetChange() { } oai_settings.bias_preset_selected = value; - $('.openai_logit_bias_list').empty(); + const list = $('.openai_logit_bias_list'); + list.empty(); for (const entry of preset) { if (entry) { @@ -3458,12 +3495,33 @@ function onLogitBiasPresetChange() { } } + // Check if a sortable instance exists + if (list.sortable('instance') !== undefined) { + // Destroy the instance + list.sortable('destroy'); + } + + // Make the list sortable + list.sortable({ + delay: getSortableDelay(), + handle: '.drag-handle', + stop: function () { + const order = []; + list.children().each(function () { + order.unshift($(this).data('id')); + }); + preset.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); + console.log('Logit bias reordered:', preset); + saveSettingsDebounced(); + }, + }); + biasCache = undefined; saveSettingsDebounced(); } function createNewLogitBiasEntry() { - const entry = { text: '', value: 0 }; + const entry = { id: uuidv4(), text: '', value: 0 }; oai_settings.bias_presets[oai_settings.bias_preset_selected].push(entry); biasCache = undefined; createLogitBiasListItem(entry); @@ -3471,11 +3529,14 @@ function createNewLogitBiasEntry() { } function createLogitBiasListItem(entry) { - const id = oai_settings.bias_presets[oai_settings.bias_preset_selected].indexOf(entry); + if (!entry.id) { + entry.id = uuidv4(); + } + const id = entry.id; const template = $('#openai_logit_bias_template .openai_logit_bias_form').clone(); template.data('id', id); template.find('.openai_logit_bias_text').val(entry.text).on('input', function () { - oai_settings.bias_presets[oai_settings.bias_preset_selected][id].text = String($(this).val()); + entry.text = String($(this).val()); biasCache = undefined; saveSettingsDebounced(); }); @@ -3494,13 +3555,17 @@ function createLogitBiasListItem(entry) { value = max; } - oai_settings.bias_presets[oai_settings.bias_preset_selected][id].value = value; + entry.value = value; biasCache = undefined; saveSettingsDebounced(); }); template.find('.openai_logit_bias_remove').on('click', function () { $(this).closest('.openai_logit_bias_form').remove(); - oai_settings.bias_presets[oai_settings.bias_preset_selected].splice(id, 1); + const preset = oai_settings.bias_presets[oai_settings.bias_preset_selected]; + const index = preset.findIndex(item => item.id === id); + if (index >= 0) { + preset.splice(index, 1); + } onLogitBiasPresetChange(); }); $('.openai_logit_bias_list').prepend(template); @@ -3680,6 +3745,9 @@ async function onLogitBiasPresetImportFileChange(e) { if (typeof entry == 'object' && entry !== null) { if (Object.hasOwn(entry, 'text') && Object.hasOwn(entry, 'value')) { + if (!entry.id) { + entry.id = uuidv4(); + } validEntries.push(entry); } } @@ -3780,6 +3848,7 @@ function onSettingsPresetChange() { openrouter_sort_models: ['#openrouter_sort_models', 'openrouter_sort_models', false], openrouter_providers: ['#openrouter_providers_chat', 'openrouter_providers', false], openrouter_allow_fallbacks: ['#openrouter_allow_fallbacks', 'openrouter_allow_fallbacks', true], + openrouter_middleout: ['#openrouter_middleout', 'openrouter_middleout', false], ai21_model: ['#model_ai21_select', 'ai21_model', false], mistralai_model: ['#model_mistralai_select', 'mistralai_model', false], cohere_model: ['#model_cohere_select', 'cohere_model', false], @@ -4075,19 +4144,14 @@ async function onModelChange() { if (oai_settings.chat_completion_source == chat_completion_sources.MAKERSUITE) { if (oai_settings.max_context_unlocked) { $('#openai_max_context').attr('max', max_2mil); - } else if (value.includes('gemini-exp-1114') || value.includes('gemini-exp-1121')) { + } else if (value.includes('gemini-exp-1114') || value.includes('gemini-exp-1121') || value.includes('gemini-2.0-flash-thinking-exp-1219')) { $('#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')) { + } else if (value.includes('gemini-1.5-flash') || value.includes('gemini-2.0-flash-exp')) { $('#openai_max_context').attr('max', max_1mil); - } else if (value.includes('gemini-1.0-pro-vision') || value === 'gemini-pro-vision') { - $('#openai_max_context').attr('max', max_16k); } else if (value.includes('gemini-1.0-pro') || value === 'gemini-pro') { $('#openai_max_context').attr('max', max_32k); - } else if (value === 'text-bison-001') { - $('#openai_max_context').attr('max', max_8k); - // The ultra endpoints are possibly dead: } else if (value.includes('gemini-1.0-ultra') || value === 'gemini-ultra') { $('#openai_max_context').attr('max', max_32k); } else { @@ -4209,16 +4273,13 @@ async function onModelChange() { if (oai_settings.max_context_unlocked) { $('#openai_max_context').attr('max', unlocked_max); } - else if (['command-light', 'command'].includes(oai_settings.cohere_model)) { + else if (['command-light-nightly', 'command-light', 'command'].includes(oai_settings.cohere_model)) { $('#openai_max_context').attr('max', max_4k); } - else if (['command-light-nightly', 'command-nightly'].includes(oai_settings.cohere_model)) { - $('#openai_max_context').attr('max', max_8k); - } - else if (oai_settings.cohere_model.includes('command-r') || ['c4ai-aya-expanse-32b'].includes(oai_settings.cohere_model)) { + else if (oai_settings.cohere_model.includes('command-r') || ['c4ai-aya-23', 'c4ai-aya-expanse-32b', 'command-nightly'].includes(oai_settings.cohere_model)) { $('#openai_max_context').attr('max', max_128k); } - else if (['c4ai-aya-23', 'c4ai-aya-expanse-8b'].includes(oai_settings.cohere_model)) { + else if (['c4ai-aya-23-8b', 'c4ai-aya-expanse-8b'].includes(oai_settings.cohere_model)) { $('#openai_max_context').attr('max', max_8k); } else { @@ -4269,7 +4330,7 @@ async function onModelChange() { else if (oai_settings.groq_model.includes('llama-3.2') && oai_settings.groq_model.includes('-preview')) { $('#openai_max_context').attr('max', max_8k); } - else if (oai_settings.groq_model.includes('llama-3.2') || oai_settings.groq_model.includes('llama-3.1')) { + else if (oai_settings.groq_model.includes('llama-3.3') || oai_settings.groq_model.includes('llama-3.2') || oai_settings.groq_model.includes('llama-3.1')) { $('#openai_max_context').attr('max', max_128k); } else if (oai_settings.groq_model.includes('llama3-groq')) { @@ -4751,6 +4812,8 @@ export function isImageInliningSupported() { // gultra just isn't being offered as multimodal, thanks google. const visionSupportedModels = [ 'gpt-4-vision', + 'gemini-2.0-flash-thinking-exp-1219', + 'gemini-2.0-flash-exp', 'gemini-1.5-flash', 'gemini-1.5-flash-latest', 'gemini-1.5-flash-001', @@ -4769,7 +4832,6 @@ export function isImageInliningSupported() { 'gemini-1.5-pro-002', 'gemini-1.5-pro-exp-0801', 'gemini-1.5-pro-exp-0827', - 'gemini-pro-vision', 'claude-3', 'claude-3-5', 'gpt-4-turbo', @@ -5213,6 +5275,11 @@ export function initOpenAI() { saveSettingsDebounced(); }); + $('#openrouter_middleout').on('input', function () { + oai_settings.openrouter_middleout = String($(this).val()); + saveSettingsDebounced(); + }); + $('#squash_system_messages').on('input', function () { oai_settings.squash_system_messages = !!$(this).prop('checked'); saveSettingsDebounced(); diff --git a/public/scripts/personas.js b/public/scripts/personas.js index 01747a528..930e87a4e 100644 --- a/public/scripts/personas.js +++ b/public/scripts/personas.js @@ -21,8 +21,10 @@ import { PAGINATION_TEMPLATE, debounce, delay, download, ensureImageFormatSuppor import { debounce_timeout } from './constants.js'; import { FILTER_TYPES, FilterHelper } from './filters.js'; import { selected_group } from './group-chats.js'; -import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; +import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; import { t } from './i18n.js'; +import { openWorldInfoEditor, world_names } from './world-info.js'; +import { renderTemplateAsync } from './templates.js'; let savePersonasPage = 0; const GRID_STORAGE_KEY = 'Personas_GridView'; @@ -375,6 +377,7 @@ export function initPersona(avatarId, personaName, personaDescription) { position: persona_description_positions.IN_PROMPT, depth: DEFAULT_DEPTH, role: DEFAULT_ROLE, + lorebook: '', }; saveSettingsDebounced(); @@ -418,6 +421,7 @@ export async function convertCharacterToPersona(characterId = null) { position: persona_description_positions.IN_PROMPT, depth: DEFAULT_DEPTH, role: DEFAULT_ROLE, + lorebook: '', }; // If the user is currently using this persona, update the description @@ -461,6 +465,7 @@ export function setPersonaDescription() { .val(power_user.persona_description_role) .find(`option[value="${power_user.persona_description_role}"]`) .prop('selected', String(true)); + $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook); countPersonaDescriptionTokens(); } @@ -490,6 +495,7 @@ async function updatePersonaNameIfExists(avatarId, newName) { position: persona_description_positions.IN_PROMPT, depth: DEFAULT_DEPTH, role: DEFAULT_ROLE, + lorebook: '', }; console.log(`Created persona name for ${avatarId} as ${newName}`); } @@ -535,6 +541,7 @@ async function bindUserNameToPersona(e) { position: isCurrentPersona ? power_user.persona_description_position : persona_description_positions.IN_PROMPT, depth: isCurrentPersona ? power_user.persona_description_depth : DEFAULT_DEPTH, role: isCurrentPersona ? power_user.persona_description_role : DEFAULT_ROLE, + lorebook: isCurrentPersona ? power_user.persona_description_lorebook : '', }; } @@ -579,12 +586,20 @@ function selectCurrentPersona() { power_user.persona_description_position = descriptor.position ?? persona_description_positions.IN_PROMPT; power_user.persona_description_depth = descriptor.depth ?? DEFAULT_DEPTH; power_user.persona_description_role = descriptor.role ?? DEFAULT_ROLE; + power_user.persona_description_lorebook = descriptor.lorebook ?? ''; } else { power_user.persona_description = ''; power_user.persona_description_position = persona_description_positions.IN_PROMPT; power_user.persona_description_depth = DEFAULT_DEPTH; power_user.persona_description_role = DEFAULT_ROLE; - power_user.persona_descriptions[user_avatar] = { description: '', position: persona_description_positions.IN_PROMPT, depth: DEFAULT_DEPTH, role: DEFAULT_ROLE }; + power_user.persona_description_lorebook = ''; + power_user.persona_descriptions[user_avatar] = { + description: '', + position: persona_description_positions.IN_PROMPT, + depth: DEFAULT_DEPTH, + role: DEFAULT_ROLE, + lorebook: '', + }; } setPersonaDescription(); @@ -652,6 +667,7 @@ async function lockPersona() { position: persona_description_positions.IN_PROMPT, depth: DEFAULT_DEPTH, role: DEFAULT_ROLE, + lorebook: '', }; } @@ -731,6 +747,7 @@ function onPersonaDescriptionInput() { position: Number($('#persona_description_position').find(':selected').val()), depth: Number($('#persona_depth_value').val()), role: Number($('#persona_depth_role').find(':selected').val()), + lorebook: '', }; power_user.persona_descriptions[user_avatar] = object; } @@ -766,6 +783,52 @@ function onPersonaDescriptionDepthRoleInput() { saveSettingsDebounced(); } +/** + * Opens a popup to set the lorebook for the current persona. + * @param {PointerEvent} event Click event + */ +async function onPersonaLoreButtonClick(event) { + const personaName = power_user.personas[user_avatar]; + const selectedLorebook = power_user.persona_description_lorebook; + + if (!personaName) { + toastr.warning(t`You must bind a name to this persona before you can set a lorebook.`, t`Persona name not set`); + return; + } + + if (event.altKey && selectedLorebook) { + openWorldInfoEditor(selectedLorebook); + return; + } + + const template = $(await renderTemplateAsync('personaLorebook')); + + const worldSelect = template.find('select'); + template.find('.persona_name').text(personaName); + + for (const worldName of world_names) { + const option = document.createElement('option'); + option.value = worldName; + option.innerText = worldName; + option.selected = selectedLorebook === worldName; + worldSelect.append(option); + } + + worldSelect.on('change', function () { + power_user.persona_description_lorebook = String($(this).val()); + + if (power_user.personas[user_avatar]) { + const object = getOrCreatePersonaDescriptor(); + object.lorebook = power_user.persona_description_lorebook; + } + + $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook); + saveSettingsDebounced(); + }); + + await callGenericPopup(template, POPUP_TYPE.TEXT); +} + function onPersonaDescriptionPositionInput() { power_user.persona_description_position = Number( $('#persona_description_position').find(':selected').val(), @@ -789,6 +852,7 @@ function getOrCreatePersonaDescriptor() { position: power_user.persona_description_position, depth: power_user.persona_description_depth, role: power_user.persona_description_role, + lorebook: power_user.persona_description_lorebook, }; power_user.persona_descriptions[user_avatar] = object; } @@ -1038,6 +1102,7 @@ async function duplicatePersona(avatarId) { position: descriptor?.position ?? persona_description_positions.IN_PROMPT, depth: descriptor?.depth ?? DEFAULT_DEPTH, role: descriptor?.role ?? DEFAULT_ROLE, + lorebook: descriptor?.lorebook ?? '', }; await uploadUserAvatar(getUserAvatar(avatarId), newAvatarId); @@ -1055,6 +1120,7 @@ export function initPersonas() { $('#persona_description_position').on('input', onPersonaDescriptionPositionInput); $('#persona_depth_value').on('input', onPersonaDescriptionDepthValueInput); $('#persona_depth_role').on('input', onPersonaDescriptionDepthRoleInput); + $('#persona_lore_button').on('click', onPersonaLoreButtonClick); $('#personas_backup').on('click', onBackupPersonas); $('#personas_restore').on('click', () => $('#personas_restore_input').trigger('click')); $('#personas_restore_input').on('change', onPersonasRestoreInput); diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js index 4c17b10de..5c830fec6 100644 --- a/public/scripts/power-user.js +++ b/public/scripts/power-user.js @@ -261,6 +261,7 @@ let power_user = { persona_description_position: persona_description_positions.IN_PROMPT, persona_description_role: 0, persona_description_depth: 2, + persona_description_lorebook: '', persona_show_notifications: true, persona_sort_order: 'asc', @@ -1756,7 +1757,7 @@ async function loadContextSettings() { } else { $element.val(power_user.context[control.property]); } - console.log(`Setting ${$element.prop('id')} to ${power_user.context[control.property]}`); + console.debug(`Setting ${$element.prop('id')} to ${power_user.context[control.property]}`); // If the setting already exists, no need to duplicate it // TODO: Maybe check the power_user object for the setting instead of a flag? @@ -1767,7 +1768,7 @@ async function loadContextSettings() { } else { power_user.context[control.property] = value; } - console.log(`Setting ${$element.prop('id')} to ${value}`); + console.debug(`Setting ${$element.prop('id')} to ${value}`); if (!CSS.supports('field-sizing', 'content') && $(this).is('textarea')) { await resetScrollHeight($(this)); } diff --git a/public/scripts/preset-manager.js b/public/scripts/preset-manager.js index 4c14b7519..0b6f3dcec 100644 --- a/public/scripts/preset-manager.js +++ b/public/scripts/preset-manager.js @@ -585,6 +585,7 @@ class PresetManager { 'openrouter_allow_fallbacks', 'tabby_model', 'derived', + 'generic_model', ]; const settings = Object.assign({}, getSettingsByApiId(this.apiId)); diff --git a/public/scripts/samplerSelect.js b/public/scripts/samplerSelect.js index c949b8e91..35f5629ba 100644 --- a/public/scripts/samplerSelect.js +++ b/public/scripts/samplerSelect.js @@ -129,6 +129,10 @@ function setSamplerListListeners() { relatedDOMElement = $('#sampler_priority_block_ooba'); } + if (samplerName === 'samplers_priorities') { //this is for aphrodite's sampler priority + relatedDOMElement = $('#sampler_priority_block_aphrodite'); + } + if (samplerName === 'penalty_alpha') { //contrastive search only has one sampler, does it need its own block? relatedDOMElement = $('#contrastiveSearchBlock'); } @@ -237,6 +241,11 @@ async function listSamplers(main_api, arrayOnly = false) { displayname = 'Ooba Sampler Priority Block'; } + if (sampler === 'samplers_priorities') { //this is for aphrodite's sampler priority + targetDOMelement = $('#sampler_priority_block_aphrodite'); + displayname = 'Aphrodite Sampler Priority Block'; + } + if (sampler === 'penalty_alpha') { //contrastive search only has one sampler, does it need its own block? targetDOMelement = $('#contrastiveSearchBlock'); displayname = 'Contrast Search Block'; @@ -373,6 +382,10 @@ export async function validateDisabledSamplers(redraw = false) { relatedDOMElement = $('#sampler_priority_block_ooba'); } + if (sampler === 'samplers_priorities') { //this is for aphrodite's sampler priority + relatedDOMElement = $('#sampler_priority_block_aphrodite'); + } + if (sampler === 'dry_multiplier') { relatedDOMElement = $('#dryBlock'); targetDisplayType = 'block'; diff --git a/public/scripts/secrets.js b/public/scripts/secrets.js index 4fce1e36c..816973232 100644 --- a/public/scripts/secrets.js +++ b/public/scripts/secrets.js @@ -38,6 +38,7 @@ export const SECRET_KEYS = { NANOGPT: 'api_key_nanogpt', TAVILY: 'api_key_tavily', BFL: 'api_key_bfl', + GENERIC: 'api_key_generic', }; const INPUT_MAP = { @@ -71,6 +72,7 @@ const INPUT_MAP = { [SECRET_KEYS.HUGGINGFACE]: '#api_key_huggingface', [SECRET_KEYS.BLOCKENTROPY]: '#api_key_blockentropy', [SECRET_KEYS.NANOGPT]: '#api_key_nanogpt', + [SECRET_KEYS.GENERIC]: '#api_key_generic', }; async function clearSecret() { diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index 23a36828f..7265b76bf 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -47,7 +47,7 @@ import { } from '../script.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommandParserError } from './slash-commands/SlashCommandParserError.js'; -import { getMessageTimeStamp } from './RossAscends-mods.js'; +import { getMessageTimeStamp, isMobile } from './RossAscends-mods.js'; import { hideChatMessageRange } from './chats.js'; import { getContext, saveMetadataDebounced } from './extensions.js'; import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; @@ -84,6 +84,25 @@ export const parser = new SlashCommandParser(); const registerSlashCommand = SlashCommandParser.addCommand.bind(SlashCommandParser); const getSlashCommandsHelp = parser.getHelpString.bind(parser); +/** + * Converts a SlashCommandClosure to a filter function that returns a boolean. + * @param {SlashCommandClosure} closure + * @returns {() => Promise} + */ +function closureToFilter(closure) { + return async () => { + try { + const localClosure = closure.getCopy(); + localClosure.onProgress = () => { }; + const result = await localClosure.execute(); + return isTrueBoolean(result.pipe); + } catch (e) { + console.error('Error executing filter closure', e); + return false; + } + }; +} + export function initDefaultSlashCommands() { SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: '?', @@ -1611,6 +1630,13 @@ export function initDefaultSlashCommands() { new SlashCommandNamedArgument( 'ephemeral', 'remove injection after generation', [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', ), + SlashCommandNamedArgument.fromProps({ + name: 'filter', + description: 'if a filter is defined, an injection will only be performed if the closure returns true', + typeList: [ARGUMENT_TYPE.CLOSURE], + isRequired: false, + acceptsMultiple: false, + }), ], unnamedArgumentList: [ new SlashCommandArgument( @@ -1892,6 +1918,52 @@ export function initDefaultSlashCommands() { ], helpString: 'Converts the provided string to lowercase.', })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'substr', + aliases: ['substring'], + callback: (arg, text) => typeof text === 'string' ? text.slice(...[Number(arg.start), arg.end && Number(arg.end)]) : '', + returns: 'substring', + namedArgumentList: [ + new SlashCommandNamedArgument( + 'start', 'start index', [ARGUMENT_TYPE.NUMBER], false, false, + ), + new SlashCommandNamedArgument( + 'end', 'end index', [ARGUMENT_TYPE.NUMBER], false, false, + ), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + 'string', [ARGUMENT_TYPE.STRING], true, false, + ), + ], + helpString: ` +
+ Extracts text from the provided string. +
+
+ If start is omitted, it's treated as 0.
+ If start < 0, the index is counted from the end of the string.
+ If start >= the string's length, an empty string is returned.
+ If end is omitted, or if end >= the string's length, extracts to the end of the string.
+ If end < 0, the index is counted from the end of the string.
+ If end <= start after normalizing negative values, an empty string is returned. +
+
+ Example: +
/let x The morning is upon us.     ||                                     
+
/substr start=-3 {{var::x}}         | /echo  |/# us.                    ||
+
/substr start=-3 end=-1 {{var::x}}  | /echo  |/# us                     ||
+
/substr end=-1 {{var::x}}           | /echo  |/# The morning is upon us ||
+
/substr start=4 end=-1 {{var::x}}   | /echo  |/# morning is upon us     ||
+
+ `, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'is-mobile', + callback: () => String(isMobile()), + returns: ARGUMENT_TYPE.BOOLEAN, + helpString: 'Returns true if the current device is a mobile device, false otherwise. Equivalent to {{isMobile}} macro.', + })); registerVariableCommands(); } @@ -1901,6 +1973,11 @@ const NARRATOR_NAME_DEFAULT = 'System'; export const COMMENT_NAME_DEFAULT = 'Note'; const SCRIPT_PROMPT_KEY = 'script_inject_'; +/** + * Adds a new script injection to the chat. + * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments + * @param {import('./slash-commands/SlashCommand.js').UnnamedArguments} value Unnamed argument + */ function injectCallback(args, value) { const positions = { 'before': extension_prompt_types.BEFORE_PROMPT, @@ -1914,8 +1991,8 @@ function injectCallback(args, value) { 'assistant': extension_prompt_roles.ASSISTANT, }; - const id = args?.id; - const ephemeral = isTrueBoolean(args?.ephemeral); + const id = String(args?.id); + const ephemeral = isTrueBoolean(String(args?.ephemeral)); if (!id) { console.warn('WARN: No ID provided for /inject command'); @@ -1931,9 +2008,15 @@ function injectCallback(args, value) { const depth = isNaN(depthValue) ? defaultDepth : depthValue; const roleValue = typeof args?.role === 'string' ? args.role.toLowerCase().trim() : Number(args?.role ?? extension_prompt_roles.SYSTEM); const role = roles[roleValue] ?? roles[extension_prompt_roles.SYSTEM]; - const scan = isTrueBoolean(args?.scan); + const scan = isTrueBoolean(String(args?.scan)); + const filter = args?.filter instanceof SlashCommandClosure ? args.filter.rawText : null; + const filterFunction = args?.filter instanceof SlashCommandClosure ? closureToFilter(args.filter) : null; value = value || ''; + if (args?.filter && !String(filter ?? '').trim()) { + throw new Error('Failed to parse the filter argument. Make sure it is a valid non-empty closure.'); + } + const prefixedId = `${SCRIPT_PROMPT_KEY}${id}`; if (!chat_metadata.script_injects) { @@ -1941,13 +2024,13 @@ function injectCallback(args, value) { } if (value) { - const inject = { value, position, depth, scan, role }; + const inject = { value, position, depth, scan, role, filter }; chat_metadata.script_injects[id] = inject; } else { delete chat_metadata.script_injects[id]; } - setExtensionPrompt(prefixedId, value, position, depth, scan, role); + setExtensionPrompt(prefixedId, String(value), position, depth, scan, role, filterFunction); saveMetadataDebounced(); if (ephemeral) { @@ -1958,7 +2041,7 @@ function injectCallback(args, value) { } console.log('Removing ephemeral script injection', id); delete chat_metadata.script_injects[id]; - setExtensionPrompt(prefixedId, '', position, depth, scan, role); + setExtensionPrompt(prefixedId, '', position, depth, scan, role, filterFunction); saveMetadataDebounced(); deleted = true; }; @@ -2053,9 +2136,28 @@ export function processChatSlashCommands() { } for (const [id, inject] of Object.entries(context.chatMetadata.script_injects)) { + /** + * Rehydrates a filter closure from a string. + * @returns {SlashCommandClosure | null} + */ + function reviveFilterClosure() { + if (!inject.filter) { + return null; + } + + try { + return new SlashCommandParser().parse(inject.filter, true); + } catch (error) { + console.warn('Failed to revive filter closure for script injection', id, error); + return null; + } + } + const prefixedId = `${SCRIPT_PROMPT_KEY}${id}`; + const filterClosure = reviveFilterClosure(); + const filter = filterClosure ? closureToFilter(filterClosure) : null; console.log('Adding script injection', id); - setExtensionPrompt(prefixedId, inject.value, inject.position, inject.depth, inject.scan, inject.role); + setExtensionPrompt(prefixedId, inject.value, inject.position, inject.depth, inject.scan, inject.role, filter); } } @@ -3687,6 +3789,7 @@ function setBackgroundCallback(_, bg) { function getModelOptions(quiet) { const nullResult = { control: null, options: null }; const modelSelectMap = [ + { id: 'generic_model_textgenerationwebui', api: 'textgenerationwebui', type: textgen_types.GENERIC }, { id: 'custom_model_textgenerationwebui', api: 'textgenerationwebui', type: textgen_types.OOBA }, { id: 'model_togetherai_select', api: 'textgenerationwebui', type: textgen_types.TOGETHERAI }, { id: 'openrouter_model', api: 'textgenerationwebui', type: textgen_types.OPENROUTER }, diff --git a/public/scripts/st-context.js b/public/scripts/st-context.js index 4f0abdd8d..9cbf18210 100644 --- a/public/scripts/st-context.js +++ b/public/scripts/st-context.js @@ -63,7 +63,7 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from ' import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { tag_map, tags } from './tags.js'; import { textgenerationwebui_settings } from './textgen-settings.js'; -import { getTokenCount, getTokenCountAsync, getTokenizerModel } from './tokenizers.js'; +import { tokenizers, getTextTokens, getTokenCount, getTokenCountAsync, getTokenizerModel } from './tokenizers.js'; import { ToolManager } from './tool-calling.js'; import { timestampToMoment } from './utils.js'; @@ -95,6 +95,8 @@ export function getContext() { sendStreamingRequest, sendGenerationRequest, stopGeneration, + tokenizers, + getTextTokens, /** @deprecated Use getTokenCountAsync instead */ getTokenCount, getTokenCountAsync, diff --git a/public/scripts/templates/chatLorebook.html b/public/scripts/templates/chatLorebook.html new file mode 100644 index 000000000..c23c466e4 --- /dev/null +++ b/public/scripts/templates/chatLorebook.html @@ -0,0 +1,18 @@ +
+
+

+ Chat Lorebook for +

+
+
+ + A selected World Info will be bound to this chat. When generating an AI reply, + it will be combined with the entries from global and character lorebooks. + +
+
+ +
+
diff --git a/public/scripts/templates/macros.html b/public/scripts/templates/macros.html index 84a163c4e..7490596d3 100644 --- a/public/scripts/templates/macros.html +++ b/public/scripts/templates/macros.html @@ -48,6 +48,7 @@
  • {{random::(arg1)::(arg2)}}alternative syntax for random that allows to use commas in the list items.
  • {{pick::(args)}}picks a random item from the list. Works the same as {{random}}, with the same possible syntax options, but the pick will stay consistent for this chat once picked and won't be re-rolled on consecutive messages and prompt processing.
  • {{banned "text here"}}dynamically add text in the quotes to banned words sequences, if Text Generation WebUI backend used. Do nothing for others backends. Can be used anywhere (Character description, WI, AN, etc.) Quotes around the text are important.
  • +
  • {{isMobile}}"true" if currently running in a mobile environment, "false" otherwise
  • Instruct Mode and Context Template Macros: diff --git a/public/scripts/templates/personaLorebook.html b/public/scripts/templates/personaLorebook.html new file mode 100644 index 000000000..5a8a2b928 --- /dev/null +++ b/public/scripts/templates/personaLorebook.html @@ -0,0 +1,18 @@ +
    +
    +

    + Persona Lorebook for +

    +
    +
    + + A selected World Info will be bound to this persona. When generating an AI reply, + it will be combined with the entries from global, character and chat lorebooks. + +
    +
    + +
    +
    diff --git a/public/scripts/templates/tagManagement.html b/public/scripts/templates/tagManagement.html index ada2107fe..820af245a 100644 --- a/public/scripts/templates/tagManagement.html +++ b/public/scripts/templates/tagManagement.html @@ -20,14 +20,14 @@ Drag handle to reorder. Click name to rename. Click color to change display.
    {{#if bogus_folders}}Click on the folder icon to use this tag as a folder.
    {{/if}} -
    -
    \ No newline at end of file + diff --git a/public/scripts/textgen-models.js b/public/scripts/textgen-models.js index 53efe597d..851c3940d 100644 --- a/public/scripts/textgen-models.js +++ b/public/scripts/textgen-models.js @@ -25,6 +25,7 @@ const OPENROUTER_PROVIDERS = [ 'Anthropic', 'Google', 'Google AI Studio', + 'Amazon Bedrock', 'Groq', 'SambaNova', 'Cohere', @@ -50,6 +51,8 @@ const OPENROUTER_PROVIDERS = [ 'Featherless', 'Inflection', 'xAI', + 'Cloudflare', + 'SF Compute', '01.AI', 'HuggingFace', 'Mancer', @@ -160,6 +163,24 @@ export async function loadInfermaticAIModels(data) { } } +export function loadGenericModels(data) { + if (!Array.isArray(data)) { + console.error('Invalid Generic models data', data); + return; + } + + data.sort((a, b) => a.id.localeCompare(b.id)); + const dataList = $('#generic_model_fill'); + dataList.empty(); + + for (const model of data) { + const option = document.createElement('option'); + option.value = model.id; + option.text = model.id; + dataList.append(option); + } +} + export async function loadDreamGenModels(data) { if (!Array.isArray(data)) { console.error('Invalid DreamGen models data', data); diff --git a/public/scripts/textgen-settings.js b/public/scripts/textgen-settings.js index 19c270e49..e80c7e910 100644 --- a/public/scripts/textgen-settings.js +++ b/public/scripts/textgen-settings.js @@ -16,7 +16,7 @@ import { power_user, registerDebugFunction } from './power-user.js'; import { getEventSourceStream } from './sse-stream.js'; import { getCurrentDreamGenModelTokenizer, getCurrentOpenRouterModelTokenizer } from './textgen-models.js'; import { ENCODE_TOKENIZERS, TEXTGEN_TOKENIZERS, getTextTokens, tokenizers } from './tokenizers.js'; -import { getSortableDelay, onlyUnique } from './utils.js'; +import { getSortableDelay, onlyUnique, arraysEqual } from './utils.js'; export const textgen_types = { OOBA: 'ooba', @@ -33,9 +33,11 @@ export const textgen_types = { OPENROUTER: 'openrouter', FEATHERLESS: 'featherless', HUGGINGFACE: 'huggingface', + GENERIC: 'generic', }; const { + GENERIC, MANCER, VLLM, APHRODITE, @@ -53,6 +55,7 @@ const { } = textgen_types; const LLAMACPP_DEFAULT_ORDER = [ + 'dry', 'top_k', 'tfs_z', 'typical_p', @@ -82,6 +85,22 @@ const OOBA_DEFAULT_ORDER = [ 'encoder_repetition_penalty', 'no_repeat_ngram', ]; +const APHRODITE_DEFAULT_ORDER = [ + 'dry', + 'penalties', + 'no_repeat_ngram', + 'temperature', + 'top_nsigma', + 'top_p_top_k', + 'top_a', + 'min_p', + 'tfs', + 'eta_cutoff', + 'epsilon_cutoff', + 'typical_p', + 'quadratic', + 'xtc', +]; const BIAS_KEY = '#textgenerationwebui_api-settings'; // Maybe let it be configurable in the future? @@ -104,6 +123,7 @@ export const SERVER_INPUTS = { [textgen_types.LLAMACPP]: '#llamacpp_api_url_text', [textgen_types.OLLAMA]: '#ollama_api_url_text', [textgen_types.HUGGINGFACE]: '#huggingface_api_url_text', + [textgen_types.GENERIC]: '#generic_api_url_text', }; const KOBOLDCPP_ORDER = [6, 0, 1, 3, 4, 2, 5]; @@ -163,6 +183,7 @@ const settings = { banned_tokens: '', sampler_priority: OOBA_DEFAULT_ORDER, samplers: LLAMACPP_DEFAULT_ORDER, + samplers_priorities: APHRODITE_DEFAULT_ORDER, ignore_eos_token: false, spaces_between_special_tokens: true, speculative_ngram: false, @@ -188,6 +209,7 @@ const settings = { xtc_probability: 0, nsigma: 0.0, featherless_model: '', + generic_model: '', }; export { @@ -256,6 +278,7 @@ export const setting_names = [ 'sampler_order', 'sampler_priority', 'samplers', + 'samplers_priorities', 'n', 'logit_bias', 'custom_model', @@ -264,6 +287,7 @@ export const setting_names = [ 'xtc_threshold', 'xtc_probability', 'nsigma', + 'generic_model', ]; const DYNATEMP_BLOCK = document.getElementById('dynatemp_block_ooba'); @@ -553,6 +577,20 @@ function sortOobaItemsByOrder(orderArray) { }); } +/** + * Sorts the Aphrodite sampler items by the given order. + * @param {string[]} orderArray Sampler order array. + */ +function sortAphroditeItemsByOrder(orderArray) { + console.debug('Preset samplers order: ', orderArray); + const $container = $('#sampler_priority_container_aphrodite'); + + orderArray.forEach((name) => { + const $item = $container.find(`[data-name="${name}"]`).detach(); + $container.append($item); + }); +} + jQuery(function () { $('#koboldcpp_order').sortable({ delay: getSortableDelay(), @@ -606,6 +644,19 @@ jQuery(function () { }, }); + $('#sampler_priority_container_aphrodite').sortable({ + delay: getSortableDelay(), + stop: function () { + const order = []; + $('#sampler_priority_container_aphrodite').children().each(function () { + order.push($(this).data('name')); + }); + settings.samplers_priorities = order; + console.log('Samplers reordered:', settings.samplers_priorities); + saveSettingsDebounced(); + }, + }); + $('#tabby_json_schema').on('input', function () { const json_schema_string = String($(this).val()); @@ -624,6 +675,13 @@ jQuery(function () { saveSettingsDebounced(); }); + $('#aphrodite_default_order').on('click', function () { + sortAphroditeItemsByOrder(APHRODITE_DEFAULT_ORDER); + settings.samplers_priorities = APHRODITE_DEFAULT_ORDER; + console.log('Default samplers order loaded:', settings.samplers_priorities); + saveSettingsDebounced(); + }); + $('#textgen_type').on('change', function () { const type = String($(this).val()); settings.type = type; @@ -781,7 +839,14 @@ jQuery(function () { function showTypeSpecificControls(type) { $('[data-tg-type]').each(function () { + const mode = String($(this).attr('data-tg-type-mode') ?? '').toLowerCase().trim(); const tgTypes = $(this).attr('data-tg-type').split(',').map(x => x.trim()); + + if (mode === 'except') { + $(this)[tgTypes.includes(type) ? 'hide' : 'show'](); + return; + } + for (const tgType of tgTypes) { if (tgType === type || tgType == 'all') { $(this).show(); @@ -832,6 +897,14 @@ function setSettingByName(setting, value, trigger) { return; } + if ('samplers_priorities' === setting) { + value = Array.isArray(value) ? value : APHRODITE_DEFAULT_ORDER; + insertMissingArrayItems(APHRODITE_DEFAULT_ORDER, value); + sortAphroditeItemsByOrder(value); + settings.samplers_priorities = value; + return; + } + if ('samplers' === setting) { value = Array.isArray(value) ? value : LLAMACPP_DEFAULT_ORDER; insertMissingArrayItems(LLAMACPP_DEFAULT_ORDER, value); @@ -966,12 +1039,30 @@ export function parseTextgenLogprobs(token, logprobs) { return { token, topLogprobs: candidates }; } case LLAMACPP: { - /** @type {Record[]} */ if (!logprobs?.length) { return null; } - const candidates = logprobs[0].probs.map(x => [x.tok_str, x.prob]); - return { token, topLogprobs: candidates }; + + // 3 cases: + // 1. Before commit 6c5bc06, "probs" key with "tok_str"/"prob", and probs are [0, 1] so use them directly. + // 2. After commit 6c5bc06 but before commit 89d604f broke logprobs (they all return the first token's logprobs) + // We don't know the llama.cpp version so we can't do much about this. + // 3. After commit 89d604f uses OpenAI-compatible format with "completion_probabilities" and "token"/"logprob" keys. + // Note that it is also the *actual* logprob (negative number), so we need to convert to [0, 1]. + if (logprobs?.[0]?.probs) { + const candidates = logprobs?.[0]?.probs?.map(x => [x.tok_str, x.prob]); + if (!candidates) { + return null; + } + return { token, topLogprobs: candidates }; + } else if (logprobs?.[0].top_logprobs) { + const candidates = logprobs?.[0]?.top_logprobs?.map(x => [x.token, Math.exp(x.logprob)]); + if (!candidates) { + return null; + } + return { token, topLogprobs: candidates }; + } + return null; } default: return null; @@ -1040,6 +1131,11 @@ export function getTextGenModel() { return settings.custom_model; } break; + case GENERIC: + if (settings.generic_model) { + return settings.generic_model; + } + break; case MANCER: return settings.mancer_model; case TOGETHERAI: @@ -1256,6 +1352,11 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, 'nsigma': settings.nsigma, 'custom_token_bans': toIntArray(banned_tokens), 'no_repeat_ngram_size': settings.no_repeat_ngram_size, + 'sampler_priority': settings.type === APHRODITE && !arraysEqual( + settings.samplers_priorities, + APHRODITE_DEFAULT_ORDER) + ? settings.samplers_priorities + : undefined, }; if (settings.type === OPENROUTER) { diff --git a/public/scripts/tokenizers.js b/public/scripts/tokenizers.js index c25075a0f..4d3eb1eb7 100644 --- a/public/scripts/tokenizers.js +++ b/public/scripts/tokenizers.js @@ -32,6 +32,7 @@ export const tokenizers = { COMMAND_R: 16, NEMO: 17, BEST_MATCH: 99, + MANUAL_SELECTION: 411, }; // A list of local tokenizers that support encoding and decoding token ids. @@ -536,7 +537,6 @@ export function getTokenizerModel() { return oai_settings.openai_model; } - const turbo0301Tokenizer = 'gpt-3.5-turbo-0301'; const turboTokenizer = 'gpt-3.5-turbo'; const gpt4Tokenizer = 'gpt-4'; const gpt4oTokenizer = 'gpt-4o'; @@ -562,9 +562,6 @@ export function getTokenizerModel() { if (oai_settings.windowai_model.includes('gpt-4')) { return gpt4Tokenizer; } - else if (oai_settings.windowai_model.includes('gpt-3.5-turbo-0301')) { - return turbo0301Tokenizer; - } else if (oai_settings.windowai_model.includes('gpt-3.5-turbo')) { return turboTokenizer; } @@ -610,9 +607,6 @@ export function getTokenizerModel() { else if (oai_settings.openrouter_model.includes('gpt-4')) { return gpt4Tokenizer; } - else if (oai_settings.openrouter_model.includes('gpt-3.5-turbo-0301')) { - return turbo0301Tokenizer; - } else if (oai_settings.openrouter_model.includes('gpt-3.5-turbo')) { return turboTokenizer; } @@ -1064,9 +1058,14 @@ function decodeTextTokensFromServer(endpoint, ids, resolve) { * Encodes a string to tokens using the server API. * @param {number} tokenizerType Tokenizer type. * @param {string} str String to tokenize. + * @param {string} overrideModel Tokenizer for {tokenizers.MANUAL_SELECTION}. * @returns {number[]} Array of token ids. */ -export function getTextTokens(tokenizerType, str) { +export function getTextTokens(tokenizerType, str, overrideModel = undefined) { + if (overrideModel && tokenizerType !== tokenizers.MANUAL_SELECTION) { + console.warn('overrideModel must be undefined unless using tokenizers.MANUAL_SELECTION', tokenizerType); + return []; + } switch (tokenizerType) { case tokenizers.API_CURRENT: return getTextTokens(currentRemoteTokenizerAPI(), str); @@ -1087,6 +1086,9 @@ export function getTextTokens(tokenizerType, str) { console.warn('This tokenizer type does not support encoding', tokenizerType); return []; } + if (tokenizerType === tokenizers.MANUAL_SELECTION) { + endpointUrl += `?model=${overrideModel}`; + } if (tokenizerType === tokenizers.OPENAI) { endpointUrl += `?model=${getTokenizerModel()}`; } diff --git a/public/scripts/tool-calling.js b/public/scripts/tool-calling.js index deda15289..b56e3027c 100644 --- a/public/scripts/tool-calling.js +++ b/public/scripts/tool-calling.js @@ -25,6 +25,7 @@ import { isTrueBoolean } from './utils.js'; * @typedef {object} ToolInvocationResult * @property {ToolInvocation[]} invocations Successful tool invocations * @property {Error[]} errors Errors that occurred during tool invocation + * @property {string[]} stealthCalls Names of stealth tools that were invoked */ /** @@ -36,6 +37,7 @@ import { isTrueBoolean } from './utils.js'; * @property {function} action - The action to perform when the tool is invoked. * @property {function} [formatMessage] - A function to format the tool call message. * @property {function} [shouldRegister] - A function to determine if the tool should be registered. + * @property {boolean} [stealth] - A tool call result will not be shown in the chat. No follow-up generation will be performed. */ /** @@ -147,6 +149,12 @@ class ToolDefinition { */ #shouldRegister; + /** + * A tool call result will not be shown in the chat. No follow-up generation will be performed. + * @type {boolean} + */ + #stealth; + /** * Creates a new ToolDefinition. * @param {string} name A unique name for the tool. @@ -156,8 +164,9 @@ class ToolDefinition { * @param {function} action A function that will be called when the tool is executed. * @param {function} formatMessage A function that will be called to format the tool call toast. * @param {function} shouldRegister A function that will be called to determine if the tool should be registered. + * @param {boolean} stealth A tool call result will not be shown in the chat. No follow-up generation will be performed. */ - constructor(name, displayName, description, parameters, action, formatMessage, shouldRegister) { + constructor(name, displayName, description, parameters, action, formatMessage, shouldRegister, stealth) { this.#name = name; this.#displayName = displayName; this.#description = description; @@ -165,6 +174,7 @@ class ToolDefinition { this.#action = action; this.#formatMessage = formatMessage; this.#shouldRegister = shouldRegister; + this.#stealth = stealth; } /** @@ -214,6 +224,10 @@ class ToolDefinition { get displayName() { return this.#displayName; } + + get stealth() { + return this.#stealth; + } } /** @@ -246,7 +260,7 @@ export class ToolManager { * Registers a new tool with the tool registry. * @param {ToolRegistration} tool The tool to register. */ - static registerFunctionTool({ name, displayName, description, parameters, action, formatMessage, shouldRegister }) { + static registerFunctionTool({ name, displayName, description, parameters, action, formatMessage, shouldRegister, stealth }) { // Convert WIP arguments if (typeof arguments[0] !== 'object') { [name, description, parameters, action] = arguments; @@ -256,7 +270,16 @@ export class ToolManager { console.warn(`[ToolManager] A tool with the name "${name}" has already been registered. The definition will be overwritten.`); } - const definition = new ToolDefinition(name, displayName, description, parameters, action, formatMessage, shouldRegister); + const definition = new ToolDefinition( + name, + displayName, + description, + parameters, + action, + formatMessage, + shouldRegister, + stealth, + ); this.#tools.set(name, definition); console.log('[ToolManager] Registered function tool:', definition); } @@ -302,6 +325,20 @@ export class ToolManager { } } + /** + * Checks if a tool is a stealth tool. + * @param {string} name The name of the tool to check. + * @returns {boolean} Whether the tool is a stealth tool. + */ + static isStealthTool(name) { + if (!this.#tools.has(name)) { + return false; + } + + const tool = this.#tools.get(name); + return !!tool.stealth; + } + /** * Formats a message for a tool call by name. * @param {string} name The name of the tool to format the message for. @@ -608,6 +645,7 @@ export class ToolManager { const result = { invocations: [], errors: [], + stealthCalls: [], }; const toolCalls = ToolManager.#getToolCallsFromData(data); @@ -625,7 +663,7 @@ export class ToolManager { const parameters = toolCall.function.arguments; const name = toolCall.function.name; const displayName = ToolManager.getDisplayName(name); - + const isStealth = ToolManager.isStealthTool(name); const message = await ToolManager.formatToolCallMessage(name, parameters); const toast = message && toastr.info(message, 'Tool Calling', { timeOut: 0 }); const toolResult = await ToolManager.invokeFunctionTool(name, parameters); @@ -638,6 +676,12 @@ export class ToolManager { continue; } + // Don't save stealth tool invocations + if (isStealth) { + result.stealthCalls.push(name); + continue; + } + const invocation = { id, displayName, @@ -860,6 +904,14 @@ export class ToolManager { isRequired: false, acceptsMultiple: false, }), + SlashCommandNamedArgument.fromProps({ + name: 'stealth', + description: 'If true, a tool call result will not be shown in the chat and no follow-up generation will be performed.', + typeList: [ARGUMENT_TYPE.BOOLEAN], + isRequired: false, + acceptsMultiple: false, + defaultValue: String(false), + }), ], unnamedArgumentList: [ SlashCommandArgument.fromProps({ @@ -891,7 +943,7 @@ export class ToolManager { }; } - const { name, displayName, description, parameters, formatMessage, shouldRegister } = args; + const { name, displayName, description, parameters, formatMessage, shouldRegister, stealth } = args; if (!(action instanceof SlashCommandClosure)) { throw new Error('The unnamed argument must be a closure.'); @@ -927,6 +979,7 @@ export class ToolManager { action: actionFunc, formatMessage: formatMessageFunc, shouldRegister: shouldRegisterFunc, + stealth: stealth && isTrueBoolean(String(stealth)), }); return ''; diff --git a/public/scripts/user.js b/public/scripts/user.js index c5d984e1b..51f7e1011 100644 --- a/public/scripts/user.js +++ b/public/scripts/user.js @@ -31,7 +31,7 @@ export async function setUserControls(isEnabled) { * Check if the current user is an admin. * @returns {boolean} True if the current user is an admin */ -function isAdmin() { +export function isAdmin() { if (!currentUser) { return false; } diff --git a/public/scripts/utils.js b/public/scripts/utils.js index a198cb7db..eb944b586 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -665,9 +665,10 @@ export function trimToEndSentence(input) { const characters = Array.from(input); for (let i = characters.length - 1; i >= 0; i--) { const char = characters[i]; + const emoji = isEmoji(char); - if (punctuation.has(char) || isEmoji(char)) { - if (i > 0 && /[\s\n]/.test(characters[i - 1])) { + if (punctuation.has(char) || emoji) { + if (!emoji && i > 0 && /[\s\n]/.test(characters[i - 1])) { last = i - 1; } else { last = i; @@ -2112,7 +2113,7 @@ export async function showFontAwesomePicker(customList = null) { * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by * @param {boolean} [options.preferCurrentChar=true] - Whether to prefer the current character(s) * @param {boolean} [options.quiet=false] - Whether to suppress warnings - * @returns {any?} - The found character or null if not found + * @returns {import('./char-data.js').v1CharData?} - The found character or null if not found */ export function findChar({ name = null, allowAvatar = true, insensitive = true, filteredByTags = null, preferCurrentChar = true, quiet = false } = {}) { const matches = (char) => !name || (allowAvatar && char.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name); @@ -2173,3 +2174,20 @@ export function getCharIndex(char) { if (index === -1) throw new Error(`Character not found: ${char.avatar}`); return index; } + +/** + * Compares two arrays for equality + * @param {any[]} a - The first array + * @param {any[]} b - The second array + * @returns {boolean} True if the arrays are equal, false otherwise + */ +export function arraysEqual(a, b) { + if (a === b) return true; + if (a == null || b == null) return false; + if (a.length !== b.length) return false; + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/public/scripts/variables.js b/public/scripts/variables.js index df22db8ad..fd533f656 100644 --- a/public/scripts/variables.js +++ b/public/scripts/variables.js @@ -46,6 +46,10 @@ function getLocalVariable(name, args = {}) { } function setLocalVariable(name, value, args = {}) { + if (!name) { + throw new Error('Variable name cannot be empty or undefined.'); + } + if (!chat_metadata.variables) { chat_metadata.variables = {}; } @@ -99,6 +103,10 @@ function getGlobalVariable(name, args = {}) { } function setGlobalVariable(name, value, args = {}) { + if (!name) { + throw new Error('Variable name cannot be empty or undefined.'); + } + if (args.index !== undefined) { try { let globalVariable = JSON.parse(extension_settings.variables.global[name] ?? 'null'); diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 0f4351933..e4d106170 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -1,7 +1,7 @@ import { Fuse } from '../lib.js'; import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js'; -import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray, cancelDebounce } from './utils.js'; +import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray, cancelDebounce, findChar, onlyUnique } from './utils.js'; import { extension_settings, getContext } from './extensions.js'; import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js'; import { isMobile } from './RossAscends-mods.js'; @@ -930,7 +930,50 @@ function registerWorldInfoSlashCommands() { return entries; } - async function getChatBookCallback() { + /** + * Gets the name of the persona-bound lorebook. + * @returns {string} The name of the persona-bound lorebook + */ + function getPersonaBookCallback() { + return power_user.persona_description_lorebook || ''; + } + + /** + * Gets the name of the character-bound lorebook. + * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments + * @param {import('./slash-commands/SlashCommand.js').UnnamedArguments} name Character name + * @returns {string} The name of the character-bound lorebook, a JSON string of the character's lorebooks, or an empty string + */ + function getCharBookCallback({ type }, name) { + const context = getContext(); + if (context.groupId && !name) throw new Error('This command is not available in groups without providing a character name'); + type = String(type ?? '').trim().toLowerCase() || 'primary'; + name = String(name ?? '') || context.characters[context.characterId]?.avatar || null; + const character = findChar({ name }); + if (!character) { + toastr.error('Character not found.'); + return ''; + } + const books = []; + if (type === 'all' || type === 'primary') { + books.push(character.data?.extensions?.world); + } + if (type === 'all' || type === 'additional') { + const fileName = getCharaFilename(context.characters.indexOf(character)); + const extraCharLore = world_info.charLore?.find((e) => e.name === fileName); + if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) { + books.push(...extraCharLore.extraBooks); + } + } + return type === 'primary' ? (books[0] ?? '') : JSON.stringify(books.filter(onlyUnique).filter(Boolean)); + } + + /** + * Gets the name of the chat-bound lorebook. Creates a new one if it doesn't exist. + * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments + * @returns {Promise} The name of the chat-bound lorebook + */ + async function getChatBookCallback(args) { const chatId = getCurrentChatId(); if (!chatId) { @@ -942,8 +985,19 @@ function registerWorldInfoSlashCommands() { return chat_metadata[METADATA_KEY]; } - // Replace non-alphanumeric characters with underscores, cut to 64 characters - const name = `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64); + const name = (() => { + // Use the provided name if it's not in use + if (typeof args.name === 'string') { + const name = String(args.name); + if (world_names.includes(name)) { + throw new Error('This World Info file name is already in use'); + } + return name; + } + + // Replace non-alphanumeric characters with underscores, cut to 64 characters + return `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64); + })(); await createNewWorldInfo(name); chat_metadata[METADATA_KEY] = name; @@ -1289,8 +1343,48 @@ function registerWorldInfoSlashCommands() { callback: getChatBookCallback, returns: 'lorebook name', helpString: 'Get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe.', + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'lorebook name if creating a new one, will be auto-generated otherwise', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + acceptsMultiple: false, + }), + ], aliases: ['getchatlore', 'getchatwi'], })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'getpersonabook', + callback: getPersonaBookCallback, + returns: 'lorebook name', + helpString: 'Get a name of the current persona-bound lorebook and pass it down the pipe. Returns empty string if persona lorebook is not set.', + aliases: ['getpersonalore', 'getpersonawi'], + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'getcharbook', + callback: getCharBookCallback, + returns: 'lorebook name or a list of lorebook names', + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'type', + description: 'type of the lorebook to get, returns a list for "all" and "additional"', + typeList: [ARGUMENT_TYPE.STRING], + enumList: ['primary', 'additional', 'all'], + defaultValue: 'primary', + }), + ], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'Character name - or unique character identifier (avatar key). If not provided, the current character is used.', + typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], + isRequired: false, + enumProvider: commonEnumProviders.characters('character'), + }), + ], + helpString: 'Get a name of the character-bound lorebook and pass it down the pipe. Returns empty string if character lorebook is not set. Does not work in group chats without providing a character avatar name.', + aliases: ['getcharlore', 'getcharwi'], + })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'findentry', @@ -3548,6 +3642,11 @@ async function getCharacterLore() { continue; } + if (power_user.persona_description_lorebook === worldName) { + console.debug(`[WI] Character ${name}'s world ${worldName} is already activated in persona lore! Skipping...`); + continue; + } + const data = await loadWorldInfo(worldName); const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: worldName, ...rest })) : []; entries = entries.concat(newEntries); @@ -3598,11 +3697,45 @@ async function getChatLore() { return entries; } +async function getPersonaLore() { + const chatWorld = chat_metadata[METADATA_KEY]; + const personaWorld = power_user.persona_description_lorebook; + + if (!personaWorld) { + return []; + } + + if (chatWorld === personaWorld) { + console.debug(`[WI] Persona world ${personaWorld} is already activated in chat world! Skipping...`); + return []; + } + + if (selected_world_info.includes(personaWorld)) { + console.debug(`[WI] Persona world ${personaWorld} is already activated in global world info! Skipping...`); + return []; + } + + const data = await loadWorldInfo(personaWorld); + const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: personaWorld, ...rest })) : []; + + console.debug(`[WI] Persona lore has ${entries.length} entries`, [personaWorld]); + + return entries; +} + export async function getSortedEntries() { try { - const globalLore = await getGlobalLore(); - const characterLore = await getCharacterLore(); - const chatLore = await getChatLore(); + const [ + globalLore, + characterLore, + chatLore, + personaLore, + ] = await Promise.all([ + getGlobalLore(), + getCharacterLore(), + getChatLore(), + getPersonaLore(), + ]); let entries; @@ -3622,8 +3755,8 @@ export async function getSortedEntries() { break; } - // Chat lore always goes first - entries = [...chatLore.sort(sortFn), ...entries]; + // Chat lore always goes first, then persona lore, then the rest + entries = [...chatLore.sort(sortFn), ...personaLore.sort(sortFn), ...entries]; // Calculate hash and parse decorators. Split maps to preserve old hashes. entries = entries.map((entry) => { @@ -3721,7 +3854,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun) { // Put this code here since otherwise, the chat reference is modified for (const key of Object.keys(context.extensionPrompts)) { if (context.extensionPrompts[key]?.scan) { - const prompt = getExtensionPromptByName(key); + const prompt = await getExtensionPromptByName(key); if (prompt) { buffer.addInject(prompt); } @@ -4816,9 +4949,33 @@ export async function importWorldInfo(file) { }); } -export function assignLorebookToChat() { +/** + * Forces the world info editor to open on a specific world. + * @param {string} worldName The name of the world to open + */ +export function openWorldInfoEditor(worldName) { + console.log(`Opening lorebook for ${worldName}`); + if (!$('#WorldInfo').is(':visible')) { + $('#WIDrawerIcon').trigger('click'); + } + const index = world_names.indexOf(worldName); + $('#world_editor_select').val(index).trigger('change'); +} + +/** + * Assigns a lorebook to the current chat. + * @param {PointerEvent} event Pointer event + * @returns {Promise} + */ +export async function assignLorebookToChat(event) { const selectedName = chat_metadata[METADATA_KEY]; - const template = $('#chat_world_template .chat_world').clone(); + + if (selectedName && event.altKey) { + openWorldInfoEditor(selectedName); + return; + } + + const template = $(await renderTemplateAsync('chatLorebook')); const worldSelect = template.find('select'); const chatName = template.find('.chat_name'); @@ -4846,7 +5003,7 @@ export function assignLorebookToChat() { saveMetadata(); }); - callPopup(template, 'text'); + return callGenericPopup(template, POPUP_TYPE.TEXT); } jQuery(() => { @@ -4997,11 +5154,7 @@ jQuery(() => { const worldName = characters[chid]?.data?.extensions?.world; const hasEmbed = checkEmbeddedWorld(chid); if (worldName && world_names.includes(worldName) && !event.shiftKey) { - if (!$('#WorldInfo').is(':visible')) { - $('#WIDrawerIcon').trigger('click'); - } - const index = world_names.indexOf(worldName); - $('#world_editor_select').val(index).trigger('change'); + openWorldInfoEditor(worldName); } else if (hasEmbed && !event.shiftKey) { await importEmbeddedWorldInfo(); saveCharacterDebounced(); diff --git a/public/style.css b/public/style.css index 6f935ec4c..ce076f915 100644 --- a/public/style.css +++ b/public/style.css @@ -92,8 +92,6 @@ /* base variable for shadow width slider calculations */ --shadowWidth: 2; - color-scheme: only light; - /* Send form variables */ --bottomFormBlockPadding: calc(var(--mainFontSize) / 2.5); --bottomFormIconSize: calc(var(--mainFontSize) * 1.9); @@ -141,7 +139,7 @@ body { height: 100dvh; /*defaults as 100%, then reassigned via JS as pixels, will work on PC and Android*/ /*height: calc(var(--doc-height) - 1px);*/ - background-color: var(--greyCAIbg); + background-color: var(--SmartThemeBlurTintColor); background-repeat: no-repeat; background-attachment: fixed; background-size: cover; @@ -149,6 +147,7 @@ body { font-size: var(--mainFontSize); color: var(--SmartThemeBodyColor); overflow: hidden; + color-scheme: only light; } ::-webkit-scrollbar { @@ -519,6 +518,40 @@ hr { transition: background-image 0.5s ease-in-out; } +/* Background fitting options */ +#background_fitting { + max-width: 6em; +} + +/* Fill/Cover - scales to fill width while maintaining aspect ratio */ +#bg1.cover, +#bg_custom.cover { + background-size: cover; + background-position: center; +} + +/* Fit/Contain - shows entire image maintaining aspect ratio */ +#bg1.contain, +#bg_custom.contain { + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +/* Stretch - stretches to fill entire space */ +#bg1.stretch, +#bg_custom.stretch { + background-size: 100% 100%; +} + +/* Center - centers without scaling */ +#bg1.center, +#bg_custom.center { + background-size: auto; + background-position: center; + background-repeat: no-repeat; +} + body.reduced-motion #bg1, body.reduced-motion #bg_custom { transition: none; @@ -1264,6 +1297,7 @@ button { text-shadow: 0px 0px calc(var(--shadowWidth) * 1px) var(--SmartThemeShadowColor); flex: 1; order: 3; + field-sizing: content; --progColor: rgb(146, 190, 252); --progFlashColor: rgb(215, 136, 114); @@ -2788,6 +2822,10 @@ select option:not(:checked) { width: max-content; } +#persona-management-block .menu_button { + filter: grayscale(0.5); +} + input[type=search]::-webkit-search-cancel-button { -webkit-appearance: none; height: 1em; @@ -2870,6 +2908,7 @@ input[type=search]:focus::-webkit-search-cancel-button { .mes_block .ch_name { max-width: 100%; + min-height: 22px; } /*applies to both groups and solos chars in the char list*/ @@ -3631,7 +3670,9 @@ body #toast-container { } body #toast-container>div { - opacity: 0.95; + opacity: 1; + filter: unset; + -ms-filter: unset; } #dialogue_del_mes { @@ -4039,7 +4080,7 @@ input[type="range"]::-webkit-slider-thumb { .mes_button, .extraMesButtons>div { cursor: pointer; - transition: 0.3s ease-in-out; + transition: opacity 0.2s ease-in-out; filter: drop-shadow(0px 0px 2px black); opacity: 0.3; padding: 1px 3px; @@ -4106,6 +4147,7 @@ input[type="range"]::-webkit-slider-thumb { line-height: calc(var(--mainFontSize) + .25rem); max-height: 75vh; max-height: 75dvh; + field-sizing: content; } #anchor_order { @@ -5172,7 +5214,7 @@ body:not(.movingUI) .drawer-content.maximized { width: 100%; height: 100%; opacity: 0.8; - min-height: 3rem; + min-height: 2.5em; } .openai_restorable, diff --git a/src/additional-headers.js b/src/additional-headers.js index e35456ae4..1ea5d035f 100644 --- a/src/additional-headers.js +++ b/src/additional-headers.js @@ -172,6 +172,19 @@ function getHuggingFaceHeaders(directories) { }) : {}; } +/** + * Gets the headers for the Generic text completion API. + * @param {import('./users.js').UserDirectoryList} directories + * @returns {object} Headers for the request + */ +function getGenericHeaders(directories) { + const apiKey = readSecret(directories, SECRET_KEYS.GENERIC); + + return apiKey ? ({ + 'Authorization': `Bearer ${apiKey}`, + }) : {}; +} + export function getOverrideHeaders(urlHost) { const requestOverrides = getConfigValue('requestOverrides', []); const overrideHeaders = requestOverrides?.find((e) => e.hosts?.includes(urlHost))?.headers; @@ -214,6 +227,7 @@ export function setAdditionalHeadersByType(requestHeaders, type, server, directo [TEXTGEN_TYPES.LLAMACPP]: getLlamaCppHeaders, [TEXTGEN_TYPES.FEATHERLESS]: getFeatherlessHeaders, [TEXTGEN_TYPES.HUGGINGFACE]: getHuggingFaceHeaders, + [TEXTGEN_TYPES.GENERIC]: getGenericHeaders, }; const getHeaders = headerGetters[type]; diff --git a/src/constants.js b/src/constants.js index 35ba17f85..7fb98eef9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -3,6 +3,7 @@ export const PUBLIC_DIRECTORIES = { backups: 'backups/', sounds: 'public/sounds', extensions: 'public/scripts/extensions', + globalExtensions: 'public/scripts/extensions/third-party', }; export const SETTINGS_FILE = 'settings.json'; @@ -158,33 +159,6 @@ export const GEMINI_SAFETY = [ }, ]; -export const BISON_SAFETY = [ - { - category: 'HARM_CATEGORY_DEROGATORY', - threshold: 'BLOCK_NONE', - }, - { - category: 'HARM_CATEGORY_TOXICITY', - threshold: 'BLOCK_NONE', - }, - { - category: 'HARM_CATEGORY_VIOLENCE', - threshold: 'BLOCK_NONE', - }, - { - category: 'HARM_CATEGORY_SEXUAL', - threshold: 'BLOCK_NONE', - }, - { - category: 'HARM_CATEGORY_MEDICAL', - threshold: 'BLOCK_NONE', - }, - { - category: 'HARM_CATEGORY_DANGEROUS', - threshold: 'BLOCK_NONE', - }, -]; - export const CHAT_COMPLETION_SOURCES = { OPENAI: 'openai', WINDOWAI: 'windowai', @@ -224,6 +198,7 @@ export const TEXTGEN_TYPES = { OPENROUTER: 'openrouter', FEATHERLESS: 'featherless', HUGGINGFACE: 'huggingface', + GENERIC: 'generic', }; export const INFERMATICAI_KEYS = [ @@ -345,6 +320,24 @@ export const OLLAMA_KEYS = [ 'min_p', ]; +// https://platform.openai.com/docs/api-reference/completions +export const OPENAI_KEYS = [ + 'model', + 'prompt', + 'stream', + 'temperature', + 'top_p', + 'frequency_penalty', + 'presence_penalty', + 'stop', + 'seed', + 'logit_bias', + 'logprobs', + 'max_tokens', + 'n', + 'best_of', +]; + export const AVATAR_WIDTH = 512; export const AVATAR_HEIGHT = 768; diff --git a/src/endpoints/backends/chat-completions.js b/src/endpoints/backends/chat-completions.js index 3bb3a998a..af7bd1816 100644 --- a/src/endpoints/backends/chat-completions.js +++ b/src/endpoints/backends/chat-completions.js @@ -6,7 +6,6 @@ import { jsonParser } from '../../express-common.js'; import { CHAT_COMPLETION_SOURCES, GEMINI_SAFETY, - BISON_SAFETY, OPENROUTER_HEADERS, } from '../../constants.js'; import { @@ -28,6 +27,7 @@ import { mergeMessages, cachingAtDepthForOpenRouterClaude, cachingAtDepthForClaude, + getPromptNames, } from '../../prompt-converters.js'; import { readSecret, SECRET_KEYS } from '../secrets.js'; @@ -56,22 +56,37 @@ const API_NANOGPT = 'https://nano-gpt.com/api/v1'; * Applies a post-processing step to the generated messages. * @param {object[]} messages Messages to post-process * @param {string} type Prompt conversion type - * @param {string} charName Character name - * @param {string} userName User name + * @param {import('../../prompt-converters.js').PromptNames} names Prompt names * @returns */ -function postProcessPrompt(messages, type, charName, userName) { +function postProcessPrompt(messages, type, names) { switch (type) { case 'merge': case 'claude': - return mergeMessages(messages, charName, userName, false); + return mergeMessages(messages, names, false); case 'strict': - return mergeMessages(messages, charName, userName, true); + return mergeMessages(messages, names, true); default: return messages; } } +/** + * Gets OpenRouter transforms based on the request. + * @param {import('express').Request} request Express request + * @returns {string[] | undefined} OpenRouter transforms + */ +function getOpenRouterTransforms(request) { + switch (request.body.middleout) { + case 'on': + return ['middle-out']; + case 'off': + return []; + case 'auto': + return undefined; + } +} + /** * Sends a request to Claude API. * @param {express.Request} request Express request @@ -102,7 +117,7 @@ async function sendClaudeRequest(request, response) { const additionalHeaders = {}; const useTools = request.body.model.startsWith('claude-3') && Array.isArray(request.body.tools) && request.body.tools.length > 0; const useSystemPrompt = (request.body.model.startsWith('claude-2') || request.body.model.startsWith('claude-3')) && request.body.claude_use_sysprompt; - const convertedPrompt = convertClaudeMessages(request.body.messages, request.body.assistant_prefill, useSystemPrompt, useTools, request.body.char_name, request.body.user_name); + const convertedPrompt = convertClaudeMessages(request.body.messages, request.body.assistant_prefill, useSystemPrompt, useTools, getPromptNames(request)); // Add custom stop sequences const stopSequences = []; if (Array.isArray(request.body.stop)) { @@ -262,9 +277,7 @@ async function sendMakerSuiteRequest(request, response) { } const model = String(request.body.model); - const isGemini = model.includes('gemini'); - const isText = model.includes('text'); - const stream = Boolean(request.body.stream) && isGemini; + const stream = Boolean(request.body.stream); const generationConfig = { stopSequences: request.body.stop, @@ -280,8 +293,15 @@ async function sendMakerSuiteRequest(request, response) { delete generationConfig.stopSequences; } - const should_use_system_prompt = (model.includes('gemini-1.5-flash') || model.includes('gemini-1.5-pro') || model.startsWith('gemini-exp')) && request.body.use_makersuite_sysprompt; - const prompt = convertGooglePrompt(request.body.messages, model, should_use_system_prompt, request.body.char_name, request.body.user_name); + const should_use_system_prompt = ( + model.includes('gemini-2.0-flash-thinking-exp') || + model.includes('gemini-2.0-flash-exp') || + model.includes('gemini-1.5-flash') || + model.includes('gemini-1.5-pro') || + model.startsWith('gemini-exp') + ) && request.body.use_makersuite_sysprompt; + + const prompt = convertGooglePrompt(request.body.messages, model, should_use_system_prompt, getPromptNames(request)); let body = { contents: prompt.contents, safetySettings: GEMINI_SAFETY, @@ -295,39 +315,7 @@ async function sendMakerSuiteRequest(request, response) { return body; } - function getBisonBody() { - const prompt = isText - ? ({ text: convertTextCompletionPrompt(request.body.messages) }) - : ({ messages: convertGooglePrompt(request.body.messages, model).contents }); - - /** @type {any} Shut the lint up */ - const bisonBody = { - ...generationConfig, - safetySettings: BISON_SAFETY, - candidate_count: 1, // lewgacy spelling - prompt: prompt, - }; - - if (!isText) { - delete bisonBody.stopSequences; - delete bisonBody.maxOutputTokens; - delete bisonBody.safetySettings; - - if (Array.isArray(prompt.messages)) { - for (const msg of prompt.messages) { - msg.author = msg.role; - msg.content = msg.parts[0].text; - delete msg.parts; - delete msg.role; - } - } - } - - delete bisonBody.candidateCount; - return bisonBody; - } - - const body = isGemini ? getGeminiBody() : getBisonBody(); + const body = getGeminiBody(); console.log('Google AI Studio request:', body); try { @@ -337,10 +325,8 @@ async function sendMakerSuiteRequest(request, response) { controller.abort(); }); - const apiVersion = isGemini ? 'v1beta' : 'v1beta2'; - const responseType = isGemini - ? (stream ? 'streamGenerateContent' : 'generateContent') - : (isText ? 'generateText' : 'generateMessage'); + const apiVersion = 'v1beta'; + const responseType = (stream ? 'streamGenerateContent' : 'generateContent'); const generateResponse = await fetch(`${apiUrl.toString().replace(/\/$/, '')}/${apiVersion}/models/${model}:${responseType}?key=${apiKey}${stream ? '&alt=sse' : ''}`, { body: JSON.stringify(body), @@ -381,15 +367,15 @@ async function sendMakerSuiteRequest(request, response) { } const responseContent = candidates[0].content ?? candidates[0].output; - const responseText = typeof responseContent === 'string' ? responseContent : responseContent?.parts?.[0]?.text; + console.log('Google AI Studio response:', responseContent); + + const responseText = typeof responseContent === 'string' ? responseContent : responseContent?.parts?.map(part => part.text)?.join('\n\n'); if (!responseText) { let message = 'Google AI Studio Candidate text empty'; console.log(message, generateResponseJson); return response.send({ error: { message } }); } - console.log('Google AI Studio response:', responseText); - // Wrap it back to OAI format const reply = { choices: [{ 'message': { 'content': responseText } }] }; return response.send(reply); @@ -415,7 +401,7 @@ async function sendAI21Request(request, response) { request.socket.on('close', function () { controller.abort(); }); - const convertedPrompt = convertAI21Messages(request.body.messages, request.body.char_name, request.body.user_name); + const convertedPrompt = convertAI21Messages(request.body.messages, getPromptNames(request)); const body = { messages: convertedPrompt, model: request.body.model, @@ -478,7 +464,7 @@ async function sendMistralAIRequest(request, response) { } try { - const messages = convertMistralMessages(request.body.messages, request.body.char_name, request.body.user_name); + const messages = convertMistralMessages(request.body.messages, getPromptNames(request)); const controller = new AbortController(); request.socket.removeAllListeners('close'); request.socket.on('close', function () { @@ -559,7 +545,7 @@ async function sendCohereRequest(request, response) { } try { - const convertedHistory = convertCohereMessages(request.body.messages, request.body.char_name, request.body.user_name); + const convertedHistory = convertCohereMessages(request.body.messages, getPromptNames(request)); const tools = []; if (Array.isArray(request.body.tools) && request.body.tools.length > 0) { @@ -865,7 +851,9 @@ router.post('/generate', jsonParser, function (request, response) { apiKey = readSecret(request.user.directories, SECRET_KEYS.OPENROUTER); // OpenRouter needs to pass the Referer and X-Title: https://openrouter.ai/docs#requests headers = { ...OPENROUTER_HEADERS }; - bodyParams = { 'transforms': ['middle-out'] }; + bodyParams = { + 'transforms': getOpenRouterTransforms(request), + }; if (request.body.min_p !== undefined) { bodyParams['min_p'] = request.body.min_p; @@ -917,15 +905,14 @@ router.post('/generate', jsonParser, function (request, response) { request.body.messages = postProcessPrompt( request.body.messages, request.body.custom_prompt_post_processing, - request.body.char_name, - request.body.user_name); + getPromptNames(request)); } } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.PERPLEXITY) { apiUrl = API_PERPLEXITY; apiKey = readSecret(request.user.directories, SECRET_KEYS.PERPLEXITY); headers = {}; bodyParams = {}; - request.body.messages = postProcessPrompt(request.body.messages, 'strict', request.body.char_name, request.body.user_name); + request.body.messages = postProcessPrompt(request.body.messages, 'strict', getPromptNames(request)); } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.GROQ) { apiUrl = API_GROQ; apiKey = readSecret(request.user.directories, SECRET_KEYS.GROQ); diff --git a/src/endpoints/backends/text-completions.js b/src/endpoints/backends/text-completions.js index 80955696e..28651e93a 100644 --- a/src/endpoints/backends/text-completions.js +++ b/src/endpoints/backends/text-completions.js @@ -13,6 +13,7 @@ import { VLLM_KEYS, DREAMGEN_KEYS, FEATHERLESS_KEYS, + OPENAI_KEYS, } from '../../constants.js'; import { forwardFetchResponse, trimV1, getConfigValue } from '../../util.js'; import { setAdditionalHeaders } from '../../additional-headers.js'; @@ -113,8 +114,8 @@ router.post('/status', jsonParser, async function (request, response) { let url = baseUrl; let result = ''; - switch (apiType) { + case TEXTGEN_TYPES.GENERIC: case TEXTGEN_TYPES.OOBA: case TEXTGEN_TYPES.VLLM: case TEXTGEN_TYPES.APHRODITE: @@ -287,6 +288,7 @@ router.post('/generate', jsonParser, async function (request, response) { let url = trimV1(baseUrl); switch (request.body.api_type) { + case TEXTGEN_TYPES.GENERIC: case TEXTGEN_TYPES.VLLM: case TEXTGEN_TYPES.FEATHERLESS: case TEXTGEN_TYPES.APHRODITE: @@ -347,6 +349,12 @@ router.post('/generate', jsonParser, async function (request, response) { args.body = JSON.stringify(request.body); } + if (request.body.api_type === TEXTGEN_TYPES.GENERIC) { + request.body = _.pickBy(request.body, (_, key) => OPENAI_KEYS.includes(key)); + if (Array.isArray(request.body.stop)) { request.body.stop = request.body.stop.slice(0, 4); } + args.body = JSON.stringify(request.body); + } + if (request.body.api_type === TEXTGEN_TYPES.OPENROUTER) { if (Array.isArray(request.body.provider) && request.body.provider.length > 0) { request.body.provider = { diff --git a/src/endpoints/characters.js b/src/endpoints/characters.js index 9c117cb31..eebabea59 100644 --- a/src/endpoints/characters.js +++ b/src/endpoints/characters.js @@ -158,7 +158,8 @@ async function tryReadImage(imgPath, crop) { return image; } // If it's an unsupported type of image (APNG) - just read the file as buffer - catch { + catch (error) { + console.log(`Failed to read image: ${imgPath}`, error); return fs.readFileSync(imgPath); } } diff --git a/src/endpoints/chats.js b/src/endpoints/chats.js index 2eb8c379e..01fd12821 100644 --- a/src/endpoints/chats.js +++ b/src/endpoints/chats.js @@ -9,7 +9,18 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic'; import _ from 'lodash'; import { jsonParser, urlencodedParser } from '../express-common.js'; -import { getConfigValue, humanizedISO8601DateTime, tryParse, generateTimestamp, removeOldBackups } from '../util.js'; +import { + getConfigValue, + humanizedISO8601DateTime, + tryParse, + generateTimestamp, + removeOldBackups, + formatBytes, +} from '../util.js'; + +const isBackupDisabled = getConfigValue('disableChatBackup', false); +const maxTotalChatBackups = Number(getConfigValue('maxTotalChatBackups', -1)); +const throttleInterval = getConfigValue('chatBackupThrottleInterval', 10_000); /** * Saves a chat to the backups directory. @@ -19,7 +30,6 @@ import { getConfigValue, humanizedISO8601DateTime, tryParse, generateTimestamp, */ function backupChat(directory, name, chat) { try { - const isBackupDisabled = getConfigValue('disableChatBackup', false); if (isBackupDisabled) { return; @@ -32,11 +42,20 @@ function backupChat(directory, name, chat) { writeFileAtomicSync(backupFile, chat, 'utf-8'); removeOldBackups(directory, `chat_${name}_`); + + if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) { + return; + } + + removeOldBackups(directory, 'chat_', maxTotalChatBackups); } catch (err) { console.log(`Could not backup chat for ${name}`, err); } } +/** + * @type {Map>} + */ const backupFunctions = new Map(); /** @@ -45,24 +64,10 @@ const backupFunctions = new Map(); * @returns {function(string, string, string): void} Backup function */ function getBackupFunction(handle) { - const throttleInterval = getConfigValue('chatBackupThrottleInterval', 10_000); if (!backupFunctions.has(handle)) { backupFunctions.set(handle, _.throttle(backupChat, throttleInterval, { leading: true, trailing: true })); } - return backupFunctions.get(handle); -} - -/** - * Formats a byte size into a human-readable string with units - * @param {number} bytes - The size in bytes to format - * @returns {string} The formatted string (e.g., "1.5 MB") - */ -function formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + return backupFunctions.get(handle) || (() => {}); } /** @@ -702,7 +707,7 @@ router.post('/search', jsonParser, function (request, response) { } // Sort by last message date descending - results.sort((a, b) => new Date(b.last_mes) - new Date(a.last_mes)); + results.sort((a, b) => new Date(b.last_mes).getTime() - new Date(a.last_mes).getTime()); return response.send(results); } catch (error) { diff --git a/src/endpoints/extensions.js b/src/endpoints/extensions.js index ecc2f1d23..b3b04b32d 100644 --- a/src/endpoints/extensions.js +++ b/src/endpoints/extensions.js @@ -73,8 +73,19 @@ router.post('/install', jsonParser, async (request, response) => { fs.mkdirSync(path.join(request.user.directories.extensions)); } - const url = request.body.url; - const extensionPath = path.join(request.user.directories.extensions, path.basename(url, '.git')); + if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) { + fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions); + } + + const { url, global } = request.body; + + if (global && !request.user.profile.admin) { + console.warn(`User ${request.user.profile.handle} does not have permission to install global extensions.`); + return response.status(403).send('Forbidden: No permission to install global extensions.'); + } + + const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; + const extensionPath = path.join(basePath, sanitize(path.basename(url, '.git'))); if (fs.existsSync(extensionPath)) { return response.status(409).send(`Directory already exists at ${extensionPath}`); @@ -83,10 +94,8 @@ router.post('/install', jsonParser, async (request, response) => { await git.clone(url, extensionPath, { '--depth': 1 }); console.log(`Extension has been cloned at ${extensionPath}`); - const { version, author, display_name } = await getManifest(extensionPath); - return response.send({ version, author, display_name, extensionPath }); } catch (error) { console.log('Importing custom content failed', error); @@ -112,8 +121,15 @@ router.post('/update', jsonParser, async (request, response) => { } try { - const extensionName = request.body.extensionName; - const extensionPath = path.join(request.user.directories.extensions, extensionName); + const { extensionName, global } = request.body; + + if (global && !request.user.profile.admin) { + console.warn(`User ${request.user.profile.handle} does not have permission to update global extensions.`); + return response.status(403).send('Forbidden: No permission to update global extensions.'); + } + + const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; + const extensionPath = path.join(basePath, extensionName); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); @@ -122,7 +138,6 @@ router.post('/update', jsonParser, async (request, response) => { const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); const currentBranch = await git.cwd(extensionPath).branch(); if (!isUpToDate) { - await git.cwd(extensionPath).pull('origin', currentBranch.current); console.log(`Extension has been updated at ${extensionPath}`); } else { @@ -140,6 +155,50 @@ router.post('/update', jsonParser, async (request, response) => { } }); +router.post('/move', jsonParser, async (request, response) => { + try { + const { extensionName, source, destination } = request.body; + + if (!extensionName || !source || !destination) { + return response.status(400).send('Bad Request. Not all required parameters are provided.'); + } + + if (!request.user.profile.admin) { + console.warn(`User ${request.user.profile.handle} does not have permission to move extensions.`); + return response.status(403).send('Forbidden: No permission to move extensions.'); + } + + const sourceDirectory = source === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; + const destinationDirectory = destination === 'global' ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; + const sourcePath = path.join(sourceDirectory, sanitize(extensionName)); + const destinationPath = path.join(destinationDirectory, sanitize(extensionName)); + + if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) { + console.error(`Source directory does not exist at ${sourcePath}`); + return response.status(404).send('Source directory does not exist.'); + } + + if (fs.existsSync(destinationPath)) { + console.error(`Destination directory already exists at ${destinationPath}`); + return response.status(409).send('Destination directory already exists.'); + } + + if (source === destination) { + console.error('Source and destination directories are the same'); + return response.status(409).send('Source and destination directories are the same.'); + } + + fs.cpSync(sourcePath, destinationPath, { recursive: true, force: true }); + fs.rmSync(sourcePath, { recursive: true, force: true }); + console.log(`Extension has been moved from ${sourcePath} to ${destinationPath}`); + + return response.sendStatus(204); + } catch (error) { + console.log('Moving extension failed', error); + return response.status(500).send('Internal Server Error. Try again later.'); + } +}); + /** * HTTP POST handler function to get the current git commit hash and branch name for a given extension. * It checks whether the repository is up-to-date with the remote, and returns the status along with @@ -157,19 +216,28 @@ router.post('/version', jsonParser, async (request, response) => { } try { - const extensionName = request.body.extensionName; - const extensionPath = path.join(request.user.directories.extensions, extensionName); + const { extensionName, global } = request.body; + const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; + const extensionPath = path.join(basePath, sanitize(extensionName)); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); } + let currentCommitHash; + try { + currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); + } catch (error) { + // it is not a git repo, or has no commits yet, or is a bare repo + // not possible to update it, most likely can't get the branch name either + return response.send({ currentBranchName: null, currentCommitHash, isUpToDate: true, remoteUrl: null }); + } + const currentBranch = await git.cwd(extensionPath).branch(); // get only the working branch const currentBranchName = currentBranch.current; await git.cwd(extensionPath).fetch('origin'); - const currentCommitHash = await git.cwd(extensionPath).revparse(['HEAD']); - console.log(currentBranch, currentCommitHash); + console.log(extensionName, currentBranchName, currentCommitHash); const { isUpToDate, remoteUrl } = await checkIfRepoIsUpToDate(extensionPath); return response.send({ currentBranchName, currentCommitHash, isUpToDate, remoteUrl }); @@ -193,11 +261,16 @@ router.post('/delete', jsonParser, async (request, response) => { return response.status(400).send('Bad Request: extensionName is required in the request body.'); } - // Sanitize the extension name to prevent directory traversal - const extensionName = sanitize(request.body.extensionName); - try { - const extensionPath = path.join(request.user.directories.extensions, extensionName); + const { extensionName, global } = request.body; + + if (global && !request.user.profile.admin) { + console.warn(`User ${request.user.profile.handle} does not have permission to delete global extensions.`); + return response.status(403).send('Forbidden: No permission to delete global extensions.'); + } + + const basePath = global ? PUBLIC_DIRECTORIES.globalExtensions : request.user.directories.extensions; + const extensionPath = path.join(basePath, sanitize(extensionName)); if (!fs.existsSync(extensionPath)) { return response.status(404).send(`Directory does not exist at ${extensionPath}`); @@ -219,26 +292,38 @@ router.post('/delete', jsonParser, async (request, response) => { * If the folder is called third-party, search for subfolders instead */ router.get('/discover', jsonParser, function (request, response) { - // get all folders in the extensions folder, except third-party - const extensions = fs - .readdirSync(PUBLIC_DIRECTORIES.extensions) - .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory()) - .filter(f => f !== 'third-party'); - - // get all folders in the third-party folder, if it exists - if (!fs.existsSync(path.join(request.user.directories.extensions))) { - return response.send(extensions); + fs.mkdirSync(path.join(request.user.directories.extensions)); } - const thirdPartyExtensions = fs + if (!fs.existsSync(PUBLIC_DIRECTORIES.globalExtensions)) { + fs.mkdirSync(PUBLIC_DIRECTORIES.globalExtensions); + } + + // Get all folders in system extensions folder, excluding third-party + const builtInExtensions = fs + .readdirSync(PUBLIC_DIRECTORIES.extensions) + .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory()) + .filter(f => f !== 'third-party') + .map(f => ({ type: 'system', name: f })); + + // Get all folders in local extensions folder + const userExtensions = fs .readdirSync(path.join(request.user.directories.extensions)) - .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory()); + .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory()) + .map(f => ({ type: 'local', name: `third-party/${f}` })); - // add the third-party extensions to the extensions array - extensions.push(...thirdPartyExtensions.map(f => `third-party/${f}`)); - console.log(extensions); + // Get all folders in global extensions folder + // In case of a conflict, the extension will be loaded from the user folder + const globalExtensions = fs + .readdirSync(PUBLIC_DIRECTORIES.globalExtensions) + .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory()) + .map(f => ({ type: 'global', name: `third-party/${f}` })) + .filter(f => !userExtensions.some(e => e.name === f.name)); + // Combine all extensions + const allExtensions = [...builtInExtensions, ...userExtensions, ...globalExtensions]; + console.log('Extensions available for', request.user.profile.handle, allExtensions); - return response.send(extensions); + return response.send(allExtensions); }); diff --git a/src/endpoints/novelai.js b/src/endpoints/novelai.js index 53ed8258e..9629aad40 100644 --- a/src/endpoints/novelai.js +++ b/src/endpoints/novelai.js @@ -307,15 +307,18 @@ router.post('/generate-image', jsonParser, async (request, response) => { }, body: JSON.stringify({ action: 'generate', - input: request.body.prompt, + input: request.body.prompt ?? '', model: request.body.model ?? 'nai-diffusion', parameters: { + params_version: 3, + prefer_brownian: true, negative_prompt: request.body.negative_prompt ?? '', height: request.body.height ?? 512, width: request.body.width ?? 512, scale: request.body.scale ?? 9, seed: request.body.seed >= 0 ? request.body.seed : Math.floor(Math.random() * 9999999999), sampler: request.body.sampler ?? 'k_dpmpp_2m', + noise_schedule: request.body.scheduler ?? 'karras', steps: request.body.steps ?? 28, n_samples: 1, // NAI handholding for prompts @@ -323,11 +326,32 @@ router.post('/generate-image', jsonParser, async (request, response) => { qualityToggle: false, add_original_image: false, controlnet_strength: 1, + deliberate_euler_ancestral_bug: false, dynamic_thresholding: request.body.decrisper ?? false, legacy: false, + legacy_v3_extend: false, sm: request.body.sm ?? false, sm_dyn: request.body.sm_dyn ?? false, uncond_scale: 1, + use_coords: false, + characterPrompts: [], + reference_image_multiple: [], + reference_information_extracted_multiple: [], + reference_strength_multiple: [], + v4_negative_prompt: { + caption: { + base_caption: request.body.negative_prompt ?? '', + char_captions: [], + }, + }, + v4_prompt: { + caption: { + base_caption: request.body.prompt ?? '', + char_captions: [], + }, + use_coords: false, + use_order: true, + }, }, }), }); diff --git a/src/endpoints/secrets.js b/src/endpoints/secrets.js index b30a51f48..ed2a0e4fc 100644 --- a/src/endpoints/secrets.js +++ b/src/endpoints/secrets.js @@ -50,6 +50,7 @@ export const SECRET_KEYS = { TAVILY: 'api_key_tavily', NANOGPT: 'api_key_nanogpt', BFL: 'api_key_bfl', + GENERIC: 'api_key_generic', }; // These are the keys that are safe to expose, even if allowKeysExposure is false diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index 733b12478..6a2e5e6b9 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -124,9 +124,10 @@ router.get('/get', jsonParser, function (request, response) { }) .map((file) => { const pathToSprite = path.join(spritesPath, file); + const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); return { label: path.parse(pathToSprite).name.toLowerCase(), - path: `/characters/${name}/${file}`, + path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), }; }); } diff --git a/src/endpoints/stable-diffusion.js b/src/endpoints/stable-diffusion.js index 87d0489f9..e8340c564 100644 --- a/src/endpoints/stable-diffusion.js +++ b/src/endpoints/stable-diffusion.js @@ -6,6 +6,7 @@ import fetch from 'node-fetch'; import sanitize from 'sanitize-filename'; import { sync as writeFileAtomicSync } from 'write-file-atomic'; import FormData from 'form-data'; +import urlJoin from 'url-join'; import { delay, getBasicAuthHeader, tryParse } from '../util.js'; import { jsonParser } from '../express-common.js'; @@ -364,8 +365,7 @@ const comfy = express.Router(); comfy.post('/ping', jsonParser, async (request, response) => { try { - const url = new URL(request.body.url); - url.pathname = '/system_stats'; + const url = new URL(urlJoin(request.body.url, '/system_stats')); const result = await fetch(url); if (!result.ok) { @@ -381,8 +381,7 @@ comfy.post('/ping', jsonParser, async (request, response) => { comfy.post('/samplers', jsonParser, async (request, response) => { try { - const url = new URL(request.body.url); - url.pathname = '/object_info'; + const url = new URL(urlJoin(request.body.url, '/object_info')); const result = await fetch(url); if (!result.ok) { @@ -400,8 +399,7 @@ comfy.post('/samplers', jsonParser, async (request, response) => { comfy.post('/models', jsonParser, async (request, response) => { try { - const url = new URL(request.body.url); - url.pathname = '/object_info'; + const url = new URL(urlJoin(request.body.url, '/object_info')); const result = await fetch(url); if (!result.ok) { @@ -429,8 +427,7 @@ comfy.post('/models', jsonParser, async (request, response) => { comfy.post('/schedulers', jsonParser, async (request, response) => { try { - const url = new URL(request.body.url); - url.pathname = '/object_info'; + const url = new URL(urlJoin(request.body.url, '/object_info')); const result = await fetch(url); if (!result.ok) { @@ -448,8 +445,7 @@ comfy.post('/schedulers', jsonParser, async (request, response) => { comfy.post('/vaes', jsonParser, async (request, response) => { try { - const url = new URL(request.body.url); - url.pathname = '/object_info'; + const url = new URL(urlJoin(request.body.url, '/object_info')); const result = await fetch(url); if (!result.ok) { @@ -516,15 +512,13 @@ comfy.post('/delete-workflow', jsonParser, async (request, response) => { comfy.post('/generate', jsonParser, async (request, response) => { try { - const url = new URL(request.body.url); - url.pathname = '/prompt'; + const url = new URL(urlJoin(request.body.url, '/prompt')); const controller = new AbortController(); request.socket.removeAllListeners('close'); request.socket.on('close', function () { if (!response.writableEnded && !item) { - const interruptUrl = new URL(request.body.url); - interruptUrl.pathname = '/interrupt'; + const interruptUrl = new URL(urlJoin(request.body.url, '/interrupt')); fetch(interruptUrl, { method: 'POST', headers: { 'Authorization': getBasicAuthHeader(request.body.auth) } }); } controller.abort(); @@ -543,8 +537,7 @@ comfy.post('/generate', jsonParser, async (request, response) => { const data = await promptResult.json(); const id = data.prompt_id; let item; - const historyUrl = new URL(request.body.url); - historyUrl.pathname = '/history'; + const historyUrl = new URL(urlJoin(request.body.url, '/history')); while (true) { const result = await fetch(historyUrl); if (!result.ok) { @@ -568,8 +561,7 @@ comfy.post('/generate', jsonParser, async (request, response) => { throw new Error(`ComfyUI generation did not succeed.\n\n${errorMessages}`.trim()); } const imgInfo = Object.keys(item.outputs).map(it => item.outputs[it].images).flat()[0]; - const imgUrl = new URL(request.body.url); - imgUrl.pathname = '/view'; + const imgUrl = new URL(urlJoin(request.body.url, '/view')); imgUrl.search = `?filename=${imgInfo.filename}&subfolder=${imgInfo.subfolder}&type=${imgInfo.type}`; const imgResponse = await fetch(imgUrl); if (!imgResponse.ok) { diff --git a/src/endpoints/translate.js b/src/endpoints/translate.js index 8a53e9de4..b8793dced 100644 --- a/src/endpoints/translate.js +++ b/src/endpoints/translate.js @@ -1,10 +1,10 @@ -import https from 'node:https'; import { createRequire } from 'node:module'; import fetch from 'node-fetch'; import express from 'express'; -import bingTranslateApi from 'bing-translate-api'; +import { translate as bingTranslate } from 'bing-translate-api'; import iconv from 'iconv-lite'; +import urlJoin from 'url-join'; import { readSecret, SECRET_KEYS } from './secrets.js'; import { getConfigValue, uuidv4 } from '../util.js'; @@ -12,6 +12,7 @@ import { jsonParser } from '../express-common.js'; const DEEPLX_URL_DEFAULT = 'http://127.0.0.1:1188/translate'; const ONERING_URL_DEFAULT = 'http://127.0.0.1:4990/translate'; +const LINGVA_DEFAULT = 'https://lingva.ml/api/v1'; export const router = express.Router(); @@ -40,32 +41,36 @@ function decodeBuffer(buffer) { } router.post('/libre', jsonParser, async (request, response) => { - const key = readSecret(request.user.directories, SECRET_KEYS.LIBRE); - const url = readSecret(request.user.directories, SECRET_KEYS.LIBRE_URL); - - if (!url) { - console.log('LibreTranslate URL is not configured.'); - return response.sendStatus(400); - } - - if (request.body.lang === 'zh-CN') { - request.body.lang = 'zh'; - } - - if (request.body.lang === 'zh-TW') { - request.body.lang = 'zt'; - } - - const text = request.body.text; - const lang = request.body.lang; - - if (!text || !lang) { - return response.sendStatus(400); - } - - console.log('Input text: ' + text); - try { + const key = readSecret(request.user.directories, SECRET_KEYS.LIBRE); + const url = readSecret(request.user.directories, SECRET_KEYS.LIBRE_URL); + + if (!url) { + console.log('LibreTranslate URL is not configured.'); + return response.sendStatus(400); + } + + if (request.body.lang === 'zh-CN') { + request.body.lang = 'zh'; + } + + if (request.body.lang === 'zh-TW') { + request.body.lang = 'zt'; + } + + if (request.body.lang === 'pt-BR' || request.body.lang === 'pt-PT') { + request.body.lang = 'pt'; + } + + const text = request.body.text; + const lang = request.body.lang; + + if (!text || !lang) { + return response.sendStatus(400); + } + + console.log('Input text: ' + text); + const result = await fetch(url, { method: 'POST', body: JSON.stringify({ @@ -81,7 +86,7 @@ router.post('/libre', jsonParser, async (request, response) => { if (!result.ok) { const error = await result.text(); console.log('LibreTranslate error: ', result.statusText, error); - return response.sendStatus(result.status); + return response.sendStatus(500); } /** @type {any} */ @@ -130,6 +135,14 @@ router.post('/google', jsonParser, async (request, response) => { router.post('/yandex', jsonParser, async (request, response) => { try { + if (request.body.lang === 'pt-PT') { + request.body.lang = 'pt'; + } + + if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') { + request.body.lang = 'zh'; + } + const chunks = request.body.chunks; const lang = request.body.lang; @@ -178,11 +191,19 @@ router.post('/yandex', jsonParser, async (request, response) => { router.post('/lingva', jsonParser, async (request, response) => { try { - const baseUrl = readSecret(request.user.directories, SECRET_KEYS.LINGVA_URL); + const secretUrl = readSecret(request.user.directories, SECRET_KEYS.LINGVA_URL); + const baseUrl = secretUrl || LINGVA_DEFAULT; - if (!baseUrl) { - console.log('Lingva URL is not configured.'); - return response.sendStatus(400); + if (!secretUrl && baseUrl === LINGVA_DEFAULT) { + console.log('Lingva URL is using default value.', LINGVA_DEFAULT); + } + + if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') { + request.body.lang = 'zh'; + } + + if (request.body.lang === 'pt-BR' || request.body.lang === 'pt-PT') { + request.body.lang = 'pt'; } const text = request.body.text; @@ -193,29 +214,19 @@ router.post('/lingva', jsonParser, async (request, response) => { } console.log('Input text: ' + text); - const url = `${baseUrl}/auto/${lang}/${encodeURIComponent(text)}`; - https.get(url, (resp) => { - let data = ''; + const url = urlJoin(baseUrl, 'auto', lang, encodeURIComponent(text)); + const result = await fetch(url); - resp.on('data', (chunk) => { - data += chunk; - }); + if (!result.ok) { + const error = await result.text(); + console.log('Lingva error: ', result.statusText, error); + } - resp.on('end', () => { - try { - const result = JSON.parse(data); - console.log('Translated text: ' + result.translation); - return response.send(result.translation); - } catch (error) { - console.log('Translation error', error); - return response.sendStatus(500); - } - }); - }).on('error', (err) => { - console.log('Translation error: ' + err.message); - return response.sendStatus(500); - }); + /** @type {any} */ + const data = await result.json(); + console.log('Translated text: ' + data.translation); + return response.send(data.translation); } catch (error) { console.log('Translation error', error); return response.sendStatus(500); @@ -223,38 +234,41 @@ router.post('/lingva', jsonParser, async (request, response) => { }); router.post('/deepl', jsonParser, async (request, response) => { - const key = readSecret(request.user.directories, SECRET_KEYS.DEEPL); - - if (!key) { - console.log('DeepL key is not configured.'); - return response.sendStatus(400); - } - - if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') { - request.body.lang = 'ZH'; - } - - const text = request.body.text; - const lang = request.body.lang; - const formality = getConfigValue('deepl.formality', 'default'); - - if (!text || !lang) { - return response.sendStatus(400); - } - - console.log('Input text: ' + text); - - const params = new URLSearchParams(); - params.append('text', text); - params.append('target_lang', lang); - - if (['de', 'fr', 'it', 'es', 'nl', 'ja', 'ru'].includes(lang)) { - // We don't specify a Portuguese variant, so ignore formality for it. - params.append('formality', formality); - } - try { - const result = await fetch('https://api-free.deepl.com/v2/translate', { + const key = readSecret(request.user.directories, SECRET_KEYS.DEEPL); + + if (!key) { + console.log('DeepL key is not configured.'); + return response.sendStatus(400); + } + + if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') { + request.body.lang = 'ZH'; + } + + const text = request.body.text; + const lang = request.body.lang; + const formality = getConfigValue('deepl.formality', 'default'); + + if (!text || !lang) { + return response.sendStatus(400); + } + + console.log('Input text: ' + text); + + const params = new URLSearchParams(); + params.append('text', text); + params.append('target_lang', lang); + + if (['de', 'fr', 'it', 'es', 'nl', 'ja', 'ru', 'pt-BR', 'pt-PT'].includes(lang)) { + params.append('formality', formality); + } + + const endpoint = request.body.endpoint === 'pro' + ? 'https://api.deepl.com/v2/translate' + : 'https://api-free.deepl.com/v2/translate'; + + const result = await fetch(endpoint, { method: 'POST', body: params, headers: { @@ -267,7 +281,7 @@ router.post('/deepl', jsonParser, async (request, response) => { if (!result.ok) { const error = await result.text(); console.log('DeepL error: ', result.statusText, error); - return response.sendStatus(result.status); + return response.sendStatus(500); } /** @type {any} */ @@ -282,34 +296,38 @@ router.post('/deepl', jsonParser, async (request, response) => { }); router.post('/onering', jsonParser, async (request, response) => { - const secretUrl = readSecret(request.user.directories, SECRET_KEYS.ONERING_URL); - const url = secretUrl || ONERING_URL_DEFAULT; - - if (!url) { - console.log('OneRing URL is not configured.'); - return response.sendStatus(400); - } - - if (!secretUrl && url === ONERING_URL_DEFAULT) { - console.log('OneRing URL is using default value.', ONERING_URL_DEFAULT); - } - - const text = request.body.text; - const from_lang = request.body.from_lang; - const to_lang = request.body.to_lang; - - if (!text || !from_lang || !to_lang) { - return response.sendStatus(400); - } - - const params = new URLSearchParams(); - params.append('text', text); - params.append('from_lang', from_lang); - params.append('to_lang', to_lang); - - console.log('Input text: ' + text); - try { + const secretUrl = readSecret(request.user.directories, SECRET_KEYS.ONERING_URL); + const url = secretUrl || ONERING_URL_DEFAULT; + + if (!url) { + console.log('OneRing URL is not configured.'); + return response.sendStatus(400); + } + + if (!secretUrl && url === ONERING_URL_DEFAULT) { + console.log('OneRing URL is using default value.', ONERING_URL_DEFAULT); + } + + if (request.body.lang === 'pt-BR' || request.body.lang === 'pt-PT') { + request.body.lang = 'pt'; + } + + const text = request.body.text; + const from_lang = request.body.from_lang; + const to_lang = request.body.to_lang; + + if (!text || !from_lang || !to_lang) { + return response.sendStatus(400); + } + + const params = new URLSearchParams(); + params.append('text', text); + params.append('from_lang', from_lang); + params.append('to_lang', to_lang); + + console.log('Input text: ' + text); + const fetchUrl = new URL(url); fetchUrl.search = params.toString(); @@ -320,7 +338,7 @@ router.post('/onering', jsonParser, async (request, response) => { if (!result.ok) { const error = await result.text(); console.log('OneRing error: ', result.statusText, error); - return response.sendStatus(result.status); + return response.sendStatus(500); } /** @type {any} */ @@ -335,31 +353,31 @@ router.post('/onering', jsonParser, async (request, response) => { }); router.post('/deeplx', jsonParser, async (request, response) => { - const secretUrl = readSecret(request.user.directories, SECRET_KEYS.DEEPLX_URL); - const url = secretUrl || DEEPLX_URL_DEFAULT; - - if (!url) { - console.log('DeepLX URL is not configured.'); - return response.sendStatus(400); - } - - if (!secretUrl && url === DEEPLX_URL_DEFAULT) { - console.log('DeepLX URL is using default value.', DEEPLX_URL_DEFAULT); - } - - const text = request.body.text; - let lang = request.body.lang; - if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') { - lang = 'ZH'; - } - - if (!text || !lang) { - return response.sendStatus(400); - } - - console.log('Input text: ' + text); - try { + const secretUrl = readSecret(request.user.directories, SECRET_KEYS.DEEPLX_URL); + const url = secretUrl || DEEPLX_URL_DEFAULT; + + if (!url) { + console.log('DeepLX URL is not configured.'); + return response.sendStatus(400); + } + + if (!secretUrl && url === DEEPLX_URL_DEFAULT) { + console.log('DeepLX URL is using default value.', DEEPLX_URL_DEFAULT); + } + + const text = request.body.text; + let lang = request.body.lang; + if (request.body.lang === 'zh-CN' || request.body.lang === 'zh-TW') { + lang = 'ZH'; + } + + if (!text || !lang) { + return response.sendStatus(400); + } + + console.log('Input text: ' + text); + const result = await fetch(url, { method: 'POST', body: JSON.stringify({ @@ -376,7 +394,7 @@ router.post('/deeplx', jsonParser, async (request, response) => { if (!result.ok) { const error = await result.text(); console.log('DeepLX error: ', result.statusText, error); - return response.sendStatus(result.status); + return response.sendStatus(500); } /** @type {any} */ @@ -391,24 +409,34 @@ router.post('/deeplx', jsonParser, async (request, response) => { }); router.post('/bing', jsonParser, async (request, response) => { - const text = request.body.text; - let lang = request.body.lang; + try { + const text = request.body.text; + let lang = request.body.lang; - if (request.body.lang === 'zh-CN') { - lang = 'zh-Hans'; - } + if (request.body.lang === 'zh-CN') { + lang = 'zh-Hans'; + } - if (!text || !lang) { - return response.sendStatus(400); - } + if (request.body.lang === 'zh-TW') { + lang = 'zh-Hant'; + } - console.log('Input text: ' + text); + if (request.body.lang === 'pt-BR') { + lang = 'pt'; + } - bingTranslateApi.translate(text, null, lang).then(result => { - console.log('Translated text: ' + result.translation); - return response.send(result.translation); - }).catch(err => { - console.log('Translation error: ' + err.message); + if (!text || !lang) { + return response.sendStatus(400); + } + + console.log('Input text: ' + text); + + const result = await bingTranslate(text, null, lang); + const translatedText = result?.translation; + console.log('Translated text: ' + translatedText); + return response.send(translatedText); + } catch (error) { + console.log('Translation error', error); return response.sendStatus(500); - }); + } }); diff --git a/src/endpoints/users-private.js b/src/endpoints/users-private.js index 440079092..81b60acea 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 = null; return response.sendStatus(204); } catch (error) { console.error(error); diff --git a/src/prompt-converters.js b/src/prompt-converters.js index 3f052c8c5..91df7fd65 100644 --- a/src/prompt-converters.js +++ b/src/prompt-converters.js @@ -3,6 +3,30 @@ import { getConfigValue } from './util.js'; const PROMPT_PLACEHOLDER = getConfigValue('promptPlaceholder', 'Let\'s get started.'); +/** + * @typedef {object} PromptNames + * @property {string} charName Character name + * @property {string} userName User name + * @property {string[]} groupNames Group member names + * @property {function(string): boolean} startsWithGroupName Check if a message starts with a group name + */ + +/** + * Extracts the character name, user name, and group member names from the request. + * @param {import('express').Request} request Express request object + * @returns {PromptNames} Prompt names + */ +export function getPromptNames(request) { + return { + charName: String(request.body.char_name || ''), + userName: String(request.body.user_name || ''), + groupNames: Array.isArray(request.body.group_names) ? request.body.group_names.map(String) : [], + startsWithGroupName: function (message) { + return this.groupNames.some(name => message.startsWith(`${name}: `)); + }, + }; +} + /** * Convert a prompt from the ChatML objects to the format used by Claude. * Mainly deprecated. Only used for counting tokens. @@ -91,10 +115,10 @@ export function convertClaudePrompt(messages, addAssistantPostfix, addAssistantP * @param {string} prefillString User determined prefill string * @param {boolean} useSysPrompt See if we want to use a system prompt * @param {boolean} useTools See if we want to use tools - * @param {string} charName Character name - * @param {string} userName User name + * @param {PromptNames} names Prompt names + * @returns {{messages: object[], systemPrompt: object[]}} Prompt for Anthropic */ -export function convertClaudeMessages(messages, prefillString, useSysPrompt, useTools, charName, userName) { +export function convertClaudeMessages(messages, prefillString, useSysPrompt, useTools, names) { let systemPrompt = []; if (useSysPrompt) { // Collect all the system messages up until the first instance of a non-system message, and then remove them from the messages array. @@ -104,14 +128,14 @@ export function convertClaudeMessages(messages, prefillString, useSysPrompt, use break; } // Append example names if not already done by the frontend (e.g. for group chats). - if (userName && messages[i].name === 'example_user') { - if (!messages[i].content.startsWith(`${userName}: `)) { - messages[i].content = `${userName}: ${messages[i].content}`; + if (names.userName && messages[i].name === 'example_user') { + if (!messages[i].content.startsWith(`${names.userName}: `)) { + messages[i].content = `${names.userName}: ${messages[i].content}`; } } - if (charName && messages[i].name === 'example_assistant') { - if (!messages[i].content.startsWith(`${charName}: `)) { - messages[i].content = `${charName}: ${messages[i].content}`; + if (names.charName && messages[i].name === 'example_assistant') { + if (!messages[i].content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(messages[i].content)) { + messages[i].content = `${names.charName}: ${messages[i].content}`; } } systemPrompt.push({ type: 'text', text: messages[i].content }); @@ -151,11 +175,15 @@ export function convertClaudeMessages(messages, prefillString, useSysPrompt, use } if (message.role === 'system') { - if (userName && message.name === 'example_user') { - message.content = `${userName}: ${message.content}`; + if (names.userName && message.name === 'example_user') { + if (!message.content.startsWith(`${names.userName}: `)) { + message.content = `${names.userName}: ${message.content}`; + } } - if (charName && message.name === 'example_assistant') { - message.content = `${charName}: ${message.content}`; + if (names.charName && message.name === 'example_assistant') { + if (!message.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(message.content)) { + message.content = `${names.charName}: ${message.content}`; + } } message.role = 'user'; @@ -274,11 +302,10 @@ export function convertClaudeMessages(messages, prefillString, useSysPrompt, use /** * Convert a prompt from the ChatML objects to the format used by Cohere. * @param {object[]} messages Array of messages - * @param {string} charName Character name - * @param {string} userName User name + * @param {PromptNames} names Prompt names * @returns {{chatHistory: object[]}} Prompt for Cohere */ -export function convertCohereMessages(messages, charName = '', userName = '') { +export function convertCohereMessages(messages, names) { if (messages.length === 0) { messages.unshift({ role: 'user', @@ -299,13 +326,13 @@ export function convertCohereMessages(messages, charName = '', userName = '') { // No names support (who would've thought) if (msg.name) { if (msg.role == 'system' && msg.name == 'example_assistant') { - if (charName && !msg.content.startsWith(`${charName}: `)) { - msg.content = `${charName}: ${msg.content}`; + if (names.charName && !msg.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(msg.content)) { + msg.content = `${names.charName}: ${msg.content}`; } } if (msg.role == 'system' && msg.name == 'example_user') { - if (userName && !msg.content.startsWith(`${userName}: `)) { - msg.content = `${userName}: ${msg.content}`; + if (names.userName && !msg.content.startsWith(`${names.userName}: `)) { + msg.content = `${names.userName}: ${msg.content}`; } } if (msg.role !== 'system' && !msg.content.startsWith(`${msg.name}: `)) { @@ -328,15 +355,13 @@ export function convertCohereMessages(messages, charName = '', userName = '') { * @param {object[]} messages Array of messages * @param {string} model Model name * @param {boolean} useSysPrompt Use system prompt - * @param {string} charName Character name - * @param {string} userName User name + * @param {PromptNames} names Prompt names * @returns {{contents: *[], system_instruction: {parts: {text: string}}}} Prompt for Google MakerSuite models */ -export function convertGooglePrompt(messages, model, useSysPrompt = false, charName = '', userName = '') { - // This is a 1x1 transparent PNG - const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; - +export function convertGooglePrompt(messages, model, useSysPrompt, names) { const visionSupportedModels = [ + 'gemini-2.0-flash-thinking-exp-1219', + 'gemini-2.0-flash-exp', 'gemini-1.5-flash', 'gemini-1.5-flash-latest', 'gemini-1.5-flash-001', @@ -354,30 +379,22 @@ export function convertGooglePrompt(messages, model, useSysPrompt = false, charN 'gemini-1.5-pro-002', 'gemini-1.5-pro-exp-0801', 'gemini-1.5-pro-exp-0827', - 'gemini-1.0-pro-vision-latest', - 'gemini-pro-vision', - ]; - - const dummyRequiredModels = [ - 'gemini-1.0-pro-vision-latest', - 'gemini-pro-vision', ]; const isMultimodal = visionSupportedModels.includes(model); - let hasImage = false; let sys_prompt = ''; if (useSysPrompt) { while (messages.length > 1 && messages[0].role === 'system') { // Append example names if not already done by the frontend (e.g. for group chats). - if (userName && messages[0].name === 'example_user') { - if (!messages[0].content.startsWith(`${userName}: `)) { - messages[0].content = `${userName}: ${messages[0].content}`; + if (names.userName && messages[0].name === 'example_user') { + if (!messages[0].content.startsWith(`${names.userName}: `)) { + messages[0].content = `${names.userName}: ${messages[0].content}`; } } - if (charName && messages[0].name === 'example_assistant') { - if (!messages[0].content.startsWith(`${charName}: `)) { - messages[0].content = `${charName}: ${messages[0].content}`; + if (names.charName && messages[0].name === 'example_assistant') { + if (!messages[0].content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(messages[0].content)) { + messages[0].content = `${names.charName}: ${messages[0].content}`; } } sys_prompt += `${messages[0].content}\n\n`; @@ -396,53 +413,62 @@ export function convertGooglePrompt(messages, model, useSysPrompt = false, charN message.role = 'model'; } + // Convert the content to an array of parts + if (!Array.isArray(message.content)) { + message.content = [{ type: 'text', text: String(message.content ?? '') }]; + } + // similar story as claude if (message.name) { - if (userName && message.name === 'example_user') { - message.name = userName; - } - if (charName && message.name === 'example_assistant') { - message.name = charName; - } - - if (Array.isArray(message.content)) { - if (!message.content[0].text.startsWith(`${message.name}: `)) { - message.content[0].text = `${message.name}: ${message.content[0].text}`; + message.content.forEach((part) => { + if (part.type !== 'text') { + return; } - } else { - if (!message.content.startsWith(`${message.name}: `)) { - message.content = `${message.name}: ${message.content}`; + if (message.name === 'example_user') { + if (names.userName && !part.text.startsWith(`${names.userName}: `)) { + part.text = `${names.userName}: ${part.text}`; + } + } else if (message.name === 'example_assistant') { + if (names.charName && !part.text.startsWith(`${names.charName}: `) && !names.startsWithGroupName(part.text)) { + part.text = `${names.charName}: ${part.text}`; + } + } else { + if (!part.text.startsWith(`${message.name}: `)) { + part.text = `${message.name}: ${part.text}`; + } } - } + }); delete message.name; } //create the prompt parts const parts = []; - if (typeof message.content === 'string') { - parts.push({ text: message.content }); - } else if (Array.isArray(message.content)) { - message.content.forEach((part) => { - if (part.type === 'text') { - parts.push({ text: part.text }); - } else if (part.type === 'image_url' && isMultimodal) { - const mimeType = part.image_url.url.split(';')[0].split(':')[1]; - const base64Data = part.image_url.url.split(',')[1]; - parts.push({ - inlineData: { - mimeType: mimeType, - data: base64Data, - }, - }); - hasImage = true; - } - }); - } + message.content.forEach((part) => { + if (part.type === 'text') { + parts.push({ text: part.text }); + } else if (part.type === 'image_url' && isMultimodal) { + const mimeType = part.image_url.url.split(';')[0].split(':')[1]; + const base64Data = part.image_url.url.split(',')[1]; + parts.push({ + inlineData: { + mimeType: mimeType, + data: base64Data, + }, + }); + } + }); // merge consecutive messages with the same role if (index > 0 && message.role === contents[contents.length - 1].role) { - contents[contents.length - 1].parts[0].text += '\n\n' + parts[0].text; + parts.forEach((part) => { + if (part.text) { + contents[contents.length - 1].parts[0].text += '\n\n' + part.text; + } + if (part.inlineData) { + contents[contents.length - 1].parts.push(part); + } + }); } else { contents.push({ role: message.role, @@ -451,26 +477,16 @@ export function convertGooglePrompt(messages, model, useSysPrompt = false, charN } }); - // pro 1.5 doesn't require a dummy image to be attached, other vision models do - if (isMultimodal && dummyRequiredModels.includes(model) && !hasImage) { - contents[0].parts.push({ - inlineData: { - mimeType: 'image/png', - data: PNG_PIXEL, - }, - }); - } - return { contents: contents, system_instruction: system_instruction }; } /** * Convert AI21 prompt. Classic: system message squash, user/assistant message merge. * @param {object[]} messages Array of messages - * @param {string} charName Character name - * @param {string} userName User name + * @param {PromptNames} names Prompt names + * @returns {object[]} Prompt for AI21 */ -export function convertAI21Messages(messages, charName = '', userName = '') { +export function convertAI21Messages(messages, names) { if (!Array.isArray(messages)) { return []; } @@ -483,14 +499,14 @@ export function convertAI21Messages(messages, charName = '', userName = '') { break; } // Append example names if not already done by the frontend (e.g. for group chats). - if (userName && messages[i].name === 'example_user') { - if (!messages[i].content.startsWith(`${userName}: `)) { - messages[i].content = `${userName}: ${messages[i].content}`; + if (names.userName && messages[i].name === 'example_user') { + if (!messages[i].content.startsWith(`${names.userName}: `)) { + messages[i].content = `${names.userName}: ${messages[i].content}`; } } - if (charName && messages[i].name === 'example_assistant') { - if (!messages[i].content.startsWith(`${charName}: `)) { - messages[i].content = `${charName}: ${messages[i].content}`; + if (names.charName && messages[i].name === 'example_assistant') { + if (!messages[i].content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(messages[i].content)) { + messages[i].content = `${names.charName}: ${messages[i].content}`; } } systemPrompt += `${messages[i].content}\n\n`; @@ -539,10 +555,10 @@ export function convertAI21Messages(messages, charName = '', userName = '') { /** * Convert a prompt from the ChatML objects to the format used by MistralAI. * @param {object[]} messages Array of messages - * @param {string} charName Character name - * @param {string} userName User name + * @param {PromptNames} names Prompt names + * @returns {object[]} Prompt for MistralAI */ -export function convertMistralMessages(messages, charName = '', userName = '') { +export function convertMistralMessages(messages, names) { if (!Array.isArray(messages)) { return []; } @@ -567,15 +583,15 @@ export function convertMistralMessages(messages, charName = '', userName = '') { msg.tool_call_id = sanitizeToolId(msg.tool_call_id); } if (msg.role === 'system' && msg.name === 'example_assistant') { - if (charName && !msg.content.startsWith(`${charName}: `)) { - msg.content = `${charName}: ${msg.content}`; + if (names.charName && !msg.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(msg.content)) { + msg.content = `${names.charName}: ${msg.content}`; } delete msg.name; } if (msg.role === 'system' && msg.name === 'example_user') { - if (userName && !msg.content.startsWith(`${userName}: `)) { - msg.content = `${userName}: ${msg.content}`; + if (names.userName && !msg.content.startsWith(`${names.userName}: `)) { + msg.content = `${names.userName}: ${msg.content}`; } delete msg.name; } @@ -621,12 +637,11 @@ export function convertMistralMessages(messages, charName = '', userName = '') { /** * Merge messages with the same consecutive role, removing names if they exist. * @param {any[]} messages Messages to merge - * @param {string} charName Character name - * @param {string} userName User name + * @param {PromptNames} names Prompt names * @param {boolean} strict Enable strict mode: only allow one system message at the start, force user first message * @returns {any[]} Merged messages */ -export function mergeMessages(messages, charName, userName, strict) { +export function mergeMessages(messages, names, strict) { let mergedMessages = []; /** @type {Map} */ @@ -654,13 +669,13 @@ export function mergeMessages(messages, charName, userName, strict) { message.content = text; } if (message.role === 'system' && message.name === 'example_assistant') { - if (charName && !message.content.startsWith(`${charName}: `)) { - message.content = `${charName}: ${message.content}`; + if (names.charName && !message.content.startsWith(`${names.charName}: `) && !names.startsWithGroupName(message.content)) { + message.content = `${names.charName}: ${message.content}`; } } if (message.role === 'system' && message.name === 'example_user') { - if (userName && !message.content.startsWith(`${userName}: `)) { - message.content = `${userName}: ${message.content}`; + if (names.userName && !message.content.startsWith(`${names.userName}: `)) { + message.content = `${names.userName}: ${message.content}`; } } if (message.name && message.role !== 'system') { @@ -734,7 +749,7 @@ export function mergeMessages(messages, charName, userName, strict) { mergedMessages.unshift({ role: 'user', content: PROMPT_PLACEHOLDER }); } } - return mergeMessages(mergedMessages, charName, userName, false); + return mergeMessages(mergedMessages, names, false); } return mergedMessages; diff --git a/src/users.js b/src/users.js index 96e21c5ab..36a5d62b3 100644 --- a/src/users.js +++ b/src/users.js @@ -782,6 +782,34 @@ function createRouteHandler(directoryFn) { }; } +/** + * Creates a route handler for serving extensions. + * @param {(req: import('express').Request) => string} directoryFn A function that returns the directory path to serve files from + * @returns {import('express').RequestHandler} + */ +function createExtensionsRouteHandler(directoryFn) { + return async (req, res) => { + try { + const directory = directoryFn(req); + const filePath = decodeURIComponent(req.params[0]); + + const existsLocal = fs.existsSync(path.join(directory, filePath)); + if (existsLocal) { + return res.sendFile(filePath, { root: directory }); + } + + const existsGlobal = fs.existsSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, filePath)); + if (existsGlobal) { + return res.sendFile(filePath, { root: PUBLIC_DIRECTORIES.globalExtensions }); + } + + return res.sendStatus(404); + } catch (error) { + return res.sendStatus(500); + } + }; +} + /** * Verifies that the current user is an admin. * @param {import('express').Request} request Request object @@ -872,4 +900,4 @@ router.use('/User%20Avatars/*', createRouteHandler(req => req.user.directories.a router.use('/assets/*', createRouteHandler(req => req.user.directories.assets)); router.use('/user/images/*', createRouteHandler(req => req.user.directories.userImages)); router.use('/user/files/*', createRouteHandler(req => req.user.directories.files)); -router.use('/scripts/extensions/third-party/*', createRouteHandler(req => req.user.directories.extensions)); +router.use('/scripts/extensions/third-party/*', createExtensionsRouteHandler(req => req.user.directories.extensions)); diff --git a/src/util.js b/src/util.js index 7b8445453..13176f46e 100644 --- a/src/util.js +++ b/src/util.js @@ -142,6 +142,19 @@ export function getHexString(length) { return result; } +/** + * Formats a byte size into a human-readable string with units + * @param {number} bytes - The size in bytes to format + * @returns {string} The formatted string (e.g., "1.5 MB") + */ +export function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + /** * Extracts a file with given extension from an ArrayBuffer containing a ZIP archive. * @param {ArrayBuffer} archiveBuffer Buffer containing a ZIP archive @@ -376,16 +389,24 @@ export function generateTimestamp() { * Remove old backups with the given prefix from a specified directory. * @param {string} directory The root directory to remove backups from. * @param {string} prefix File prefix to filter backups by. + * @param {number?} limit Maximum number of backups to keep. If null, the limit is determined by the `numberOfBackups` config value. */ -export function removeOldBackups(directory, prefix) { - const MAX_BACKUPS = Number(getConfigValue('numberOfBackups', 50)); +export function removeOldBackups(directory, prefix, limit = null) { + const MAX_BACKUPS = limit ?? Number(getConfigValue('numberOfBackups', 50)); let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix)); if (files.length > MAX_BACKUPS) { files = files.map(f => path.join(directory, f)); files.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs); - fs.rmSync(files[0]); + while (files.length > MAX_BACKUPS) { + const oldest = files.shift(); + if (!oldest) { + break; + } + + fs.rmSync(oldest); + } } }