Enhance Vectorize All process with error handling, retries and minor improvements (#5479) * fix (vectors): Fixed Vectorize All progress report and ETA issues * fix (vectors): Added strip reasoning block function for extras/WebLLM summaries * feat(vectors): Retry failed summaries with configurable attempts * feat(vectors): Skip summarization for short messages * feat(vectors): Skip failed messages during Vectorize All instead of aborting all Prevents the "Vectorize All" process from stopping on single-message errors. Failed items are now skipped and reported at the end of the session rather than aborting the entire sync. Summarization: Implements per-message retries; failures use the original text as a fallback or mark for skipping. Vector Insertion: Differentiates fatal configuration errors (abort) from transient batch failures (skip and notify). * Resolved: 'account_id_missing' is missing * Resolved: Refactored out summarizeSkipOnFailure() functionality into summarize() via options parameter * Fix eslint and type checks * feat(vectors): add types to maps and sets, improve summarize function options --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -44,6 +44,7 @@ import { oai_settings } from '../../openai.js'; | |||
| 44 | * @property {string} text - The hashed message text | 44 | * @property {string} text - The hashed message text |
| 45 | * @property {number} hash - The hash used as the vector key | 45 | * @property {number} hash - The hash used as the vector key |
| 46 | * @property {number} index - The index of the message in the chat | 46 | * @property {number} index - The index of the message in the chat |
| 47 | * @property {boolean} [summaryFailed] - Whether summarization failed for this message (used internally to skip messages that fail summarization) | ||
| 47 | */ | 48 | */ |
| 48 | 49 | ||
| 49 | const MODULE_NAME = 'vectors'; | 50 | const MODULE_NAME = 'vectors'; |
| @@ -77,6 +78,8 @@ const settings = { | |||
| 77 | summarize_sent: false, | 78 | summarize_sent: false, |
| 78 | summary_source: 'main', | 79 | summary_source: 'main', |
| 79 | summary_prompt: 'Ignore previous instructions. Summarize the most important parts of the message. Limit yourself to 250 words or less. Your response should include nothing but the summary.', | 80 | summary_prompt: 'Ignore previous instructions. Summarize the most important parts of the message. Limit yourself to 250 words or less. Your response should include nothing but the summary.', |
| 81 | summary_retries: 2, | ||
| 82 | summary_threshold: 200, | ||
| 80 | force_chunk_delimiter: '', | 83 | force_chunk_delimiter: '', |
| 81 | 84 | ||
| 82 | // For chats | 85 | // For chats |
| @@ -118,7 +121,21 @@ const settings = { | |||
| 118 | 121 | ||
| 119 | const moduleWorker = new ModuleWorkerWrapper(synchronizeChat); | 122 | const moduleWorker = new ModuleWorkerWrapper(synchronizeChat); |
| 120 | const webllmProvider = new WebLlmVectorProvider(); | 123 | const webllmProvider = new WebLlmVectorProvider(); |
| 124 | /** | ||
| 125 | * Cache for storing summaries of messages by their hash. | ||
| 126 | * @type {Map<number, string>} | ||
| 127 | */ | ||
| 121 | const cachedSummaries = new Map(); | 128 | const cachedSummaries = new Map(); |
| 129 | /** | ||
| 130 | * Hashes skipped this Vectorize All session (summary or embed failure). Cleared on next Vectorize All click. | ||
| 131 | * @type {Set<number>} | ||
| 132 | */ | ||
| 133 | const skippedHashes = new Set(); | ||
| 134 | /** | ||
| 135 | * Error causes treated as fatal — abort Vectorize All rather than skip. | ||
| 136 | * @type {Set<string>} | ||
| 137 | */ | ||
| 138 | const FATAL_CAUSES = new Set(['account_id_missing', 'api_key_missing', 'api_url_missing', 'api_model_missing', 'extras_module_missing', 'webllm_not_supported', 'summary_endpoint_invalid']); | ||
| 122 | const vectorApiRequiresUrl = ['llamacpp', 'vllm', 'ollama', 'koboldcpp']; | 139 | const vectorApiRequiresUrl = ['llamacpp', 'vllm', 'ollama', 'koboldcpp']; |
| 123 | 140 | ||
| 124 | /** | 141 | /** |
| @@ -199,10 +216,12 @@ async function onVectorizeAllClick() { | |||
| 199 | // Clear all cached summaries to ensure that new ones are created | 216 | // Clear all cached summaries to ensure that new ones are created |
| 200 | // upon request of a full vectorise | 217 | // upon request of a full vectorise |
| 201 | cachedSummaries.clear(); | 218 | cachedSummaries.clear(); |
| 219 | skippedHashes.clear(); | ||
| 202 | 220 | ||
| 203 | const batchSize = getBatchSize(); | 221 | const batchSize = getBatchSize(); |
| 204 | const elapsedLog = []; | 222 | const elapsedLog = []; |
| 205 | let finished = false; | 223 | let finished = false; |
| 224 | let initialPending = null; // total items pending at the start of this run — set on first sync return | ||
| 206 | $('#vectorize_progress').show(); | 225 | $('#vectorize_progress').show(); |
| 207 | $('#vectorize_progress_percent').text('0'); | 226 | $('#vectorize_progress_percent').text('0'); |
| 208 | $('#vectorize_progress_eta').text('...'); | 227 | $('#vectorize_progress_eta').text('...'); |
| @@ -216,16 +235,27 @@ async function onVectorizeAllClick() { | |||
| 216 | const startTime = Date.now(); | 235 | const startTime = Date.now(); |
| 217 | const remaining = await synchronizeChat(batchSize); | 236 | const remaining = await synchronizeChat(batchSize); |
| 218 | const elapsed = Date.now() - startTime; | 237 | const elapsed = Date.now() - startTime; |
| 238 | |||
| 239 | if (remaining === null) { | ||
| 240 | // synchronizeChat already surfaced a toast; bail out of the loop. | ||
| 241 | throw new Error('Vectorization aborted'); | ||
| 242 | } | ||
| 243 | |||
| 219 | elapsedLog.push(elapsed); | 244 | elapsedLog.push(elapsed); |
| 220 | finished = remaining <= 0; | 245 | finished = remaining <= 0; |
| 221 | 246 | ||
| 222 | const total = getContext().chat.length; | 247 | if (initialPending === null) { |
| 223 | const processed = total - remaining; | 248 | initialPending = Math.max(0, remaining + batchSize); |
| 224 | const processedPercent = Math.round((processed / total) * 100); // percentage of the work done | 249 | } |
| 250 | const pending = Math.max(0, remaining); | ||
| 251 | const processed = Math.max(0, initialPending - pending); | ||
| 252 | const processedPercent = initialPending > 0 | ||
| 253 | ? Math.min(100, Math.round((processed / initialPending) * 100)) | ||
| 254 | : 100; | ||
| 225 | const lastElapsed = elapsedLog.slice(-5); // last 5 elapsed times | 255 | const lastElapsed = elapsedLog.slice(-5); // last 5 elapsed times |
| 226 | const averageElapsed = lastElapsed.reduce((a, b) => a + b, 0) / lastElapsed.length; // average time needed to process one item | 256 | const averageElapsed = lastElapsed.reduce((a, b) => a + b, 0) / lastElapsed.length; // average time needed to process one item |
| 227 | const pace = averageElapsed / batchSize; // time needed to process one item | 257 | const pace = averageElapsed / batchSize; // time needed to process one item |
| 228 | const remainingTime = Math.round(pace * remaining / 1000); | 258 | const remainingTime = Math.round(pace * pending / 1000); |
| 229 | 259 | ||
| 230 | $('#vectorize_progress_percent').text(processedPercent); | 260 | $('#vectorize_progress_percent').text(processedPercent); |
| 231 | $('#vectorize_progress_eta').text(remainingTime); | 261 | $('#vectorize_progress_eta').text(remainingTime); |
| @@ -234,6 +264,9 @@ async function onVectorizeAllClick() { | |||
| 234 | throw new Error('Chat changed'); | 264 | throw new Error('Chat changed'); |
| 235 | } | 265 | } |
| 236 | } | 266 | } |
| 267 | if (skippedHashes.size > 0) { | ||
| 268 | toastr.warning(`${skippedHashes.size} message(s) skipped due to errors. Click Vectorize All again to retry.`, 'Vectorization partial'); | ||
| 269 | } | ||
| 237 | } catch (error) { | 270 | } catch (error) { |
| 238 | console.error('Vectors: Failed to vectorize all', error); | 271 | console.error('Vectors: Failed to vectorize all', error); |
| 239 | } finally { | 272 | } finally { |
| @@ -304,7 +337,7 @@ async function summarizeExtra(element) { | |||
| 304 | 337 | ||
| 305 | if (apiResult.ok) { | 338 | if (apiResult.ok) { |
| 306 | const data = await apiResult.json(); | 339 | const data = await apiResult.json(); |
| 307 | element.text = data.summary; | 340 | element.text = removeReasoningFromString(data.summary); |
| 308 | } | 341 | } |
| 309 | } catch (error) { | 342 | } catch (error) { |
| 310 | console.log(error); | 343 | console.log(error); |
| @@ -336,45 +369,70 @@ async function summarizeWebLLM(element) { | |||
| 336 | } | 369 | } |
| 337 | 370 | ||
| 338 | const messages = [{ role: 'system', content: settings.summary_prompt }, { role: 'user', content: element.text }]; | 371 | const messages = [{ role: 'system', content: settings.summary_prompt }, { role: 'user', content: element.text }]; |
| 339 | element.text = await generateWebLlmChatPrompt(messages); | 372 | element.text = removeReasoningFromString(await generateWebLlmChatPrompt(messages)); |
| 340 | 373 | ||
| 341 | return true; | 374 | return true; |
| 342 | } | 375 | } |
| 343 | 376 | ||
| 344 | /** | 377 | /** |
| 345 | * Summarizes messages using the chosen method. | 378 | * Runs one summarization attempt for a single element via the chosen endpoint. |
| 346 | * @param {HashedMessage[]} hashedMessages Array of hashed messages | 379 | * @param {HashedMessage} element |
| 380 | * @param {string} endpoint | ||
| 381 | * @returns {Promise<boolean>} Whether the attempt succeeded. | ||
| 382 | */ | ||
| 383 | async function summarizeOne(element, endpoint) { | ||
| 384 | switch (endpoint) { | ||
| 385 | case 'main': | ||
| 386 | return await summarizeMain(element); | ||
| 387 | case 'extras': | ||
| 388 | return await summarizeExtra(element); | ||
| 389 | case 'webllm': | ||
| 390 | return await summarizeWebLLM(element); | ||
| 391 | default: | ||
| 392 | throw new Error(`Unsupported summary endpoint: ${endpoint}`, { cause: 'summary_endpoint_invalid' }); | ||
| 393 | } | ||
| 394 | } | ||
| 395 | |||
| 396 | /** | ||
| 397 | * Summarizes messages using the chosen method. Every returned element has been | ||
| 398 | * summarized (via live call or cache). Throws if any element fails after | ||
| 399 | * `settings.summary_retries` attempts. | ||
| 400 | * @param {HashedMessage[]} hashedMessages Array of hashed messages (mutated in place) | ||
| 347 | * @param {string} endpoint Type of endpoint to use | 401 | * @param {string} endpoint Type of endpoint to use |
| 402 | * @param {Object} [options] Options for summarization behavior | ||
| 403 | * @param {boolean} [options.skipOnFailure=false] If true, tags failed elements with `summaryFailed = true` instead of throwing | ||
| 348 | * @returns {Promise<HashedMessage[]>} Summarized messages | 404 | * @returns {Promise<HashedMessage[]>} Summarized messages |
| 349 | */ | 405 | */ |
| 350 | async function summarize(hashedMessages, endpoint = 'main') { | 406 | async function summarize(hashedMessages, endpoint = 'main', { skipOnFailure = false } = {}) { |
| 407 | const maxAttempts = Math.max(1, Number(settings.summary_retries) || 1); | ||
| 351 | for (const element of hashedMessages) { | 408 | for (const element of hashedMessages) { |
| 352 | const cachedSummary = cachedSummaries.get(element.hash); | 409 | const cachedSummary = cachedSummaries.get(element.hash); |
| 353 | if (!cachedSummary) { | 410 | if (cachedSummary) { |
| 354 | let success = true; | 411 | element.text = cachedSummary; |
| 355 | switch (endpoint) { | 412 | continue; |
| 356 | case 'main': | 413 | } |
| 357 | success = await summarizeMain(element); | 414 | |
| 358 | break; | 415 | let success = false; |
| 359 | case 'extras': | 416 | for (let attempt = 1; attempt <= maxAttempts; attempt++) { |
| 360 | success = await summarizeExtra(element); | 417 | try { |
| 361 | break; | 418 | success = await summarizeOne(element, endpoint); |
| 362 | case 'webllm': | 419 | if (success) break; |
| 363 | success = await summarizeWebLLM(element); | 420 | } catch (error) { |
| 364 | break; | 421 | if (FATAL_CAUSES.has(error?.cause)) throw error; |
| 365 | default: | 422 | console.warn(`Vectors: summary attempt ${attempt}/${maxAttempts} threw for hash ${element.hash}`, error); |
| 366 | console.error('Unsupported endpoint', endpoint); | ||
| 367 | success = false; | ||
| 368 | break; | ||
| 369 | } | 423 | } |
| 370 | if (success) { | 424 | console.warn(`Vectors: summary attempt ${attempt}/${maxAttempts} failed for hash ${element.hash}`); |
| 371 | cachedSummaries.set(element.hash, element.text); | 425 | } |
| 372 | } else { | 426 | if (!success) { |
| 373 | break; | 427 | if (skipOnFailure) { |
| 428 | console.warn(`Vectors: summarization exhausted ${maxAttempts} attempt(s) for hash ${element.hash} — marking for skip`); | ||
| 429 | element.summaryFailed = true; | ||
| 430 | continue; | ||
| 374 | } | 431 | } |
| 375 | } else { | 432 | |
| 376 | element.text = cachedSummary; | 433 | throw new Error(`Summarization failed after ${maxAttempts} attempt(s)`, { cause: 'summary_failed' }); |
| 377 | } | 434 | } |
| 435 | cachedSummaries.set(element.hash, element.text); | ||
| 378 | } | 436 | } |
| 379 | return hashedMessages; | 437 | return hashedMessages; |
| 380 | } | 438 | } |
| @@ -401,21 +459,43 @@ async function synchronizeChat(batchSize = 5) { | |||
| 401 | return -1; | 459 | return -1; |
| 402 | } | 460 | } |
| 403 | 461 | ||
| 462 | /** @type {HashedMessage[]} */ | ||
| 404 | const hashedMessages = context.chat.filter(x => settings.keep_hidden || !x.is_system).map(x => ({ text: String(substituteParams(x.mes)), hash: getStringHash(substituteParams(x.mes)), index: context.chat.indexOf(x) })); | 463 | const hashedMessages = context.chat.filter(x => settings.keep_hidden || !x.is_system).map(x => ({ text: String(substituteParams(x.mes)), hash: getStringHash(substituteParams(x.mes)), index: context.chat.indexOf(x) })); |
| 405 | const hashesInCollection = await getSavedHashes(chatId); | 464 | const hashesInCollection = await getSavedHashes(chatId); |
| 406 | 465 | ||
| 407 | let newVectorItems = hashedMessages.filter(x => !hashesInCollection.includes(x.hash)); | 466 | const newVectorItems = hashedMessages |
| 467 | .filter(x => !hashesInCollection.includes(x.hash)) | ||
| 468 | .filter(x => !skippedHashes.has(x.hash)); | ||
| 408 | const deletedHashes = hashesInCollection.filter(x => !hashedMessages.some(y => y.hash === x)); | 469 | const deletedHashes = hashesInCollection.filter(x => !hashedMessages.some(y => y.hash === x)); |
| 409 | 470 | ||
| 471 | let batch = newVectorItems.slice(0, batchSize); | ||
| 472 | |||
| 410 | if (settings.summarize) { | 473 | if (settings.summarize) { |
| 411 | newVectorItems = await summarize(newVectorItems, settings.summary_source); | 474 | const minLength = Math.max(0, Number(settings.summary_threshold) || 0); |
| 475 | const toSummarize = minLength > 0 ? batch.filter(x => x.text.length >= minLength) : batch; | ||
| 476 | if (toSummarize.length > 0) { | ||
| 477 | await summarize(toSummarize, settings.summary_source, { skipOnFailure: true }); | ||
| 478 | const failed = toSummarize.filter(x => x.summaryFailed); | ||
| 479 | if (failed.length > 0) { | ||
| 480 | for (const item of failed) skippedHashes.add(item.hash); | ||
| 481 | batch = batch.filter(x => !x.summaryFailed); | ||
| 482 | } | ||
| 483 | } | ||
| 412 | } | 484 | } |
| 413 | 485 | ||
| 414 | if (newVectorItems.length > 0) { | 486 | if (batch.length > 0) { |
| 415 | const chunkedBatch = splitByChunks(newVectorItems.slice(0, batchSize)); | 487 | const chunkedBatch = splitByChunks(batch); |
| 416 | 488 | ||
| 417 | console.log(`Vectors: Found ${newVectorItems.length} new items. Processing ${batchSize}...`); | 489 | console.log(`Vectors: Found ${newVectorItems.length} new items. Processing ${batch.length}...`); |
| 418 | await insertVectorItems(chatId, chunkedBatch); | 490 | try { |
| 491 | await insertVectorItems(chatId, chunkedBatch); | ||
| 492 | } catch (insertError) { | ||
| 493 | if (FATAL_CAUSES.has(insertError?.cause)) { | ||
| 494 | throw insertError; | ||
| 495 | } | ||
| 496 | console.warn('Vectors: insert failed for batch — marking for skip', insertError); | ||
| 497 | for (const item of batch) skippedHashes.add(item.hash); | ||
| 498 | } | ||
| 419 | } | 499 | } |
| 420 | 500 | ||
| 421 | if (deletedHashes.length > 0) { | 501 | if (deletedHashes.length > 0) { |
| @@ -444,6 +524,10 @@ async function synchronizeChat(batchSize = 5) { | |||
| 444 | return 'WebLLM extension is not installed or the model is not set.'; | 524 | return 'WebLLM extension is not installed or the model is not set.'; |
| 445 | case 'account_id_missing': | 525 | case 'account_id_missing': |
| 446 | return 'Workers AI account ID is required. Save it in the "API Connections" panel.'; | 526 | return 'Workers AI account ID is required. Save it in the "API Connections" panel.'; |
| 527 | case 'summary_endpoint_invalid': | ||
| 528 | return 'Summarization endpoint is not supported.'; | ||
| 529 | case 'summary_failed': | ||
| 530 | return 'Summarization failed after the configured number of retries.'; | ||
| 447 | default: | 531 | default: |
| 448 | return 'Check server console for more details'; | 532 | return 'Check server console for more details'; |
| 449 | } | 533 | } |
| @@ -453,7 +537,7 @@ async function synchronizeChat(batchSize = 5) { | |||
| 453 | 537 | ||
| 454 | const message = getErrorMessage(error.cause); | 538 | const message = getErrorMessage(error.cause); |
| 455 | toastr.error(message, 'Vectorization failed', { preventDuplicates: true }); | 539 | toastr.error(message, 'Vectorization failed', { preventDuplicates: true }); |
| 456 | return -1; | 540 | return null; |
| 457 | } finally { | 541 | } finally { |
| 458 | syncBlocked = false; | 542 | syncBlocked = false; |
| 459 | } | 543 | } |
| @@ -827,7 +911,11 @@ async function getQueryText(chat, initiator) { | |||
| 827 | .slice(0, settings.query); | 911 | .slice(0, settings.query); |
| 828 | 912 | ||
| 829 | if (initiator === 'chat' && settings.enabled_chats && settings.summarize && settings.summarize_sent) { | 913 | if (initiator === 'chat' && settings.enabled_chats && settings.summarize && settings.summarize_sent) { |
| 830 | hashedMessages = await summarize(hashedMessages, settings.summary_source); | 914 | const minLength = Math.max(0, Number(settings.summary_threshold) || 0); |
| 915 | const toSummarize = minLength > 0 ? hashedMessages.filter(x => x.text.length >= minLength) : hashedMessages; | ||
| 916 | if (toSummarize.length > 0) { | ||
| 917 | await summarize(toSummarize, settings.summary_source, { skipOnFailure: true }); | ||
| 918 | } | ||
| 831 | } | 919 | } |
| 832 | 920 | ||
| 833 | const queryText = hashedMessages.map(x => x.text).join('\n'); | 921 | const queryText = hashedMessages.map(x => x.text).join('\n'); |
| @@ -1830,6 +1918,20 @@ export async function init() { | |||
| 1830 | saveSettingsDebounced(); | 1918 | saveSettingsDebounced(); |
| 1831 | }); | 1919 | }); |
| 1832 | 1920 | ||
| 1921 | $('#vectors_summary_retries').val(settings.summary_retries).on('input', () => { | ||
| 1922 | const parsed = Number($('#vectors_summary_retries').val()); | ||
| 1923 | settings.summary_retries = Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : 1; | ||
| 1924 | Object.assign(extension_settings.vectors, settings); | ||
| 1925 | saveSettingsDebounced(); | ||
| 1926 | }); | ||
| 1927 | |||
| 1928 | $('#vectors_summary_threshold').val(settings.summary_threshold).on('input', () => { | ||
| 1929 | const parsed = Number($('#vectors_summary_threshold').val()); | ||
| 1930 | settings.summary_threshold = Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0; | ||
| 1931 | Object.assign(extension_settings.vectors, settings); | ||
| 1932 | saveSettingsDebounced(); | ||
| 1933 | }); | ||
| 1934 | |||
| 1833 | $('#vectors_message_chunk_size').val(settings.message_chunk_size).on('input', () => { | 1935 | $('#vectors_message_chunk_size').val(settings.message_chunk_size).on('input', () => { |
| 1834 | settings.message_chunk_size = Number($('#vectors_message_chunk_size').val()); | 1936 | settings.message_chunk_size = Number($('#vectors_message_chunk_size').val()); |
| 1835 | Object.assign(extension_settings.vectors, settings); | 1937 | Object.assign(extension_settings.vectors, settings); |
| @@ -493,6 +493,16 @@ | |||
| 493 | <label for="vectors_summary_prompt" title="Summary Prompt:">Summary Prompt:</label> | 493 | <label for="vectors_summary_prompt" title="Summary Prompt:">Summary Prompt:</label> |
| 494 | <small data-i18n="Only used when Main API or WebLLM Extension is selected.">Only used when Main API or WebLLM Extension is selected.</small> | 494 | <small data-i18n="Only used when Main API or WebLLM Extension is selected.">Only used when Main API or WebLLM Extension is selected.</small> |
| 495 | <textarea id="vectors_summary_prompt" class="text_pole textarea_compact" rows="6" placeholder="This prompt will be sent to AI to request the summary generation."></textarea> | 495 | <textarea id="vectors_summary_prompt" class="text_pole textarea_compact" rows="6" placeholder="This prompt will be sent to AI to request the summary generation."></textarea> |
| 496 | |||
| 497 | <label for="vectors_summary_retries" title="Number of attempts per message before aborting vectorization."> | ||
| 498 | <span data-i18n="Summarization retries per message">Summarization retries per message</span> | ||
| 499 | </label> | ||
| 500 | <input id="vectors_summary_retries" type="number" class="text_pole widthUnset" min="1" max="10" step="1" /> | ||
| 501 | |||
| 502 | <label for="vectors_summary_threshold" title="Messages shorter than this (in characters) are embedded as-is without summarization. Set to 0 to always summarize."> | ||
| 503 | <span data-i18n="Summarization min length (chars)">Summarization min length (chars)</span> | ||
| 504 | </label> | ||
| 505 | <input id="vectors_summary_threshold" type="number" class="text_pole widthUnset" min="0" step="1" /> | ||
| 496 | </div> | 506 | </div> |
| 497 | </div> | 507 | </div> |
| 498 | <small data-i18n="Old messages are vectorized gradually as you chat. To process all previous messages, click the button below."> | 508 | <small data-i18n="Old messages are vectorized gradually as you chat. To process all previous messages, click the button below."> |