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

1cb9287684fd40821f7e784635dccf9db391503e

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
5 files changed, +227 -10Ignore whitespace
public/scripts/extensions.js+1 -1
@@ -1070,7 +1070,7 @@ export async function installExtension(url, global) {
1070 toastr.success(t`Extension '${response.display_name}' by ${response.author} (version ${response.version}) has been installed successfully!`, t`Extension installation successful`);1070 toastr.success(t`Extension '${response.display_name}' by ${response.author} (version ${response.version}) has been installed successfully!`, t`Extension installation successful`);
1071 console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);1071 console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);
1072 await loadExtensionSettings({}, false, false);1072 await loadExtensionSettings({}, false, false);
1073 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED);1073 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response);
1074}1074}
10751075
1076/**1076/**
public/scripts/extensions/vectors/index.js+135 -9
@@ -19,6 +19,7 @@ import {
19 modules,19 modules,
20 renderExtensionTemplateAsync,20 renderExtensionTemplateAsync,
21 doExtrasFetch, getApiUrl,21 doExtrasFetch, getApiUrl,
22 openThirdPartyExtensionMenu,
22} from '../../extensions.js';23} from '../../extensions.js';
23import { collapseNewlines, registerDebugFunction } from '../../power-user.js';24import { collapseNewlines, registerDebugFunction } from '../../power-user.js';
24import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js';25import { SECRET_KEYS, secret_state, writeSecret } from '../../secrets.js';
@@ -34,6 +35,7 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
34import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';35import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
35import { callGenericPopup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';36import { callGenericPopup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
36import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';37import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
38import { WebLlmVectorProvider } from './webllm.js';
3739
38/**40/**
39 * @typedef {object} HashedMessage41 * @typedef {object} HashedMessage
@@ -60,6 +62,7 @@ const settings = {
60 ollama_model: 'mxbai-embed-large',62 ollama_model: 'mxbai-embed-large',
61 ollama_keep: false,63 ollama_keep: false,
62 vllm_model: '',64 vllm_model: '',
65 webllm_model: '',
63 summarize: false,66 summarize: false,
64 summarize_sent: false,67 summarize_sent: false,
65 summary_source: 'main',68 summary_source: 'main',
@@ -103,7 +106,7 @@ const settings = {
103};106};
104107
105const moduleWorker = new ModuleWorkerWrapper(synchronizeChat);108const moduleWorker = new ModuleWorkerWrapper(synchronizeChat);
106109const webllmProvider = new WebLlmVectorProvider();
107const cachedSummaries = new Map();110const cachedSummaries = new Map();
108111
109/**112/**
@@ -373,6 +376,8 @@ async function synchronizeChat(batchSize = 5) {
373 return 'Vectorization Source Model is required, but not set.';376 return 'Vectorization Source Model is required, but not set.';
374 case 'extras_module_missing':377 case 'extras_module_missing':
375 return 'Extras API must provide an "embeddings" module.';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 default:381 default:
377 return 'Check server console for more details';382 return 'Check server console for more details';
378 }383 }
@@ -747,14 +752,15 @@ async function getQueryText(chat, initiator) {
747752
748/**753/**
749 * Gets common body parameters for vector requests.754 * Gets common body parameters for vector requests.
750 * @returns {object}755 * @param {object} args Additional arguments
756 * @returns {object} Request body
751 */757 */
752function getVectorsRequestBody() {758function getVectorsRequestBody(args = {}) {
753 const body = {};759 const body = Object.assign({}, args);
754 switch (settings.source) {760 switch (settings.source) {
755 case 'extras':761 case 'extras':
756 body.extrasUrl = extension_settings.apiUrl;762 body.extrasUrl = extension_settings.apiUrl;
757 body.extrasKey = extension_settings.apiKey;763 body.extrasKey = extension_settings.apiKey;
758 break;764 break;
759 case 'togetherai':765 case 'togetherai':
760 body.model = extension_settings.vectors.togetherai_model;766 body.model = extension_settings.vectors.togetherai_model;
@@ -777,6 +783,9 @@ function getVectorsRequestBody() {
777 body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.VLLM];783 body.apiUrl = textgenerationwebui_settings.server_urls[textgen_types.VLLM];
778 body.model = extension_settings.vectors.vllm_model;784 body.model = extension_settings.vectors.vllm_model;
779 break;785 break;
786 case 'webllm':
787 body.model = extension_settings.vectors.webllm_model;
788 break;
780 default:789 default:
781 break;790 break;
782 }791 }
@@ -784,6 +793,21 @@ function getVectorsRequestBody() {
784}793}
785794
786/**795/**
796 * Gets additional arguments for vector requests.
797 * @param {string[]} items Items to embed
798 * @returns {Promise<object>} Additional arguments
799 */
800async 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 * Gets the saved hashes for a collection811 * Gets the saved hashes for a collection
788* @param {string} collectionId812* @param {string} collectionId
789* @returns {Promise<number[]>} Saved hashes813* @returns {Promise<number[]>} Saved hashes
@@ -816,11 +840,12 @@ async function getSavedHashes(collectionId) {
816async function insertVectorItems(collectionId, items) {840async function insertVectorItems(collectionId, items) {
817 throwIfSourceInvalid();841 throwIfSourceInvalid();
818842
843 const args = await getAdditionalArgs(items.map(x => x.text));
819 const response = await fetch('/api/vector/insert', {844 const response = await fetch('/api/vector/insert', {
820 method: 'POST',845 method: 'POST',
821 headers: getRequestHeaders(),846 headers: getRequestHeaders(),
822 body: JSON.stringify({847 body: JSON.stringify({
823 ...getVectorsRequestBody(),848 ...getVectorsRequestBody(args),
824 collectionId: collectionId,849 collectionId: collectionId,
825 items: items,850 items: items,
826 source: settings.source,851 source: settings.source,
@@ -858,6 +883,10 @@ function throwIfSourceInvalid() {
858 if (settings.source === 'extras' && !modules.includes('embeddings')) {883 if (settings.source === 'extras' && !modules.includes('embeddings')) {
859 throw new Error('Vectors: Embeddings module missing', { cause: 'extras_module_missing' });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}
862891
863/**892/**
@@ -890,11 +919,12 @@ async function deleteVectorItems(collectionId, hashes) {
890 * @returns {Promise<{ hashes: number[], metadata: object[]}>} - Hashes of the results919 * @returns {Promise<{ hashes: number[], metadata: object[]}>} - Hashes of the results
891 */920 */
892async function queryCollection(collectionId, searchText, topK) {921async function queryCollection(collectionId, searchText, topK) {
922 const args = await getAdditionalArgs([searchText]);
893 const response = await fetch('/api/vector/query', {923 const response = await fetch('/api/vector/query', {
894 method: 'POST',924 method: 'POST',
895 headers: getRequestHeaders(),925 headers: getRequestHeaders(),
896 body: JSON.stringify({926 body: JSON.stringify({
897 ...getVectorsRequestBody(),927 ...getVectorsRequestBody(args),
898 collectionId: collectionId,928 collectionId: collectionId,
899 searchText: searchText,929 searchText: searchText,
900 topK: topK,930 topK: topK,
@@ -919,11 +949,12 @@ async function queryCollection(collectionId, searchText, topK) {
919 * @returns {Promise<Record<string, { hashes: number[], metadata: object[] }>>} - Results mapped to collection IDs949 * @returns {Promise<Record<string, { hashes: number[], metadata: object[] }>>} - Results mapped to collection IDs
920 */950 */
921async function queryMultipleCollections(collectionIds, searchText, topK, threshold) {951async function queryMultipleCollections(collectionIds, searchText, topK, threshold) {
952 const args = await getAdditionalArgs([searchText]);
922 const response = await fetch('/api/vector/query-multi', {953 const response = await fetch('/api/vector/query-multi', {
923 method: 'POST',954 method: 'POST',
924 headers: getRequestHeaders(),955 headers: getRequestHeaders(),
925 body: JSON.stringify({956 body: JSON.stringify({
926 ...getVectorsRequestBody(),957 ...getVectorsRequestBody(args),
927 collectionIds: collectionIds,958 collectionIds: collectionIds,
928 searchText: searchText,959 searchText: searchText,
929 topK: topK,960 topK: topK,
@@ -1039,6 +1070,72 @@ function toggleSettings() {
1039 $('#llamacpp_vectorsModel').toggle(settings.source === 'llamacpp');1070 $('#llamacpp_vectorsModel').toggle(settings.source === 'llamacpp');
1040 $('#vllm_vectorsModel').toggle(settings.source === 'vllm');1071 $('#vllm_vectorsModel').toggle(settings.source === 'vllm');
1041 $('#nomicai_apiKey').toggle(settings.source === 'nomicai');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 */
1085async 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 */
1108function 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 */
1130async 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}
10431140
1044async function onPurgeClick() {1141async function onPurgeClick() {
@@ -1567,6 +1664,30 @@ jQuery(async () => {
1567 $('#dialogue_popup_input').val(presetModel);1664 $('#dialogue_popup_input').val(presetModel);
1568 });1665 });
15691666
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 $('#api_key_nomicai').toggleClass('success', !!secret_state[SECRET_KEYS.NOMICAI]);1691 $('#api_key_nomicai').toggleClass('success', !!secret_state[SECRET_KEYS.NOMICAI]);
15711692
1572 toggleSettings();1693 toggleSettings();
@@ -1578,6 +1699,11 @@ jQuery(async () => {
1578 eventSource.on(event_types.CHAT_DELETED, purgeVectorIndex);1699 eventSource.on(event_types.CHAT_DELETED, purgeVectorIndex);
1579 eventSource.on(event_types.GROUP_CHAT_DELETED, purgeVectorIndex);1700 eventSource.on(event_types.GROUP_CHAT_DELETED, purgeVectorIndex);
1580 eventSource.on(event_types.FILE_ATTACHMENT_DELETED, purgeFileVectorIndex);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 });
15811707
1582 SlashCommandParser.addCommandObject(SlashCommand.fromProps({1708 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1583 name: 'db-ingest',1709 name: 'db-ingest',
public/scripts/extensions/vectors/settings.html+16 -0
@@ -21,8 +21,24 @@
21 <option value="openai">OpenAI</option>21 <option value="openai">OpenAI</option>
22 <option value="togetherai">TogetherAI</option>22 <option value="togetherai">TogetherAI</option>
23 <option value="vllm">vLLM</option>23 <option value="vllm">vLLM</option>
24 <option value="webllm" data-i18n="WebLLM Extension">WebLLM Extension</option>
24 </select>25 </select>
25 </div>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">
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>
41 </div>
26 <div class="flex-container flexFlowColumn" id="ollama_vectorsModel">42 <div class="flex-container flexFlowColumn" id="ollama_vectorsModel">
27 <label for="vectors_ollama_model" data-i18n="Vectorization Model">43 <label for="vectors_ollama_model" data-i18n="Vectorization Model">
28 Vectorization Model44 Vectorization Model
public/scripts/extensions/vectors/webllm.js+64 -0
@@ -0,0 +1,64 @@
1export 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}
src/endpoints/vectors.js+11 -0
@@ -31,6 +31,7 @@ const SOURCES = [
31 'ollama',31 'ollama',
32 'llamacpp',32 'llamacpp',
33 'vllm',33 'vllm',
34 'webllm',
34];35];
3536
36/**37/**
@@ -64,6 +65,8 @@ async function getVector(source, sourceSettings, text, isQuery, directories) {
64 return getVllmVector(text, sourceSettings.apiUrl, sourceSettings.model, directories);65 return getVllmVector(text, sourceSettings.apiUrl, sourceSettings.model, directories);
65 case 'ollama':66 case 'ollama':
66 return getOllamaVector(text, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories);67 return getOllamaVector(text, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories);
68 case 'webllm':
69 return sourceSettings.embeddings[text];
67 }70 }
6871
69 throw new Error(`Unknown vector source ${source}`);72 throw new Error(`Unknown vector source ${source}`);
@@ -114,6 +117,9 @@ async function getBatchVector(source, sourceSettings, texts, isQuery, directorie
114 case 'ollama':117 case 'ollama':
115 results.push(...await getOllamaBatchVector(batch, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories));118 results.push(...await getOllamaBatchVector(batch, sourceSettings.apiUrl, sourceSettings.model, sourceSettings.keep, directories));
116 break;119 break;
120 case 'webllm':
121 results.push(...texts.map(x => sourceSettings.embeddings[x]));
122 break;
117 default:123 default:
118 throw new Error(`Unknown vector source ${source}`);124 throw new Error(`Unknown vector source ${source}`);
119 }125 }
@@ -179,6 +185,11 @@ function getSourceSettings(source, request) {
179 return {185 return {
180 model: 'nomic-embed-text-v1.5',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 default:193 default:
183 return {};194 return {};
184 }195 }