Vectors WebLLM (#3631) * Add WebLLM support for vectorization * Load models when WebLLM extension installed * Consistency updated * Move checkWebLlm to initEngine * Refactor vector request handling to use getAdditionalArgs * Add error handling for unsupported WebLLM extension * Add prefix to error causes
Signed| @@ -1070,7 +1070,7 @@ export async function installExtension(url, global) { | ||
| 1070 | 1070 | toastr.success(t`Extension '${response.display_name}' by ${response.author} (version ${response.version}) has been installed successfully!`, t`Extension installation successful`); |
| 1071 | 1071 | console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`); |
| 1072 | 1072 | await loadExtensionSettings({}, false, false); |
| 1073 | 1073 | await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response); |
| 1074 | 1074 | } |
| 1075 | 1075 | |
| 1076 | 1076 | /** |
| @@ -19,6 +19,7 @@ import { | ||
| 19 | 19 | modules, |
| 20 | 20 | renderExtensionTemplateAsync, |
| 21 | 21 | doExtrasFetch, getApiUrl, |
| 22 | + openThirdPartyExtensionMenu, | |
| 22 | 23 | } from '../../extensions.js'; |
| 23 | 24 | import { collapseNewlines, registerDebugFunction } from '../../power-user.js'; |
| 24 | 25 | import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js'; |
| @@ -34,6 +35,7 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm | ||
| 34 | 35 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; |
| 35 | 36 | import { callGenericPopup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js'; |
| 36 | 37 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
| 38 | +import { WebLlmVectorProvider } from './webllm.js'; | |
| 37 | 39 | |
| 38 | 40 | /** |
| 39 | 41 | * @typedef {object} HashedMessage |
| @@ -60,6 +62,7 @@ const settings = { | ||
| 60 | 62 | ollama_model: 'mxbai-embed-large', |
| 61 | 63 | ollama_keep: false, |
| 62 | 64 | vllm_model: '', |
| 65 | + webllm_model: '', | |
| 63 | 66 | summarize: false, |
| 64 | 67 | summarize_sent: false, |
| 65 | 68 | summary_source: 'main', |
| @@ -103,7 +106,7 @@ const settings = { | ||
| 103 | 106 | }; |
| 104 | 107 | |
| 105 | 108 | const moduleWorker = new ModuleWorkerWrapper(synchronizeChat); |
| 106 | - | |
| 109 | +const webllmProvider = new WebLlmVectorProvider(); | |
| 107 | 110 | const cachedSummaries = new Map(); |
| 108 | 111 | |
| 109 | 112 | /** |
| @@ -373,6 +376,8 @@ async function synchronizeChat(batchSize = 5) { | ||
| 373 | 376 | return 'Vectorization Source Model is required, but not set.'; |
| 374 | 377 | case 'extras_module_missing': |
| 375 | 378 | return 'Extras API must provide an "embeddings" module.'; |
| 379 | + case 'webllm_not_supported': | |
| 380 | + return 'WebLLM extension is not installed or the model is not set.'; | |
| 376 | 381 | default: |
| 377 | 382 | return 'Check server console for more details'; |
| 378 | 383 | } |
| @@ -747,10 +752,11 @@ async function getQueryText(chat, initiator) { | ||
| 747 | 752 | |
| 748 | 753 | /** |
| 749 | 754 | * Gets common body parameters for vector requests. |
| 750 | 755 | * @returnsparam {object} args Additional arguments |
| 756 | + * @returns {object} Request body | |
| 751 | 757 | */ |
| 752 | 758 | function getVectorsRequestBody(args = {}) { |
| 753 | 759 | const body = Object.assign({}, args); |
| 754 | 760 | switch (settings.source) { |
| 755 | 761 | case 'extras': |
| 756 | 762 | body.extrasUrl = extension_settings.apiUrl; |
| @@ -777,6 +783,9 @@ function getVectorsRequestBody() { | ||
| 777 | 783 | body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.VLLM]; |
| 778 | 784 | body.model = extension_settings.vectors.vllm_model; |
| 779 | 785 | break; |
| 786 | + case 'webllm': | |
| 787 | + body.model = extension_settings.vectors.webllm_model; | |
| 788 | + break; | |
| 780 | 789 | default: |
| 781 | 790 | break; |
| 782 | 791 | } |
| @@ -784,6 +793,21 @@ function getVectorsRequestBody() { | ||
| 784 | 793 | } |
| 785 | 794 | |
| 786 | 795 | /** |
| 796 | + * Gets additional arguments for vector requests. | |
| 797 | + * @param {string[]} items Items to embed | |
| 798 | + * @returns {Promise<object>} Additional arguments | |
| 799 | + */ | |
| 800 | +async function getAdditionalArgs(items) { | |
| 801 | + const args = {}; | |
| 802 | + switch (settings.source) { | |
| 803 | + case 'webllm': | |
| 804 | + args.embeddings = await createWebLlmEmbeddings(items); | |
| 805 | + break; | |
| 806 | + } | |
| 807 | + return args; | |
| 808 | +} | |
| 809 | + | |
| 810 | +/** | |
| 787 | 811 | * Gets the saved hashes for a collection |
| 788 | 812 | * @param {string} collectionId |
| 789 | 813 | * @returns {Promise<number[]>} Saved hashes |
| @@ -816,11 +840,12 @@ async function getSavedHashes(collectionId) { | ||
| 816 | 840 | async function insertVectorItems(collectionId, items) { |
| 817 | 841 | throwIfSourceInvalid(); |
| 818 | 842 | |
| 843 | + const args = await getAdditionalArgs(items.map(x => x.text)); | |
| 819 | 844 | const response = await fetch('/api/vector/insert', { |
| 820 | 845 | method: 'POST', |
| 821 | 846 | headers: getRequestHeaders(), |
| 822 | 847 | body: JSON.stringify({ |
| 823 | 848 | ...getVectorsRequestBody(args), |
| 824 | 849 | collectionId: collectionId, |
| 825 | 850 | items: items, |
| 826 | 851 | source: settings.source, |
| @@ -858,6 +883,10 @@ function throwIfSourceInvalid() { | ||
| 858 | 883 | if (settings.source === 'extras' && !modules.includes('embeddings')) { |
| 859 | 884 | throw new Error('Vectors: Embeddings module missing', { cause: 'extras_module_missing' }); |
| 860 | 885 | } |
| 886 | + | |
| 887 | + if (settings.source === 'webllm' && (!isWebLlmSupported() || !settings.webllm_model)) { | |
| 888 | + throw new Error('Vectors: WebLLM is not supported', { cause: 'webllm_not_supported' }); | |
| 889 | + } | |
| 861 | 890 | } |
| 862 | 891 | |
| 863 | 892 | /** |
| @@ -890,11 +919,12 @@ async function deleteVectorItems(collectionId, hashes) { | ||
| 890 | 919 | * @returns {Promise<{ hashes: number[], metadata: object[]}>} - Hashes of the results |
| 891 | 920 | */ |
| 892 | 921 | async function queryCollection(collectionId, searchText, topK) { |
| 922 | + const args = await getAdditionalArgs([searchText]); | |
| 893 | 923 | const response = await fetch('/api/vector/query', { |
| 894 | 924 | method: 'POST', |
| 895 | 925 | headers: getRequestHeaders(), |
| 896 | 926 | body: JSON.stringify({ |
| 897 | 927 | ...getVectorsRequestBody(args), |
| 898 | 928 | collectionId: collectionId, |
| 899 | 929 | searchText: searchText, |
| 900 | 930 | topK: topK, |
| @@ -919,11 +949,12 @@ async function queryCollection(collectionId, searchText, topK) { | ||
| 919 | 949 | * @returns {Promise<Record<string, { hashes: number[], metadata: object[] }>>} - Results mapped to collection IDs |
| 920 | 950 | */ |
| 921 | 951 | async function queryMultipleCollections(collectionIds, searchText, topK, threshold) { |
| 952 | + const args = await getAdditionalArgs([searchText]); | |
| 922 | 953 | const response = await fetch('/api/vector/query-multi', { |
| 923 | 954 | method: 'POST', |
| 924 | 955 | headers: getRequestHeaders(), |
| 925 | 956 | body: JSON.stringify({ |
| 926 | 957 | ...getVectorsRequestBody(args), |
| 927 | 958 | collectionIds: collectionIds, |
| 928 | 959 | searchText: searchText, |
| 929 | 960 | topK: topK, |
| @@ -1039,6 +1070,72 @@ function toggleSettings() { | ||
| 1039 | 1070 | $('#llamacpp_vectorsModel').toggle(settings.source === 'llamacpp'); |
| 1040 | 1071 | $('#vllm_vectorsModel').toggle(settings.source === 'vllm'); |
| 1041 | 1072 | $('#nomicai_apiKey').toggle(settings.source === 'nomicai'); |
| 1073 | + $('#webllm_vectorsModel').toggle(settings.source === 'webllm'); | |
| 1074 | + if (settings.source === 'webllm') { | |
| 1075 | + loadWebLlmModels(); | |
| 1076 | + } | |
| 1077 | +} | |
| 1078 | + | |
| 1079 | +/** | |
| 1080 | + * Executes a function with WebLLM error handling. | |
| 1081 | + * @param {function(): Promise<T>} func Function to execute | |
| 1082 | + * @returns {Promise<T>} | |
| 1083 | + * @template T | |
| 1084 | + */ | |
| 1085 | +async function executeWithWebLlmErrorHandling(func) { | |
| 1086 | + try { | |
| 1087 | + return await func(); | |
| 1088 | + } catch (error) { | |
| 1089 | + console.log('Vectors: Failed to load WebLLM models', error); | |
| 1090 | + if (!(error instanceof Error)) { | |
| 1091 | + return; | |
| 1092 | + } | |
| 1093 | + switch (error.cause) { | |
| 1094 | + case 'webllm-not-available': | |
| 1095 | + toastr.warning('WebLLM is not available. Please install the extension.', 'WebLLM not installed'); | |
| 1096 | + break; | |
| 1097 | + case 'webllm-not-updated': | |
| 1098 | + toastr.warning('The installed extension version does not support embeddings.', 'WebLLM update required'); | |
| 1099 | + break; | |
| 1100 | + } | |
| 1101 | + } | |
| 1102 | +} | |
| 1103 | + | |
| 1104 | +/** | |
| 1105 | + * Loads and displays WebLLM models in the settings. | |
| 1106 | + * @returns {Promise<void>} | |
| 1107 | + */ | |
| 1108 | +function loadWebLlmModels() { | |
| 1109 | + return executeWithWebLlmErrorHandling(() => { | |
| 1110 | + const models = webllmProvider.getModels(); | |
| 1111 | + $('#vectors_webllm_model').empty(); | |
| 1112 | + for (const model of models) { | |
| 1113 | + $('#vectors_webllm_model').append($('<option>', { value: model.id, text: model.toString() })); | |
| 1114 | + } | |
| 1115 | + if (!settings.webllm_model || !models.some(x => x.id === settings.webllm_model)) { | |
| 1116 | + if (models.length) { | |
| 1117 | + settings.webllm_model = models[0].id; | |
| 1118 | + } | |
| 1119 | + } | |
| 1120 | + $('#vectors_webllm_model').val(settings.webllm_model); | |
| 1121 | + return Promise.resolve(); | |
| 1122 | + }); | |
| 1123 | +} | |
| 1124 | + | |
| 1125 | +/** | |
| 1126 | + * Creates WebLLM embeddings for a list of items. | |
| 1127 | + * @param {string[]} items Items to embed | |
| 1128 | + * @returns {Promise<Record<string, number[]>>} Calculated embeddings | |
| 1129 | + */ | |
| 1130 | +async function createWebLlmEmbeddings(items) { | |
| 1131 | + return executeWithWebLlmErrorHandling(async () => { | |
| 1132 | + const embeddings = await webllmProvider.embedTexts(items, settings.webllm_model); | |
| 1133 | + const result = /** @type {Record<string, number[]>} */ ({}); | |
| 1134 | + for (let i = 0; i < items.length; i++) { | |
| 1135 | + result[items[i]] = embeddings[i]; | |
| 1136 | + } | |
| 1137 | + return result; | |
| 1138 | + }); | |
| 1042 | 1139 | } |
| 1043 | 1140 | |
| 1044 | 1141 | async function onPurgeClick() { |
| @@ -1567,6 +1664,30 @@ jQuery(async () => { | ||
| 1567 | 1664 | $('#dialogue_popup_input').val(presetModel); |
| 1568 | 1665 | }); |
| 1569 | 1666 | |
| 1667 | + $('#vectors_webllm_install').on('click', (e) => { | |
| 1668 | + e.preventDefault(); | |
| 1669 | + e.stopPropagation(); | |
| 1670 | + | |
| 1671 | + if (Object.hasOwn(SillyTavern, 'llm')) { | |
| 1672 | + toastr.info('WebLLM is already installed'); | |
| 1673 | + return; | |
| 1674 | + } | |
| 1675 | + | |
| 1676 | + openThirdPartyExtensionMenu('https://github.com/SillyTavern/Extension-WebLLM'); | |
| 1677 | + }); | |
| 1678 | + | |
| 1679 | + $('#vectors_webllm_model').on('input', () => { | |
| 1680 | + settings.webllm_model = String($('#vectors_webllm_model').val()); | |
| 1681 | + Object.assign(extension_settings.vectors, settings); | |
| 1682 | + saveSettingsDebounced(); | |
| 1683 | + }); | |
| 1684 | + | |
| 1685 | + $('#vectors_webllm_load').on('click', async () => { | |
| 1686 | + if (!settings.webllm_model) return; | |
| 1687 | + await webllmProvider.loadModel(settings.webllm_model); | |
| 1688 | + toastr.success('WebLLM model loaded'); | |
| 1689 | + }); | |
| 1690 | + | |
| 1570 | 1691 | $('#api_key_nomicai').toggleClass('success', !!secret_state[SECRET_KEYS.NOMICAI]); |
| 1571 | 1692 | |
| 1572 | 1693 | toggleSettings(); |
| @@ -1578,6 +1699,11 @@ jQuery(async () => { | ||
| 1578 | 1699 | eventSource.on(event_types.CHAT_DELETED, purgeVectorIndex); |
| 1579 | 1700 | eventSource.on(event_types.GROUP_CHAT_DELETED, purgeVectorIndex); |
| 1580 | 1701 | eventSource.on(event_types.FILE_ATTACHMENT_DELETED, purgeFileVectorIndex); |
| 1702 | + eventSource.on(event_types.EXTENSION_SETTINGS_LOADED, async (manifest) => { | |
| 1703 | + if (settings.source === 'webllm' && manifest?.display_name === 'WebLLM') { | |
| 1704 | + await loadWebLlmModels(); | |
| 1705 | + } | |
| 1706 | + }); | |
| 1581 | 1707 | |
| 1582 | 1708 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1583 | 1709 | name: 'db-ingest', |
| @@ -21,7 +21,23 @@ | ||
| 21 | 21 | <option value="openai">OpenAI</option> |
| 22 | 22 | <option value="togetherai">TogetherAI</option> |
| 23 | 23 | <option value="vllm">vLLM</option> |
| 24 | + <option value="webllm" data-i18n="WebLLM Extension">WebLLM Extension</option> | |
| 25 | + </select> | |
| 26 | + </div> | |
| 27 | + <div class="flex-container flexFlowColumn" id="webllm_vectorsModel"> | |
| 28 | + <label for="vectors_webllm_model" data-i18n="Vectorization Model"> | |
| 29 | + Vectorization Model | |
| 30 | + </label> | |
| 31 | + <div class="flex-container"> | |
| 32 | + <select id="vectors_webllm_model" class="text_pole flex1"> | |
| 24 | 33 | </select> |
| 34 | + <div id="vectors_webllm_load" class="menu_button menu_button_icon" title="Verify and load the selected model."> | |
| 35 | + <i class="fa-solid fa-check-to-slot"></i> | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | + <div> | |
| 39 | + Requires the WebLLM extension to be installed. Click <a href="#" id="vectors_webllm_install">here</a> to install. | |
| 40 | + </div> | |
| 25 | 41 | </div> |
| 26 | 42 | <div class="flex-container flexFlowColumn" id="ollama_vectorsModel"> |
| 27 | 43 | <label for="vectors_ollama_model" data-i18n="Vectorization Model"> |
| @@ -0,0 +1,64 @@ | ||
| 1 | +export class WebLlmVectorProvider { | |
| 2 | + /** @type {object?} WebLLM engine */ | |
| 3 | + #engine = null; | |
| 4 | + | |
| 5 | + constructor() { | |
| 6 | + this.#engine = null; | |
| 7 | + } | |
| 8 | + | |
| 9 | + /** | |
| 10 | + * Check if WebLLM is available and up-to-date | |
| 11 | + * @throws {Error} If WebLLM is not available or not up-to-date | |
| 12 | + */ | |
| 13 | + #checkWebLlm() { | |
| 14 | + if (!Object.hasOwn(SillyTavern, 'llm')) { | |
| 15 | + throw new Error('WebLLM is not available', { cause: 'webllm-not-available' }); | |
| 16 | + } | |
| 17 | + | |
| 18 | + if (typeof SillyTavern.llm.generateEmbedding !== 'function') { | |
| 19 | + throw new Error('WebLLM is not updated', { cause: 'webllm-not-updated' }); | |
| 20 | + } | |
| 21 | + } | |
| 22 | + | |
| 23 | + /** | |
| 24 | + * Initialize the engine with a model. | |
| 25 | + * @param {string} modelId Model ID to initialize the engine with | |
| 26 | + * @returns {Promise<void>} Promise that resolves when the engine is initialized | |
| 27 | + */ | |
| 28 | + #initEngine(modelId) { | |
| 29 | + this.#checkWebLlm(); | |
| 30 | + if (!this.#engine) { | |
| 31 | + this.#engine = SillyTavern.llm.getEngine(); | |
| 32 | + } | |
| 33 | + | |
| 34 | + return this.#engine.loadModel(modelId); | |
| 35 | + } | |
| 36 | + | |
| 37 | + /** | |
| 38 | + * Get available models. | |
| 39 | + * @returns {{id:string, toString: function(): string}[]} Array of available models | |
| 40 | + */ | |
| 41 | + getModels() { | |
| 42 | + this.#checkWebLlm(); | |
| 43 | + return SillyTavern.llm.getEmbeddingModels(); | |
| 44 | + } | |
| 45 | + | |
| 46 | + /** | |
| 47 | + * Generate embeddings for a list of texts. | |
| 48 | + * @param {string[]} texts Array of texts to generate embeddings for | |
| 49 | + * @param {string} modelId Model to use for generating embeddings | |
| 50 | + * @returns {Promise<number[][]>} Array of embeddings for each text | |
| 51 | + */ | |
| 52 | + async embedTexts(texts, modelId) { | |
| 53 | + await this.#initEngine(modelId); | |
| 54 | + return this.#engine.generateEmbedding(texts); | |
| 55 | + } | |
| 56 | + | |
| 57 | + /** | |
| 58 | + * Loads a model into the engine. | |
| 59 | + * @param {string} modelId Model ID to load | |
| 60 | + */ | |
| 61 | + async loadModel(modelId) { | |
| 62 | + await this.#initEngine(modelId); | |
| 63 | + } | |
| 64 | +} | |
| @@ -31,6 +31,7 @@ const SOURCES = [ | ||
| 31 | 31 | 'ollama', |
| 32 | 32 | 'llamacpp', |
| 33 | 33 | 'vllm', |
| 34 | + 'webllm', | |
| 34 | 35 | ]; |
| 35 | 36 | |
| 36 | 37 | /** |
| @@ -64,6 +65,8 @@ async function getVector(source, sourceSettings, text, isQuery, directories) { | ||
| 64 | 65 | return getVllmVector(text, sourceSettings.apiUrl, sourceSettings.model, directories); |
| 65 | 66 | case 'ollama': |
| 66 | 67 | return getOllamaVector(text, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories); |
| 68 | + case 'webllm': | |
| 69 | + return sourceSettings.embeddings[text]; | |
| 67 | 70 | } |
| 68 | 71 | |
| 69 | 72 | throw new Error(`Unknown vector source ${source}`); |
| @@ -114,6 +117,9 @@ async function getBatchVector(source, sourceSettings, texts, isQuery, directorie | ||
| 114 | 117 | case 'ollama': |
| 115 | 118 | results.push(...await getOllamaBatchVector(batch, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories)); |
| 116 | 119 | break; |
| 120 | + case 'webllm': | |
| 121 | + results.push(...texts.map(x => sourceSettings.embeddings[x])); | |
| 122 | + break; | |
| 117 | 123 | default: |
| 118 | 124 | throw new Error(`Unknown vector source ${source}`); |
| 119 | 125 | } |
| @@ -179,6 +185,11 @@ function getSourceSettings(source, request) { | ||
| 179 | 185 | return { |
| 180 | 186 | model: 'nomic-embed-text-v1.5', |
| 181 | 187 | }; |
| 188 | + case 'webllm': | |
| 189 | + return { | |
| 190 | + model: String(request.body.model), | |
| 191 | + embeddings: request.body.embeddings ?? {}, | |
| 192 | + }; | |
| 182 | 193 | default: |
| 183 | 194 | return {}; |
| 184 | 195 | } |