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 | 44 | * @property {string} text - The hashed message text |
| 45 | 45 | * @property {number} hash - The hash used as the vector key |
| 46 | 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 | 50 | const MODULE_NAME = 'vectors'; |
| @@ -77,6 +78,8 @@ const settings = { | ||
| 77 | 78 | summarize_sent: false, |
| 78 | 79 | summary_source: 'main', |
| 79 | 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 | 83 | force_chunk_delimiter: '', |
| 81 | 84 | |
| 82 | 85 | // For chats |
| @@ -118,7 +121,21 @@ const settings = { | ||
| 118 | 121 | |
| 119 | 122 | const moduleWorker = new ModuleWorkerWrapper(synchronizeChat); |
| 120 | 123 | const webllmProvider = new WebLlmVectorProvider(); |
| 124 | +/** | |
| 125 | + * Cache for storing summaries of messages by their hash. | |
| 126 | + * @type {Map<number, string>} | |
| 127 | + */ | |
| 121 | 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 | 139 | const vectorApiRequiresUrl = ['llamacpp', 'vllm', 'ollama', 'koboldcpp']; |
| 123 | 140 | |
| 124 | 141 | /** |
| @@ -199,10 +216,12 @@ async function onVectorizeAllClick() { | ||
| 199 | 216 | // Clear all cached summaries to ensure that new ones are created |
| 200 | 217 | // upon request of a full vectorise |
| 201 | 218 | cachedSummaries.clear(); |
| 219 | + skippedHashes.clear(); | |
| 202 | 220 | |
| 203 | 221 | const batchSize = getBatchSize(); |
| 204 | 222 | const elapsedLog = []; |
| 205 | 223 | let finished = false; |
| 224 | + let initialPending = null; // total items pending at the start of this run — set on first sync return | |
| 206 | 225 | $('#vectorize_progress').show(); |
| 207 | 226 | $('#vectorize_progress_percent').text('0'); |
| 208 | 227 | $('#vectorize_progress_eta').text('...'); |
| @@ -216,16 +235,27 @@ async function onVectorizeAllClick() { | ||
| 216 | 235 | const startTime = Date.now(); |
| 217 | 236 | const remaining = await synchronizeChat(batchSize); |
| 218 | 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 | 244 | elapsedLog.push(elapsed); |
| 220 | 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 | 255 | const lastElapsed = elapsedLog.slice(-5); // last 5 elapsed times |
| 226 | 256 | const averageElapsed = lastElapsed.reduce((a, b) => a + b, 0) / lastElapsed.length; // average time needed to process one item |
| 227 | 257 | const pace = averageElapsed / batchSize; // time needed to process one item |
| 228 | 258 | const remainingTime = Math.round(pace * remainingpending / 1000); |
| 229 | 259 | |
| 230 | 260 | $('#vectorize_progress_percent').text(processedPercent); |
| 231 | 261 | $('#vectorize_progress_eta').text(remainingTime); |
| @@ -234,6 +264,9 @@ async function onVectorizeAllClick() { | ||
| 234 | 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 | 270 | } catch (error) { |
| 238 | 271 | console.error('Vectors: Failed to vectorize all', error); |
| 239 | 272 | } finally { |
| @@ -304,7 +337,7 @@ async function summarizeExtra(element) { | ||
| 304 | 337 | |
| 305 | 338 | if (apiResult.ok) { |
| 306 | 339 | const data = await apiResult.json(); |
| 307 | 340 | element.text = removeReasoningFromString(data.summary); |
| 308 | 341 | } |
| 309 | 342 | } catch (error) { |
| 310 | 343 | console.log(error); |
| @@ -336,45 +369,70 @@ async function summarizeWebLLM(element) { | ||
| 336 | 369 | } |
| 337 | 370 | |
| 338 | 371 | const messages = [{ role: 'system', content: settings.summary_prompt }, { role: 'user', content: element.text }]; |
| 339 | 372 | element.text = removeReasoningFromString(await generateWebLlmChatPrompt(messages)); |
| 340 | 373 | |
| 341 | 374 | return true; |
| 342 | 375 | } |
| 343 | 376 | |
| 344 | 377 | /** |
| 345 | 378 | * SummarizesRuns messagesone usingsummarization attempt for a single element via the chosen methodendpoint. |
| 346 | 379 | * @param {HashedMessage[]} hashedMessages Array of hashed messageselement |
| 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 | 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 | 404 | * @returns {Promise<HashedMessage[]>} Summarized messages |
| 349 | 405 | */ |
| 350 | 406 | async function summarize(hashedMessages, endpoint = 'main', { skipOnFailure = false } = {}) { |
| 407 | + const maxAttempts = Math.max(1, Number(settings.summary_retries) || 1); | |
| 351 | 408 | for (const element of hashedMessages) { |
| 352 | 409 | const cachedSummary = cachedSummaries.get(element.hash); |
| 353 | 410 | if (!cachedSummary) { |
| 354 | 411 | let successelement.text = truecachedSummary; |
| 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 | 437 | return hashedMessages; |
| 380 | 438 | } |
| @@ -401,21 +459,43 @@ async function synchronizeChat(batchSize = 5) { | ||
| 401 | 459 | return -1; |
| 402 | 460 | } |
| 403 | 461 | |
| 462 | + /** @type {HashedMessage[]} */ | |
| 404 | 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 | 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 | 469 | const deletedHashes = hashesInCollection.filter(x => !hashedMessages.some(y => y.hash === x)); |
| 409 | 470 | |
| 471 | + let batch = newVectorItems.slice(0, batchSize); | |
| 472 | + | |
| 410 | 473 | if (settings.summarize) { |
| 411 | 474 | newVectorItemsconst =minLength await= summarizeMath.max(newVectorItems0, Number(settings.summary_sourcesummary_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 | 486 | if (newVectorItemsbatch.length > 0) { |
| 415 | 487 | const chunkedBatch = splitByChunks(newVectorItems.slice(0, batchSize)batch); |
| 416 | 488 | |
| 417 | 489 | console.log(`Vectors: Found ${newVectorItems.length} new items. Processing ${batchSizebatch.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 | 501 | if (deletedHashes.length > 0) { |
| @@ -444,6 +524,10 @@ async function synchronizeChat(batchSize = 5) { | ||
| 444 | 524 | return 'WebLLM extension is not installed or the model is not set.'; |
| 445 | 525 | case 'account_id_missing': |
| 446 | 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 | 531 | default: |
| 448 | 532 | return 'Check server console for more details'; |
| 449 | 533 | } |
| @@ -453,7 +537,7 @@ async function synchronizeChat(batchSize = 5) { | ||
| 453 | 537 | |
| 454 | 538 | const message = getErrorMessage(error.cause); |
| 455 | 539 | toastr.error(message, 'Vectorization failed', { preventDuplicates: true }); |
| 456 | 540 | return -1null; |
| 457 | 541 | } finally { |
| 458 | 542 | syncBlocked = false; |
| 459 | 543 | } |
| @@ -827,7 +911,11 @@ async function getQueryText(chat, initiator) { | ||
| 827 | 911 | .slice(0, settings.query); |
| 828 | 912 | |
| 829 | 913 | if (initiator === 'chat' && settings.enabled_chats && settings.summarize && settings.summarize_sent) { |
| 830 | 914 | hashedMessagesconst =minLength await= summarizeMath.max(hashedMessages0, Number(settings.summary_sourcesummary_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 | 921 | const queryText = hashedMessages.map(x => x.text).join('\n'); |
| @@ -1830,6 +1918,20 @@ export async function init() { | ||
| 1830 | 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 | 1935 | $('#vectors_message_chunk_size').val(settings.message_chunk_size).on('input', () => { |
| 1834 | 1936 | settings.message_chunk_size = Number($('#vectors_message_chunk_size').val()); |
| 1835 | 1937 | Object.assign(extension_settings.vectors, settings); |
| @@ -493,6 +493,16 @@ | ||
| 493 | 493 | <label for="vectors_summary_prompt" title="Summary Prompt:">Summary Prompt:</label> |
| 494 | 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 | 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 | 506 | </div> |
| 497 | 507 | </div> |
| 498 | 508 | <small data-i18n="Old messages are vectorized gradually as you chat. To process all previous messages, click the button below."> |