Merge branch 'staging' of github.com-RA:sillytavern/sillytavern into staging

a0b2b740c941b5213371f4f70a96e6a15ebd3929

RossAscends <rossascendshamma@gmail.com>

28 files changed, +1061 -136Showing whitespace changes
default/content/presets/openai/Default.json+3 -0
@@ -8,6 +8,9 @@
88 "openrouter_sort_models": "alphabetically",
99 "ai21_model": "jamba-large",
1010 "mistralai_model": "mistral-large-latest",
11+ "electronhub_model": "gpt-4o-mini",
12+ "electronhub_sort_models": "alphabetically",
13+ "electronhub_group_models": false,
1114 "custom_model": "",
1215 "custom_url": "",
1316 "custom_include_body": "",
public/css/backgrounds.css+53 -27
@@ -88,6 +88,24 @@ body.reduced-motion #bg_custom {
8888 gap: 5px;
8989}
9090
91+/* Control buttons in header */
92+.heading-container-with-controls {
93+ position: relative;
94+}
95+
96+.heading-container-with-controls .heading-text {
97+ margin: 10px 0;
98+}
99+
100+.heading-container-with-controls .heading-controls {
101+ position: absolute;
102+ right: 5px;
103+ top: 50%;
104+ transform: translateY(-50%);
105+ display: flex;
106+ gap: 5px;
107+}
108+
91109#bg-scrollable-content {
92110 flex-grow: 1;
93111 overflow-y: auto;
@@ -95,46 +113,43 @@ body.reduced-motion #bg_custom {
95113 padding: 0 5px 15px;
96114}
97115
116+#bg_menu_content,
117+#bg_custom_content {
118+ display: grid;
119+ gap: 5px;
120+ width: 100%;
121+ grid-template-columns: repeat(var(--bg-thumb-columns, 5), 1fr);
122+}
123+
98124#bg-filter {
99125 font-size: calc(var(--mainFontSize) * 0.95);
100126}
101127
102128/* Thumbnail Menu & ButtonsThumbnails */
129+.bg_example:hover .BGSampleTitle {
130+ opacity: 1;
131+}
132+
103133.bg_example .mobile-only-menu-toggle {
104134 display: none;
105135}
106136
107137.bg_example.flex-container {
108- width: 30%;
109- max-width: 200px;
110- margin: 5px;
111- aspect-ratio: 16/9;
112138 cursor: pointer;
113139 box-shadow: 0 0 7px var(--black50a);
114-
115140 position: relative;
116141 overflow: hidden;
117142 border-radius: 8px;
118143 border: 0px solid transparent;
119144 outline: 2px solid var(--SmartThemeBorderColor);
120145 outline-offset: -1px;
121-}
122146
123-.bg_example.flex-container:focus-visible {
147+ height: auto;
124148 outlineaspect-offsetratio: inherit16 / 9;
125149}
126150
127151.bg_example_imgbg_example:focus-visible {
128152 positionoutline-offset: absoluteinherit;
129- top: -2px;
130- left: -2px;
131- right: -2px;
132- bottom: -2px;
133-
134- background-image: inherit;
135-
136- background-size: cover;
137- background-position: center;
138153}
139154
140155.bg_example .jg-menu {
@@ -143,9 +158,8 @@ body.reduced-motion #bg_custom {
143158 top: 2px;
144159 right: 2px;
145160 background-color: rgba(0, 0, 0, 0.5);
146161 border-radius: 8px5px;
147162 gappadding: 3px 3px;
148- padding: 3px 5px;
149163 z-index: 3;
150164 backdrop-filter: blur(4px);
151165 border: 1px solid var(--SmartThemeBorderColor);
@@ -168,14 +182,14 @@ body.reduced-motion #bg_custom {
168182
169183.bg_example .jg-button {
170184 display: flex;
171185 width: 30px24px;
172186 height: 30px24px;
173187 align-items: center;
174188 justify-content: center;
175189 color: white;
176190 padding: 5px;
177191 font-size: 1.1em;
178192 border-radius: 6px5px;
179193 transition: background-color var(--animation-duration) ease;
180194}
181195
@@ -195,6 +209,18 @@ body.reduced-motion #bg_custom {
195209 display: flex;
196210}
197211
212+.thumbnail-clipper {
213+ position: absolute;
214+ top: -2px;
215+ left: -2px;
216+ right: -2px;
217+ bottom: -2px;
218+ overflow: hidden;
219+ border-radius: inherit;
220+ background-size: cover;
221+ background-position: center;
222+}
223+
198224.bg_example:not([custom="true"]) .jg-copy,
199225.bg_example[custom="true"] .jg-edit {
200226 display: none;
public/css/mobile-styles.css+18 -4
@@ -30,6 +30,14 @@
3030 display: none;
3131 }
3232
33+ #bg_menu_content, #bg_custom_content {
34+ grid-template-columns: repeat(var(--bg-thumb-columns, 3), 1fr);
35+ }
36+
37+ .bg_list {
38+ width: unset;
39+ }
40+
3341 .bg_button {
3442 font-size: 15px;
3543 }
@@ -49,6 +57,11 @@
4957 z-index: 4;
5058 }
5159
60+ .bg_example.mobile-menu-open .mobile-only-menu-toggle {
61+ opacity: 0;
62+ pointer-events: none;
63+ }
64+
5265 .bg_example .mobile-only-menu-toggle {
5366 display: flex;
5467 align-items: center;
@@ -66,6 +79,11 @@
6679 backdrop-filter: blur(2px);
6780 }
6881
82+ .bg_example .jg-button {
83+ width: 30px;
84+ height: 30px;
85+ }
86+
6987 #bg-header-controls {
7088 flex-wrap: wrap;
7189 row-gap: 10px;
@@ -438,10 +456,6 @@
438456 .horde_multiple_hint {
439457 display: none;
440458 }
441-
442- .bg_list {
443- width: unset;
444- }
445459}
446460
447461/*landscape mode phones and ipads*/
public/index.html+52 -3
@@ -676,6 +676,9 @@
676676 <div data-source="openrouter">
677677 <span data-i18n="Max prompt cost:">Max prompt cost:</span> <span id="openrouter_max_prompt_cost" data-i18n="Unknown">Unknown</span>
678678 </div>
679+ <div data-source="electronhub">
680+ <span data-i18n="Max prompt cost:">Max prompt cost:</span> <span id="electronhub_max_prompt_cost" data-i18n="Unknown">Unknown</span>
681+ </div>
679682 <hr>
680683 <div class="range-block">
681684 <label for="stream_toggle" title="Enable OpenAI completion streaming" data-i18n="[title]Enable OpenAI completion streaming" class="checkbox_label widthFreeExpand">
@@ -2123,7 +2126,7 @@
21232126 </div>
21242127 </div>
21252128 </div>
21262129 <div class="range-block m-t-1" data-source="openai,aimlapi,openrouter,custom,electronhub,azure_openai">
21272130 <div id="logit_bias_openai" class="range-block-title openai_restorable" data-i18n="Logit Bias">
21282131 Logit Bias
21292132 </div>
@@ -3061,7 +3064,7 @@
30613064 <div class="marginTopBot5">
30623065 <div class="inline-drawer wide100p">
30633066 <div class="inline-drawer-toggle inline-drawer-header">
30643067 <b data-i18n="OpenRouter Model OrderSorting">OpenRouter Model Sorting</b>
30653068 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
30663069 </div>
30673070 <div class="inline-drawer-content m-b-1">
@@ -3452,6 +3455,9 @@
34523455 </div>
34533456 <div id="electronhub_form" data-source="electronhub">
34543457 <h4 data-i18n="Electron Hub API Key">Electron Hub API Key</h4>
3458+ <div>
3459+ <a href="https://playground.electronhub.ai/console" target="_blank" data-i18n="View Remaining Credits">View Remaining Credits</a>
3460+ </div>
34553461 <div class="flex-container">
34563462 <input id="api_key_electronhub" name="api_key_electronhub" class="text_pole flex1" value="" type="text" autocomplete="off">
34573463 <div title="Manage API keys" data-i18n="[title]Manage API keys" class="menu_button fa-solid fa-key fa-fw manage-api-keys" data-key="api_key_electronhub"></div>
@@ -3459,11 +3465,44 @@
34593465 <div data-for="api_key_electronhub" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
34603466 For privacy reasons, your API key will be hidden after you click 'Connect'.
34613467 </div>
3468+ <div>
34623469 <h4 data-i18n="Electron Hub Model">Electron Hub Model</h4>
34633470 <select id="model_electronhub_select">
34643471 <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
34653472 </select>
34663473 </div>
3474+ <div class="marginTopBot5">
3475+ <div class="inline-drawer wide100p">
3476+ <div class="inline-drawer-toggle inline-drawer-header">
3477+ <b data-i18n="Electron Hub Model Sorting">Electron Hub Model Sorting</b>
3478+ <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
3479+ </div>
3480+ <div class="inline-drawer-content m-b-1">
3481+ <div class="marginTopBot5">
3482+ <label for="electronhub_sort_models" class="checkbox_label">
3483+ <select id="electronhub_sort_models">
3484+ <option data-i18n="Alphabetically" value="alphabetically">Alphabetically</option>
3485+ <option data-i18n="Input Price" value="pricing.input">Input Price (cheapest)</option>
3486+ <option data-i18n="Output Price" value="pricing.output">Output Price (cheapest)</option>
3487+ <option data-i18n="Context Size" value="context_length">Context Size</option>
3488+ </select>
3489+ </label>
3490+ </div>
3491+ <div class="marginTopBot5">
3492+ <label for="electronhub_group_models" class="checkbox_label">
3493+ <input id="electronhub_group_models" type="checkbox" />
3494+ <span data-i18n="Group by vendors">Group by vendors</span>
3495+ </label>
3496+ <div class="toggle-description justifyLeft wide100p">
3497+ <span data-i18n="Group by vendors Description">
3498+ Put OpenAI models in one group, Anthropic models in other group, etc. Can be combined with sorting.
3499+ </span>
3500+ </div>
3501+ </div>
3502+ </div>
3503+ </div>
3504+ </div>
3505+ </div>
34673506 <div id="nanogpt_form" data-source="nanogpt">
34683507 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
34693508 <div class="flex-container">
@@ -5345,9 +5384,19 @@
53455384 </div>
53465385 </div>
53475386 <div id="bg-scrollable-content">
5348- <h3 data-i18n="System Backgrounds" class="wide100p textAlignCenter">
5387+ <div class="heading-container-with-controls">
5388+ <h3 data-i18n="System Backgrounds" class="wide100p textAlignCenter heading-text">
53495389 System Backgrounds
53505390 </h3>
5391+ <div class="heading-controls">
5392+ <button id="bg_thumb_zoom_out" class="menu_button menu_button_icon" title="Make thumbnails smaller" data-i18n="[title]Make thumbnails smaller">
5393+ <i class="fa-solid fa-minus"></i>
5394+ </button>
5395+ <button id="bg_thumb_zoom_in" class="menu_button menu_button_icon" title="Make thumbnails larger" data-i18n="[title]Make thumbnails larger">
5396+ <i class="fa-solid fa-plus"></i>
5397+ </button>
5398+ </div>
5399+ </div>
53515400 <div id="bg_menu_content" class="bg_list">
53525401 </div>
53535402 <h3 data-i18n="Chat Backgrounds" class="wide100p textAlignCenter">
public/locales/ar-sa.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "وحدة تحكم المطور في Anthropic",
370370 "Claude Model": "نموذج Claude",
371371 "Window AI Model": "نموذج Window AI",
372372 "OpenRouter Model OrderSorting": "فرز نموذج OpenRouter",
373373 "Alphabetically": "أبجديا",
374374 "Price": "السعر (الأرخص)",
375375 "Context Size": "حجم السياق",
public/locales/de-de.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "Anthropics Entwicklerkonsole",
370370 "Claude Model": "Claude-Modell",
371371 "Window AI Model": "Fenster AI-Modell",
372372 "OpenRouter Model OrderSorting": "Sortierung des OpenRouter-Modells",
373373 "Alphabetically": "Alphabetisch",
374374 "Price": "Preis (am günstigsten)",
375375 "Context Size": "Kontextgröße",
public/locales/es-es.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "la consola de desarrolladores de Anthropic",
370370 "Claude Model": "Modelo de Claude",
371371 "Window AI Model": "Modelo de Window AI",
372372 "OpenRouter Model OrderSorting": "Clasificación de modelos de OpenRouter",
373373 "Alphabetically": "Alfabéticamente",
374374 "Price": "Precio (más barato)",
375375 "Context Size": "Tamaño del contexto",
public/locales/fr-fr.json+2 -2
@@ -348,7 +348,7 @@
348348 "Anthropic's developer console": "Console de développement d'Anthropic",
349349 "Claude Model": "Modèle Claude",
350350 "Window AI Model": "Modèle Window AI",
351351 "OpenRouter Model OrderSorting": "Tri des modèles OpenRouter",
352352 "Alphabetically": "Alphabétiquement",
353353 "Price": "Prix ​​(le moins cher)",
354354 "Context Size": "Taille du contexte",
@@ -1566,7 +1566,7 @@
15661566 "These files are available for all characters in the current chat.": "Ces fichiers sont disponibles pour tous les personnages du chat actuel.",
15671567 "Image Captioning": "Légende des images",
15681568 "Local": "Local",
1569- "Multimodal (OpenAI / Anthropic / llama / Google)": "Multimodal (OpenAI / Anthropic / llama / Google)",
1569+ "Multimodal": "Multimodal",
15701570 "Extras": "Extras",
15711571 "Horde": "Horde",
15721572 "API": "API",
public/locales/is-is.json+1 -1
@@ -367,7 +367,7 @@
367367 "Anthropic's developer console": "Uppbyggingaraðilar Forritara stjórnborð",
368368 "Claude Model": "Claude módel",
369369 "Window AI Model": "Vindauga AI módel",
370370 "OpenRouter Model OrderSorting": "OpenRouter líkanaflokkun",
371371 "Alphabetically": "Stafrófsröð",
372372 "Price": "Verð (ódýrast)",
373373 "Context Size": "Samhengisstærð",
public/locales/it-it.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "Console dello sviluppatore di Anthropic",
370370 "Claude Model": "Modello Claude",
371371 "Window AI Model": "Modello AI di Window",
372372 "OpenRouter Model OrderSorting": "Ordinamento dei modelli OpenRouter",
373373 "Alphabetically": "In ordine alfabetico",
374374 "Price": "Prezzo (il più economico)",
375375 "Context Size": "Dimensione del contesto",
public/locales/ja-jp.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "Anthropicの開発者コンソール",
370370 "Claude Model": "Claudeモデル",
371371 "Window AI Model": "Window AIモデル",
372372 "OpenRouter Model OrderSorting": "OpenRouter モデルのソート",
373373 "Alphabetically": "アルファベット順",
374374 "Price": "価格(最安値)",
375375 "Context Size": "コンテキストサイズ",
public/locales/ko-kr.json+1 -1
@@ -374,7 +374,7 @@
374374 "Slack and Poe cookies will not work here, do not bother trying.": "Slack과 Poe 쿠키는 여기서 작동하지 않습니다. 시도하지 마세요.",
375375 "Claude Model": "Claude 모델",
376376 "Window AI Model": "Window AI 모델",
377377 "OpenRouter Model OrderSorting": "OpenRouter 모델 정렬",
378378 "Alphabetically": "알파벳순",
379379 "Price": "가격(가장 저렴)",
380380 "Context Size": "컨텍스트 크기",
public/locales/nl-nl.json+1 -1
@@ -367,7 +367,7 @@
367367 "Anthropic's developer console": "Anthropic's ontwikkelaarsconsole",
368368 "Claude Model": "Claude-model",
369369 "Window AI Model": "Window AI-model",
370370 "OpenRouter Model OrderSorting": "Sorteren van OpenRouter-modellen",
371371 "Alphabetically": "Alfabetisch",
372372 "Price": "Prijs (goedkoopste)",
373373 "Context Size": "Contextgrootte",
public/locales/pt-pt.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "console de desenvolvedor da Anthropic",
370370 "Claude Model": "Modelo Claude",
371371 "Window AI Model": "Modelo Window AI",
372372 "OpenRouter Model OrderSorting": "Classificação de modelo OpenRouter",
373373 "Alphabetically": "Alfabeticamente",
374374 "Price": "Preço (mais barato)",
375375 "Context Size": "Tamanho do contexto",
public/locales/ru-ru.json+1 -1
@@ -718,7 +718,7 @@
718718 "Google Model": "Модель Google",
719719 "Cohere API Key": "Ключ от API Cohere",
720720 "Cohere Model": "Модель Cohere",
721721 "OpenRouter Model OrderSorting": "Сортировка моделей OpenRouter",
722722 "Alphabetically": "По алфавиту",
723723 "Price": "По цене (наиболее низкая)",
724724 "Context Size": "По размеру контекста",
public/locales/uk-ua.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "консолі розробника Anthropic",
370370 "Claude Model": "Модель Claude",
371371 "Window AI Model": "Модель Window AI",
372372 "OpenRouter Model OrderSorting": "Сортування моделі OpenRouter",
373373 "Alphabetically": "За алфавітом",
374374 "Price": "Ціна (найдешевша)",
375375 "Context Size": "Розмір контексту",
public/locales/vi-vn.json+1 -1
@@ -369,7 +369,7 @@
369369 "Anthropic's developer console": "developer console của Anthropic",
370370 "Claude Model": "Model Claude",
371371 "Window AI Model": "Model Window AI",
372372 "OpenRouter Model OrderSorting": "Sắp xếp model OpenRouter",
373373 "Alphabetically": "Theo thứ tự bảng chữ cái",
374374 "Price": "Giá (rẻ nhất)",
375375 "Context Size": "Kích thước bối cảnh",
public/locales/zh-cn.json+2 -2
@@ -452,7 +452,7 @@
452452 "Claude Model": "Claude 模型",
453453 "Allow fallback routes Description": "如果所选模型无法响应您的请求,则自动选择备用模型。",
454454 "Allow fallback models": "允许后备模型",
455455 "OpenRouter Model OrderSorting": "OpenRouter 模型顺序",
456456 "Alphabetically": "按字母顺序",
457457 "Price": "价格(最便宜)",
458458 "Context Size": "上下文长度",
@@ -1479,7 +1479,7 @@
14791479 "Image Captioning": "图像描述",
14801480 "Source": "来源",
14811481 "Local": "本地",
1482- "Multimodal (OpenAI / Anthropic / llama / Google)": "多模态(OpenAI / Anthropic / llama / Google)",
1482+ "Multimodal": "多模态",
14831483 "Extras": "更多",
14841484 "Horde": "Horde",
14851485 "API": "API",
public/locales/zh-tw.json+2 -2
@@ -368,7 +368,7 @@
368368 "Anthropic's developer console": "Anthropic 的開發者控制台",
369369 "Claude Model": "Claude 模型",
370370 "Window AI Model": "Window AI 模型",
371371 "OpenRouter Model OrderSorting": "模型順序",
372372 "Alphabetically": "按字母順序",
373373 "Price": "價格(最便宜的)",
374374 "Context Size": "上下文長度",
@@ -1674,7 +1674,7 @@
16741674 "Message Template": "訊息範本",
16751675 "Model ID": "模型 ID",
16761676 "mui_reset": "重設",
1677- "Multimodal (OpenAI / Anthropic / llama / Google)": "多模態(OpenAI/Anthropic/llama/Google)",
1677+ "Multimodal": "多模態",
16781678 "must be set in Tabby's config.yml to switch models.": "須在 Tabby's config.yml 中設定以切換模型。",
16791679 "Names as Stop Strings": "將名稱用作停止字串",
16801680 "Never": "從不",
public/scripts/backgrounds.js+149 -70
@@ -3,7 +3,8 @@ import { chat_metadata, eventSource, event_types, generateQuietPrompt, getCurren
33import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
44import { SlashCommand } from './slash-commands/SlashCommand.js';
55import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
66import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce } from './utils.js';
7+import { debounce_timeout } from './constants.js';
78import { t } from './i18n.js';
89import { Popup } from './popup.js';
910
@@ -15,6 +16,11 @@ const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkY
1516const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
1617const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`;
1718
19+const THUMBNAIL_COLUMNS_MIN = 2;
20+const THUMBNAIL_COLUMNS_MAX = 8;
21+const THUMBNAIL_COLUMNS_DEFAULT_DESKTOP = 5;
22+const THUMBNAIL_COLUMNS_DEFAULT_MOBILE = 3;
23+
1824/**
1925 * Storage for frontend-generated background thumbnails.
2026 * This is used to store thumbnails for backgrounds that cannot be generated on the server.
@@ -45,6 +51,53 @@ export let background_settings = {
4551 animation: false,
4652};
4753
54+/**
55+ * Creates a single thumbnail DOM element. The CSS now handles all sizing.
56+ * @param {object} imageData - Data for the image (filename, isCustom).
57+ * @returns {HTMLElement} The created thumbnail element.
58+ */
59+function createThumbnailElement(imageData) {
60+ const bg = imageData.filename;
61+ const isCustom = imageData.isCustom;
62+
63+ const thumbnail = $('#background_template .bg_example').clone();
64+
65+ const clipper = document.createElement('div');
66+ clipper.className = 'thumbnail-clipper lazy-load-background';
67+ clipper.style.backgroundImage = PLACEHOLDER_IMAGE;
68+
69+ const titleElement = thumbnail.find('.BGSampleTitle');
70+ clipper.appendChild(titleElement.get(0));
71+ thumbnail.append(clipper);
72+
73+ const url = generateUrlParameter(bg, isCustom);
74+ const title = isCustom ? bg.split('/').pop() : bg;
75+ const friendlyTitle = title.slice(0, title.lastIndexOf('.'));
76+
77+ thumbnail.attr('title', title);
78+ thumbnail.attr('bgfile', bg);
79+ thumbnail.attr('custom', String(isCustom));
80+ thumbnail.data('url', url);
81+ titleElement.text(friendlyTitle);
82+
83+ return thumbnail.get(0);
84+}
85+
86+/**
87+ * Applies the thumbnail column count to the CSS and updates button states.
88+ * @param {number} count - The number of columns to display.
89+ */
90+function applyThumbnailColumns(count) {
91+ const newCount = Math.max(THUMBNAIL_COLUMNS_MIN, Math.min(count, THUMBNAIL_COLUMNS_MAX));
92+ background_settings.thumbnailColumns = newCount;
93+ document.documentElement.style.setProperty('--bg-thumb-columns', newCount.toString());
94+
95+ $('#bg_thumb_zoom_in').prop('disabled', newCount <= THUMBNAIL_COLUMNS_MIN);
96+ $('#bg_thumb_zoom_out').prop('disabled', newCount >= THUMBNAIL_COLUMNS_MAX);
97+
98+ saveSettingsDebounced();
99+}
100+
48101export function loadBackgroundSettings(settings) {
49102 let backgroundSettings = settings.background;
50103 if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {
@@ -56,6 +109,16 @@ export function loadBackgroundSettings(settings) {
56109 if (!Object.hasOwn(backgroundSettings, 'animation')) {
57110 backgroundSettings.animation = false;
58111 }
112+
113+ // If a value is already saved, use it. Otherwise, determine default based on screen size.
114+ let columns = backgroundSettings.thumbnailColumns;
115+ if (!columns) {
116+ const isNarrowScreen = window.matchMedia('(max-width: 480px)').matches;
117+ columns = isNarrowScreen ? THUMBNAIL_COLUMNS_DEFAULT_MOBILE : THUMBNAIL_COLUMNS_DEFAULT_DESKTOP;
118+ }
119+ background_settings.thumbnailColumns = columns;
120+ applyThumbnailColumns(background_settings.thumbnailColumns);
121+
59122 setBackground(backgroundSettings.name, backgroundSettings.url);
60123 setFittingClass(backgroundSettings.fitting);
61124 $('#background_fitting').val(backgroundSettings.fitting);
@@ -75,7 +138,7 @@ async function forceSetBackground(backgroundInfo) {
75138 list.push(bg);
76139 chat_metadata[LIST_METADATA_KEY] = list;
77140 saveMetadataDebounced();
78141 await getChatBackgroundsListrenderChatBackgrounds();
79142 highlightNewBackground(bg);
80143 highlightLockedBackground();
81144}
@@ -88,28 +151,10 @@ async function onChatChanged() {
88151 unsetCustomBackground();
89152 }
90153
91154 await getChatBackgroundsListrenderChatBackgrounds();
92155 highlightLockedBackground();
93156}
94157
95-async function getChatBackgroundsList() {
96- const list = chat_metadata[LIST_METADATA_KEY];
97- const listEmpty = !Array.isArray(list) || list.length === 0;
98-
99- $('#bg_custom_content').empty();
100- $('#bg_chat_hint').toggle(listEmpty);
101-
102- if (listEmpty) {
103- return;
104- }
105-
106- for (const bg of list) {
107- const template = await getBackgroundFromTemplate(bg, true);
108- $('#bg_custom_content').append(template);
109- }
110- activateLazyLoader();
111-}
112-
113158function getBackgroundPath(fileUrl) {
114159 return `backgrounds/${encodeURIComponent(fileUrl)}`;
115160}
@@ -257,7 +302,7 @@ async function onCopyToSystemBackgroundClick(e) {
257302 const index = list.indexOf(bgNames.oldBg);
258303 list.splice(index, 1);
259304 saveMetadataDebounced();
260305 await getChatBackgroundsListrenderChatBackgrounds();
261306}
262307
263308/**
@@ -382,17 +427,21 @@ async function onDeleteBackgroundClick(e) {
382427 list.splice(index, 1);
383428 }
384429
385- const siblingSelector = '.bg_example:not(#form_bg_download)';
430+ if (bg === background_settings.name) {
431+ const siblingSelector = '.bg_example';
386432 const nextBg = bgToDelete.next(siblingSelector);
387433 const prevBg = bgToDelete.prev(siblingSelector);
388- const anyBg = $(siblingSelector);
389434
390435 if (nextBg.length > 0) {
391436 nextBg.trigger('click');
392437 } else if (prevBg.length > 0) {
393438 prevBg.trigger('click');
394439 } else {
395- $(anyBg[Math.floor(Math.random() * anyBg.length)]).trigger('click');
440+ const anyOtherBg = $('.bg_example').not(bgToDelete).first();
441+ if (anyOtherBg.length > 0) {
442+ anyOtherBg.trigger('click');
443+ }
444+ }
396445 }
397446
398447 bgToDelete.remove();
@@ -404,7 +453,7 @@ async function onDeleteBackgroundClick(e) {
404453 }
405454
406455 if (isCustom) {
407456 await getChatBackgroundsListrenderChatBackgrounds();
408457 saveMetadataDebounced();
409458 }
410459 }
@@ -445,6 +494,47 @@ async function autoBackgroundCommand() {
445494 return '';
446495}
447496
497+/**
498+ * Renders the system backgrounds gallery.
499+ * @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
500+ */
501+function renderSystemBackgrounds(backgrounds) {
502+ const sourceList = backgrounds || [];
503+ const container = $('#bg_menu_content');
504+ container.empty();
505+
506+ if (sourceList.length === 0) return;
507+
508+ sourceList.forEach(bg => {
509+ const imageData = { filename: bg, isCustom: false };
510+ const thumbnail = createThumbnailElement(imageData);
511+ container.append(thumbnail);
512+ });
513+
514+ activateLazyLoader();
515+}
516+
517+/**
518+ * Renders the chat-specific (custom) backgrounds gallery.
519+ * @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
520+ */
521+function renderChatBackgrounds(backgrounds) {
522+ const sourceList = backgrounds ?? (chat_metadata[LIST_METADATA_KEY] || []);
523+ const container = $('#bg_custom_content');
524+ container.empty();
525+ $('#bg_chat_hint').toggle(!sourceList.length);
526+
527+ if (sourceList.length === 0) return;
528+
529+ sourceList.forEach(bg => {
530+ const imageData = { filename: bg, isCustom: true };
531+ const thumbnail = createThumbnailElement(imageData);
532+ container.append(thumbnail);
533+ });
534+
535+ activateLazyLoader();
536+}
537+
448538export async function getBackgrounds() {
449539 const response = await fetch('/api/backgrounds/all', {
450540 method: 'POST',
@@ -454,12 +544,7 @@ export async function getBackgrounds() {
454544 if (response.ok) {
455545 const { images, config } = await response.json();
456546 Object.assign(THUMBNAIL_CONFIG, config);
457- $('#bg_menu_content').children('div').remove();
547+ renderSystemBackgrounds(images);
458- for (const bg of images) {
459- const template = await getBackgroundFromTemplate(bg, false);
460- $('#bg_menu_content').append(template);
461- }
462- activateLazyLoader();
463548 }
464549}
465550
@@ -481,14 +566,19 @@ function activateLazyLoader() {
481566 lazyLoadObserver = new IntersectionObserver((entries, observer) => {
482567 entries.forEach(entry => {
483568 if (entry.target instanceof HTMLElement && entry.isIntersecting) {
484569 const targetclipper = entry.target;
485570 const bgparentThumbnail = targetclipper.getAttributeclosest('bgfile.bg_example');
486- const isCustom = target.getAttribute('custom') === 'true';
571+
572+ if (parentThumbnail) {
573+ const bg = parentThumbnail.getAttribute('bgfile');
574+ const isCustom = parentThumbnail.getAttribute('custom') === 'true';
487575 resolveImageUrl(bg, isCustom)
488576 .then(url => { targetclipper.style.backgroundImage = url; })
489577 .catch(() => { targetclipper.style.backgroundImage = PLACEHOLDER_IMAGE; });
490- target.classList.remove('lazy-load-background');
578+ }
491- observer.unobserve(target);
579+
580+ clipper.classList.remove('lazy-load-background');
581+ observer.unobserve(clipper);
492582 }
493583 });
494584 }, options);
@@ -529,28 +619,6 @@ async function resolveImageUrl(bg, isCustom) {
529619 return `url("${thumbnailUrl}")`;
530620}
531621
532-/**
533- * Instantiates a background template
534- * @param {string} bg Path to background
535- * @param {boolean} isCustom Whether the background is custom
536- * @returns {Promise<JQuery<HTMLElement>>} Background template
537- */
538-async function getBackgroundFromTemplate(bg, isCustom) {
539- const template = $('#background_template .bg_example').clone();
540- const url = generateUrlParameter(bg, isCustom);
541- const title = isCustom ? bg.split('/').pop() : bg;
542- const friendlyTitle = title.slice(0, title.lastIndexOf('.'));
543-
544- template.attr('title', title);
545- template.attr('bgfile', bg);
546- template.attr('custom', String(isCustom));
547- template.data('url', url);
548- template.addClass('lazy-load-background');
549- template.css('background-image', PLACEHOLDER_IMAGE);
550- template.find('.BGSampleTitle').text(friendlyTitle);
551- return template;
552-}
553-
554622async function setBackground(bg, url) {
555623 $('#bg1').css('background-image', url);
556624 background_settings.name = bg;
@@ -688,17 +756,17 @@ function setFittingClass(fitting) {
688756}
689757
690758function onBackgroundFilterInput() {
691759 const filterValue = String($(this'#bg-filter').val()).toLowerCase();
692760 $('#bg_menu_content > div.bg_example, #bg_custom_content > .bg_example').each(function () {
693761 const $bgContentbg = $(this);
694- if ($bgContent.attr('title').toLowerCase().includes(filterValue)) {
762+ const title = $bg.attr('title') || '';
695- $bgContent.show();
763+ const hasMatch = title.toLowerCase().includes(filterValue);
696- } else {
764+ $bg.toggle(hasMatch);
697- $bgContent.hide();
698- }
699765 });
700766}
701767
768+const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
769+
702770export function initBackgrounds() {
703771 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
704772 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
@@ -715,6 +783,11 @@ export function initBackgrounds() {
715783 $context.addClass('mobile-menu-open');
716784 }
717785 })
786+ .off('blur', '.bg_example.mobile-menu-open').on('blur', '.bg_example.mobile-menu-open', function () {
787+ if (!$(this).is(':focus-within')) {
788+ $(this).removeClass('mobile-menu-open');
789+ }
790+ })
718791 .off('click', '.jg-button').on('click', '.jg-button', function (e) {
719792 e.stopPropagation();
720793 const action = $(this).data('action');
@@ -738,9 +811,15 @@ export function initBackgrounds() {
738811 }
739812 });
740813
814+ $('#bg_thumb_zoom_in').on('click', () => {
815+ applyThumbnailColumns(background_settings.thumbnailColumns - 1);
816+ });
817+ $('#bg_thumb_zoom_out').on('click', () => {
818+ applyThumbnailColumns(background_settings.thumbnailColumns + 1);
819+ });
741820 $('#auto_background').on('click', autoBackgroundCommand);
742821 $('#add_bg_button').on('change', onBackgroundUploadSelected);
743822 $('#bg-filter').on('input', onBackgroundFilterInput() => debouncedOnBackgroundFilterInput());
744823 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
745824 name: 'lockbg',
746825 callback: () => onLockBackgroundClick(new CustomEvent('click')),
public/scripts/extensions/caption/settings.html+2 -2
@@ -9,7 +9,7 @@
99 <label for="caption_source" data-i18n="Source">Source</label>
1010 <select id="caption_source" class="text_pole">
1111 <option value="local" data-i18n="Local">Local</option>
1212 <option value="multimodal" data-i18n="Multimodal (OpenAI / Anthropic / llama / Google)">Multimodal (OpenAI / Anthropic / llama / Google)</option>
1313 <option value="extras" data-i18n="Extras">Extras (deprecated)</option>
1414 <option value="horde" data-i18n="Horde">Horde</option>
1515 </select>
@@ -18,7 +18,7 @@
1818 <label for="caption_multimodal_api" data-i18n="API">API</label>
1919 <select id="caption_multimodal_api" class="flex1 text_pole">
2020 <option value="aimlapi">AI/ML API</option>
2121 <option value="anthropic">AnthropicClaude</option>
2222 <option value="cohere">Cohere</option>
2323 <option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>
2424 <option value="electronhub">Electron Hub</option>
public/scripts/extensions/tts/electronhub.js+455 -0
@@ -0,0 +1,455 @@
1+import { event_types, eventSource, getRequestHeaders } from '../../../script.js';
2+import { SECRET_KEYS, secret_state } from '../../secrets.js';
3+import { getPreviewString, saveTtsProviderSettings, initVoiceMap } from './index.js';
4+
5+export { ElectronHubTtsProvider };
6+
7+class ElectronHubTtsProvider {
8+ settings;
9+ voices = [];
10+ models = [];
11+ separator = ' . ';
12+ audioElement = document.createElement('audio');
13+
14+ defaultSettings = {
15+ voiceMap: {},
16+ model: 'tts-1',
17+ speed: 1,
18+ temperature: 1,
19+ top_p: 1,
20+ // GPT-4o Mini TTS
21+ instructions: '',
22+ // Dia
23+ speaker_transcript: '',
24+ cfg_filter_top_k: 25,
25+ cfg_scale: 3,
26+ // Microsoft TTS
27+ speech_rate: 0,
28+ pitch_adjustment: 0,
29+ emotional_style: '',
30+ };
31+
32+ get settingsHtml() {
33+ let html = `
34+ <div>Electron Hub unified TTS API.</div>
35+ <div class="flex-container alignItemsCenter">
36+ <div class="flex1"></div>
37+ <div id="electronhub_tts_key" class="menu_button menu_button_icon manage-api-keys" data-key="api_key_electronhub">
38+ <i class="fa-solid fa-key"></i>
39+ <span>API Key</span>
40+ </div>
41+ </div>
42+ <div class="flex-container flexGap10 wrap">
43+ <div class="flex1">
44+ <label for="electronhub_tts_model">Model</label>
45+ <select id="electronhub_tts_model" class="text_pole"></select>
46+ </div>
47+ <div>
48+ <label for="electronhub_tts_speed">Speed <span id="electronhub_tts_speed_output"></span></label>
49+ <input type="range" id="electronhub_tts_speed" value="1" min="0.25" max="4" step="0.05">
50+ </div>
51+ <div>
52+ <label for="electronhub_tts_temperature">Temperature</label>
53+ <input id="electronhub_tts_temperature" class="text_pole" type="number" min="0" max="2" step="0.1" value="1" />
54+ </div>
55+ <div id="electronhub_block_top_p" style="display:none;">
56+ <label for="electronhub_tts_top_p">Top-p</label>
57+ <input id="electronhub_tts_top_p" class="text_pole" type="number" min="0" max="1" step="0.01" value="1" />
58+ </div>
59+ </div>
60+
61+ <div id="electronhub_block_instructions" style="display:none;">
62+ <label for="electronhub_tts_instructions">Instructions (GPT-4o Mini TTS):</label>
63+ <textarea id="electronhub_tts_instructions" class="textarea_compact autoSetHeight" placeholder="e.g., 'Speak cheerfully and energetically'"></textarea>
64+ </div>
65+
66+ <div id="electronhub_block_dia" style="display:none;">
67+ <label for="electronhub_tts_speaker_transcript">Speaker transcript (Dia):</label>
68+ <textarea id="electronhub_tts_speaker_transcript" class="textarea_compact autoSetHeight" maxlength="1000"></textarea>
69+ <label for="electronhub_tts_cfg_scale">CFG scale (1-5):</label>
70+ <input id="electronhub_tts_cfg_scale" type="number" min="1" max="5" step="1" />
71+ <label for="electronhub_tts_cfg_topk">CFG filter top_k (15-50):</label>
72+ <input id="electronhub_tts_cfg_topk" type="number" min="15" max="50" step="1" />
73+ </div>
74+
75+ <div id="electronhub_block_msft" style="display:none;">
76+ <div class="flex-container flexGap10 wrap">
77+ <div>
78+ <label for="electronhub_tts_speech_rate">Speech rate (-100..100)</label>
79+ <input id="electronhub_tts_speech_rate" class="text_pole" type="number" min="-100" max="100" step="1" style="width:120px;" />
80+ </div>
81+ <div>
82+ <label for="electronhub_tts_pitch_adjustment">Pitch adjustment (-100..100)</label>
83+ <input id="electronhub_tts_pitch_adjustment" class="text_pole" type="number" min="-100" max="100" step="1" style="width:120px;" />
84+ </div>
85+ </div>
86+ <div class="flex-container flexGap10">
87+ <div class="flex1">
88+ <label for="electronhub_tts_emotional_style">Emotional style</label>
89+ <input id="electronhub_tts_emotional_style" class="text_pole" type="text" placeholder="cheerful, sad, angry, gentle..." />
90+ </div>
91+ </div>
92+ </div>
93+
94+ <div id="electronhub_dynamic_params" class="flex-container flexGap10 wrap" style="display:none;"></div>`;
95+ return html;
96+ }
97+
98+ constructor() {
99+ this.handler = async function (/** @type {string} */ key) {
100+ if (key !== SECRET_KEYS.ELECTRONHUB) return;
101+ $('#electronhub_tts_key').toggleClass('success', !!secret_state[SECRET_KEYS.ELECTRONHUB]);
102+ await this.onRefreshClick();
103+ }.bind(this);
104+ }
105+
106+ dispose() {
107+ [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
108+ eventSource.removeListener(event, this.handler);
109+ });
110+ }
111+
112+ async loadSettings(settings) {
113+ if (Object.keys(settings).length == 0) {
114+ console.info('Using default Electron Hub TTS settings');
115+ }
116+
117+ this.settings = { ...this.defaultSettings, ...settings };
118+
119+ await this.loadModels();
120+ this.populateModelSelect();
121+
122+ $('#electronhub_tts_model').val(this.settings.model);
123+ $('#electronhub_tts_model').on('change', () => { this.onSettingsChange(); });
124+
125+ $('#electronhub_tts_speed').val(this.settings.speed);
126+ $('#electronhub_tts_speed_output').text(this.settings.speed);
127+ $('#electronhub_tts_speed').on('input', () => { this.onSettingsChange(); });
128+
129+ $('#electronhub_tts_temperature').val(this.settings.temperature);
130+ $('#electronhub_tts_temperature').on('input', () => { this.onSettingsChange(); });
131+
132+ $('#electronhub_tts_top_p').val(this.settings.top_p);
133+ $('#electronhub_tts_top_p').on('input', () => { this.onSettingsChange(); });
134+
135+ $('#electronhub_tts_instructions').val(this.settings.instructions);
136+ $('#electronhub_tts_instructions').on('input', () => { this.onSettingsChange(); });
137+
138+ $('#electronhub_tts_speaker_transcript').val(this.settings.speaker_transcript);
139+ $('#electronhub_tts_speaker_transcript').on('input', () => { this.onSettingsChange(); });
140+ $('#electronhub_tts_cfg_scale').val(this.settings.cfg_scale);
141+ $('#electronhub_tts_cfg_scale').on('input', () => { this.onSettingsChange(); });
142+ $('#electronhub_tts_cfg_topk').val(this.settings.cfg_filter_top_k);
143+ $('#electronhub_tts_cfg_topk').on('input', () => { this.onSettingsChange(); });
144+
145+ $('#electronhub_tts_speech_rate').val(this.settings.speech_rate);
146+ $('#electronhub_tts_speech_rate').on('input', () => { this.onSettingsChange(); });
147+ $('#electronhub_tts_pitch_adjustment').val(this.settings.pitch_adjustment);
148+ $('#electronhub_tts_pitch_adjustment').on('input', () => { this.onSettingsChange(); });
149+ $('#electronhub_tts_emotional_style').val(this.settings.emotional_style);
150+ $('#electronhub_tts_emotional_style').on('input', () => { this.onSettingsChange(); });
151+
152+ $('#electronhub_tts_key').toggleClass('success', !!secret_state[SECRET_KEYS.ELECTRONHUB]);
153+ [event_types.SECRET_WRITTEN, event_types.SECRET_DELETED, event_types.SECRET_ROTATED].forEach(event => {
154+ eventSource.on(event, this.handler);
155+ });
156+
157+ await this.checkReady();
158+ this.updateConditionalBlocks();
159+ this.renderDynamicParams();
160+ console.debug('Electron Hub TTS: Settings loaded');
161+ }
162+
163+ async onSettingsChange() {
164+ const previousModel = this.settings.model;
165+ this.settings.model = String($('#electronhub_tts_model').find(':selected').val() || this.settings.model);
166+ this.settings.speed = Number($('#electronhub_tts_speed').val());
167+ $('#electronhub_tts_speed_output').text(this.settings.speed);
168+ this.settings.temperature = Number($('#electronhub_tts_temperature').val());
169+ this.settings.top_p = Number($('#electronhub_tts_top_p').val());
170+ this.settings.instructions = String($('#electronhub_tts_instructions').val() || '');
171+ this.settings.speaker_transcript = String($('#electronhub_tts_speaker_transcript').val() || '');
172+ this.settings.cfg_scale = Number($('#electronhub_tts_cfg_scale').val());
173+ this.settings.cfg_filter_top_k = Number($('#electronhub_tts_cfg_topk').val());
174+ this.settings.speech_rate = Number($('#electronhub_tts_speech_rate').val());
175+ this.settings.pitch_adjustment = Number($('#electronhub_tts_pitch_adjustment').val());
176+ this.settings.emotional_style = String($('#electronhub_tts_emotional_style').val() || '');
177+ this.updateConditionalBlocks();
178+ this.renderDynamicParams();
179+ saveTtsProviderSettings();
180+ if (previousModel !== this.settings.model) {
181+ this.voices = await this.fetchTtsVoiceObjects();
182+ await initVoiceMap();
183+ }
184+ }
185+
186+ async loadModels() {
187+ try {
188+ const response = await fetch('/api/openai/electronhub/models', {
189+ method: 'POST',
190+ headers: getRequestHeaders(),
191+ });
192+ if (!response.ok) {
193+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
194+ }
195+ /** @type {Array<any>} */
196+ const data = await response.json();
197+ const allModels = Array.isArray(data) ? data : [];
198+ const ttsModels = allModels.filter(m => {
199+ const eps = Array.isArray(m?.endpoints) ? m.endpoints : [];
200+ return eps.some(ep => {
201+ if (typeof ep !== 'string') return false;
202+ return ep === '/v1/audio/speech' || ep.endsWith('/audio/speech') || ep === 'audio/speech';
203+ });
204+ });
205+
206+ this.models = ttsModels;
207+
208+ if (this.models.length > 0 && !this.models.find(m => m.id === this.settings.model)) {
209+ this.settings.model = this.models[0].id;
210+ saveTtsProviderSettings();
211+ }
212+ } catch (err) {
213+ console.warn('Electron Hub models fetch failed', err);
214+ this.models = [];
215+ }
216+ }
217+
218+ populateModelSelect() {
219+ const select = $('#electronhub_tts_model');
220+ select.empty();
221+ const groups = this.groupByVendor(this.models);
222+ for (const [vendor, models] of groups.entries()) {
223+ const optgroup = document.createElement('optgroup');
224+ optgroup.label = vendor;
225+ for (const m of models) {
226+ const opt = document.createElement('option');
227+ opt.value = m.id;
228+ opt.text = m.name || m.id;
229+ optgroup.appendChild(opt);
230+ }
231+ select.append(optgroup);
232+ }
233+
234+ if (this.models.find(x => x.id === this.settings.model)) {
235+ select.val(this.settings.model);
236+ }
237+ }
238+
239+ /**
240+ * Group models by vendor prefix from name before ':'
241+ * @param {Array<any>} array
242+ * @returns {Map<string, any[]>}
243+ */
244+ groupByVendor(array) {
245+ return array.reduce((acc, curr) => {
246+ const name = String(curr?.name || curr?.id || 'Other');
247+ const vendor = name.split(':')[0].trim() || 'Other';
248+ if (!acc.has(vendor)) acc.set(vendor, []);
249+ acc.get(vendor).push(curr);
250+ return acc;
251+ }, new Map());
252+ }
253+
254+ updateConditionalBlocks() {
255+ const modelId = this.settings.model;
256+ const model = this.models.find(m => m.id === modelId);
257+ const params = model?.parameters || {};
258+ const vendorName = String(model?.name || '').split(':')[0].trim().toLowerCase();
259+
260+ const hasInstructions = 'instructions' in params || modelId === 'gpt-4o-mini-tts';
261+ const hasDia = 'speaker_transcript' in params || 'cfg_scale' in params || 'cfg_filter_top_k' in params || modelId.includes('dia');
262+
263+ const hasMsft = 'speech_rate' in params || 'pitch_adjustment' in params || 'emotional_style' in params || vendorName === 'microsoft' || modelId === 'microsoft-tts';
264+ const hasTopP = 'top_p' in params;
265+
266+ $('#electronhub_block_instructions').toggle(!!hasInstructions);
267+ $('#electronhub_block_dia').toggle(!!hasDia);
268+ $('#electronhub_block_msft').toggle(!!hasMsft);
269+ $('#electronhub_block_top_p').toggle(!!hasTopP);
270+ }
271+
272+ /**
273+ * Build UI for additional model parameters dynamically
274+ */
275+ renderDynamicParams() {
276+ const container = $('#electronhub_dynamic_params');
277+ container.empty();
278+ const model = this.models.find(m => m.id === this.settings.model);
279+ const params = model?.parameters || {};
280+ const modelHasVoices = Array.isArray(model?.voices) && model.voices.length > 0;
281+ const exclude = new Set(['input', 'response_format', 'model', 'speed', 'temperature', 'top_p', 'instructions', 'speaker_transcript', 'cfg_scale', 'cfg_filter_top_k', 'speech_rate', 'pitch_adjustment', 'emotional_style']);
282+ if (modelHasVoices) exclude.add('voice');
283+
284+ const entries = Object.entries(params).filter(([k]) => !exclude.has(k));
285+ container.toggle(entries.length > 0);
286+ if (entries.length === 0) return;
287+
288+ for (const [key, spec] of entries) {
289+ const nice = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
290+ const type = String(spec?.type || 'string');
291+ const id = `electronhub_dyn_${key.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
292+
293+ if (Array.isArray(spec?.enum) && spec.enum.length) {
294+ const select = $(`<div><label for="${id}">${nice}</label><select id="${id}" class="text_pole"></select></div>`);
295+ container.append(select);
296+ const el = select.find('select');
297+ for (const opt of spec.enum) el.append(new Option(String(opt), String(opt)));
298+ const val = this.settings[key] ?? spec.default ?? spec.enum[0];
299+ el.val(String(val));
300+ el.on('change', () => { this.settings[key] = String(el.val() || ''); saveTtsProviderSettings(); });
301+ continue;
302+ }
303+
304+ if (type === 'boolean') {
305+ const block = $(`<label class="checkbox_label" for="${id}"><input type="checkbox" id="${id}"> <small>${nice}</small></label>`);
306+ container.append(block);
307+ const el = block.find('input');
308+ el.prop('checked', !!(this.settings[key] ?? spec.default ?? false));
309+ el.on('change', () => { this.settings[key] = !!el.is(':checked'); saveTtsProviderSettings(); });
310+ continue;
311+ }
312+
313+ if (type === 'number' || type === 'integer') {
314+ const min = spec.minimum ?? undefined;
315+ const max = spec.maximum ?? undefined;
316+ const step = type === 'integer' ? 1 : (spec.step ?? 0.01);
317+ const block = $(`<div><label for="${id}">${nice}${(min != null || max != null) ? ` (${min ?? ''}..${max ?? ''})` : ''}:</label><input id="${id}" type="number" class="text_pole" ${min != null ? `min="${min}"` : ''} ${max != null ? `max="${max}"` : ''} step="${step}"></div>`);
318+ container.append(block);
319+ const el = block.find('input');
320+ const val = this.settings[key] ?? spec.default ?? '';
321+ if (val !== '') el.val(val);
322+ el.on('input', () => {
323+ const raw = el.val();
324+ this.settings[key] = (raw === '') ? '' : Number(raw);
325+ saveTtsProviderSettings();
326+ });
327+ continue;
328+ }
329+
330+ const isLong = /instructions|transcript|style|prompt|description/i.test(key);
331+ if (isLong) {
332+ const block = $(`<div><label for="${id}">${nice}</label><textarea id="${id}" class="textarea_compact autoSetHeight"></textarea></div>`);
333+ container.append(block);
334+ const el = block.find('textarea');
335+ el.val(String(this.settings[key] ?? spec.default ?? ''));
336+ el.on('input', () => { this.settings[key] = String(el.val() || ''); saveTtsProviderSettings(); });
337+ } else {
338+ const block = $(`<div><label for="${id}">${nice}</label><input id="${id}" type="text" class="text_pole" /></div>`);
339+ container.append(block);
340+ const el = block.find('input');
341+ el.val(String(this.settings[key] ?? spec.default ?? ''));
342+ el.on('input', () => { this.settings[key] = String(el.val() || ''); saveTtsProviderSettings(); });
343+ }
344+ }
345+ }
346+
347+ async checkReady() {
348+ this.voices = await this.fetchTtsVoiceObjects();
349+ }
350+
351+ async onRefreshClick() {
352+ await this.loadModels();
353+ this.populateModelSelect();
354+ this.voices = await this.fetchTtsVoiceObjects();
355+ this.updateConditionalBlocks();
356+ this.renderDynamicParams();
357+ saveTtsProviderSettings();
358+ }
359+
360+ async getVoice(voiceName) {
361+ if (this.voices.length == 0) {
362+ this.voices = await this.fetchTtsVoiceObjects();
363+ }
364+ const match = this.voices.filter(v => v.name == voiceName)[0];
365+ if (!match) {
366+ throw `TTS Voice name ${voiceName} not found`;
367+ }
368+ return match;
369+ }
370+
371+ async generateTts(text, voiceId) {
372+ const response = await this.fetchTtsGeneration(text, voiceId);
373+ return response;
374+ }
375+
376+ async fetchTtsVoiceObjects() {
377+ const modelId = this.settings.model;
378+ const model = this.models.find(m => m.id === modelId);
379+ if (model && Array.isArray(model.voices) && model.voices.length) {
380+ return model.voices.map(name => ({ name, voice_id: name, lang: 'en-US' }));
381+ }
382+ // Fallback to common OpenAI voices
383+ const fallback = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer', 'verse'];
384+ return fallback.map(name => ({ name, voice_id: name, lang: 'en-US' }));
385+ }
386+
387+ async previewTtsVoice(voiceId) {
388+ this.audioElement.pause();
389+ this.audioElement.currentTime = 0;
390+ const text = getPreviewString('en-US');
391+ const response = await this.fetchTtsGeneration(text, voiceId);
392+ if (!response.ok) {
393+ throw new Error(`HTTP ${response.status}`);
394+ }
395+ const audio = await response.blob();
396+ const url = URL.createObjectURL(audio);
397+ this.audioElement.src = url;
398+ this.audioElement.play();
399+ this.audioElement.onended = () => URL.revokeObjectURL(url);
400+ }
401+
402+ async fetchTtsGeneration(inputText, voiceId) {
403+ console.info(`Generating Electron Hub TTS for voice_id ${voiceId}`);
404+ const body = {
405+ input: inputText,
406+ voice: voiceId,
407+ speed: this.settings.speed,
408+ temperature: this.settings.temperature,
409+ model: this.settings.model,
410+ };
411+
412+ const model = (this.settings.model || '').toLowerCase();
413+ if (model === 'gpt-4o-mini-tts') {
414+ if (this.settings.instructions?.trim()) body.instructions = this.settings.instructions.trim();
415+ }
416+ if (model.includes('dia')) {
417+ if (this.settings.speaker_transcript?.trim()) body.speaker_transcript = this.settings.speaker_transcript.trim();
418+ if (Number.isFinite(this.settings.cfg_scale)) body.cfg_scale = Number(this.settings.cfg_scale);
419+ if (Number.isFinite(this.settings.cfg_filter_top_k)) body.cfg_filter_top_k = Number(this.settings.cfg_filter_top_k);
420+ }
421+ if (model.includes('microsoft-tts')) {
422+ if (Number.isFinite(this.settings.speech_rate)) body.speech_rate = Number(this.settings.speech_rate);
423+ if (Number.isFinite(this.settings.pitch_adjustment)) body.pitch_adjustment = Number(this.settings.pitch_adjustment);
424+ if ((this.settings.emotional_style || '').trim()) body.emotional_style = String(this.settings.emotional_style).trim();
425+ }
426+ if (Number.isFinite(this.settings.top_p)) {
427+ body.top_p = Number(this.settings.top_p);
428+ }
429+
430+ // add dynamic params based on schema
431+ const modelObj = this.models.find(m => m.id === this.settings.model);
432+ const params = modelObj?.parameters || {};
433+ const modelHasVoices = Array.isArray(modelObj?.voices) && modelObj.voices.length > 0;
434+ const exclude = new Set(['input', 'response_format', 'model', 'speed', 'temperature', 'top_p', 'instructions', 'speaker_transcript', 'cfg_scale', 'cfg_filter_top_k', 'speech_rate', 'pitch_adjustment', 'emotional_style']);
435+ if (modelHasVoices) exclude.add('voice');
436+ for (const key of Object.keys(params)) {
437+ if (exclude.has(key)) continue;
438+ const val = this.settings[key];
439+ if (val === undefined || val === '') continue;
440+ body[key] = val;
441+ }
442+
443+ const response = await fetch('/api/openai/electronhub/generate-voice', {
444+ method: 'POST',
445+ headers: getRequestHeaders(),
446+ body: JSON.stringify(body),
447+ });
448+
449+ if (!response.ok) {
450+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
451+ }
452+
453+ return response;
454+ }
455+}
public/scripts/extensions/tts/index.js+2 -0
@@ -33,6 +33,7 @@ import { KokoroTtsProvider } from './kokoro.js';
3333import { TtsWebuiProvider } from './tts-webui.js';
3434import { PollinationsTtsProvider } from './pollinations.js';
3535import { MiniMaxTtsProvider } from './minimax.js';
36+import { ElectronHubTtsProvider } from './electronhub.js';
3637
3738const UPDATE_INTERVAL = 1000;
3839const wrapper = new ModuleWorkerWrapper(moduleWorker);
@@ -123,6 +124,7 @@ const ttsProviders = {
123124 'CosyVoice (Unofficial)': CosyVoiceProvider,
124125 Edge: EdgeTtsProvider,
125126 ElevenLabs: ElevenLabsTtsProvider,
127+ 'Electron Hub': ElectronHubTtsProvider,
126128 'Google Translate': GoogleTranslateTtsProvider,
127129 'Google Gemini TTS': GoogleNativeTtsProvider,
128130 GSVI: GSVITtsProvider,
public/scripts/openai.js+156 -10
@@ -276,6 +276,8 @@ export const settingsToUpdate = {
276276 perplexity_model: ['#model_perplexity_select', 'perplexity_model', false, true],
277277 groq_model: ['#model_groq_select', 'groq_model', false, true],
278278 electronhub_model: ['#model_electronhub_select', 'electronhub_model', false, true],
279+ electronhub_sort_models: ['#electronhub_sort_models', 'electronhub_sort_models', false, true],
280+ electronhub_group_models: ['#electronhub_group_models', 'electronhub_group_models', false, true],
279281 nanogpt_model: ['#model_nanogpt_select', 'nanogpt_model', false, true],
280282 deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],
281283 aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],
@@ -379,6 +381,8 @@ const default_settings = {
379381 perplexity_model: 'sonar-pro',
380382 groq_model: 'llama-3.3-70b-versatile',
381383 electronhub_model: 'gpt-4o-mini',
384+ electronhub_sort_models: 'alphabetically',
385+ electronhub_group_models: false,
382386 nanogpt_model: 'gpt-4o-mini',
383387 deepseek_model: 'deepseek-chat',
384388 aimlapi_model: 'gpt-4o-mini-2024-07-18',
@@ -473,6 +477,8 @@ const oai_settings = {
473477 perplexity_model: 'sonar-pro',
474478 groq_model: 'llama-3.1-70b-versatile',
475479 electronhub_model: 'gpt-4o-mini',
480+ electronhub_sort_models: 'alphabetically',
481+ electronhub_group_models: false,
476482 nanogpt_model: 'gpt-4o-mini',
477483 deepseek_model: 'deepseek-chat',
478484 aimlapi_model: 'gpt-4-turbo',
@@ -1710,6 +1716,59 @@ function calculateOpenRouterCost() {
17101716 $('#openrouter_max_prompt_cost').text(cost);
17111717}
17121718
1719+function getElectronHubModelTemplate(option) {
1720+ const model = model_list.find(x => x.id === option?.element?.value);
1721+
1722+ if (!option.id || !model) {
1723+ return option.text;
1724+ }
1725+
1726+ const inputPrice = model.pricing?.input;
1727+ const outputPrice = model.pricing?.output;
1728+ const price = inputPrice && outputPrice ? `$${inputPrice}/$${outputPrice} in/out Mtoken` : 'Unknown';
1729+
1730+ const visionIcon = model.metadata?.vision ? '<i class="fa-solid fa-eye fa-sm" title="This model supports vision"></i>' : '';
1731+ const reasoningIcon = model.metadata?.reasoning ? '<i class="fa-solid fa-brain fa-sm" title="This model supports reasoning"></i>' : '';
1732+ const toolCallsIcon = model.metadata?.function_call ? '<i class="fa-solid fa-wrench fa-sm" title="This model supports function tools"></i>' : '';
1733+ const premiumIcon = model?.premium_model ? '<i class="fa-solid fa-crown fa-sm" title="This model requires a subscription"></i>' : '';
1734+
1735+ const iconsContainer = document.createElement('span');
1736+ iconsContainer.insertAdjacentHTML('beforeend', visionIcon);
1737+ iconsContainer.insertAdjacentHTML('beforeend', reasoningIcon);
1738+ iconsContainer.insertAdjacentHTML('beforeend', toolCallsIcon);
1739+ iconsContainer.insertAdjacentHTML('beforeend', premiumIcon);
1740+
1741+ const capabilities = (iconsContainer.children.length) ? ` | ${iconsContainer.innerHTML}` : '';
1742+
1743+ return $((`
1744+ <div class="flex-container alignItemsBaseline" title="${DOMPurify.sanitize(model.id)}">
1745+ <strong>${DOMPurify.sanitize(model.name)}</strong> | ${model.tokens} ctx | <small>${price}</small>${capabilities}
1746+ </div>
1747+ `));
1748+}
1749+
1750+function calculateElectronHubCost() {
1751+ if (oai_settings.chat_completion_source !== chat_completion_sources.ELECTRONHUB) {
1752+ return;
1753+ }
1754+
1755+ let cost = 'Unknown';
1756+ const model = model_list.find(x => x.id === oai_settings.electronhub_model);
1757+
1758+ if (model?.pricing) {
1759+ const outputCost = Number(model.pricing.output / 1000000);
1760+ const inputCost = Number(model.pricing.input / 1000000);
1761+ const outputTokens = oai_settings.openai_max_tokens;
1762+ const inputTokens = (oai_settings.openai_max_context - outputTokens);
1763+ const totalCost = (outputCost * outputTokens) + (inputCost * inputTokens);
1764+ if (!isNaN(totalCost)) {
1765+ cost = '$' + totalCost.toFixed(4);
1766+ }
1767+ }
1768+
1769+ $('#electronhub_max_prompt_cost').text(cost);
1770+}
1771+
17131772function saveModelList(data) {
17141773 model_list = data.map((model) => ({ ...model }));
17151774 model_list.sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id));
@@ -1803,16 +1862,14 @@ function saveModelList(data) {
18031862 }
18041863
18051864 if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB) {
1865+ model_list = model_list.filter(model => model?.endpoints?.includes('/v1/chat/completions'));
1866+
1867+ model_list = electronHubSortBy(model_list, oai_settings.electronhub_sort_models);
1868+
18061869 $('#model_electronhub_select').empty();
1807- model_list.forEach((model) => {
1870+
1808- if (model?.endpoints?.includes('/v1/chat/completions')) {
1871+ const groupedList = oai_settings.electronhub_group_models ? electronHubGroupByVendor(model_list) : model_list;
1809- $('#model_electronhub_select').append(
1872+ appendElectronHubOptions(groupedList, oai_settings.electronhub_group_models);
1810- $('<option>', {
1811- value: model.id,
1812- text: model.name,
1813- }));
1814- }
1815- });
18161873
18171874 const selectedModel = model_list.find(model => model.id === oai_settings.electronhub_model);
18181875 if (model_list.length > 0 && (!selectedModel || !oai_settings.electronhub_model)) {
@@ -2041,6 +2098,61 @@ function openRouterGroupByVendor(array) {
20412098 }, new Map());
20422099}
20432100
2101+function appendElectronHubOptions(model_list, groupModels = false) {
2102+ const appendOption = (model, parent = null) => {
2103+ (parent || $('#model_electronhub_select')).append(
2104+ $('<option>', {
2105+ value: model.id,
2106+ text: model.name,
2107+ }));
2108+ };
2109+
2110+ if (groupModels) {
2111+ model_list.forEach((models, vendor) => {
2112+ const optgroup = $('<optgroup>').attr('label', vendor);
2113+
2114+ models.forEach((model) => {
2115+ appendOption(model, optgroup);
2116+ });
2117+
2118+ $('#model_electronhub_select').append(optgroup);
2119+ });
2120+ } else {
2121+ model_list.forEach((model) => {
2122+ appendOption(model);
2123+ });
2124+ }
2125+
2126+}
2127+
2128+function electronHubSortBy(data, property = 'alphabetically') {
2129+ return data.sort((a, b) => {
2130+ if (property === 'context_length') {
2131+ return b.tokens - a.tokens;
2132+ } else if (property === 'pricing.input') {
2133+ return parseFloat(a.pricing.input) - parseFloat(b.pricing.input);
2134+ } else if (property === 'pricing.output') {
2135+ return parseFloat(a.pricing.output) - parseFloat(b.pricing.output);
2136+ } else {
2137+ return a?.name && b?.name && a.name.localeCompare(b.name);
2138+ }
2139+ });
2140+}
2141+
2142+function electronHubGroupByVendor(array) {
2143+ return array.reduce((acc, curr) => {
2144+ const vendor = String(curr?.name || curr?.id || 'Other').split(':')[0].trim() || 'Other';
2145+
2146+ if (!acc.has(vendor)) {
2147+ acc.set(vendor, []);
2148+ }
2149+
2150+ acc.get(vendor).push(curr);
2151+
2152+ return acc;
2153+ }, new Map());
2154+}
2155+
20442156function aimlapiGroupByVendor(array) {
20452157 return array.reduce((acc, curr) => {
20462158 const vendor = curr.info.developer;
@@ -2191,7 +2303,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
21912303 const useLogprobs = !!power_user.request_token_probabilities;
21922304 const canMultiSwipe = oai_settings.n > 1 && !isContinue && !isImpersonate && !isQuiet && (isOAI || isAzureOpenAI || isCustom || isXAI || isAimlapi || isMoonshot);
21932305
21942306 const logitBiasSources = [chat_completion_sources.OPENAI, chat_completion_sources.AZURE_OPENAI, chat_completion_sources.OPENROUTER, chat_completion_sources.ELECTRONHUB, chat_completion_sources.CUSTOM];
21952307 if (oai_settings.bias_preset_selected
21962308 && logitBiasSources.includes(oai_settings.chat_completion_source)
21972309 && Array.isArray(oai_settings.bias_presets[oai_settings.bias_preset_selected])
@@ -3523,6 +3635,8 @@ function loadOpenAISettings(data, settings) {
35233635 oai_settings.perplexity_model = settings.perplexity_model ?? default_settings.perplexity_model;
35243636 oai_settings.groq_model = settings.groq_model ?? default_settings.groq_model;
35253637 oai_settings.electronhub_model = settings.electronhub_model ?? default_settings.electronhub_model;
3638+ oai_settings.electronhub_sort_models = settings.electronhub_sort_models ?? default_settings.electronhub_sort_models;
3639+ oai_settings.electronhub_group_models = settings.electronhub_group_models ?? default_settings.electronhub_group_models;
35263640 oai_settings.nanogpt_model = settings.nanogpt_model ?? default_settings.nanogpt_model;
35273641 oai_settings.deepseek_model = settings.deepseek_model ?? default_settings.deepseek_model;
35283642 oai_settings.aimlapi_model = settings.aimlapi_model ?? default_settings.aimlapi_model;
@@ -3666,6 +3780,8 @@ function loadOpenAISettings(data, settings) {
36663780 $('#openrouter_allow_fallbacks').prop('checked', oai_settings.openrouter_allow_fallbacks);
36673781 $('#openrouter_providers_chat').val(oai_settings.openrouter_providers).trigger('change');
36683782 $('#openrouter_middleout').val(oai_settings.openrouter_middleout);
3783+ $('#electronhub_sort_models').val(oai_settings.electronhub_sort_models);
3784+ $('#electronhub_group_models').prop('checked', oai_settings.electronhub_group_models);
36693785 $('#squash_system_messages').prop('checked', oai_settings.squash_system_messages);
36703786 $('#continue_prefill').prop('checked', oai_settings.continue_prefill);
36713787 $('#openai_function_calling').prop('checked', oai_settings.function_calling);
@@ -3928,6 +4044,8 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
39284044 pollinations_model: settings.pollinations_model,
39294045 aimlapi_model: settings.aimlapi_model,
39304046 electronhub_model: settings.electronhub_model,
4047+ electronhub_sort_models: settings.electronhub_sort_models,
4048+ electronhub_group_models: settings.electronhub_group_models,
39314049 moonshot_model: settings.moonshot_model,
39324050 fireworks_model: settings.fireworks_model,
39334051 cometapi_model: settings.cometapi_model,
@@ -5093,6 +5211,8 @@ async function onModelChange() {
50935211 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
50945212 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
50955213 $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
5214+
5215+ calculateElectronHubCost();
50965216 }
50975217
50985218 if (oai_settings.chat_completion_source === chat_completion_sources.NANOGPT) {
@@ -5220,6 +5340,10 @@ async function onOpenrouterModelSortChange() {
52205340 await getStatusOpen();
52215341}
52225342
5343+async function onElectronHubModelSortChange() {
5344+ await getStatusOpen();
5345+}
5346+
52235347async function onNewPresetClick() {
52245348 const name = await Popup.show.input(t`Preset name:`, t`Hint: Use a character/group name to bind preset to a specific chat.`, oai_settings.preset_settings_openai);
52255349
@@ -6117,12 +6241,14 @@ export function initOpenAI() {
61176241 oai_settings.openai_max_context = Number($(this).val());
61186242 $('#openai_max_context_counter').val(`${$(this).val()}`);
61196243 calculateOpenRouterCost();
6244+ calculateElectronHubCost();
61206245 saveSettingsDebounced();
61216246 });
61226247
61236248 $('#openai_max_tokens').on('input', function () {
61246249 oai_settings.openai_max_tokens = Number($(this).val());
61256250 calculateOpenRouterCost();
6251+ calculateElectronHubCost();
61266252 saveSettingsDebounced();
61276253 });
61286254
@@ -6326,6 +6452,16 @@ export function initOpenAI() {
63266452 saveSettingsDebounced();
63276453 });
63286454
6455+ $('#electronhub_sort_models').on('input', function () {
6456+ oai_settings.electronhub_sort_models = String($(this).val());
6457+ saveSettingsDebounced();
6458+ });
6459+
6460+ $('#electronhub_group_models').on('input', function () {
6461+ oai_settings.electronhub_group_models = !!$(this).prop('checked');
6462+ saveSettingsDebounced();
6463+ });
6464+
63296465 $('#squash_system_messages').on('input', function () {
63306466 oai_settings.squash_system_messages = !!$(this).prop('checked');
63316467 saveSettingsDebounced();
@@ -6503,6 +6639,14 @@ export function initOpenAI() {
65036639 width: '100%',
65046640 templateResult: getAimlapiModelTemplate,
65056641 });
6642+ $('#model_electronhub_select').select2({
6643+ placeholder: t`Select a model`,
6644+ searchInputPlaceholder: t`Search models...`,
6645+ searchInputCssClass: 'text_pole',
6646+ width: '100%',
6647+ templateResult: getElectronHubModelTemplate,
6648+ matcher: textValueMatcher,
6649+ });
65066650 $('#completion_prompt_manager_popup_entry_form_injection_trigger').select2({
65076651 placeholder: t`All types (default)`,
65086652 width: '100%',
@@ -6549,6 +6693,8 @@ export function initOpenAI() {
65496693 $('#model_openrouter_select').on('change', onModelChange);
65506694 $('#openrouter_group_models').on('change', onOpenrouterModelSortChange);
65516695 $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange);
6696+ $('#electronhub_group_models').on('change', onElectronHubModelSortChange);
6697+ $('#electronhub_sort_models').on('change', onElectronHubModelSortChange);
65526698 $('#model_ai21_select').on('change', onModelChange);
65536699 $('#model_mistralai_select').on('change', onModelChange);
65546700 $('#model_cohere_select').on('change', onModelChange);
public/scripts/tokenizers.js+51 -0
@@ -648,6 +648,57 @@ export function getTokenizerModel() {
648648 }
649649 }
650650
651+ if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB && oai_settings.electronhub_model) {
652+ if (oai_settings.electronhub_model.includes('gpt-4o') || oai_settings.electronhub_model.includes('gpt-5')) {
653+ return gpt4oTokenizer;
654+ }
655+ else if (oai_settings.electronhub_model.includes('gpt-4.1') || oai_settings.electronhub_model.includes('gpt-4.5')) {
656+ return gpt4oTokenizer;
657+ }
658+ else if (oai_settings.electronhub_model.includes('gpt-4')) {
659+ return gpt4Tokenizer;
660+ }
661+ else if (oai_settings.electronhub_model.includes('gpt-3.5-turbo')) {
662+ return turboTokenizer;
663+ }
664+ else if (oai_settings.electronhub_model.includes('claude')) {
665+ return claudeTokenizer;
666+ }
667+ else if (oai_settings.electronhub_model.includes('jamba')) {
668+ return jambaTokenizer;
669+ }
670+ else if (oai_settings.electronhub_model.includes('deepseek') || oai_settings.electronhub_model.includes('sonar-reasoning') || oai_settings.electronhub_model.includes('r1')) {
671+ return deepseekTokenizer;
672+ }
673+ else if (oai_settings.electronhub_model.includes('qwen')) {
674+ return qwen2Tokenizer;
675+ }
676+ else if (oai_settings.electronhub_model.includes('gemma')) {
677+ return gemmaTokenizer;
678+ }
679+ else if (oai_settings.electronhub_model.includes('mistral')) {
680+ return mistralTokenizer;
681+ }
682+ else if (oai_settings.electronhub_model.includes('yi')) {
683+ return yiTokenizer;
684+ }
685+ else if (oai_settings.electronhub_model.includes('llama3') || oai_settings.electronhub_model.includes('llama-3') || oai_settings.electronhub_model.startsWith('l3')) {
686+ return llama3Tokenizer;
687+ }
688+ else if (oai_settings.electronhub_model.includes('llama')) {
689+ return llamaTokenizer;
690+ }
691+ else if (oai_settings.electronhub_model.includes('command-a')) {
692+ return commandATokenizer;
693+ }
694+ else if (oai_settings.electronhub_model.includes('command-r')) {
695+ return commandRTokenizer;
696+ }
697+ else if (oai_settings.electronhub_model.includes('nemo')) {
698+ return nemoTokenizer;
699+ }
700+ }
701+
651702 if (oai_settings.chat_completion_source == chat_completion_sources.COHERE) {
652703 if (oai_settings.cohere_model.includes('command-a')) {
653704 return commandATokenizer;
public/style.css+3 -2
@@ -2940,6 +2940,7 @@ select option:not(:checked) {
29402940 text-align: center;
29412941}
29422942
2943+.menu_button[disabled],
29432944.menu_button.disabled {
29442945 filter: brightness(75%) grayscale(1);
29452946 opacity: 0.5;
@@ -3675,8 +3676,8 @@ grammarly-extension {
36753676 min-width: calc(1.25em + 12px);
36763677}
36773678
36783679.menu_button:not(.disabled):not([disabled]):hover,
36793680.menu_button:not(.disabled):not([disabled]).active {
36803681 background-color: var(--white30a);
36813682}
36823683
src/endpoints/backends/chat-completions.js+1 -0
@@ -1255,6 +1255,7 @@ async function sendElectronHubRequest(request, response) {
12551255 'frequency_penalty': request.body.frequency_penalty,
12561256 'top_p': request.body.top_p,
12571257 'top_k': request.body.top_k,
1258+ 'logit_bias': request.body.logit_bias,
12581259 'seed': request.body.seed,
12591260 ...bodyParams,
12601261 };
src/endpoints/openai.js+98 -0
@@ -325,6 +325,104 @@ router.post('/generate-voice', async (request, response) => {
325325 }
326326});
327327
328+// ElectronHub TTS proxy
329+router.post('/electronhub/generate-voice', async (request, response) => {
330+ try {
331+ const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
332+
333+ if (!key) {
334+ console.warn('No ElectronHub key found');
335+ return response.sendStatus(400);
336+ }
337+
338+ const requestBody = {
339+ input: request.body.input,
340+ voice: request.body.voice,
341+ speed: request.body.speed ?? 1,
342+ temperature: request.body.temperature ?? undefined,
343+ model: request.body.model || 'tts-1',
344+ response_format: 'mp3',
345+ };
346+
347+ // Optional provider-specific params
348+ if (request.body.instructions) requestBody.instructions = request.body.instructions;
349+ if (request.body.speaker_transcript) requestBody.speaker_transcript = request.body.speaker_transcript;
350+ if (Number.isFinite(request.body.cfg_scale)) requestBody.cfg_scale = Number(request.body.cfg_scale);
351+ if (Number.isFinite(request.body.cfg_filter_top_k)) requestBody.cfg_filter_top_k = Number(request.body.cfg_filter_top_k);
352+ if (Number.isFinite(request.body.speech_rate)) requestBody.speech_rate = Number(request.body.speech_rate);
353+ if (Number.isFinite(request.body.pitch_adjustment)) requestBody.pitch_adjustment = Number(request.body.pitch_adjustment);
354+ if (request.body.emotional_style) requestBody.emotional_style = request.body.emotional_style;
355+
356+ // Handle dynamic parameters sent from the frontend
357+ const knownParams = new Set(Object.keys(requestBody));
358+ for (const key in request.body) {
359+ if (!knownParams.has(key) && request.body[key] !== undefined) {
360+ requestBody[key] = request.body[key];
361+ }
362+ }
363+
364+ // Clean undefineds
365+ Object.keys(requestBody).forEach(k => requestBody[k] === undefined && delete requestBody[k]);
366+
367+ console.debug('ElectronHub TTS request', requestBody);
368+
369+ const result = await fetch('https://api.electronhub.ai/v1/audio/speech', {
370+ method: 'POST',
371+ headers: {
372+ 'Content-Type': 'application/json',
373+ Authorization: `Bearer ${key}`,
374+ },
375+ body: JSON.stringify(requestBody),
376+ });
377+
378+ if (!result.ok) {
379+ const text = await result.text();
380+ console.warn('ElectronHub TTS request failed', result.statusText, text);
381+ return response.status(500).send(text);
382+ }
383+
384+ const contentType = result.headers.get('content-type') || 'audio/mpeg';
385+ const buffer = await result.arrayBuffer();
386+ response.setHeader('Content-Type', contentType);
387+ return response.send(Buffer.from(buffer));
388+ } catch (error) {
389+ console.error('ElectronHub TTS generation failed', error);
390+ response.status(500).send('Internal server error');
391+ }
392+});
393+
394+// ElectronHub model list
395+router.post('/electronhub/models', async (request, response) => {
396+ try {
397+ const key = readSecret(request.user.directories, SECRET_KEYS.ELECTRONHUB);
398+
399+ if (!key) {
400+ console.warn('No ElectronHub key found');
401+ return response.sendStatus(400);
402+ }
403+
404+ const result = await fetch('https://api.electronhub.ai/v1/models', {
405+ method: 'GET',
406+ headers: {
407+ Authorization: `Bearer ${key}`,
408+ },
409+ });
410+
411+ if (!result.ok) {
412+ const text = await result.text();
413+ console.warn('ElectronHub models request failed', result.statusText, text);
414+ return response.status(500).send(text);
415+ }
416+
417+ const data = await result.json();
418+ const models = data && Array.isArray(data['data']) ? data['data'] : [];
419+ return response.json(models);
420+ } catch (error) {
421+ console.error('ElectronHub models fetch failed', error);
422+ response.status(500).send('Internal server error');
423+ }
424+});
425+
328426router.post('/generate-image', async (request, response) => {
329427 try {
330428 const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);