feat: [Electron Hub] Support Vector Storage, Better searching for image engine (#4540) * feat: [Electron Hub] Add Vector Storage, Better searching for image model list * feat: [Electron Hub] Add quality parameter for Image Engine * fixed ESLint * Update public/scripts/extensions/vectors/index.js Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * small tweaks * Use default getOpenAIVector * Refactor and clean-up code * Move endpoint filtering logic to backend --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -1277,9 +1277,16 @@ async function validateComfyUrl() { | ||
| 1277 | 1277 | } |
| 1278 | 1278 | |
| 1279 | 1279 | async function onModelChange() { |
| 1280 | 1280 | extension_settings.sd.modelconst selectedModel = $('#sd_model').find(':selected').val(); |
| 1281 | + extension_settings.sd.model = selectedModel.val(); | |
| 1281 | 1282 | saveSettingsDebounced(); |
| 1282 | 1283 | |
| 1284 | + if (extension_settings.sd.model && extension_settings.sd.source === sources.electronhub) { | |
| 1285 | + const cachedModel = selectedModel.data('model'); | |
| 1286 | + const models = cachedModel ? [cachedModel] : await loadElectronHubModels(); | |
| 1287 | + ensureElectronHubQualitySelect(models); | |
| 1288 | + } | |
| 1289 | + | |
| 1283 | 1290 | const cloudSources = [ |
| 1284 | 1291 | sources.horde, |
| 1285 | 1292 | sources.novel, |
| @@ -1726,11 +1733,16 @@ async function loadModels() { | ||
| 1726 | 1733 | break; |
| 1727 | 1734 | } |
| 1728 | 1735 | |
| 1736 | + if (extension_settings.sd.source === sources.electronhub) { | |
| 1737 | + ensureElectronHubQualitySelect(models); | |
| 1738 | + } | |
| 1739 | + | |
| 1729 | 1740 | for (const model of models) { |
| 1730 | 1741 | const option = document.createElement('option'); |
| 1731 | 1742 | option.innerText = model.text; |
| 1732 | 1743 | option.value = model.value; |
| 1733 | 1744 | option.selected = model.value === extension_settings.sd.model; |
| 1745 | + $(option).data('model', model); | |
| 1734 | 1746 | $('#sd_model').append(option); |
| 1735 | 1747 | } |
| 1736 | 1748 | |
| @@ -1740,6 +1752,49 @@ async function loadModels() { | ||
| 1740 | 1752 | } |
| 1741 | 1753 | } |
| 1742 | 1754 | |
| 1755 | +/** | |
| 1756 | + * Ensure the Electron Hub quality select is populated based on the selected model. | |
| 1757 | + * @param {any[]} models Array of models | |
| 1758 | + */ | |
| 1759 | +function ensureElectronHubQualitySelect(models) { | |
| 1760 | + try { | |
| 1761 | + const modelId = String(extension_settings.sd.model || ''); | |
| 1762 | + if (!modelId) return; | |
| 1763 | + | |
| 1764 | + const model = Array.isArray(models) ? models.find(m => String(m?.id) === modelId) : undefined; | |
| 1765 | + const qualities = Array.isArray(model?.qualities) ? model.qualities : undefined; | |
| 1766 | + | |
| 1767 | + const $qualityRow = $('#sd_electronhub_quality_row'); | |
| 1768 | + const $select = $('#sd_electronhub_quality'); | |
| 1769 | + | |
| 1770 | + $qualityRow.toggle(!!qualities && qualities.length > 0); | |
| 1771 | + $select.empty(); | |
| 1772 | + | |
| 1773 | + if (!qualities || qualities.length === 0) { | |
| 1774 | + extension_settings.sd.electronhub_quality = undefined; | |
| 1775 | + saveSettingsDebounced(); | |
| 1776 | + return; | |
| 1777 | + } | |
| 1778 | + | |
| 1779 | + for (const q of qualities) { | |
| 1780 | + const opt = document.createElement('option'); | |
| 1781 | + opt.value = String(q); | |
| 1782 | + opt.textContent = String(q); | |
| 1783 | + opt.selected = String(q) === String(extension_settings.sd.electronhub_quality || ''); | |
| 1784 | + $select.append(opt); | |
| 1785 | + } | |
| 1786 | + | |
| 1787 | + if (!$select.val()) { | |
| 1788 | + const first = String(qualities[0]); | |
| 1789 | + extension_settings.sd.electronhub_quality = first; | |
| 1790 | + $select.val(first); | |
| 1791 | + saveSettingsDebounced(); | |
| 1792 | + } | |
| 1793 | + } catch (e) { | |
| 1794 | + console.error(e); | |
| 1795 | + } | |
| 1796 | +} | |
| 1797 | + | |
| 1743 | 1798 | async function loadStabilityModels() { |
| 1744 | 1799 | $('#sd_stability_key').toggleClass('success', !!secret_state[SECRET_KEYS.STABILITY]); |
| 1745 | 1800 | |
| @@ -1824,8 +1879,23 @@ async function loadElectronHubModels() { | ||
| 1824 | 1879 | headers: getRequestHeaders(), |
| 1825 | 1880 | }); |
| 1826 | 1881 | |
| 1882 | + function getModelName(model) { | |
| 1883 | + const name = String(model?.name || model?.id || ''); | |
| 1884 | + const premium = model?.premium_model ? ' | Premium' : ''; | |
| 1885 | + let price = 'Unknown'; | |
| 1886 | + if (model?.pricing?.type === 'per_image') { | |
| 1887 | + const coeff = Number(model.pricing.coefficient); | |
| 1888 | + if (!isNaN(coeff)) { | |
| 1889 | + price = `$${coeff}/image`; | |
| 1890 | + } | |
| 1891 | + } | |
| 1892 | + return `${name} | ${price}${premium}`; | |
| 1893 | + } | |
| 1894 | + | |
| 1827 | 1895 | if (result.ok) { |
| 1828 | - return await result.json(); | |
| 1896 | + /** @type {any[]} */ | |
| 1897 | + const data = await result.json(); | |
| 1898 | + return Array.isArray(data) ? data.map(m => ({ ...m, text: getModelName(m) })) : []; | |
| 1829 | 1899 | } |
| 1830 | 1900 | |
| 1831 | 1901 | return []; |
| @@ -3665,6 +3735,7 @@ async function generateElectronHubImage(prompt, signal) { | ||
| 3665 | 3735 | model: extension_settings.sd.model, |
| 3666 | 3736 | prompt: prompt, |
| 3667 | 3737 | size: size, |
| 3738 | + quality: String(extension_settings.sd.electronhub_quality || '').trim() || undefined, | |
| 3668 | 3739 | }), |
| 3669 | 3740 | }); |
| 3670 | 3741 | |
| @@ -4816,6 +4887,10 @@ jQuery(async () => { | ||
| 4816 | 4887 | extension_settings.sd.google_enhance = $(this).prop('checked'); |
| 4817 | 4888 | saveSettingsDebounced(); |
| 4818 | 4889 | }); |
| 4890 | + $('#sd_electronhub_quality').on('change', function () { | |
| 4891 | + extension_settings.sd.electronhub_quality = String($(this).val()); | |
| 4892 | + saveSettingsDebounced(); | |
| 4893 | + }); | |
| 4819 | 4894 | |
| 4820 | 4895 | if (!CSS.supports('field-sizing', 'content')) { |
| 4821 | 4896 | $('.sd_settings .inline-drawer-toggle').on('click', function () { |
| @@ -96,6 +96,12 @@ | ||
| 96 | 96 | </div> |
| 97 | 97 | <div data-sd-source="electronhub"> |
| 98 | 98 | <i data-i18n="Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.">Hint: Save an API key in the Electron Hub (Chat Completion) API settings to use it here.</i> |
| 99 | + <div class="flex-container" id="sd_electronhub_quality_row"> | |
| 100 | + <div class="flex1"> | |
| 101 | + <label for="sd_electronhub_quality" data-i18n="Image Quality">Image Quality</label> | |
| 102 | + <select id="sd_electronhub_quality"></select> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 99 | 105 | </div> |
| 100 | 106 | <div data-sd-source="nanogpt"> |
| 101 | 107 | <i data-i18n="Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.">Hint: Save an API key in the NanoGPT (Chat Completion) API settings to use it here.</i> |
| @@ -61,6 +61,7 @@ const settings = { | ||
| 61 | 61 | include_wi: false, |
| 62 | 62 | togetherai_model: 'togethercomputer/m2-bert-80M-32k-retrieval', |
| 63 | 63 | openai_model: 'text-embedding-ada-002', |
| 64 | + electronhub_model: 'text-embedding-3-small', | |
| 64 | 65 | cohere_model: 'embed-english-v3.0', |
| 65 | 66 | ollama_model: 'mxbai-embed-large', |
| 66 | 67 | ollama_keep: false, |
| @@ -771,6 +772,9 @@ function getVectorsRequestBody(args = {}) { | ||
| 771 | 772 | body.extrasUrl = extension_settings.apiUrl; |
| 772 | 773 | body.extrasKey = extension_settings.apiKey; |
| 773 | 774 | break; |
| 775 | + case 'electronhub': | |
| 776 | + body.model = extension_settings.vectors.electronhub_model; | |
| 777 | + break; | |
| 774 | 778 | case 'togetherai': |
| 775 | 779 | body.model = extension_settings.vectors.togetherai_model; |
| 776 | 780 | break; |
| @@ -889,6 +893,7 @@ async function insertVectorItems(collectionId, items) { | ||
| 889 | 893 | */ |
| 890 | 894 | function throwIfSourceInvalid() { |
| 891 | 895 | if (settings.source === 'openai' && !secret_state[SECRET_KEYS.OPENAI] || |
| 896 | + settings.source === 'electronhub' && !secret_state[SECRET_KEYS.ELECTRONHUB] || | |
| 892 | 897 | settings.source === 'palm' && !secret_state[SECRET_KEYS.MAKERSUITE] || |
| 893 | 898 | settings.source === 'vertexai' && !secret_state[SECRET_KEYS.VERTEXAI] && !secret_state[SECRET_KEYS.VERTEXAI_SERVICE_ACCOUNT] || |
| 894 | 899 | settings.source === 'mistral' && !secret_state[SECRET_KEYS.MISTRALAI] || |
| @@ -1101,6 +1106,7 @@ function toggleSettings() { | ||
| 1101 | 1106 | $('#vectors_world_info_settings').toggle(!!settings.enabled_world_info); |
| 1102 | 1107 | $('#together_vectorsModel').toggle(settings.source === 'togetherai'); |
| 1103 | 1108 | $('#openai_vectorsModel').toggle(settings.source === 'openai'); |
| 1109 | + $('#electronhub_vectorsModel').toggle(settings.source === 'electronhub'); | |
| 1104 | 1110 | $('#cohere_vectorsModel').toggle(settings.source === 'cohere'); |
| 1105 | 1111 | $('#ollama_vectorsModel').toggle(settings.source === 'ollama'); |
| 1106 | 1112 | $('#llamacpp_vectorsModel').toggle(settings.source === 'llamacpp'); |
| @@ -1110,9 +1116,53 @@ function toggleSettings() { | ||
| 1110 | 1116 | $('#koboldcpp_vectorsModel').toggle(settings.source === 'koboldcpp'); |
| 1111 | 1117 | $('#google_vectorsModel').toggle(settings.source === 'palm' || settings.source === 'vertexai'); |
| 1112 | 1118 | $('#vector_altEndpointUrl').toggle(vectorApiRequiresUrl.includes(settings.source)); |
| 1113 | 1119 | ifswitch (settings.source === 'webllm') { |
| 1120 | + case 'webllm': | |
| 1114 | 1121 | loadWebLlmModels(); |
| 1122 | + break; | |
| 1123 | + case 'electronhub': | |
| 1124 | + loadElectronHubModels(); | |
| 1125 | + break; | |
| 1126 | + } | |
| 1127 | +} | |
| 1128 | + | |
| 1129 | +async function loadElectronHubModels() { | |
| 1130 | + try { | |
| 1131 | + const response = await fetch('/api/openai/electronhub/models', { | |
| 1132 | + method: 'POST', | |
| 1133 | + headers: getRequestHeaders(), | |
| 1134 | + }); | |
| 1135 | + if (!response.ok) { | |
| 1136 | + throw new Error(`HTTP ${response.status}`); | |
| 1137 | + } | |
| 1138 | + /** @type {Array<any>} */ | |
| 1139 | + const data = await response.json(); | |
| 1140 | + // filter by embeddings endpoint | |
| 1141 | + const models = Array.isArray(data) ? data.filter(m => Array.isArray(m?.endpoints) && m.endpoints.includes('/v1/embeddings')) : []; | |
| 1142 | + populateElectronHubModelSelect(models); | |
| 1143 | + } catch (err) { | |
| 1144 | + console.warn('Electron Hub models fetch failed', err); | |
| 1145 | + populateElectronHubModelSelect([]); | |
| 1146 | + } | |
| 1147 | +} | |
| 1148 | + | |
| 1149 | +/** | |
| 1150 | + * Populates the Electron Hub model select element. | |
| 1151 | + * @param {{ id: string, name: string }[]} models Electron Hub models | |
| 1152 | + */ | |
| 1153 | +function populateElectronHubModelSelect(models) { | |
| 1154 | + const select = $('#vectors_electronhub_model'); | |
| 1155 | + select.empty(); | |
| 1156 | + for (const m of models) { | |
| 1157 | + const option = document.createElement('option'); | |
| 1158 | + option.value = m.id; | |
| 1159 | + option.text = m.name || m.id; | |
| 1160 | + select.append(option); | |
| 1161 | + } | |
| 1162 | + if (!settings.electronhub_model && models.length) { | |
| 1163 | + settings.electronhub_model = models[0].id; | |
| 1115 | 1164 | } |
| 1165 | + $('#vectors_electronhub_model').val(settings.electronhub_model); | |
| 1116 | 1166 | } |
| 1117 | 1167 | |
| 1118 | 1168 | /** |
| @@ -1513,6 +1563,11 @@ jQuery(async () => { | ||
| 1513 | 1563 | Object.assign(extension_settings.vectors, settings); |
| 1514 | 1564 | saveSettingsDebounced(); |
| 1515 | 1565 | }); |
| 1566 | + $('#vectors_electronhub_model').val(settings.electronhub_model).on('change', () => { | |
| 1567 | + settings.electronhub_model = String($('#vectors_electronhub_model').val()); | |
| 1568 | + Object.assign(extension_settings.vectors, settings); | |
| 1569 | + saveSettingsDebounced(); | |
| 1570 | + }); | |
| 1516 | 1571 | $('#vectors_cohere_model').val(settings.cohere_model).on('change', () => { |
| 1517 | 1572 | settings.cohere_model = String($('#vectors_cohere_model').val()); |
| 1518 | 1573 | Object.assign(extension_settings.vectors, settings); |
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | </label> |
| 12 | 12 | <select id="vectors_source" class="text_pole"> |
| 13 | 13 | <option value="cohere">Cohere</option> |
| 14 | + <option value="electronhub">Electron Hub</option> | |
| 14 | 15 | <option value="extras">Extras (deprecated)</option> |
| 15 | 16 | <option value="palm">Google AI Studio</option> |
| 16 | 17 | <option value="vertexai">Google Vertex AI</option> |
| @@ -26,6 +27,15 @@ | ||
| 26 | 27 | <option value="webllm" data-i18n="WebLLM Extension">WebLLM Extension</option> |
| 27 | 28 | </select> |
| 28 | 29 | </div> |
| 30 | + <div class="flex-container flexFlowColumn" id="electronhub_vectorsModel"> | |
| 31 | + <label for="vectors_electronhub_model" data-i18n="Vectorization Model"> | |
| 32 | + Vectorization Model | |
| 33 | + </label> | |
| 34 | + <select id="vectors_electronhub_model" class="text_pole"></select> | |
| 35 | + <i data-i18n="Hint: Set your Electron Hub API key in API Connections."> | |
| 36 | + Hint: Set your Electron Hub API key in API Connections. | |
| 37 | + </i> | |
| 38 | + </div> | |
| 29 | 39 | <div class="flex-container flexFlowColumn" id="vector_altEndpointUrl"> |
| 30 | 40 | <label class="checkbox_label" for="vector_altEndpointUrl_enabled" title="Enable secondary endpoint URL usage, instead of the main one."> |
| 31 | 41 | <input id="vector_altEndpointUrl_enabled" type="checkbox" class="checkbox"> |
| @@ -4820,7 +4820,7 @@ function getElectronHubMaxContext(model, isUnlocked) { | ||
| 4820 | 4820 | return modelInfo.tokens; |
| 4821 | 4821 | } |
| 4822 | 4822 | } |
| 4823 | 4823 | return max_8kmax_128k; |
| 4824 | 4824 | } |
| 4825 | 4825 | |
| 4826 | 4826 | /** |
| @@ -983,7 +983,15 @@ electronhub.post('/models', async (request, response) => { | ||
| 983 | 983 | |
| 984 | 984 | /** @type {any} */ |
| 985 | 985 | const data = await modelsResponse.json(); |
| 986 | - const models = data.data.filter(x => x.endpoints.includes('/v1/images/generations')).map(x => ({ value: x.id, text: x.name })); | |
| 986 | + | |
| 987 | + if (!Array.isArray(data?.data)) { | |
| 988 | + console.warn('Electron Hub returned invalid data.'); | |
| 989 | + return response.sendStatus(500); | |
| 990 | + } | |
| 991 | + | |
| 992 | + const models = data.data | |
| 993 | + .filter(x => x && Array.isArray(x.endpoints) && x.endpoints.includes('/v1/images/generations')) | |
| 994 | + .map(x => ({ ...x, value: x.id, text: x.name })); | |
| 987 | 995 | return response.send(models); |
| 988 | 996 | } catch (error) { |
| 989 | 997 | console.error(error); |
| @@ -1010,6 +1018,12 @@ electronhub.post('/generate', async (request, response) => { | ||
| 1010 | 1018 | bodyParams.size = request.body.size; |
| 1011 | 1019 | } |
| 1012 | 1020 | |
| 1021 | + if (request.body.quality) { | |
| 1022 | + bodyParams.quality = request.body.quality; | |
| 1023 | + } | |
| 1024 | + | |
| 1025 | + console.debug('Electron Hub request:', bodyParams); | |
| 1026 | + | |
| 1013 | 1027 | const result = await fetch('https://api.electronhub.ai/v1/images/generations', { |
| 1014 | 1028 | method: 'POST', |
| 1015 | 1029 | headers: { |
| @@ -34,6 +34,7 @@ const SOURCES = [ | ||
| 34 | 34 | 'webllm', |
| 35 | 35 | 'koboldcpp', |
| 36 | 36 | 'vertexai', |
| 37 | + 'electronhub', | |
| 37 | 38 | ]; |
| 38 | 39 | |
| 39 | 40 | /** |
| @@ -53,6 +54,8 @@ async function getVector(source, sourceSettings, text, isQuery, directories) { | ||
| 53 | 54 | case 'mistral': |
| 54 | 55 | case 'openai': |
| 55 | 56 | return getOpenAIVector(text, source, directories, sourceSettings.model); |
| 57 | + case 'electronhub': | |
| 58 | + return getOpenAIVector(text, source, directories, sourceSettings.model); | |
| 56 | 59 | case 'transformers': |
| 57 | 60 | return getTransformersVector(text); |
| 58 | 61 | case 'extras': |
| @@ -102,6 +105,9 @@ async function getBatchVector(source, sourceSettings, texts, isQuery, directorie | ||
| 102 | 105 | case 'openai': |
| 103 | 106 | results.push(...await getOpenAIBatchVector(batch, source, directories, sourceSettings.model)); |
| 104 | 107 | break; |
| 108 | + case 'electronhub': | |
| 109 | + results.push(...await getOpenAIBatchVector(batch, source, directories, sourceSettings.model)); | |
| 110 | + break; | |
| 105 | 111 | case 'transformers': |
| 106 | 112 | results.push(...await getTransformersBatchVector(batch)); |
| 107 | 113 | break; |
| @@ -156,6 +162,10 @@ function getSourceSettings(source, request) { | ||
| 156 | 162 | return { |
| 157 | 163 | model: String(request.body.model), |
| 158 | 164 | }; |
| 165 | + case 'electronhub': | |
| 166 | + return { | |
| 167 | + model: String(request.body.model || 'text-embedding-3-small'), | |
| 168 | + }; | |
| 159 | 169 | case 'cohere': |
| 160 | 170 | return { |
| 161 | 171 | model: String(request.body.model), |
| @@ -17,6 +17,11 @@ const SOURCES = { | ||
| 17 | 17 | url: 'api.openai.com', |
| 18 | 18 | model: 'text-embedding-ada-002', |
| 19 | 19 | }, |
| 20 | + 'electronhub': { | |
| 21 | + secretKey: SECRET_KEYS.ELECTRONHUB, | |
| 22 | + url: 'api.electronhub.ai', | |
| 23 | + model: 'text-embedding-3-small', | |
| 24 | + }, | |
| 20 | 25 | }; |
| 21 | 26 | |
| 22 | 27 | /** |