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

a0b2b740c941b5213371f4f70a96e6a15ebd3929

RossAscends <rossascendshamma@gmail.com>

28 files changed, +1077 -152Ignore whitespace
default/content/presets/openai/Default.json+3 -0
@@ -8,6 +8,9 @@
8 "openrouter_sort_models": "alphabetically",8 "openrouter_sort_models": "alphabetically",
9 "ai21_model": "jamba-large",9 "ai21_model": "jamba-large",
10 "mistralai_model": "mistral-large-latest",10 "mistralai_model": "mistral-large-latest",
11 "electronhub_model": "gpt-4o-mini",
12 "electronhub_sort_models": "alphabetically",
13 "electronhub_group_models": false,
11 "custom_model": "",14 "custom_model": "",
12 "custom_url": "",15 "custom_url": "",
13 "custom_include_body": "",16 "custom_include_body": "",
public/css/backgrounds.css+53 -27
@@ -88,6 +88,24 @@ body.reduced-motion #bg_custom {
88 gap: 5px;88 gap: 5px;
89}89}
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
91#bg-scrollable-content {109#bg-scrollable-content {
92 flex-grow: 1;110 flex-grow: 1;
93 overflow-y: auto;111 overflow-y: auto;
@@ -95,46 +113,43 @@ body.reduced-motion #bg_custom {
95 padding: 0 5px 15px;113 padding: 0 5px 15px;
96}114}
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
98#bg-filter {124#bg-filter {
99 font-size: calc(var(--mainFontSize) * 0.95);125 font-size: calc(var(--mainFontSize) * 0.95);
100}126}
101127
102/* Thumbnail Menu & Buttons */128/* Thumbnails */
129.bg_example:hover .BGSampleTitle {
130 opacity: 1;
131}
132
103.bg_example .mobile-only-menu-toggle {133.bg_example .mobile-only-menu-toggle {
104 display: none;134 display: none;
105}135}
106136
107.bg_example.flex-container {137.bg_example {
108 width: 30%;
109 max-width: 200px;
110 margin: 5px;
111 aspect-ratio: 16/9;
112 cursor: pointer;138 cursor: pointer;
113 box-shadow: 0 0 7px var(--black50a);139 box-shadow: 0 0 7px var(--black50a);
114
115 position: relative;140 position: relative;
116 overflow: hidden;141 overflow: hidden;
117 border-radius: 8px;142 border-radius: 8px;
118 border: 0px solid transparent;143 border: 0px solid transparent;
119 outline: 2px solid var(--SmartThemeBorderColor);144 outline: 2px solid var(--SmartThemeBorderColor);
120 outline-offset: -1px;145 outline-offset: -1px;
121}
122146
123.bg_example.flex-container:focus-visible {147 height: auto;
124 outline-offset: inherit;148 aspect-ratio: 16 / 9;
125}149}
126150
127.bg_example_img {151.bg_example:focus-visible {
128 position: absolute;152 outline-offset: inherit;
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;
138}153}
139154
140.bg_example .jg-menu {155.bg_example .jg-menu {
@@ -143,9 +158,8 @@ body.reduced-motion #bg_custom {
143 top: 2px;158 top: 2px;
144 right: 2px;159 right: 2px;
145 background-color: rgba(0, 0, 0, 0.5);160 background-color: rgba(0, 0, 0, 0.5);
146 border-radius: 8px;161 border-radius: 5px;
147 gap: 3px;162 padding: 3px 3px;
148 padding: 3px 5px;
149 z-index: 3;163 z-index: 3;
150 backdrop-filter: blur(4px);164 backdrop-filter: blur(4px);
151 border: 1px solid var(--SmartThemeBorderColor);165 border: 1px solid var(--SmartThemeBorderColor);
@@ -168,14 +182,14 @@ body.reduced-motion #bg_custom {
168182
169.bg_example .jg-button {183.bg_example .jg-button {
170 display: flex;184 display: flex;
171 width: 30px;185 width: 24px;
172 height: 30px;186 height: 24px;
173 align-items: center;187 align-items: center;
174 justify-content: center;188 justify-content: center;
175 color: white;189 color: white;
176 padding: 5px;190 padding: 5px;
177 font-size: 1.1em;191 font-size: 1.1em;
178 border-radius: 6px;192 border-radius: 5px;
179 transition: background-color var(--animation-duration) ease;193 transition: background-color var(--animation-duration) ease;
180}194}
181195
@@ -195,6 +209,18 @@ body.reduced-motion #bg_custom {
195 display: flex;209 display: flex;
196}210}
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
198.bg_example:not([custom="true"]) .jg-copy,224.bg_example:not([custom="true"]) .jg-copy,
199.bg_example[custom="true"] .jg-edit {225.bg_example[custom="true"] .jg-edit {
200 display: none;226 display: none;
public/css/mobile-styles.css+18 -4
@@ -30,6 +30,14 @@
30 display: none;30 display: none;
31 }31 }
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
33 .bg_button {41 .bg_button {
34 font-size: 15px;42 font-size: 15px;
35 }43 }
@@ -49,6 +57,11 @@
49 z-index: 4;57 z-index: 4;
50 }58 }
5159
60 .bg_example.mobile-menu-open .mobile-only-menu-toggle {
61 opacity: 0;
62 pointer-events: none;
63 }
64
52 .bg_example .mobile-only-menu-toggle {65 .bg_example .mobile-only-menu-toggle {
53 display: flex;66 display: flex;
54 align-items: center;67 align-items: center;
@@ -66,6 +79,11 @@
66 backdrop-filter: blur(2px);79 backdrop-filter: blur(2px);
67 }80 }
6881
82 .bg_example .jg-button {
83 width: 30px;
84 height: 30px;
85 }
86
69 #bg-header-controls {87 #bg-header-controls {
70 flex-wrap: wrap;88 flex-wrap: wrap;
71 row-gap: 10px;89 row-gap: 10px;
@@ -438,10 +456,6 @@
438 .horde_multiple_hint {456 .horde_multiple_hint {
439 display: none;457 display: none;
440 }458 }
441
442 .bg_list {
443 width: unset;
444 }
445}459}
446460
447/*landscape mode phones and ipads*/461/*landscape mode phones and ipads*/
public/index.html+59 -10
@@ -676,6 +676,9 @@
676 <div data-source="openrouter">676 <div data-source="openrouter">
677 <span data-i18n="Max prompt cost:">Max prompt cost:</span> <span id="openrouter_max_prompt_cost" data-i18n="Unknown">Unknown</span>677 <span data-i18n="Max prompt cost:">Max prompt cost:</span> <span id="openrouter_max_prompt_cost" data-i18n="Unknown">Unknown</span>
678 </div>678 </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>
679 <hr>682 <hr>
680 <div class="range-block">683 <div class="range-block">
681 <label for="stream_toggle" title="Enable OpenAI completion streaming" data-i18n="[title]Enable OpenAI completion streaming" class="checkbox_label widthFreeExpand">684 <label for="stream_toggle" title="Enable OpenAI completion streaming" data-i18n="[title]Enable OpenAI completion streaming" class="checkbox_label widthFreeExpand">
@@ -2123,7 +2126,7 @@
2123 </div>2126 </div>
2124 </div>2127 </div>
2125 </div>2128 </div>
2126 <div class="range-block m-t-1" data-source="openai,aimlapi,openrouter,custom,azure_openai">2129 <div class="range-block m-t-1" data-source="openai,aimlapi,openrouter,custom,electronhub,azure_openai">
2127 <div id="logit_bias_openai" class="range-block-title openai_restorable" data-i18n="Logit Bias">2130 <div id="logit_bias_openai" class="range-block-title openai_restorable" data-i18n="Logit Bias">
2128 Logit Bias2131 Logit Bias
2129 </div>2132 </div>
@@ -3061,7 +3064,7 @@
3061 <div class="marginTopBot5">3064 <div class="marginTopBot5">
3062 <div class="inline-drawer wide100p">3065 <div class="inline-drawer wide100p">
3063 <div class="inline-drawer-toggle inline-drawer-header">3066 <div class="inline-drawer-toggle inline-drawer-header">
3064 <b data-i18n="Model Order">OpenRouter Model Sorting</b>3067 <b data-i18n="OpenRouter Model Sorting">OpenRouter Model Sorting</b>
3065 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>3068 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
3066 </div>3069 </div>
3067 <div class="inline-drawer-content m-b-1">3070 <div class="inline-drawer-content m-b-1">
@@ -3452,6 +3455,9 @@
3452 </div>3455 </div>
3453 <div id="electronhub_form" data-source="electronhub">3456 <div id="electronhub_form" data-source="electronhub">
3454 <h4 data-i18n="Electron Hub API Key">Electron Hub API Key</h4>3457 <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>
3455 <div class="flex-container">3461 <div class="flex-container">
3456 <input id="api_key_electronhub" name="api_key_electronhub" class="text_pole flex1" value="" type="text" autocomplete="off">3462 <input id="api_key_electronhub" name="api_key_electronhub" class="text_pole flex1" value="" type="text" autocomplete="off">
3457 <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>3463 <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,10 +3465,43 @@
3459 <div data-for="api_key_electronhub" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">3465 <div data-for="api_key_electronhub" class="neutral_warning" data-i18n="For privacy reasons, your API key will be hidden after you click 'Connect'.">
3460 For privacy reasons, your API key will be hidden after you click 'Connect'.3466 For privacy reasons, your API key will be hidden after you click 'Connect'.
3461 </div>3467 </div>
3462 <h4 data-i18n="Electron Hub Model">Electron Hub Model</h4>3468 <div>
3463 <select id="model_electronhub_select">3469 <h4 data-i18n="Electron Hub Model">Electron Hub Model</h4>
3464 <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>3470 <select id="model_electronhub_select">
3465 </select>3471 <option value="" data-i18n="-- Connect to the API --">-- Connect to the API --</option>
3472 </select>
3473 </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>
3466 </div>3505 </div>
3467 <div id="nanogpt_form" data-source="nanogpt">3506 <div id="nanogpt_form" data-source="nanogpt">
3468 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>3507 <h4 data-i18n="NanoGPT API Key">NanoGPT API Key</h4>
@@ -5345,9 +5384,19 @@
5345 </div>5384 </div>
5346 </div>5385 </div>
5347 <div id="bg-scrollable-content">5386 <div id="bg-scrollable-content">
5348 <h3 data-i18n="System Backgrounds" class="wide100p textAlignCenter">5387 <div class="heading-container-with-controls">
5349 System Backgrounds5388 <h3 data-i18n="System Backgrounds" class="wide100p textAlignCenter heading-text">
5350 </h3>5389 System Backgrounds
5390 </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>
5351 <div id="bg_menu_content" class="bg_list">5400 <div id="bg_menu_content" class="bg_list">
5352 </div>5401 </div>
5353 <h3 data-i18n="Chat Backgrounds" class="wide100p textAlignCenter">5402 <h3 data-i18n="Chat Backgrounds" class="wide100p textAlignCenter">
@@ -7749,4 +7798,4 @@
7749 </script>7798 </script>
7750</body>7799</body>
77517800
7752</html>
7752 \ No newline at end of file \ No newline at end of file
7801</html>
public/locales/ar-sa.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "وحدة تحكم المطور في Anthropic",369 "Anthropic's developer console": "وحدة تحكم المطور في Anthropic",
370 "Claude Model": "نموذج Claude",370 "Claude Model": "نموذج Claude",
371 "Window AI Model": "نموذج Window AI",371 "Window AI Model": "نموذج Window AI",
372 "Model Order": "فرز نموذج OpenRouter",372 "OpenRouter Model Sorting": "فرز نموذج OpenRouter",
373 "Alphabetically": "أبجديا",373 "Alphabetically": "أبجديا",
374 "Price": "السعر (الأرخص)",374 "Price": "السعر (الأرخص)",
375 "Context Size": "حجم السياق",375 "Context Size": "حجم السياق",
public/locales/de-de.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "Anthropics Entwicklerkonsole",369 "Anthropic's developer console": "Anthropics Entwicklerkonsole",
370 "Claude Model": "Claude-Modell",370 "Claude Model": "Claude-Modell",
371 "Window AI Model": "Fenster AI-Modell",371 "Window AI Model": "Fenster AI-Modell",
372 "Model Order": "Sortierung des OpenRouter-Modells",372 "OpenRouter Model Sorting": "Sortierung des OpenRouter-Modells",
373 "Alphabetically": "Alphabetisch",373 "Alphabetically": "Alphabetisch",
374 "Price": "Preis (am günstigsten)",374 "Price": "Preis (am günstigsten)",
375 "Context Size": "Kontextgröße",375 "Context Size": "Kontextgröße",
public/locales/es-es.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "la consola de desarrolladores de Anthropic",369 "Anthropic's developer console": "la consola de desarrolladores de Anthropic",
370 "Claude Model": "Modelo de Claude",370 "Claude Model": "Modelo de Claude",
371 "Window AI Model": "Modelo de Window AI",371 "Window AI Model": "Modelo de Window AI",
372 "Model Order": "Clasificación de modelos de OpenRouter",372 "OpenRouter Model Sorting": "Clasificación de modelos de OpenRouter",
373 "Alphabetically": "Alfabéticamente",373 "Alphabetically": "Alfabéticamente",
374 "Price": "Precio (más barato)",374 "Price": "Precio (más barato)",
375 "Context Size": "Tamaño del contexto",375 "Context Size": "Tamaño del contexto",
public/locales/fr-fr.json+2 -2
@@ -348,7 +348,7 @@
348 "Anthropic's developer console": "Console de développement d'Anthropic",348 "Anthropic's developer console": "Console de développement d'Anthropic",
349 "Claude Model": "Modèle Claude",349 "Claude Model": "Modèle Claude",
350 "Window AI Model": "Modèle Window AI",350 "Window AI Model": "Modèle Window AI",
351 "Model Order": "Tri des modèles OpenRouter",351 "OpenRouter Model Sorting": "Tri des modèles OpenRouter",
352 "Alphabetically": "Alphabétiquement",352 "Alphabetically": "Alphabétiquement",
353 "Price": "Prix ​​(le moins cher)",353 "Price": "Prix ​​(le moins cher)",
354 "Context Size": "Taille du contexte",354 "Context Size": "Taille du contexte",
@@ -1566,7 +1566,7 @@
1566 "These files are available for all characters in the current chat.": "Ces fichiers sont disponibles pour tous les personnages du chat actuel.",1566 "These files are available for all characters in the current chat.": "Ces fichiers sont disponibles pour tous les personnages du chat actuel.",
1567 "Image Captioning": "Légende des images",1567 "Image Captioning": "Légende des images",
1568 "Local": "Local",1568 "Local": "Local",
1569 "Multimodal (OpenAI / Anthropic / llama / Google)": "Multimodal (OpenAI / Anthropic / llama / Google)",1569 "Multimodal": "Multimodal",
1570 "Extras": "Extras",1570 "Extras": "Extras",
1571 "Horde": "Horde",1571 "Horde": "Horde",
1572 "API": "API",1572 "API": "API",
public/locales/is-is.json+1 -1
@@ -367,7 +367,7 @@
367 "Anthropic's developer console": "Uppbyggingaraðilar Forritara stjórnborð",367 "Anthropic's developer console": "Uppbyggingaraðilar Forritara stjórnborð",
368 "Claude Model": "Claude módel",368 "Claude Model": "Claude módel",
369 "Window AI Model": "Vindauga AI módel",369 "Window AI Model": "Vindauga AI módel",
370 "Model Order": "OpenRouter líkanaflokkun",370 "OpenRouter Model Sorting": "OpenRouter líkanaflokkun",
371 "Alphabetically": "Stafrófsröð",371 "Alphabetically": "Stafrófsröð",
372 "Price": "Verð (ódýrast)",372 "Price": "Verð (ódýrast)",
373 "Context Size": "Samhengisstærð",373 "Context Size": "Samhengisstærð",
public/locales/it-it.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "Console dello sviluppatore di Anthropic",369 "Anthropic's developer console": "Console dello sviluppatore di Anthropic",
370 "Claude Model": "Modello Claude",370 "Claude Model": "Modello Claude",
371 "Window AI Model": "Modello AI di Window",371 "Window AI Model": "Modello AI di Window",
372 "Model Order": "Ordinamento dei modelli OpenRouter",372 "OpenRouter Model Sorting": "Ordinamento dei modelli OpenRouter",
373 "Alphabetically": "In ordine alfabetico",373 "Alphabetically": "In ordine alfabetico",
374 "Price": "Prezzo (il più economico)",374 "Price": "Prezzo (il più economico)",
375 "Context Size": "Dimensione del contesto",375 "Context Size": "Dimensione del contesto",
public/locales/ja-jp.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "Anthropicの開発者コンソール",369 "Anthropic's developer console": "Anthropicの開発者コンソール",
370 "Claude Model": "Claudeモデル",370 "Claude Model": "Claudeモデル",
371 "Window AI Model": "Window AIモデル",371 "Window AI Model": "Window AIモデル",
372 "Model Order": "OpenRouter モデルのソート",372 "OpenRouter Model Sorting": "OpenRouter モデルのソート",
373 "Alphabetically": "アルファベット順",373 "Alphabetically": "アルファベット順",
374 "Price": "価格(最安値)",374 "Price": "価格(最安値)",
375 "Context Size": "コンテキストサイズ",375 "Context Size": "コンテキストサイズ",
public/locales/ko-kr.json+1 -1
@@ -374,7 +374,7 @@
374 "Slack and Poe cookies will not work here, do not bother trying.": "Slack과 Poe 쿠키는 여기서 작동하지 않습니다. 시도하지 마세요.",374 "Slack and Poe cookies will not work here, do not bother trying.": "Slack과 Poe 쿠키는 여기서 작동하지 않습니다. 시도하지 마세요.",
375 "Claude Model": "Claude 모델",375 "Claude Model": "Claude 모델",
376 "Window AI Model": "Window AI 모델",376 "Window AI Model": "Window AI 모델",
377 "Model Order": "OpenRouter 모델 정렬",377 "OpenRouter Model Sorting": "OpenRouter 모델 정렬",
378 "Alphabetically": "알파벳순",378 "Alphabetically": "알파벳순",
379 "Price": "가격(가장 저렴)",379 "Price": "가격(가장 저렴)",
380 "Context Size": "컨텍스트 크기",380 "Context Size": "컨텍스트 크기",
public/locales/nl-nl.json+1 -1
@@ -367,7 +367,7 @@
367 "Anthropic's developer console": "Anthropic's ontwikkelaarsconsole",367 "Anthropic's developer console": "Anthropic's ontwikkelaarsconsole",
368 "Claude Model": "Claude-model",368 "Claude Model": "Claude-model",
369 "Window AI Model": "Window AI-model",369 "Window AI Model": "Window AI-model",
370 "Model Order": "Sorteren van OpenRouter-modellen",370 "OpenRouter Model Sorting": "Sorteren van OpenRouter-modellen",
371 "Alphabetically": "Alfabetisch",371 "Alphabetically": "Alfabetisch",
372 "Price": "Prijs (goedkoopste)",372 "Price": "Prijs (goedkoopste)",
373 "Context Size": "Contextgrootte",373 "Context Size": "Contextgrootte",
public/locales/pt-pt.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "console de desenvolvedor da Anthropic",369 "Anthropic's developer console": "console de desenvolvedor da Anthropic",
370 "Claude Model": "Modelo Claude",370 "Claude Model": "Modelo Claude",
371 "Window AI Model": "Modelo Window AI",371 "Window AI Model": "Modelo Window AI",
372 "Model Order": "Classificação de modelo OpenRouter",372 "OpenRouter Model Sorting": "Classificação de modelo OpenRouter",
373 "Alphabetically": "Alfabeticamente",373 "Alphabetically": "Alfabeticamente",
374 "Price": "Preço (mais barato)",374 "Price": "Preço (mais barato)",
375 "Context Size": "Tamanho do contexto",375 "Context Size": "Tamanho do contexto",
public/locales/ru-ru.json+1 -1
@@ -718,7 +718,7 @@
718 "Google Model": "Модель Google",718 "Google Model": "Модель Google",
719 "Cohere API Key": "Ключ от API Cohere",719 "Cohere API Key": "Ключ от API Cohere",
720 "Cohere Model": "Модель Cohere",720 "Cohere Model": "Модель Cohere",
721 "Model Order": "Сортировка моделей OpenRouter",721 "OpenRouter Model Sorting": "Сортировка моделей OpenRouter",
722 "Alphabetically": "По алфавиту",722 "Alphabetically": "По алфавиту",
723 "Price": "По цене (наиболее низкая)",723 "Price": "По цене (наиболее низкая)",
724 "Context Size": "По размеру контекста",724 "Context Size": "По размеру контекста",
public/locales/uk-ua.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "консолі розробника Anthropic",369 "Anthropic's developer console": "консолі розробника Anthropic",
370 "Claude Model": "Модель Claude",370 "Claude Model": "Модель Claude",
371 "Window AI Model": "Модель Window AI",371 "Window AI Model": "Модель Window AI",
372 "Model Order": "Сортування моделі OpenRouter",372 "OpenRouter Model Sorting": "Сортування моделі OpenRouter",
373 "Alphabetically": "За алфавітом",373 "Alphabetically": "За алфавітом",
374 "Price": "Ціна (найдешевша)",374 "Price": "Ціна (найдешевша)",
375 "Context Size": "Розмір контексту",375 "Context Size": "Розмір контексту",
public/locales/vi-vn.json+1 -1
@@ -369,7 +369,7 @@
369 "Anthropic's developer console": "developer console của Anthropic",369 "Anthropic's developer console": "developer console của Anthropic",
370 "Claude Model": "Model Claude",370 "Claude Model": "Model Claude",
371 "Window AI Model": "Model Window AI",371 "Window AI Model": "Model Window AI",
372 "Model Order": "Sắp xếp model OpenRouter",372 "OpenRouter Model Sorting": "Sắp xếp model OpenRouter",
373 "Alphabetically": "Theo thứ tự bảng chữ cái",373 "Alphabetically": "Theo thứ tự bảng chữ cái",
374 "Price": "Giá (rẻ nhất)",374 "Price": "Giá (rẻ nhất)",
375 "Context Size": "Kích thước bối cảnh",375 "Context Size": "Kích thước bối cảnh",
public/locales/zh-cn.json+2 -2
@@ -452,7 +452,7 @@
452 "Claude Model": "Claude 模型",452 "Claude Model": "Claude 模型",
453 "Allow fallback routes Description": "如果所选模型无法响应您的请求,则自动选择备用模型。",453 "Allow fallback routes Description": "如果所选模型无法响应您的请求,则自动选择备用模型。",
454 "Allow fallback models": "允许后备模型",454 "Allow fallback models": "允许后备模型",
455 "Model Order": "OpenRouter 模型顺序",455 "OpenRouter Model Sorting": "OpenRouter 模型顺序",
456 "Alphabetically": "按字母顺序",456 "Alphabetically": "按字母顺序",
457 "Price": "价格(最便宜)",457 "Price": "价格(最便宜)",
458 "Context Size": "上下文长度",458 "Context Size": "上下文长度",
@@ -1479,7 +1479,7 @@
1479 "Image Captioning": "图像描述",1479 "Image Captioning": "图像描述",
1480 "Source": "来源",1480 "Source": "来源",
1481 "Local": "本地",1481 "Local": "本地",
1482 "Multimodal (OpenAI / Anthropic / llama / Google)": "多模态(OpenAI / Anthropic / llama / Google)",1482 "Multimodal": "多模态",
1483 "Extras": "更多",1483 "Extras": "更多",
1484 "Horde": "Horde",1484 "Horde": "Horde",
1485 "API": "API",1485 "API": "API",
public/locales/zh-tw.json+2 -2
@@ -368,7 +368,7 @@
368 "Anthropic's developer console": "Anthropic 的開發者控制台",368 "Anthropic's developer console": "Anthropic 的開發者控制台",
369 "Claude Model": "Claude 模型",369 "Claude Model": "Claude 模型",
370 "Window AI Model": "Window AI 模型",370 "Window AI Model": "Window AI 模型",
371 "Model Order": "模型順序",371 "OpenRouter Model Sorting": "模型順序",
372 "Alphabetically": "按字母順序",372 "Alphabetically": "按字母順序",
373 "Price": "價格(最便宜的)",373 "Price": "價格(最便宜的)",
374 "Context Size": "上下文長度",374 "Context Size": "上下文長度",
@@ -1674,7 +1674,7 @@
1674 "Message Template": "訊息範本",1674 "Message Template": "訊息範本",
1675 "Model ID": "模型 ID",1675 "Model ID": "模型 ID",
1676 "mui_reset": "重設",1676 "mui_reset": "重設",
1677 "Multimodal (OpenAI / Anthropic / llama / Google)": "多模態(OpenAI/Anthropic/llama/Google)",1677 "Multimodal": "多模態",
1678 "must be set in Tabby's config.yml to switch models.": "須在 Tabby's config.yml 中設定以切換模型。",1678 "must be set in Tabby's config.yml to switch models.": "須在 Tabby's config.yml 中設定以切換模型。",
1679 "Names as Stop Strings": "將名稱用作停止字串",1679 "Names as Stop Strings": "將名稱用作停止字串",
1680 "Never": "從不",1680 "Never": "從不",
public/scripts/backgrounds.js+158 -79
@@ -3,7 +3,8 @@ import { chat_metadata, eventSource, event_types, generateQuietPrompt, getCurren
3import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';3import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
4import { SlashCommand } from './slash-commands/SlashCommand.js';4import { SlashCommand } from './slash-commands/SlashCommand.js';
5import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';5import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
6import { createThumbnail, flashHighlight, getBase64Async, stringFormat } from './utils.js';6import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce } from './utils.js';
7import { debounce_timeout } from './constants.js';
7import { t } from './i18n.js';8import { t } from './i18n.js';
8import { Popup } from './popup.js';9import { Popup } from './popup.js';
910
@@ -15,6 +16,11 @@ const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkY
15const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });16const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
16const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`;17const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`;
1718
19const THUMBNAIL_COLUMNS_MIN = 2;
20const THUMBNAIL_COLUMNS_MAX = 8;
21const THUMBNAIL_COLUMNS_DEFAULT_DESKTOP = 5;
22const THUMBNAIL_COLUMNS_DEFAULT_MOBILE = 3;
23
18/**24/**
19 * Storage for frontend-generated background thumbnails.25 * Storage for frontend-generated background thumbnails.
20 * This is used to store thumbnails for backgrounds that cannot be generated on the server.26 * This is used to store thumbnails for backgrounds that cannot be generated on the server.
@@ -45,6 +51,53 @@ export let background_settings = {
45 animation: false,51 animation: false,
46};52};
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 */
59function 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 */
90function 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
48export function loadBackgroundSettings(settings) {101export function loadBackgroundSettings(settings) {
49 let backgroundSettings = settings.background;102 let backgroundSettings = settings.background;
50 if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {103 if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {
@@ -56,6 +109,16 @@ export function loadBackgroundSettings(settings) {
56 if (!Object.hasOwn(backgroundSettings, 'animation')) {109 if (!Object.hasOwn(backgroundSettings, 'animation')) {
57 backgroundSettings.animation = false;110 backgroundSettings.animation = false;
58 }111 }
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
59 setBackground(backgroundSettings.name, backgroundSettings.url);122 setBackground(backgroundSettings.name, backgroundSettings.url);
60 setFittingClass(backgroundSettings.fitting);123 setFittingClass(backgroundSettings.fitting);
61 $('#background_fitting').val(backgroundSettings.fitting);124 $('#background_fitting').val(backgroundSettings.fitting);
@@ -75,7 +138,7 @@ async function forceSetBackground(backgroundInfo) {
75 list.push(bg);138 list.push(bg);
76 chat_metadata[LIST_METADATA_KEY] = list;139 chat_metadata[LIST_METADATA_KEY] = list;
77 saveMetadataDebounced();140 saveMetadataDebounced();
78 await getChatBackgroundsList();141 renderChatBackgrounds();
79 highlightNewBackground(bg);142 highlightNewBackground(bg);
80 highlightLockedBackground();143 highlightLockedBackground();
81}144}
@@ -88,28 +151,10 @@ async function onChatChanged() {
88 unsetCustomBackground();151 unsetCustomBackground();
89 }152 }
90153
91 await getChatBackgroundsList();154 renderChatBackgrounds();
92 highlightLockedBackground();155 highlightLockedBackground();
93}156}
94157
95async 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
113function getBackgroundPath(fileUrl) {158function getBackgroundPath(fileUrl) {
114 return `backgrounds/${encodeURIComponent(fileUrl)}`;159 return `backgrounds/${encodeURIComponent(fileUrl)}`;
115}160}
@@ -257,7 +302,7 @@ async function onCopyToSystemBackgroundClick(e) {
257 const index = list.indexOf(bgNames.oldBg);302 const index = list.indexOf(bgNames.oldBg);
258 list.splice(index, 1);303 list.splice(index, 1);
259 saveMetadataDebounced();304 saveMetadataDebounced();
260 await getChatBackgroundsList();305 renderChatBackgrounds();
261}306}
262307
263/**308/**
@@ -382,17 +427,21 @@ async function onDeleteBackgroundClick(e) {
382 list.splice(index, 1);427 list.splice(index, 1);
383 }428 }
384429
385 const siblingSelector = '.bg_example:not(#form_bg_download)';430 if (bg === background_settings.name) {
386 const nextBg = bgToDelete.next(siblingSelector);431 const siblingSelector = '.bg_example';
387 const prevBg = bgToDelete.prev(siblingSelector);432 const nextBg = bgToDelete.next(siblingSelector);
388 const anyBg = $(siblingSelector);433 const prevBg = bgToDelete.prev(siblingSelector);
389434
390 if (nextBg.length > 0) {435 if (nextBg.length > 0) {
391 nextBg.trigger('click');436 nextBg.trigger('click');
392 } else if (prevBg.length > 0) {437 } else if (prevBg.length > 0) {
393 prevBg.trigger('click');438 prevBg.trigger('click');
394 } else {439 } 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 }
396 }445 }
397446
398 bgToDelete.remove();447 bgToDelete.remove();
@@ -404,7 +453,7 @@ async function onDeleteBackgroundClick(e) {
404 }453 }
405454
406 if (isCustom) {455 if (isCustom) {
407 await getChatBackgroundsList();456 renderChatBackgrounds();
408 saveMetadataDebounced();457 saveMetadataDebounced();
409 }458 }
410 }459 }
@@ -445,6 +494,47 @@ async function autoBackgroundCommand() {
445 return '';494 return '';
446}495}
447496
497/**
498 * Renders the system backgrounds gallery.
499 * @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
500 */
501function 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 */
521function 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
448export async function getBackgrounds() {538export async function getBackgrounds() {
449 const response = await fetch('/api/backgrounds/all', {539 const response = await fetch('/api/backgrounds/all', {
450 method: 'POST',540 method: 'POST',
@@ -454,12 +544,7 @@ export async function getBackgrounds() {
454 if (response.ok) {544 if (response.ok) {
455 const { images, config } = await response.json();545 const { images, config } = await response.json();
456 Object.assign(THUMBNAIL_CONFIG, config);546 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();
463 }548 }
464}549}
465550
@@ -481,14 +566,19 @@ function activateLazyLoader() {
481 lazyLoadObserver = new IntersectionObserver((entries, observer) => {566 lazyLoadObserver = new IntersectionObserver((entries, observer) => {
482 entries.forEach(entry => {567 entries.forEach(entry => {
483 if (entry.target instanceof HTMLElement && entry.isIntersecting) {568 if (entry.target instanceof HTMLElement && entry.isIntersecting) {
484 const target = entry.target;569 const clipper = entry.target;
485 const bg = target.getAttribute('bgfile');570 const parentThumbnail = clipper.closest('.bg_example');
486 const isCustom = target.getAttribute('custom') === 'true';571
487 resolveImageUrl(bg, isCustom)572 if (parentThumbnail) {
488 .then(url => { target.style.backgroundImage = url; })573 const bg = parentThumbnail.getAttribute('bgfile');
489 .catch(() => { target.style.backgroundImage = PLACEHOLDER_IMAGE; });574 const isCustom = parentThumbnail.getAttribute('custom') === 'true';
490 target.classList.remove('lazy-load-background');575 resolveImageUrl(bg, isCustom)
491 observer.unobserve(target);576 .then(url => { clipper.style.backgroundImage = url; })
577 .catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; });
578 }
579
580 clipper.classList.remove('lazy-load-background');
581 observer.unobserve(clipper);
492 }582 }
493 });583 });
494 }, options);584 }, options);
@@ -529,28 +619,6 @@ async function resolveImageUrl(bg, isCustom) {
529 return `url("${thumbnailUrl}")`;619 return `url("${thumbnailUrl}")`;
530}620}
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 */
538async 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
554async function setBackground(bg, url) {622async function setBackground(bg, url) {
555 $('#bg1').css('background-image', url);623 $('#bg1').css('background-image', url);
556 background_settings.name = bg;624 background_settings.name = bg;
@@ -688,17 +756,17 @@ function setFittingClass(fitting) {
688}756}
689757
690function onBackgroundFilterInput() {758function onBackgroundFilterInput() {
691 const filterValue = String($(this).val()).toLowerCase();759 const filterValue = String($('#bg-filter').val()).toLowerCase();
692 $('#bg_menu_content > div').each(function () {760 $('#bg_menu_content > .bg_example, #bg_custom_content > .bg_example').each(function () {
693 const $bgContent = $(this);761 const $bg = $(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 }
699 });765 });
700}766}
701767
768const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
769
702export function initBackgrounds() {770export function initBackgrounds() {
703 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);771 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
704 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);772 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
@@ -715,6 +783,11 @@ export function initBackgrounds() {
715 $context.addClass('mobile-menu-open');783 $context.addClass('mobile-menu-open');
716 }784 }
717 })785 })
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 })
718 .off('click', '.jg-button').on('click', '.jg-button', function (e) {791 .off('click', '.jg-button').on('click', '.jg-button', function (e) {
719 e.stopPropagation();792 e.stopPropagation();
720 const action = $(this).data('action');793 const action = $(this).data('action');
@@ -738,9 +811,15 @@ export function initBackgrounds() {
738 }811 }
739 });812 });
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 });
741 $('#auto_background').on('click', autoBackgroundCommand);820 $('#auto_background').on('click', autoBackgroundCommand);
742 $('#add_bg_button').on('change', onBackgroundUploadSelected);821 $('#add_bg_button').on('change', onBackgroundUploadSelected);
743 $('#bg-filter').on('input', onBackgroundFilterInput);822 $('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput());
744 SlashCommandParser.addCommandObject(SlashCommand.fromProps({823 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
745 name: 'lockbg',824 name: 'lockbg',
746 callback: () => onLockBackgroundClick(new CustomEvent('click')),825 callback: () => onLockBackgroundClick(new CustomEvent('click')),
public/scripts/extensions/caption/settings.html+2 -2
@@ -9,7 +9,7 @@
9 <label for="caption_source" data-i18n="Source">Source</label>9 <label for="caption_source" data-i18n="Source">Source</label>
10 <select id="caption_source" class="text_pole">10 <select id="caption_source" class="text_pole">
11 <option value="local" data-i18n="Local">Local</option>11 <option value="local" data-i18n="Local">Local</option>
12 <option value="multimodal" data-i18n="Multimodal (OpenAI / Anthropic / llama / Google)">Multimodal (OpenAI / Anthropic / llama / Google)</option>12 <option value="multimodal" data-i18n="Multimodal">Multimodal</option>
13 <option value="extras" data-i18n="Extras">Extras (deprecated)</option>13 <option value="extras" data-i18n="Extras">Extras (deprecated)</option>
14 <option value="horde" data-i18n="Horde">Horde</option>14 <option value="horde" data-i18n="Horde">Horde</option>
15 </select>15 </select>
@@ -18,7 +18,7 @@
18 <label for="caption_multimodal_api" data-i18n="API">API</label>18 <label for="caption_multimodal_api" data-i18n="API">API</label>
19 <select id="caption_multimodal_api" class="flex1 text_pole">19 <select id="caption_multimodal_api" class="flex1 text_pole">
20 <option value="aimlapi">AI/ML API</option>20 <option value="aimlapi">AI/ML API</option>
21 <option value="anthropic">Anthropic</option>21 <option value="anthropic">Claude</option>
22 <option value="cohere">Cohere</option>22 <option value="cohere">Cohere</option>
23 <option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>23 <option value="custom" data-i18n="Custom (OpenAI-compatible)">Custom (OpenAI-compatible)</option>
24 <option value="electronhub">Electron Hub</option>24 <option value="electronhub">Electron Hub</option>
public/scripts/extensions/tts/electronhub.js+455 -0
@@ -0,0 +1,455 @@
1import { event_types, eventSource, getRequestHeaders } from '../../../script.js';
2import { SECRET_KEYS, secret_state } from '../../secrets.js';
3import { getPreviewString, saveTtsProviderSettings, initVoiceMap } from './index.js';
4
5export { ElectronHubTtsProvider };
6
7class 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';
33import { TtsWebuiProvider } from './tts-webui.js';33import { TtsWebuiProvider } from './tts-webui.js';
34import { PollinationsTtsProvider } from './pollinations.js';34import { PollinationsTtsProvider } from './pollinations.js';
35import { MiniMaxTtsProvider } from './minimax.js';35import { MiniMaxTtsProvider } from './minimax.js';
36import { ElectronHubTtsProvider } from './electronhub.js';
3637
37const UPDATE_INTERVAL = 1000;38const UPDATE_INTERVAL = 1000;
38const wrapper = new ModuleWorkerWrapper(moduleWorker);39const wrapper = new ModuleWorkerWrapper(moduleWorker);
@@ -123,6 +124,7 @@ const ttsProviders = {
123 'CosyVoice (Unofficial)': CosyVoiceProvider,124 'CosyVoice (Unofficial)': CosyVoiceProvider,
124 Edge: EdgeTtsProvider,125 Edge: EdgeTtsProvider,
125 ElevenLabs: ElevenLabsTtsProvider,126 ElevenLabs: ElevenLabsTtsProvider,
127 'Electron Hub': ElectronHubTtsProvider,
126 'Google Translate': GoogleTranslateTtsProvider,128 'Google Translate': GoogleTranslateTtsProvider,
127 'Google Gemini TTS': GoogleNativeTtsProvider,129 'Google Gemini TTS': GoogleNativeTtsProvider,
128 GSVI: GSVITtsProvider,130 GSVI: GSVITtsProvider,
public/scripts/openai.js+156 -10
@@ -276,6 +276,8 @@ export const settingsToUpdate = {
276 perplexity_model: ['#model_perplexity_select', 'perplexity_model', false, true],276 perplexity_model: ['#model_perplexity_select', 'perplexity_model', false, true],
277 groq_model: ['#model_groq_select', 'groq_model', false, true],277 groq_model: ['#model_groq_select', 'groq_model', false, true],
278 electronhub_model: ['#model_electronhub_select', 'electronhub_model', false, true],278 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],
279 nanogpt_model: ['#model_nanogpt_select', 'nanogpt_model', false, true],281 nanogpt_model: ['#model_nanogpt_select', 'nanogpt_model', false, true],
280 deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],282 deepseek_model: ['#model_deepseek_select', 'deepseek_model', false, true],
281 aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],283 aimlapi_model: ['#model_aimlapi_select', 'aimlapi_model', false, true],
@@ -379,6 +381,8 @@ const default_settings = {
379 perplexity_model: 'sonar-pro',381 perplexity_model: 'sonar-pro',
380 groq_model: 'llama-3.3-70b-versatile',382 groq_model: 'llama-3.3-70b-versatile',
381 electronhub_model: 'gpt-4o-mini',383 electronhub_model: 'gpt-4o-mini',
384 electronhub_sort_models: 'alphabetically',
385 electronhub_group_models: false,
382 nanogpt_model: 'gpt-4o-mini',386 nanogpt_model: 'gpt-4o-mini',
383 deepseek_model: 'deepseek-chat',387 deepseek_model: 'deepseek-chat',
384 aimlapi_model: 'gpt-4o-mini-2024-07-18',388 aimlapi_model: 'gpt-4o-mini-2024-07-18',
@@ -473,6 +477,8 @@ const oai_settings = {
473 perplexity_model: 'sonar-pro',477 perplexity_model: 'sonar-pro',
474 groq_model: 'llama-3.1-70b-versatile',478 groq_model: 'llama-3.1-70b-versatile',
475 electronhub_model: 'gpt-4o-mini',479 electronhub_model: 'gpt-4o-mini',
480 electronhub_sort_models: 'alphabetically',
481 electronhub_group_models: false,
476 nanogpt_model: 'gpt-4o-mini',482 nanogpt_model: 'gpt-4o-mini',
477 deepseek_model: 'deepseek-chat',483 deepseek_model: 'deepseek-chat',
478 aimlapi_model: 'gpt-4-turbo',484 aimlapi_model: 'gpt-4-turbo',
@@ -1710,6 +1716,59 @@ function calculateOpenRouterCost() {
1710 $('#openrouter_max_prompt_cost').text(cost);1716 $('#openrouter_max_prompt_cost').text(cost);
1711}1717}
17121718
1719function 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
1750function 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
1713function saveModelList(data) {1772function saveModelList(data) {
1714 model_list = data.map((model) => ({ ...model }));1773 model_list = data.map((model) => ({ ...model }));
1715 model_list.sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id));1774 model_list.sort((a, b) => a?.id && b?.id && a.id.localeCompare(b.id));
@@ -1803,16 +1862,14 @@ function saveModelList(data) {
1803 }1862 }
18041863
1805 if (oai_settings.chat_completion_source == chat_completion_sources.ELECTRONHUB) {1864 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
1806 $('#model_electronhub_select').empty();1869 $('#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
1817 const selectedModel = model_list.find(model => model.id === oai_settings.electronhub_model);1874 const selectedModel = model_list.find(model => model.id === oai_settings.electronhub_model);
1818 if (model_list.length > 0 && (!selectedModel || !oai_settings.electronhub_model)) {1875 if (model_list.length > 0 && (!selectedModel || !oai_settings.electronhub_model)) {
@@ -2041,6 +2098,61 @@ function openRouterGroupByVendor(array) {
2041 }, new Map());2098 }, new Map());
2042}2099}
20432100
2101function 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
2128function 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
2142function 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
2044function aimlapiGroupByVendor(array) {2156function aimlapiGroupByVendor(array) {
2045 return array.reduce((acc, curr) => {2157 return array.reduce((acc, curr) => {
2046 const vendor = curr.info.developer;2158 const vendor = curr.info.developer;
@@ -2191,7 +2303,7 @@ async function sendOpenAIRequest(type, messages, signal, { jsonSchema = null } =
2191 const useLogprobs = !!power_user.request_token_probabilities;2303 const useLogprobs = !!power_user.request_token_probabilities;
2192 const canMultiSwipe = oai_settings.n > 1 && !isContinue && !isImpersonate && !isQuiet && (isOAI || isAzureOpenAI || isCustom || isXAI || isAimlapi || isMoonshot);2304 const canMultiSwipe = oai_settings.n > 1 && !isContinue && !isImpersonate && !isQuiet && (isOAI || isAzureOpenAI || isCustom || isXAI || isAimlapi || isMoonshot);
21932305
2194 const logitBiasSources = [chat_completion_sources.OPENAI, chat_completion_sources.AZURE_OPENAI, chat_completion_sources.OPENROUTER, chat_completion_sources.CUSTOM];2306 const logitBiasSources = [chat_completion_sources.OPENAI, chat_completion_sources.AZURE_OPENAI, chat_completion_sources.OPENROUTER, chat_completion_sources.ELECTRONHUB, chat_completion_sources.CUSTOM];
2195 if (oai_settings.bias_preset_selected2307 if (oai_settings.bias_preset_selected
2196 && logitBiasSources.includes(oai_settings.chat_completion_source)2308 && logitBiasSources.includes(oai_settings.chat_completion_source)
2197 && Array.isArray(oai_settings.bias_presets[oai_settings.bias_preset_selected])2309 && Array.isArray(oai_settings.bias_presets[oai_settings.bias_preset_selected])
@@ -3523,6 +3635,8 @@ function loadOpenAISettings(data, settings) {
3523 oai_settings.perplexity_model = settings.perplexity_model ?? default_settings.perplexity_model;3635 oai_settings.perplexity_model = settings.perplexity_model ?? default_settings.perplexity_model;
3524 oai_settings.groq_model = settings.groq_model ?? default_settings.groq_model;3636 oai_settings.groq_model = settings.groq_model ?? default_settings.groq_model;
3525 oai_settings.electronhub_model = settings.electronhub_model ?? default_settings.electronhub_model;3637 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;
3526 oai_settings.nanogpt_model = settings.nanogpt_model ?? default_settings.nanogpt_model;3640 oai_settings.nanogpt_model = settings.nanogpt_model ?? default_settings.nanogpt_model;
3527 oai_settings.deepseek_model = settings.deepseek_model ?? default_settings.deepseek_model;3641 oai_settings.deepseek_model = settings.deepseek_model ?? default_settings.deepseek_model;
3528 oai_settings.aimlapi_model = settings.aimlapi_model ?? default_settings.aimlapi_model;3642 oai_settings.aimlapi_model = settings.aimlapi_model ?? default_settings.aimlapi_model;
@@ -3666,6 +3780,8 @@ function loadOpenAISettings(data, settings) {
3666 $('#openrouter_allow_fallbacks').prop('checked', oai_settings.openrouter_allow_fallbacks);3780 $('#openrouter_allow_fallbacks').prop('checked', oai_settings.openrouter_allow_fallbacks);
3667 $('#openrouter_providers_chat').val(oai_settings.openrouter_providers).trigger('change');3781 $('#openrouter_providers_chat').val(oai_settings.openrouter_providers).trigger('change');
3668 $('#openrouter_middleout').val(oai_settings.openrouter_middleout);3782 $('#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);
3669 $('#squash_system_messages').prop('checked', oai_settings.squash_system_messages);3785 $('#squash_system_messages').prop('checked', oai_settings.squash_system_messages);
3670 $('#continue_prefill').prop('checked', oai_settings.continue_prefill);3786 $('#continue_prefill').prop('checked', oai_settings.continue_prefill);
3671 $('#openai_function_calling').prop('checked', oai_settings.function_calling);3787 $('#openai_function_calling').prop('checked', oai_settings.function_calling);
@@ -3928,6 +4044,8 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
3928 pollinations_model: settings.pollinations_model,4044 pollinations_model: settings.pollinations_model,
3929 aimlapi_model: settings.aimlapi_model,4045 aimlapi_model: settings.aimlapi_model,
3930 electronhub_model: settings.electronhub_model,4046 electronhub_model: settings.electronhub_model,
4047 electronhub_sort_models: settings.electronhub_sort_models,
4048 electronhub_group_models: settings.electronhub_group_models,
3931 moonshot_model: settings.moonshot_model,4049 moonshot_model: settings.moonshot_model,
3932 fireworks_model: settings.fireworks_model,4050 fireworks_model: settings.fireworks_model,
3933 cometapi_model: settings.cometapi_model,4051 cometapi_model: settings.cometapi_model,
@@ -5093,6 +5211,8 @@ async function onModelChange() {
5093 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');5211 $('#openai_max_context').val(oai_settings.openai_max_context).trigger('input');
5094 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);5212 oai_settings.temp_openai = Math.min(oai_max_temp, oai_settings.temp_openai);
5095 $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');5213 $('#temp_openai').attr('max', oai_max_temp).val(oai_settings.temp_openai).trigger('input');
5214
5215 calculateElectronHubCost();
5096 }5216 }
50975217
5098 if (oai_settings.chat_completion_source === chat_completion_sources.NANOGPT) {5218 if (oai_settings.chat_completion_source === chat_completion_sources.NANOGPT) {
@@ -5220,6 +5340,10 @@ async function onOpenrouterModelSortChange() {
5220 await getStatusOpen();5340 await getStatusOpen();
5221}5341}
52225342
5343async function onElectronHubModelSortChange() {
5344 await getStatusOpen();
5345}
5346
5223async function onNewPresetClick() {5347async function onNewPresetClick() {
5224 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);5348 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() {
6117 oai_settings.openai_max_context = Number($(this).val());6241 oai_settings.openai_max_context = Number($(this).val());
6118 $('#openai_max_context_counter').val(`${$(this).val()}`);6242 $('#openai_max_context_counter').val(`${$(this).val()}`);
6119 calculateOpenRouterCost();6243 calculateOpenRouterCost();
6244 calculateElectronHubCost();
6120 saveSettingsDebounced();6245 saveSettingsDebounced();
6121 });6246 });
61226247
6123 $('#openai_max_tokens').on('input', function () {6248 $('#openai_max_tokens').on('input', function () {
6124 oai_settings.openai_max_tokens = Number($(this).val());6249 oai_settings.openai_max_tokens = Number($(this).val());
6125 calculateOpenRouterCost();6250 calculateOpenRouterCost();
6251 calculateElectronHubCost();
6126 saveSettingsDebounced();6252 saveSettingsDebounced();
6127 });6253 });
61286254
@@ -6326,6 +6452,16 @@ export function initOpenAI() {
6326 saveSettingsDebounced();6452 saveSettingsDebounced();
6327 });6453 });
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
6329 $('#squash_system_messages').on('input', function () {6465 $('#squash_system_messages').on('input', function () {
6330 oai_settings.squash_system_messages = !!$(this).prop('checked');6466 oai_settings.squash_system_messages = !!$(this).prop('checked');
6331 saveSettingsDebounced();6467 saveSettingsDebounced();
@@ -6503,6 +6639,14 @@ export function initOpenAI() {
6503 width: '100%',6639 width: '100%',
6504 templateResult: getAimlapiModelTemplate,6640 templateResult: getAimlapiModelTemplate,
6505 });6641 });
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 });
6506 $('#completion_prompt_manager_popup_entry_form_injection_trigger').select2({6650 $('#completion_prompt_manager_popup_entry_form_injection_trigger').select2({
6507 placeholder: t`All types (default)`,6651 placeholder: t`All types (default)`,
6508 width: '100%',6652 width: '100%',
@@ -6549,6 +6693,8 @@ export function initOpenAI() {
6549 $('#model_openrouter_select').on('change', onModelChange);6693 $('#model_openrouter_select').on('change', onModelChange);
6550 $('#openrouter_group_models').on('change', onOpenrouterModelSortChange);6694 $('#openrouter_group_models').on('change', onOpenrouterModelSortChange);
6551 $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange);6695 $('#openrouter_sort_models').on('change', onOpenrouterModelSortChange);
6696 $('#electronhub_group_models').on('change', onElectronHubModelSortChange);
6697 $('#electronhub_sort_models').on('change', onElectronHubModelSortChange);
6552 $('#model_ai21_select').on('change', onModelChange);6698 $('#model_ai21_select').on('change', onModelChange);
6553 $('#model_mistralai_select').on('change', onModelChange);6699 $('#model_mistralai_select').on('change', onModelChange);
6554 $('#model_cohere_select').on('change', onModelChange);6700 $('#model_cohere_select').on('change', onModelChange);
public/scripts/tokenizers.js+51 -0
@@ -648,6 +648,57 @@ export function getTokenizerModel() {
648 }648 }
649 }649 }
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
651 if (oai_settings.chat_completion_source == chat_completion_sources.COHERE) {702 if (oai_settings.chat_completion_source == chat_completion_sources.COHERE) {
652 if (oai_settings.cohere_model.includes('command-a')) {703 if (oai_settings.cohere_model.includes('command-a')) {
653 return commandATokenizer;704 return commandATokenizer;
public/style.css+3 -2
@@ -2940,6 +2940,7 @@ select option:not(:checked) {
2940 text-align: center;2940 text-align: center;
2941}2941}
29422942
2943.menu_button[disabled],
2943.menu_button.disabled {2944.menu_button.disabled {
2944 filter: brightness(75%) grayscale(1);2945 filter: brightness(75%) grayscale(1);
2945 opacity: 0.5;2946 opacity: 0.5;
@@ -3675,8 +3676,8 @@ grammarly-extension {
3675 min-width: calc(1.25em + 12px);3676 min-width: calc(1.25em + 12px);
3676}3677}
36773678
3678.menu_button:not(.disabled):hover,3679.menu_button:not(.disabled):not([disabled]):hover,
3679.menu_button:not(.disabled).active {3680.menu_button:not(.disabled):not([disabled]).active {
3680 background-color: var(--white30a);3681 background-color: var(--white30a);
3681}3682}
36823683
src/endpoints/backends/chat-completions.js+1 -0
@@ -1255,6 +1255,7 @@ async function sendElectronHubRequest(request, response) {
1255 'frequency_penalty': request.body.frequency_penalty,1255 'frequency_penalty': request.body.frequency_penalty,
1256 'top_p': request.body.top_p,1256 'top_p': request.body.top_p,
1257 'top_k': request.body.top_k,1257 'top_k': request.body.top_k,
1258 'logit_bias': request.body.logit_bias,
1258 'seed': request.body.seed,1259 'seed': request.body.seed,
1259 ...bodyParams,1260 ...bodyParams,
1260 };1261 };
src/endpoints/openai.js+98 -0
@@ -325,6 +325,104 @@ router.post('/generate-voice', async (request, response) => {
325 }325 }
326});326});
327327
328// ElectronHub TTS proxy
329router.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
395router.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
328router.post('/generate-image', async (request, response) => {426router.post('/generate-image', async (request, response) => {
329 try {427 try {
330 const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);428 const key = readSecret(request.user.directories, SECRET_KEYS.OPENAI);