From cd9e45c883cadad6e49bc830f6b3ec04c6421e50 Mon Sep 17 00:00:00 2001 From: Wenhao Date: Mon, 20 Jul 2026 13:22:51 +0800 Subject: [PATCH 1/5] fix: verify provider image uploads --- .../text-injection-all-providers.js | 418 +++++++++++++- ...ovider-image-upload-content-script.test.js | 515 ++++++++++++++++++ 2 files changed, 906 insertions(+), 27 deletions(-) create mode 100644 tests/provider-image-upload-content-script.test.js diff --git a/content-scripts/text-injection-all-providers.js b/content-scripts/text-injection-all-providers.js index 6a4acbc..5a47b78 100644 --- a/content-scripts/text-injection-all-providers.js +++ b/content-scripts/text-injection-all-providers.js @@ -18,6 +18,13 @@ const MULTI_PANEL_USER_INTERACTION_TRACKING_TIMEOUT_MS = 90000; const TEMP_CHAT_POLL_INTERVAL_MS = 200; const TEMP_CHAT_POLL_TIMEOUT_MS = 1200; + const IMAGE_UPLOAD_PREVIEW_TIMEOUT_MS = 6000; + const IMAGE_INJECTION_REASONS = Object.freeze({ + CONTROL_NOT_FOUND: 'control-not-found', + UNSUPPORTED: 'unsupported', + PREVIEW_TIMEOUT: 'preview-timeout', + INJECTION_ERROR: 'injection-error' + }); let googleSearchReplaceOnNextFill = true; let chatgptSendTracking = null; let multiPanelUserInteractionTracking = null; @@ -101,7 +108,7 @@ gemini: true, grok: true, deepseek: true, - kimi: true, // Kimi supports images + kimi: true, doubao: true, 'qwen-cn': true, 'qwen-global': true, @@ -115,9 +122,6 @@ chatgpt: ['input[type="file"][data-testid="file-upload-input"]', 'input[type="file"]'], claude: ['input[type="file"]'], gemini: ['input[type="file"]'], - grok: ['input[type="file"]'], - deepseek: ['input[type="file"]'], - kimi: ['input[type="file"]'], doubao: ['input[type="file"]'], 'qwen-cn': ['input[type="file"][accept*="image"]'], 'qwen-global': [ @@ -134,9 +138,6 @@ chatgpt: ['button[aria-label="Attach files"]', 'button[data-testid="composer-attach-button"]', 'button:has(svg path[d*="M9"])'], claude: ['button[aria-label="Attach file"]', 'button[aria-label="Upload file"]', 'fieldset button:has(svg)'], gemini: ['button[aria-label="Upload file"]', 'button[mattooltip="Upload file"]', '.add-button', 'button:has(mat-icon)'], - grok: [], - deepseek: [], - kimi: [], // Kimi supports drag-drop for images doubao: [ '#input-engine-container button[data-slot="dropdown-menu-trigger"][aria-haspopup="menu"]' ], @@ -1494,7 +1495,7 @@ if (!provider) { console.warn('[Image Injection] Provider not detected'); - return; + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); } if (provider === 'google' && providerMode === GOOGLE_PROVIDER_MODE_SEARCH) { @@ -1502,7 +1503,7 @@ if (text && text.trim()) { handleGoogleTextInjection(text, autoSubmit, providerMode); } - return; + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.UNSUPPORTED); } if (!PROVIDER_IMAGE_SUPPORT[provider]) { @@ -1511,12 +1512,12 @@ if (text) { injectText(provider, text, autoSubmit, providerMode); } - return; + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.UNSUPPORTED); } if (!images || images.length === 0) { console.warn('[Image Injection] No images provided'); - return; + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); } console.log(`[Image Injection] Injecting ${images.length} images to ${provider}`); @@ -1536,12 +1537,17 @@ // Inject images first for (const image of images) { - imageInjectionResults.push(await injectSingleImage(provider, image)); + const result = await injectSingleImage(provider, image); + imageInjectionResults.push(result); + if (!result.ok) { + break; + } // Wait a bit between images await sleep(200); } - const allImagesInjected = imageInjectionResults.every(Boolean); + const allImagesInjected = imageInjectionResults.length === images.length && + imageInjectionResults.every(result => result.ok); if (!allImagesInjected) { console.warn('[Image Injection] One or more images failed to inject for:', provider); } @@ -1556,50 +1562,408 @@ } else if (autoSubmit) { if (!allImagesInjected) { console.warn('[Image Injection] Skipping auto-submit because image injection failed for:', provider); - return; + return imageInjectionResults.find(result => !result.ok) || + createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); } // If no text but autoSubmit is true, click send button await sleep(300); clickSendButton(provider, providerMode); } + + return allImagesInjected + ? createImageInjectionSuccess() + : imageInjectionResults.find(result => !result.ok) || + createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); } catch (error) { console.error('[Image Injection] Error:', error); + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); } } + function createImageInjectionSuccess() { + return { ok: true }; + } + + function createImageInjectionFailure(reason) { + return { ok: false, reason }; + } + + function normalizeImageInjectionResult(result) { + if (result && typeof result.ok === 'boolean') { + return result; + } + + return result + ? createImageInjectionSuccess() + : createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); + } + // Inject a single image to the provider using provider-specific strategy async function injectSingleImage(provider, imageData) { console.log('[Image Injection] Injecting image to', provider); // Use provider-specific strategies + let result; switch (provider) { case 'chatgpt': - return await injectImageToChatGPT(imageData); + result = await injectImageToChatGPT(imageData); + break; case 'claude': - return await injectImageToClaude(imageData); + result = await injectImageToClaude(imageData); + break; case 'gemini': - return await injectImageToGemini(imageData); + result = await injectImageToGemini(imageData); + break; case 'grok': + return await injectImageToGrok(imageData); case 'deepseek': - // These work with drag-drop - return await tryDragDropUpload(provider, imageData); + return await injectImageToDeepSeek(imageData); + case 'kimi': + return await injectImageToKimi(imageData); case 'doubao': - return await injectImageToDoubao(imageData); + result = await injectImageToDoubao(imageData); + break; case 'qwen-cn': case 'qwen-global': - return await injectImageToQwen(provider, imageData); + result = await injectImageToQwen(provider, imageData); + break; case 'chatglm': - return await injectImageToChatGLM(imageData); + result = await injectImageToChatGLM(imageData); + break; case 'zai-global': - return await injectImageToZaiGlobal(imageData); + result = await injectImageToZaiGlobal(imageData); + break; case 'google': - return await injectImageToGoogle(imageData); + result = await injectImageToGoogle(imageData); + break; default: // Fallback: try file input first, then drag-drop if (await tryFileInputUpload(provider, imageData)) { - return true; + result = true; + break; + } + result = await tryDragDropUpload(provider, imageData); + break; + } + + return normalizeImageInjectionResult(result); + } + + function findClosestAncestorContaining(element, selector, maxDepth = 8) { + let current = element; + + for (let depth = 0; current && depth <= maxDepth; depth++) { + if (current === document.body || current === document.documentElement) { + return null; + } + try { + if (current.querySelector?.(selector)) { + return current; } - return await tryDragDropUpload(provider, imageData); + } catch (error) { + console.warn('[Image Injection] Invalid scoped selector:', selector, error); + return null; + } + current = current.parentElement; + } + + return null; + } + + function findVisibleElementByExactText(selector, expectedTexts) { + const normalizedTexts = expectedTexts.map(text => text.trim().toLowerCase()); + + for (const element of document.querySelectorAll(selector)) { + const text = (element.textContent || '').trim().toLowerCase(); + if (normalizedTexts.includes(text) && isVisibleElement(element)) { + return element; + } + } + + return null; + } + + function acceptsImageFiles(fileInput) { + const accept = (fileInput?.getAttribute('accept') || '').toLowerCase(); + return !accept || accept.includes('image/') || [ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif', '.apng' + ].some(extension => accept.includes(extension)); + } + + async function createImageFile(imageData) { + const blob = await dataUrlToBlob(imageData.dataUrl); + return new File([blob], imageData.name, { type: imageData.type }); + } + + function dispatchFileInputEvents(fileInput) { + fileInput.dispatchEvent(new Event('input', { bubbles: true })); + fileInput.dispatchEvent(new Event('change', { bubbles: true })); + } + + function dispatchDragEventsToWakeInput(target, file) { + if (!target) { + return; + } + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + ['dragenter', 'dragover', 'dragleave'].forEach(type => { + target.dispatchEvent(new DragEvent(type, { + bubbles: true, + cancelable: true, + dataTransfer + })); + }); + } + + async function waitForFileInput(findFileInput, timeoutMs = 1000) { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const fileInput = findFileInput(); + if (fileInput) { + return fileInput; + } + await sleep(100); + } + + return null; + } + + async function waitForPreviewIncrease(getCount, previousCount, timeoutMs = IMAGE_UPLOAD_PREVIEW_TIMEOUT_MS) { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + if (getCount() > previousCount) { + return true; + } + await sleep(100); + } + + return false; + } + + function countFilenamePreviewEvidence(root, fileName) { + if (!root) { + return 0; + } + + const normalizedName = (fileName || '').trim().toLowerCase(); + const evidence = new Set(); + + root.querySelectorAll('img[alt], button, [role="button"]').forEach(element => { + const alt = (element.getAttribute('alt') || '').trim().toLowerCase(); + const accessibleText = getElementAccessibleText(element); + if (alt === normalizedName || accessibleText.includes(normalizedName)) { + evidence.add(element.closest('button, [role="button"]') || element); + } + }); + + return evidence.size; + } + + function getGrokComposer() { + const editor = document.querySelector('[data-testid="chat-input"] [contenteditable="true"]') || + document.querySelector('[contenteditable="true"][aria-label="Ask Grok anything"]'); + return editor?.closest('form') || null; + } + + function findGrokFileInput(composer) { + return [...(composer?.querySelectorAll('input[type="file"]') || [])] + .find(input => acceptsImageFiles(input)) || null; + } + + function countGrokAttachmentPreviews(composer, fileName) { + const attachmentList = composer?.querySelector('[role="list"][aria-label="Conversation attachments"]'); + const attachmentListCount = attachmentList?.children.length || 0; + + // Grok may render the visible attachment chip in a portal outside the form. + // The filename is exposed as the preview button/image accessible name. + const filenameEvidence = countFilenamePreviewEvidence(document, fileName); + return Math.max(attachmentListCount, filenameEvidence); + } + + async function injectImageToGrok(imageData) { + try { + const composer = getGrokComposer(); + if (!composer) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); + } + + const attachButton = composer.querySelector('button[aria-label="Attach"], button[data-testid="attach-button"]'); + if (!attachButton) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); + } + + const file = await createImageFile(imageData); + let fileInput = findGrokFileInput(composer); + if (!fileInput) { + // Grok ignores synthetic clicks when deciding whether to open its + // attachment menu. Its native composer file input is still usable; + // only use synthetic UI events to wake a lazily mounted input. + attachButton.click(); + dispatchDragEventsToWakeInput(composer, file); + fileInput = await waitForFileInput(() => findGrokFileInput(composer)); + } + if (!fileInput) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); + } + + const previousCount = countGrokAttachmentPreviews(composer, imageData.name); + if (!assignFilesToInput(fileInput, [file])) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); + } + dispatchFileInputEvents(fileInput); + + const accepted = await waitForPreviewIncrease( + () => countGrokAttachmentPreviews(composer, imageData.name), + previousCount + ); + return accepted + ? createImageInjectionSuccess() + : createImageInjectionFailure(IMAGE_INJECTION_REASONS.PREVIEW_TIMEOUT); + } catch (error) { + console.error('[Image Injection] Grok error:', error); + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); + } + } + + function getDeepSeekComposer() { + const editor = document.querySelector('textarea[placeholder="Message DeepSeek"]') || + document.querySelector('textarea.ds-scroll-area') || + document.querySelector('textarea'); + return findClosestAncestorContaining(editor, 'input[type="file"]'); + } + + function findDeepSeekFileInput(composer) { + return [...(composer?.querySelectorAll('input[type="file"]') || [])] + .find(input => input.multiple && acceptsImageFiles(input)) || null; + } + + async function injectImageToDeepSeek(imageData) { + try { + const composer = getDeepSeekComposer(); + if (!composer) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); + } + + const file = await createImageFile(imageData); + let fileInput = findDeepSeekFileInput(composer); + if (!fileInput) { + dispatchDragEventsToWakeInput(composer, file); + fileInput = await waitForFileInput(() => findDeepSeekFileInput(composer)); + } + if (!fileInput) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); + } + + // DeepSeek renders attachment previews beside the composer controls rather + // than consistently inside the nearest file-input ancestor. + const previousCount = countFilenamePreviewEvidence(document, imageData.name); + if (!assignFilesToInput(fileInput, [file])) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); + } + dispatchFileInputEvents(fileInput); + + const accepted = await waitForPreviewIncrease( + () => countFilenamePreviewEvidence(document, imageData.name), + previousCount + ); + return accepted + ? createImageInjectionSuccess() + : createImageInjectionFailure(IMAGE_INJECTION_REASONS.PREVIEW_TIMEOUT); + } catch (error) { + console.error('[Image Injection] DeepSeek error:', error); + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); + } + } + + function getKimiComposer() { + const editor = document.querySelector('.chat-input-editor[contenteditable="true"]') || + document.querySelector('.chat-input-editor'); + return editor?.closest('.chat-editor') || editor?.closest('#chat-box') || null; + } + + function countKimiAttachmentPreviews(composer) { + // Kimi promotes the thumbnail to `success` only after its upload API has + // returned a signed remote image URL. Error thumbnails use a different + // status class and must not be acknowledged as successful attachments. + return composer?.querySelectorAll( + '.image-thumbnail.success .image-wrapper.image-detail img.image-main' + ).length || 0; + } + + function countKimiPendingAttachmentPreviews(composer) { + return composer?.querySelectorAll('.image-thumbnail.loading').length || 0; + } + + async function waitForKimiPendingPreview(composer, previousSuccessCount) { + const start = Date.now(); + + while (Date.now() - start < IMAGE_UPLOAD_PREVIEW_TIMEOUT_MS) { + if (countKimiAttachmentPreviews(composer) > previousSuccessCount) { + return true; + } + if (countKimiPendingAttachmentPreviews(composer) === 0) { + return false; + } + await sleep(100); + } + + return false; + } + + async function injectImageToKimi(imageData) { + try { + const composer = getKimiComposer(); + if (!composer) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); + } + + const previousCount = countKimiAttachmentPreviews(composer); + if (countKimiPendingAttachmentPreviews(composer) > 0) { + const pendingAccepted = await waitForKimiPendingPreview(composer, previousCount); + return pendingAccepted + ? createImageInjectionSuccess() + : createImageInjectionFailure(IMAGE_INJECTION_REASONS.PREVIEW_TIMEOUT); + } + + const toolkitTrigger = composer.querySelector('.toolkit-trigger-btn'); + if (!toolkitTrigger) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.UNSUPPORTED); + } + + let uploadEntry = findVisibleElementByExactText('label', ['文件和图片', 'Files and images']); + if (!uploadEntry) { + toolkitTrigger.click(); + await sleep(100); + uploadEntry = findVisibleElementByExactText('label', ['文件和图片', 'Files and images']); + } + if (!uploadEntry) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.UNSUPPORTED); + } + + const fileInput = uploadEntry.querySelector('input[type="file"]'); + if (!fileInput || !acceptsImageFiles(fileInput)) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); + } + + const file = await createImageFile(imageData); + if (!assignFilesToInput(fileInput, [file])) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); + } + dispatchFileInputEvents(fileInput); + + const accepted = await waitForPreviewIncrease( + () => countKimiAttachmentPreviews(composer), + previousCount + ); + return accepted + ? createImageInjectionSuccess() + : createImageInjectionFailure(IMAGE_INJECTION_REASONS.PREVIEW_TIMEOUT); + } catch (error) { + console.error('[Image Injection] Kimi error:', error); + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); } } @@ -2113,7 +2477,7 @@ return false; } - // Try to upload image via drag-drop event (works for Grok, DeepSeek) + // Generic drag-drop fallback for providers without a dedicated adapter. async function tryDragDropUpload(provider, imageData) { try { const selectors = PROVIDER_SELECTORS[provider]; diff --git a/tests/provider-image-upload-content-script.test.js b/tests/provider-image-upload-content-script.test.js new file mode 100644 index 0000000..a80b0fd --- /dev/null +++ b/tests/provider-image-upload-content-script.test.js @@ -0,0 +1,515 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const contentScriptSource = readFileSync( + resolve(process.cwd(), 'content-scripts/text-injection-all-providers.js'), + 'utf8' +); + +const SAMPLE_IMAGES = [ + { + name: 'sample-one.png', + type: 'image/png', + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2Z2ioAAAAASUVORK5CYII=', + }, + { + name: 'sample-two.png', + type: 'image/png', + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2Z2ioAAAAASUVORK5CYII=', + }, +]; + +function markVisible(element) { + Object.defineProperty(element, 'offsetParent', { + configurable: true, + get: () => document.body, + }); + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + top: 10, + left: 10, + right: 100, + bottom: 40, + width: 90, + height: 30, + }), + }); +} + +function dispatchImageInjection({ images = SAMPLE_IMAGES.slice(0, 1), text = '', autoSubmit = false } = {}) { + window.dispatchEvent(new MessageEvent('message', { + data: { + type: 'INJECT_TEXT_WITH_IMAGES', + context: 'multi-panel', + images, + text, + autoSubmit, + }, + })); +} + +async function finishSuccessfulInjection() { + await vi.advanceTimersByTimeAsync(2500); +} + +async function finishTimedOutInjection() { + await vi.advanceTimersByTimeAsync(7500); +} + +function createGrokDom({ + menuAvailable = true, + includeFileInput = true, + previewOnChange = true, + previewOutsideComposer = false, +} = {}) { + document.body.innerHTML = ` +
+ ${includeFileInput ? '' : ''} +
+
+
+
+ + +
+ `; + + const composer = document.getElementById('grok-composer'); + const fileInput = document.getElementById('grok-file'); + const attachButton = composer.querySelector('[aria-label="Attach"]'); + const sendButton = composer.querySelector('[aria-label="Send"]'); + const attachmentList = composer.querySelector('[aria-label="Conversation attachments"]'); + [attachButton, sendButton].forEach(markVisible); + + attachButton.addEventListener('click', () => { + if (!menuAvailable || document.getElementById('grok-upload-menu-item')) return; + const item = document.createElement('div'); + item.id = 'grok-upload-menu-item'; + item.setAttribute('role', 'menuitem'); + item.textContent = 'Upload a file'; + markVisible(item); + document.body.append(item); + }); + + const uploadedNames = []; + fileInput?.addEventListener('change', () => { + const file = fileInput.files[0]; + uploadedNames.push(file.name); + if (!previewOnChange) return; + if (previewOutsideComposer) { + const previewButton = document.createElement('button'); + previewButton.type = 'button'; + const image = document.createElement('img'); + image.alt = file.name; + previewButton.append(image); + document.body.append(previewButton); + return; + } + const preview = document.createElement('div'); + preview.dataset.fileName = file.name; + attachmentList.append(preview); + }); + + return { attachmentList, fileInput, sendButton, uploadedNames }; +} + +function createDeepSeekDom({ + includeFileInput = true, + previewOnChange = true, + previewOutsideComposer = false, +} = {}) { + document.body.innerHTML = ` +
+ + ${includeFileInput ? '' : ''} + +
+
+ `; + + const composer = document.getElementById('deepseek-composer'); + const fileInput = document.getElementById('deepseek-file'); + const sendButton = composer.querySelector('[aria-label="Send"]'); + markVisible(sendButton); + const uploadedNames = []; + + fileInput?.addEventListener('change', () => { + const file = fileInput.files[0]; + uploadedNames.push(file.name); + if (!previewOnChange) return; + const previewButton = document.createElement('button'); + previewButton.type = 'button'; + previewButton.setAttribute('aria-label', `${file.name} No text extracted`); + const image = document.createElement('img'); + image.alt = file.name; + image.src = `blob:https://chat.deepseek.com/${file.name}`; + previewButton.append(image); + const previewRoot = previewOutsideComposer + ? document.body + : document.getElementById('deepseek-previews'); + previewRoot.append(previewButton); + }); + + return { composer, fileInput, sendButton, uploadedNames }; +} + +function createKimiDom({ + uploadAvailable = true, + previewOnChange = true, + language = 'zh', + remoteErrorPreview = false, + existingLoadingPreview = false, + existingPreviewOutcome = null, + existingPreviewDelayMs = 500, +} = {}) { + document.body.innerHTML = ` +
+
+
+
+
+
Send
+
+
+
+ `; + + const composer = document.querySelector('.chat-editor'); + const toolkitTrigger = composer.querySelector('.toolkit-trigger-btn'); + const sendButton = composer.querySelector('.send-button-container'); + [toolkitTrigger, sendButton].forEach(markVisible); + const uploadedNames = []; + + function appendThumbnail(status, fileName = '') { + const thumbnail = document.createElement('div'); + thumbnail.className = `image-thumbnail middle ${status}`; + const wrapper = document.createElement('div'); + wrapper.className = 'image-wrapper image-detail'; + const image = document.createElement('img'); + image.className = 'image-main is-cover'; + if (status === 'success') { + image.src = `https://www.kimi.com/apiv2-files/sign-obj/${fileName}`; + } else if (status === 'error') { + image.src = `https://statics.moonshot.cn/kimi-upload-error/${fileName}`; + } + wrapper.append(image); + thumbnail.append(wrapper); + document.getElementById('kimi-previews').append(thumbnail); + return { thumbnail, image }; + } + + if (existingLoadingPreview) { + const { thumbnail, image } = appendThumbnail('loading', 'existing.png'); + if (existingPreviewOutcome) { + setTimeout(() => { + thumbnail.classList.replace('loading', existingPreviewOutcome); + image.src = existingPreviewOutcome === 'success' + ? 'https://www.kimi.com/apiv2-files/sign-obj/existing.png' + : 'https://statics.moonshot.cn/kimi-upload-error/existing.png'; + }, existingPreviewDelayMs); + } + } + + toolkitTrigger.addEventListener('click', () => { + if (!uploadAvailable || document.getElementById('kimi-upload-entry')) return; + const entry = document.createElement('label'); + entry.id = 'kimi-upload-entry'; + entry.textContent = language === 'en' ? 'Files and images' : '文件和图片'; + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.multiple = true; + fileInput.accept = '.jpg,.jpeg,.png,.gif,.webp,.svg'; + entry.append(fileInput); + markVisible(entry); + document.body.append(entry); + + fileInput.addEventListener('change', () => { + const file = fileInput.files[0]; + uploadedNames.push(file.name); + if (!previewOnChange) return; + appendThumbnail(remoteErrorPreview ? 'error' : 'success', file.name); + }); + }); + + return { composer, sendButton, uploadedNames }; +} + +describe('provider image upload adapters', () => { + beforeAll(() => { + window.eval(contentScriptSource); + }); + + beforeEach(() => { + vi.useFakeTimers(); + vi.restoreAllMocks(); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn(() => false), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.innerHTML = ''; + }); + + it('uploads one Grok image through the native composer input and waits for its attachment preview', async () => { + window.happyDOM.setURL('https://grok.com/'); + const { attachmentList, uploadedNames } = createGrokDom(); + + dispatchImageInjection(); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png']); + expect(attachmentList.children).toHaveLength(1); + }); + + it('verifies every Grok image in a multi-image fill', async () => { + window.happyDOM.setURL('https://grok.com/'); + const { attachmentList, uploadedNames } = createGrokDom(); + + dispatchImageInjection({ images: SAMPLE_IMAGES }); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png', 'sample-two.png']); + expect(attachmentList.children).toHaveLength(2); + }); + + it('uses Grok composer input when its synthetic attach click does not open the menu', async () => { + window.happyDOM.setURL('https://grok.com/'); + const { uploadedNames } = createGrokDom({ menuAvailable: false }); + + dispatchImageInjection(); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png']); + }); + + it('accepts a Grok filename preview rendered outside the composer form', async () => { + window.happyDOM.setURL('https://grok.com/'); + createGrokDom({ previewOutsideComposer: true }); + + dispatchImageInjection({ requestId: 'fill-request-grok-portal-preview' }); + await finishSuccessfulInjection(); + + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-grok-portal-preview', + provider: 'grok', + action: 'fill', + status: 'succeeded', + }, '*'); + }); + + it('keeps Grok text and does not submit when the attachment preview times out', async () => { + window.happyDOM.setURL('https://grok.com/'); + const { sendButton } = createGrokDom({ previewOnChange: false }); + const sendSpy = vi.fn(); + sendButton.addEventListener('click', sendSpy); + + dispatchImageInjection({ text: 'keep this text', autoSubmit: true }); + await finishTimedOutInjection(); + + expect(document.querySelector('.tiptap').textContent).toContain('keep this text'); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('does not fall back to an unrelated Grok file input outside the composer', async () => { + window.happyDOM.setURL('https://grok.com/'); + const { uploadedNames } = createGrokDom({ includeFileInput: false }); + const unrelatedInput = document.createElement('input'); + unrelatedInput.type = 'file'; + unrelatedInput.multiple = true; + document.body.append(unrelatedInput); + + dispatchImageInjection(); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual([]); + expect(unrelatedInput.files).toHaveLength(0); + }); + + it('uploads one DeepSeek image through the composer-scoped multiple input', async () => { + window.happyDOM.setURL('https://chat.deepseek.com/'); + const { composer, uploadedNames } = createDeepSeekDom(); + + dispatchImageInjection(); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png']); + expect(composer.querySelector('img[alt="sample-one.png"]')).not.toBeNull(); + }); + + it('verifies every DeepSeek image in a multi-image fill', async () => { + window.happyDOM.setURL('https://chat.deepseek.com/'); + const { composer, uploadedNames } = createDeepSeekDom(); + + dispatchImageInjection({ images: SAMPLE_IMAGES }); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png', 'sample-two.png']); + expect(composer.querySelectorAll('#deepseek-previews img')).toHaveLength(2); + }); + + it('accepts a DeepSeek filename preview rendered outside the composer ancestor', async () => { + window.happyDOM.setURL('https://chat.deepseek.com/'); + createDeepSeekDom({ previewOutsideComposer: true }); + + dispatchImageInjection({ requestId: 'fill-request-deepseek-portal-preview' }); + await finishSuccessfulInjection(); + + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-deepseek-portal-preview', + provider: 'deepseek', + action: 'fill', + status: 'succeeded', + }, '*'); + }); + + it('keeps DeepSeek text and does not submit when the attachment preview times out', async () => { + window.happyDOM.setURL('https://chat.deepseek.com/'); + const { sendButton } = createDeepSeekDom({ previewOnChange: false }); + const sendSpy = vi.fn(); + sendButton.addEventListener('click', sendSpy); + + dispatchImageInjection({ text: 'keep this text', autoSubmit: true }); + await finishTimedOutInjection(); + + expect(document.querySelector('textarea').value).toContain('keep this text'); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('does not fall back to a generic DeepSeek page input when the composer input is missing', async () => { + window.happyDOM.setURL('https://chat.deepseek.com/'); + const { uploadedNames } = createDeepSeekDom({ includeFileInput: false }); + const unrelatedInput = document.createElement('input'); + unrelatedInput.type = 'file'; + unrelatedInput.multiple = true; + document.body.append(unrelatedInput); + + dispatchImageInjection(); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual([]); + expect(unrelatedInput.files).toHaveLength(0); + }); + + it.each([ + ['Chinese', 'zh'], + ['English', 'en'], + ])('uploads one Kimi image through the native %s toolkit entry', async (_name, language) => { + window.happyDOM.setURL('https://www.kimi.com/'); + const { composer, uploadedNames } = createKimiDom({ language }); + + dispatchImageInjection(); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png']); + expect(composer.querySelector('.image-thumbnail.success img.image-main')).not.toBeNull(); + }); + + it('verifies every Kimi image in a multi-image fill', async () => { + window.happyDOM.setURL('https://www.kimi.com/'); + const { composer, uploadedNames } = createKimiDom(); + + dispatchImageInjection({ images: SAMPLE_IMAGES }); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png', 'sample-two.png']); + expect(composer.querySelectorAll('.image-thumbnail.success img.image-main')).toHaveLength(2); + }); + + it('does not accept an unnamed Kimi remote error thumbnail as a successful preview', async () => { + window.happyDOM.setURL('https://www.kimi.com/'); + const { sendButton } = createKimiDom({ remoteErrorPreview: true }); + const sendSpy = vi.fn(); + sendButton.addEventListener('click', sendSpy); + + dispatchImageInjection({ + requestId: 'fill-request-kimi-error-thumbnail', + autoSubmit: true, + }); + await finishTimedOutInjection(); + + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-kimi-error-thumbnail', + provider: 'kimi', + action: 'fill', + status: 'failed', + reason: 'preview-timeout', + }, '*'); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('reconciles an in-flight Kimi upload without injecting the image twice', async () => { + window.happyDOM.setURL('https://www.kimi.com/'); + const { uploadedNames } = createKimiDom({ + existingLoadingPreview: true, + existingPreviewOutcome: 'success', + }); + + dispatchImageInjection({ requestId: 'fill-request-kimi-pending-success' }); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual([]); + expect(document.querySelectorAll('.image-thumbnail.success')).toHaveLength(1); + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-kimi-pending-success', + provider: 'kimi', + action: 'fill', + status: 'succeeded', + }, '*'); + }); + + it('does not duplicate a Kimi upload that remains in progress', async () => { + window.happyDOM.setURL('https://www.kimi.com/'); + const { uploadedNames } = createKimiDom({ existingLoadingPreview: true }); + + dispatchImageInjection({ requestId: 'fill-request-kimi-pending-timeout' }); + await finishTimedOutInjection(); + + expect(uploadedNames).toEqual([]); + expect(document.querySelectorAll('.image-thumbnail.loading')).toHaveLength(1); + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-kimi-pending-timeout', + provider: 'kimi', + action: 'fill', + status: 'failed', + reason: 'preview-timeout', + }, '*'); + }); + + it('keeps Kimi text and does not submit when the attachment preview times out', async () => { + window.happyDOM.setURL('https://www.kimi.com/'); + const { sendButton } = createKimiDom({ previewOnChange: false }); + const sendSpy = vi.fn(); + sendButton.addEventListener('click', sendSpy); + + dispatchImageInjection({ text: 'keep this text', autoSubmit: true }); + await finishTimedOutInjection(); + + expect(document.querySelector('.chat-input-editor').textContent).toContain('keep this text'); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('reports Kimi as unsupported when the native files and images entry is unavailable', async () => { + window.happyDOM.setURL('https://www.kimi.com/'); + const { uploadedNames } = createKimiDom({ uploadAvailable: false }); + + dispatchImageInjection(); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual([]); + expect(document.querySelector('input[type="file"]')).toBeNull(); + }); +}); From ff4260a331dc8542caed50639ea13280a41df9dc Mon Sep 17 00:00:00 2001 From: Wenhao Date: Mon, 20 Jul 2026 13:30:54 +0800 Subject: [PATCH 2/5] fix: await image fill results --- .../text-injection-all-providers.js | 50 +++- modules/panel-action-results.js | 173 ++++++++++++++ multi-panel/multi-panel.js | 221 +++++++++++++++--- tests/panel-action-results.test.js | 183 +++++++++++++++ ...ovider-image-upload-content-script.test.js | 72 +++++- 5 files changed, 664 insertions(+), 35 deletions(-) create mode 100644 modules/panel-action-results.js create mode 100644 tests/panel-action-results.test.js diff --git a/content-scripts/text-injection-all-providers.js b/content-scripts/text-injection-all-providers.js index 5a47b78..7b42f33 100644 --- a/content-scripts/text-injection-all-providers.js +++ b/content-scripts/text-injection-all-providers.js @@ -7,6 +7,8 @@ const GOOGLE_PROVIDER_MODE_AI = 'ai'; const GOOGLE_PROVIDER_MODE_SEARCH = 'search'; const MULTI_PANEL_PROVIDER_STATUS_CONTEXT = 'multi-panel-provider-status'; + const MULTI_PANEL_ACTION_RESULT_CONTEXT = 'multi-panel-action-result'; + const PANELIZE_ACTION_RESULT = 'PANELIZE_ACTION_RESULT'; const PANELIZE_PROVIDER_BUSY = 'PANELIZE_PROVIDER_BUSY'; const PANELIZE_PROVIDER_IDLE = 'PANELIZE_PROVIDER_IDLE'; const PANELIZE_PROVIDER_USER_INTERACTION = 'PANELIZE_PROVIDER_USER_INTERACTION'; @@ -484,6 +486,27 @@ }, '*'); } + function postMultiPanelActionResult(requestId, provider, result) { + if (!requestId || !provider || window.parent === window) { + return; + } + + const message = { + type: PANELIZE_ACTION_RESULT, + context: MULTI_PANEL_ACTION_RESULT_CONTEXT, + requestId, + provider, + action: 'fill', + status: result.ok ? 'succeeded' : 'failed' + }; + + if (!result.ok) { + message.reason = result.reason || IMAGE_INJECTION_REASONS.INJECTION_ERROR; + } + + window.parent.postMessage(message, '*'); + } + function postTemporaryChatEnabled(provider = detectProvider()) { if (!provider || window.parent === window) { return; @@ -1556,9 +1579,10 @@ await sleep(500); // Then inject text if provided + let textInjected = true; if (text && text.trim()) { await sleep(300); - injectText(provider, text, autoSubmit && allImagesInjected, providerMode); + textInjected = injectText(provider, text, autoSubmit && allImagesInjected, providerMode); } else if (autoSubmit) { if (!allImagesInjected) { console.warn('[Image Injection] Skipping auto-submit because image injection failed for:', provider); @@ -1570,10 +1594,14 @@ clickSendButton(provider, providerMode); } - return allImagesInjected - ? createImageInjectionSuccess() - : imageInjectionResults.find(result => !result.ok) || + if (!allImagesInjected) { + return imageInjectionResults.find(result => !result.ok) || createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); + } + + return textInjected + ? createImageInjectionSuccess() + : createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); } catch (error) { console.error('[Image Injection] Error:', error); return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); @@ -2732,7 +2760,19 @@ // Handle INJECT_TEXT_WITH_IMAGES messages if (event.data.type === 'INJECT_TEXT_WITH_IMAGES' && event.data.context === 'multi-panel') { - handleImageInjection(event); + const provider = detectProvider(); + void handleImageInjection(event) + .then(result => { + postMultiPanelActionResult(event.data.requestId, provider, result); + }) + .catch(error => { + console.error('[Image Injection] Unhandled fill error:', error); + postMultiPanelActionResult( + event.data.requestId, + provider, + createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR) + ); + }); return; } diff --git a/modules/panel-action-results.js b/modules/panel-action-results.js new file mode 100644 index 0000000..9e012ad --- /dev/null +++ b/modules/panel-action-results.js @@ -0,0 +1,173 @@ +export const PANELIZE_ACTION_RESULT = 'PANELIZE_ACTION_RESULT'; +export const PANEL_ACTION_RESULT_CONTEXT = 'multi-panel-action-result'; +export const PANEL_ACTION_RESULT_TIMEOUT_MS = 8000; + +const ALLOWED_FAILURE_REASONS = new Set([ + 'control-not-found', + 'unsupported', + 'preview-timeout', + 'injection-error' +]); + +function createFailure(panel, reason) { + return { + ok: false, + panelId: panel.id, + provider: panel.providerId, + reason: ALLOWED_FAILURE_REASONS.has(reason) ? reason : 'injection-error' + }; +} + +/** + * Checks whether a message is the expected action result for one panel. + * + * @param {MessageEvent} event - Candidate window message. + * @param {object} panel - Panel containing id, providerId, and iframe. + * @param {string} requestId - Active action request ID. + * @param {string} action - Expected action name. + * @returns {boolean} Whether the message matches the pending panel action. + */ +export function isMatchingPanelActionResult(event, panel, requestId, action = 'fill') { + const data = event?.data; + return Boolean( + data && + typeof data === 'object' && + data.type === PANELIZE_ACTION_RESULT && + data.context === PANEL_ACTION_RESULT_CONTEXT && + data.requestId === requestId && + data.provider === panel.providerId && + data.action === action && + panel.iframe?.contentWindow === event.source && + (data.status === 'succeeded' || data.status === 'failed') + ); +} + +/** + * Creates a cancellable waiter for one panel action result. + * + * @param {object} options - Waiter configuration. + * @param {EventTarget} [options.target=window] - Message event target. + * @param {object} options.panel - Panel containing id, providerId, and iframe. + * @param {string} options.requestId - Active action request ID. + * @param {string} [options.action='fill'] - Expected action name. + * @param {number} [options.timeoutMs=8000] - Maximum wait time. + * @returns {{promise: Promise, cancel: (reason?: string) => void}} Waiter controls. + */ +export function createPanelActionResultWaiter({ + target = window, + panel, + requestId, + action = 'fill', + timeoutMs = PANEL_ACTION_RESULT_TIMEOUT_MS +}) { + let settled = false; + let timeoutId; + let resolvePromise; + + const cleanup = () => { + target.removeEventListener('message', handleMessage); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }; + + const settle = (result) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolvePromise(result); + }; + + const handleMessage = (event) => { + if (!isMatchingPanelActionResult(event, panel, requestId, action)) { + return; + } + + if (event.data.status === 'succeeded') { + settle({ + ok: true, + panelId: panel.id, + provider: panel.providerId + }); + return; + } + + settle(createFailure(panel, event.data.reason)); + }; + + const promise = new Promise(resolve => { + resolvePromise = resolve; + target.addEventListener('message', handleMessage); + timeoutId = setTimeout(() => { + settle(createFailure(panel, 'preview-timeout')); + }, timeoutMs); + }); + + return { + promise, + cancel(reason = 'injection-error') { + settle(createFailure(panel, reason)); + } + }; +} + +/** + * Returns only panels that still need an image fill attempt. + * + * @param {Array} panels - Current panel list. + * @param {Set} failedPanelIds - Failed panel IDs from the previous fill. + * @returns {Array} Panels targeted by the next fill. + */ +export function getFillTargetPanels(panels, failedPanelIds) { + if (!failedPanelIds || failedPanelIds.size === 0) { + return panels; + } + return panels.filter(panel => failedPanelIds.has(panel.id)); +} + +/** + * Summarizes the current overall fill state after targeted panel results. + * + * @param {Array} panels - Current panel list. + * @param {Array} results - Results from panels targeted in this attempt. + * @param {Set} previousFailedPanelIds - Failures before this attempt. + * @returns {{successfulCount: number, failedCount: number, failedPanelIds: Set, allSucceeded: boolean}} + * Overall fill state. + */ +export function summarizeFillResults(panels, results, previousFailedPanelIds = new Set()) { + const currentPanelIds = new Set(panels.map(panel => panel.id)); + const failedPanelIds = new Set( + [...previousFailedPanelIds].filter(panelId => currentPanelIds.has(panelId)) + ); + + results.forEach(result => { + if (!result?.panelId || !currentPanelIds.has(result.panelId)) { + return; + } + if (result.ok) { + failedPanelIds.delete(result.panelId); + } else { + failedPanelIds.add(result.panelId); + } + }); + + const failedCount = failedPanelIds.size; + return { + successfulCount: Math.max(0, panels.length - failedCount), + failedCount, + failedPanelIds, + allSucceeded: panels.length > 0 && failedCount === 0 + }; +} + +/** + * Returns whether unified text and attachments may be cleared after a fill. + * + * @param {object} summary - Fill summary from summarizeFillResults. + * @returns {boolean} True only when every current panel succeeded. + */ +export function shouldClearFillPayload(summary) { + return Boolean(summary?.allSucceeded); +} diff --git a/multi-panel/multi-panel.js b/multi-panel/multi-panel.js index 62e2b27..7421fdd 100644 --- a/multi-panel/multi-panel.js +++ b/multi-panel/multi-panel.js @@ -22,6 +22,12 @@ import { import { saveSetting } from '../modules/settings.js'; import { applyTheme } from '../modules/theme-manager.js'; import { t, initializeLanguage } from '../modules/i18n.js'; +import { + createPanelActionResultWaiter, + getFillTargetPanels, + shouldClearFillPayload, + summarizeFillResults +} from '../modules/panel-action-results.js'; import { getAllPrompts, searchPrompts, @@ -39,6 +45,9 @@ import { let currentLayout = '1x3'; let panels = []; // Array of { id, providerId, iframe, state } let uploadedImages = []; // Array of uploaded images { id, name, type, dataUrl } +let failedFillPanelIds = new Set(); +let fillActionRequestCounter = 0; +let fillPayloadRevision = 0; let loadingPanelIds = new Set(); // Track iframes still loading, used for focus protection let newChatFocusRestoreTimerIds = []; let isRestoringFocusAfterNewChat = false; @@ -1310,6 +1319,7 @@ async function addPanel(providerId) { currentUrl: null, state: 'loading' }); + resetFillRetryState(); bindPanelHeaderActions(panelId); @@ -1333,6 +1343,7 @@ function removePanel(panelId) { // Remove from arrays and sets panels.splice(panelIndex, 1); loadingPanelIds.delete(panelId); + resetFillRetryState(); // Auto-shrink layout if applicable const shrunkLayout = getAutoShrunkLayout(currentLayout, panels.length); @@ -1367,6 +1378,8 @@ async function switchPanelProvider(panelId, newProviderId) { const panelEl = document.getElementById(panelId); if (!panelEl) return; + resetFillRetryState(); + if (isGoogleProvider(newProviderId)) { syncGoogleModeControls(); } @@ -1457,6 +1470,57 @@ function toggleToolbar() { } // ===== Message Broadcasting ===== +function createFillActionRequestId() { + fillActionRequestCounter += 1; + return `fill-${Date.now()}-${fillActionRequestCounter}`; +} + +function updateFillRetryButton() { + const fillBtn = document.getElementById('fill-input-btn'); + if (!fillBtn) { + return; + } + + const text = fillBtn.querySelector('.btn-text'); + const hasFailures = failedFillPanelIds.size > 0; + if (text) { + text.textContent = hasFailures ? 'Retry Failed' : 'Fill'; + } + fillBtn.title = hasFailures ? 'Retry Failed Panels' : 'Fill Input Boxes'; + fillBtn.dataset.retryFailed = hasFailures ? 'true' : 'false'; +} + +function resetFillRetryState() { + fillPayloadRevision += 1; + if (failedFillPanelIds.size === 0) { + updateFillRetryButton(); + return; + } + + failedFillPanelIds = new Set(); + updateFillRetryButton(); +} + +function setFillRetryState(panelIds) { + failedFillPanelIds = new Set(panelIds); + updateFillRetryButton(); +} + +function normalizePanelResults(settledResults, targetPanels) { + return settledResults.map((settled, index) => { + const panel = targetPanels[index]; + if (settled.status === 'fulfilled' && settled.value) { + return settled.value; + } + return { + ok: false, + panelId: panel.id, + provider: panel.providerId, + reason: 'injection-error' + }; + }); +} + async function broadcastMessage(text, autoSubmit = true) { const sendBtn = document.getElementById('send-all-btn'); const fillBtn = document.getElementById('fill-input-btn'); @@ -1482,6 +1546,9 @@ async function broadcastMessage(text, autoSubmit = true) { const sendFocusRequestId = shouldAutoSubmit ? restoreUnifiedInputFocusAfterSend(getChatgptPanelsWithFrames()) : null; + const fillActionRequestId = hasImages ? createFillActionRequestId() : null; + const requestId = fillActionRequestId || sendFocusRequestId; + const payloadRevisionAtStart = fillPayloadRevision; try { // Disable buttons during send @@ -1497,13 +1564,75 @@ async function broadcastMessage(text, autoSubmit = true) { type: img.type })); - // Send to all panels + const targetPanels = hasImages + ? getFillTargetPanels(panels, failedFillPanelIds) + : panels; + const previousFailedPanelIds = new Set(failedFillPanelIds); + + // Send to all panels, or only the panels that failed the previous image fill. const panelResults = await Promise.allSettled( - panels.map(panel => sendToPanel(panel, text, imagesPayload, shouldAutoSubmit, sendFocusRequestId)) + targetPanels.map(panel => sendToPanel(panel, text, imagesPayload, shouldAutoSubmit, requestId)) ); + const normalizedResults = normalizePanelResults(panelResults, targetPanels); + + if (hasImages) { + if (fillPayloadRevision !== payloadRevisionAtStart) { + statusEl.textContent = 'Input changed; fill again'; + statusEl.className = 'send-status partial'; + setTimeout(() => { + statusEl.textContent = ''; + statusEl.className = 'send-status'; + }, 3000); + return; + } + + const summary = summarizeFillResults(panels, normalizedResults, previousFailedPanelIds); + const totalCount = panels.length; + const { successfulCount, failedCount } = summary; + + if (summary.allSucceeded) { + statusEl.textContent = `Filled ${successfulCount} input${successfulCount > 1 ? 's' : ''}`; + statusEl.className = 'send-status success'; + setFillRetryState([]); + } else if (successfulCount > 0) { + statusEl.textContent = `Filled ${successfulCount}/${totalCount}; ${failedCount} failed`; + statusEl.className = 'send-status partial'; + setFillRetryState(summary.failedPanelIds); + } else { + statusEl.textContent = `Failed to fill 0/${totalCount}; ${failedCount} failed`; + statusEl.className = 'send-status error'; + setFillRetryState(summary.failedPanelIds); + } + + if (failedCount > 0) { + const resultsByPanelId = new Map( + normalizedResults.map(result => [result.panelId, result]) + ); + const failedPanels = panels + .filter(panel => summary.failedPanelIds.has(panel.id)) + .map(panel => ({ + panelId: panel.id, + provider: panel.providerId, + reason: resultsByPanelId.get(panel.id)?.reason || 'injection-error' + })); + console.warn('[Multi-Panel] Image fill failed for panels:', failedPanels); + } + + if (shouldClearFillPayload(summary)) { + document.getElementById('unified-input').value = ''; + resizeTextarea(); + clearAllImages(); + } + + setTimeout(() => { + statusEl.textContent = ''; + statusEl.className = 'send-status'; + }, 3000); + return; + } // Count results (panels only) - const panelSuccessful = panelResults.filter(r => r.status === 'fulfilled' && r.value).length; + const panelSuccessful = normalizedResults.filter(result => result.ok).length; const totalSuccessful = panelSuccessful; const totalCount = panels.length; const failed = totalCount - totalSuccessful; @@ -1556,35 +1685,62 @@ async function broadcastMessage(text, autoSubmit = true) { } async function sendToPanel(panel, text, images = [], autoSubmit = true, requestId = null) { - return new Promise((resolve) => { - try { - if (!panel.iframe || !panel.iframe.contentWindow) { - resolve(false); - return; - } + if (!panel.iframe || !panel.iframe.contentWindow) { + return { + ok: false, + panelId: panel.id, + provider: panel.providerId, + reason: 'control-not-found' + }; + } - // Determine message type based on whether images are included - const messageType = images.length > 0 ? 'INJECT_TEXT_WITH_IMAGES' : 'INJECT_TEXT'; + const waitsForActionResult = images.length > 0 && Boolean(requestId); + const waiter = waitsForActionResult + ? createPanelActionResultWaiter({ + target: window, + panel, + requestId, + action: 'fill' + }) + : null; - // Send message to content script inside iframe with autoSubmit flag - // Add context identifier so receivers can validate origin - panel.iframe.contentWindow.postMessage({ - type: messageType, - text: text, - images: images, - autoSubmit: autoSubmit, - requestId: requestId, - providerMode: getPanelProviderMode(panel), - context: 'multi-panel' // Identify this is from multi-panel - }, '*'); + try { + // Determine message type based on whether images are included + const messageType = images.length > 0 ? 'INJECT_TEXT_WITH_IMAGES' : 'INJECT_TEXT'; - // Assume success (we can't easily verify) - resolve(true); - } catch (error) { - console.error(`Error sending to ${panel.providerId}:`, error); - resolve(false); + // Send message to content script inside iframe with autoSubmit flag. + panel.iframe.contentWindow.postMessage({ + type: messageType, + text, + images, + autoSubmit, + requestId, + action: waitsForActionResult ? 'fill' : undefined, + providerMode: getPanelProviderMode(panel), + context: 'multi-panel' + }, '*'); + + if (waiter) { + return await waiter.promise; } - }); + + return { + ok: true, + panelId: panel.id, + provider: panel.providerId + }; + } catch (error) { + console.error(`Error sending to ${panel.providerId}:`, error); + waiter?.cancel('injection-error'); + return waiter + ? await waiter.promise + : { + ok: false, + panelId: panel.id, + provider: panel.providerId, + reason: 'injection-error' + }; + } } // Clear all input boxes (unified input + all panels) @@ -1645,6 +1801,7 @@ async function addImage(file) { type: file.type, dataUrl: dataUrl }); + resetFillRetryState(); // Render preview renderImagePreviews(); @@ -1667,11 +1824,13 @@ function fileToDataUrl(file) { function removeImage(imageId) { uploadedImages = uploadedImages.filter(img => img.id !== imageId); + resetFillRetryState(); renderImagePreviews(); } function clearAllImages() { uploadedImages = []; + resetFillRetryState(); renderImagePreviews(); } @@ -2030,6 +2189,7 @@ function applyVariables() { function applyPromptToInput(content) { const input = document.getElementById('unified-input'); input.value = content; + resetFillRetryState(); resizeTextarea(); input.focus(); } @@ -2218,7 +2378,10 @@ function setupEventListeners() { // Input textarea const inputTextarea = document.getElementById('unified-input'); let isInputComposing = false; - inputTextarea.addEventListener('input', resizeTextarea); + inputTextarea.addEventListener('input', () => { + resetFillRetryState(); + resizeTextarea(); + }); inputTextarea.addEventListener('compositionstart', () => { isInputComposing = true; }); diff --git a/tests/panel-action-results.test.js b/tests/panel-action-results.test.js new file mode 100644 index 0000000..4d167ee --- /dev/null +++ b/tests/panel-action-results.test.js @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + PANEL_ACTION_RESULT_CONTEXT, + PANELIZE_ACTION_RESULT, + createPanelActionResultWaiter, + getFillTargetPanels, + isMatchingPanelActionResult, + shouldClearFillPayload, + summarizeFillResults, +} from '../modules/panel-action-results.js'; + +function createPanel(id = 'panel-1', providerId = 'grok') { + const contentWindow = {}; + return { + panel: { + id, + providerId, + iframe: { contentWindow }, + }, + contentWindow, + }; +} + +function createActionMessage(source, overrides = {}) { + return new MessageEvent('message', { + source, + data: { + type: PANELIZE_ACTION_RESULT, + context: PANEL_ACTION_RESULT_CONTEXT, + requestId: 'request-current', + provider: 'grok', + action: 'fill', + status: 'succeeded', + ...overrides, + }, + }); +} + +describe('panel action result protocol', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('accepts only results from the expected iframe source and provider', () => { + const { panel, contentWindow } = createPanel(); + + expect(isMatchingPanelActionResult( + createActionMessage(contentWindow), + panel, + 'request-current' + )).toBe(true); + expect(isMatchingPanelActionResult( + createActionMessage({}), + panel, + 'request-current' + )).toBe(false); + expect(isMatchingPanelActionResult( + createActionMessage(contentWindow, { provider: 'deepseek' }), + panel, + 'request-current' + )).toBe(false); + }); + + it('ignores stale request IDs until the active result arrives', async () => { + const target = new EventTarget(); + const { panel, contentWindow } = createPanel(); + const waiter = createPanelActionResultWaiter({ + target, + panel, + requestId: 'request-current', + timeoutMs: 1000, + }); + const settled = vi.fn(); + waiter.promise.then(settled); + + target.dispatchEvent(createActionMessage(contentWindow, { requestId: 'request-stale' })); + await vi.advanceTimersByTimeAsync(100); + expect(settled).not.toHaveBeenCalled(); + + target.dispatchEvent(createActionMessage(contentWindow)); + await expect(waiter.promise).resolves.toEqual({ + ok: true, + panelId: 'panel-1', + provider: 'grok', + }); + }); + + it('fails a panel after the eight-second result timeout', async () => { + const target = new EventTarget(); + const { panel } = createPanel(); + const waiter = createPanelActionResultWaiter({ + target, + panel, + requestId: 'request-current', + }); + + await vi.advanceTimersByTimeAsync(8000); + + await expect(waiter.promise).resolves.toEqual({ + ok: false, + panelId: 'panel-1', + provider: 'grok', + reason: 'preview-timeout', + }); + }); + + it('preserves a supported failure reason from the content script', async () => { + const target = new EventTarget(); + const { panel, contentWindow } = createPanel('panel-kimi', 'kimi'); + const waiter = createPanelActionResultWaiter({ + target, + panel, + requestId: 'request-current', + }); + + target.dispatchEvent(createActionMessage(contentWindow, { + provider: 'kimi', + status: 'failed', + reason: 'unsupported', + })); + + await expect(waiter.promise).resolves.toEqual({ + ok: false, + panelId: 'panel-kimi', + provider: 'kimi', + reason: 'unsupported', + }); + }); +}); + +describe('fill retry state', () => { + const panels = [ + { id: 'panel-grok', providerId: 'grok' }, + { id: 'panel-deepseek', providerId: 'deepseek' }, + { id: 'panel-kimi', providerId: 'kimi' }, + ]; + + it('retains the payload and records failed panels after a partial fill', () => { + const summary = summarizeFillResults(panels, [ + { ok: true, panelId: 'panel-grok' }, + { ok: false, panelId: 'panel-deepseek', reason: 'preview-timeout' }, + { ok: false, panelId: 'panel-kimi', reason: 'unsupported' }, + ]); + + expect(summary.successfulCount).toBe(1); + expect(summary.failedCount).toBe(2); + expect([...summary.failedPanelIds]).toEqual(['panel-deepseek', 'panel-kimi']); + expect(shouldClearFillPayload(summary)).toBe(false); + }); + + it('targets only failed panels on the next fill attempt', () => { + const failedPanelIds = new Set(['panel-deepseek', 'panel-kimi']); + + expect(getFillTargetPanels(panels, failedPanelIds).map(panel => panel.id)).toEqual([ + 'panel-deepseek', + 'panel-kimi', + ]); + }); + + it('clears the payload only after every failed panel succeeds on retry', () => { + const previousFailures = new Set(['panel-deepseek', 'panel-kimi']); + const partialRetry = summarizeFillResults(panels, [ + { ok: true, panelId: 'panel-deepseek' }, + { ok: false, panelId: 'panel-kimi', reason: 'unsupported' }, + ], previousFailures); + expect(shouldClearFillPayload(partialRetry)).toBe(false); + expect([...partialRetry.failedPanelIds]).toEqual(['panel-kimi']); + + const completedRetry = summarizeFillResults(panels, [ + { ok: true, panelId: 'panel-kimi' }, + ], partialRetry.failedPanelIds); + expect(completedRetry.successfulCount).toBe(3); + expect(shouldClearFillPayload(completedRetry)).toBe(true); + }); + + it('resets retry targeting when no failed IDs remain', () => { + expect(getFillTargetPanels(panels, new Set())).toBe(panels); + }); +}); diff --git a/tests/provider-image-upload-content-script.test.js b/tests/provider-image-upload-content-script.test.js index a80b0fd..b579ad3 100644 --- a/tests/provider-image-upload-content-script.test.js +++ b/tests/provider-image-upload-content-script.test.js @@ -38,7 +38,12 @@ function markVisible(element) { }); } -function dispatchImageInjection({ images = SAMPLE_IMAGES.slice(0, 1), text = '', autoSubmit = false } = {}) { +function dispatchImageInjection({ + images = SAMPLE_IMAGES.slice(0, 1), + text = '', + autoSubmit = false, + requestId, +} = {}) { window.dispatchEvent(new MessageEvent('message', { data: { type: 'INJECT_TEXT_WITH_IMAGES', @@ -46,6 +51,7 @@ function dispatchImageInjection({ images = SAMPLE_IMAGES.slice(0, 1), text = '', images, text, autoSubmit, + requestId, }, })); } @@ -247,6 +253,10 @@ describe('provider image upload adapters', () => { configurable: true, value: vi.fn(() => false), }); + Object.defineProperty(window, 'parent', { + configurable: true, + value: { postMessage: vi.fn() }, + }); }); afterEach(() => { @@ -512,4 +522,64 @@ describe('provider image upload adapters', () => { expect(uploadedNames).toEqual([]); expect(document.querySelector('input[type="file"]')).toBeNull(); }); + + it('acknowledges a verified image fill with its request ID and provider', async () => { + window.happyDOM.setURL('https://grok.com/'); + createGrokDom(); + + dispatchImageInjection({ requestId: 'fill-request-success' }); + await finishSuccessfulInjection(); + + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-success', + provider: 'grok', + action: 'fill', + status: 'succeeded', + }, '*'); + }); + + it('acknowledges an unsupported Kimi image fill without claiming success', async () => { + window.happyDOM.setURL('https://www.kimi.com/'); + createKimiDom({ uploadAvailable: false }); + + dispatchImageInjection({ requestId: 'fill-request-unsupported' }); + await finishSuccessfulInjection(); + + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-unsupported', + provider: 'kimi', + action: 'fill', + status: 'failed', + reason: 'unsupported', + }, '*'); + }); + + it('acknowledges a preview timeout and keeps the failed fill unsent', async () => { + window.happyDOM.setURL('https://chat.deepseek.com/'); + const { sendButton } = createDeepSeekDom({ previewOnChange: false }); + const sendSpy = vi.fn(); + sendButton.addEventListener('click', sendSpy); + + dispatchImageInjection({ + requestId: 'fill-request-timeout', + text: 'keep this text', + autoSubmit: true, + }); + await finishTimedOutInjection(); + + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-timeout', + provider: 'deepseek', + action: 'fill', + status: 'failed', + reason: 'preview-timeout', + }, '*'); + expect(sendSpy).not.toHaveBeenCalled(); + }); }); From 7f6b37279882e0b12c6e6ccc1901966d8f19981e Mon Sep 17 00:00:00 2001 From: Wenhao Date: Tue, 21 Jul 2026 13:22:36 +0800 Subject: [PATCH 3/5] fix: reconcile provider image upload retries --- .../text-injection-all-providers.js | 123 ++++++++++++++++-- multi-panel/multi-panel.js | 19 ++- ...ovider-image-upload-content-script.test.js | 64 ++++++++- tests/qwen-content-script.test.js | 44 ++++++- 4 files changed, 232 insertions(+), 18 deletions(-) diff --git a/content-scripts/text-injection-all-providers.js b/content-scripts/text-injection-all-providers.js index 7b42f33..ae3ef02 100644 --- a/content-scripts/text-injection-all-providers.js +++ b/content-scripts/text-injection-all-providers.js @@ -30,6 +30,7 @@ let googleSearchReplaceOnNextFill = true; let chatgptSendTracking = null; let multiPanelUserInteractionTracking = null; + const pendingKimiImageUploads = new Map(); // Provider-specific selectors const PROVIDER_SELECTORS = { @@ -1510,7 +1511,7 @@ // Handle image injection message async function handleImageInjection(event) { - const { text, images, autoSubmit, requestId } = event.data; + const { text, images, autoSubmit, requestId, retry = false } = event.data; const provider = detectProvider(); const providerMode = provider === 'google' ? normalizeGoogleProviderMode(event.data.providerMode) @@ -1560,7 +1561,7 @@ // Inject images first for (const image of images) { - const result = await injectSingleImage(provider, image); + const result = await injectSingleImage(provider, image, { retry }); imageInjectionResults.push(result); if (!result.ok) { break; @@ -1627,7 +1628,7 @@ } // Inject a single image to the provider using provider-specific strategy - async function injectSingleImage(provider, imageData) { + async function injectSingleImage(provider, imageData, { retry = false } = {}) { console.log('[Image Injection] Injecting image to', provider); // Use provider-specific strategies @@ -1647,7 +1648,7 @@ case 'deepseek': return await injectImageToDeepSeek(imageData); case 'kimi': - return await injectImageToKimi(imageData); + return await injectImageToKimi(imageData, { retry }); case 'doubao': result = await injectImageToDoubao(imageData); break; @@ -1925,6 +1926,67 @@ return composer?.querySelectorAll('.image-thumbnail.loading').length || 0; } + function getKimiImageKey(imageData) { + return `${imageData.name}\u0000${imageData.type}\u0000${imageData.dataUrl}`; + } + + function getKimiThumbnailState(thumbnail) { + if (!thumbnail?.isConnected) { + return 'missing'; + } + if ( + thumbnail.matches('.image-thumbnail.success') && + thumbnail.querySelector('.image-wrapper.image-detail img.image-main') + ) { + return 'success'; + } + return thumbnail.matches('.image-thumbnail.loading') ? 'loading' : 'failed'; + } + + async function waitForTrackedKimiThumbnail(thumbnail) { + const start = Date.now(); + + while (Date.now() - start < IMAGE_UPLOAD_PREVIEW_TIMEOUT_MS) { + const state = getKimiThumbnailState(thumbnail); + if (state !== 'loading') { + return state; + } + await sleep(100); + } + + return getKimiThumbnailState(thumbnail); + } + + async function reconcilePendingKimiImage(imageKey, composer) { + const pendingUpload = pendingKimiImageUploads.get(imageKey); + if (!pendingUpload) { + return null; + } + + // Kimi replaces the original loading thumbnail during upload finalization. + // The verified success-count increase preserves reconciliation across that + // replacement while the retry flag keeps it scoped to the failed Fill. + if (countKimiAttachmentPreviews(composer) > pendingUpload.previousSuccessCount) { + pendingKimiImageUploads.delete(imageKey); + return createImageInjectionSuccess(); + } + + const state = await waitForTrackedKimiThumbnail(pendingUpload.thumbnail); + if ( + state === 'success' || + countKimiAttachmentPreviews(composer) > pendingUpload.previousSuccessCount + ) { + pendingKimiImageUploads.delete(imageKey); + return createImageInjectionSuccess(); + } + if (state === 'loading') { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.PREVIEW_TIMEOUT); + } + + pendingKimiImageUploads.delete(imageKey); + return null; + } + async function waitForKimiPendingPreview(composer, previousSuccessCount) { const start = Date.now(); @@ -1941,13 +2003,25 @@ return false; } - async function injectImageToKimi(imageData) { + async function injectImageToKimi(imageData, { retry = false } = {}) { try { const composer = getKimiComposer(); if (!composer) { return createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND); } + // A Kimi upload can finish after the first Fill has already timed out. + // Reconcile the exact thumbnail created by that attempt before retrying. + const imageKey = getKimiImageKey(imageData); + if (retry) { + const reconciledResult = await reconcilePendingKimiImage(imageKey, composer); + if (reconciledResult) { + return reconciledResult; + } + } else { + pendingKimiImageUploads.clear(); + } + const previousCount = countKimiAttachmentPreviews(composer); if (countKimiPendingAttachmentPreviews(composer) > 0) { const pendingAccepted = await waitForKimiPendingPreview(composer, previousCount); @@ -1977,6 +2051,7 @@ } const file = await createImageFile(imageData); + const previousThumbnails = new Set(composer.querySelectorAll('.image-thumbnail')); if (!assignFilesToInput(fileInput, [file])) { return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); } @@ -1986,9 +2061,25 @@ () => countKimiAttachmentPreviews(composer), previousCount ); - return accepted - ? createImageInjectionSuccess() - : createImageInjectionFailure(IMAGE_INJECTION_REASONS.PREVIEW_TIMEOUT); + if (accepted) { + pendingKimiImageUploads.delete(imageKey); + return createImageInjectionSuccess(); + } + + const newThumbnail = [...composer.querySelectorAll('.image-thumbnail')] + .find(thumbnail => !previousThumbnails.has(thumbnail)); + const thumbnailState = getKimiThumbnailState(newThumbnail); + if (thumbnailState === 'success') { + pendingKimiImageUploads.delete(imageKey); + return createImageInjectionSuccess(); + } + if (thumbnailState === 'loading') { + pendingKimiImageUploads.set(imageKey, { + thumbnail: newThumbnail, + previousSuccessCount: previousCount + }); + } + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.PREVIEW_TIMEOUT); } catch (error) { console.error('[Image Injection] Kimi error:', error); return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); @@ -2230,15 +2321,23 @@ const previewCount = countQwenImagePreviews('qwen-cn'); let fileInput = findQwenFileInput('qwen-cn'); - // Qwen China mounts its hidden image input only after a drag interaction. + // Prefer waking Qwen China's lazy file input without dropping the image. if (!fileInput) { - dispatchFileDragEvents(composer, file); + dispatchDragEventsToWakeInput(composer, file); fileInput = await waitForQwenFileInput('qwen-cn', 1000); } if (!fileInput) { - console.warn('[Image Injection] Qwen China image input not found'); - return false; + // The current Qwen China composer can accept the native drop directly + // without ever mounting a file input. Only its new preview is success. + if (countQwenImagePreviews('qwen-cn') <= previewCount) { + dispatchFileDragEvents(composer, file); + } + const accepted = await waitForQwenImagePreview('qwen-cn', previewCount, 6000); + if (!accepted) { + console.warn('[Image Injection] Qwen China image input and preview not found'); + } + return accepted; } if (!assignFilesToInput(fileInput, [file])) { diff --git a/multi-panel/multi-panel.js b/multi-panel/multi-panel.js index 7421fdd..e184c96 100644 --- a/multi-panel/multi-panel.js +++ b/multi-panel/multi-panel.js @@ -1571,7 +1571,14 @@ async function broadcastMessage(text, autoSubmit = true) { // Send to all panels, or only the panels that failed the previous image fill. const panelResults = await Promise.allSettled( - targetPanels.map(panel => sendToPanel(panel, text, imagesPayload, shouldAutoSubmit, requestId)) + targetPanels.map(panel => sendToPanel( + panel, + text, + imagesPayload, + shouldAutoSubmit, + requestId, + previousFailedPanelIds.has(panel.id) + )) ); const normalizedResults = normalizePanelResults(panelResults, targetPanels); @@ -1684,7 +1691,14 @@ async function broadcastMessage(text, autoSubmit = true) { } } -async function sendToPanel(panel, text, images = [], autoSubmit = true, requestId = null) { +async function sendToPanel( + panel, + text, + images = [], + autoSubmit = true, + requestId = null, + isRetry = false +) { if (!panel.iframe || !panel.iframe.contentWindow) { return { ok: false, @@ -1716,6 +1730,7 @@ async function sendToPanel(panel, text, images = [], autoSubmit = true, requestI autoSubmit, requestId, action: waitsForActionResult ? 'fill' : undefined, + retry: waitsForActionResult ? isRetry : undefined, providerMode: getPanelProviderMode(panel), context: 'multi-panel' }, '*'); diff --git a/tests/provider-image-upload-content-script.test.js b/tests/provider-image-upload-content-script.test.js index b579ad3..d02f0c3 100644 --- a/tests/provider-image-upload-content-script.test.js +++ b/tests/provider-image-upload-content-script.test.js @@ -43,6 +43,7 @@ function dispatchImageInjection({ text = '', autoSubmit = false, requestId, + retry = false, } = {}) { window.dispatchEvent(new MessageEvent('message', { data: { @@ -52,6 +53,7 @@ function dispatchImageInjection({ text, autoSubmit, requestId, + retry, }, })); } @@ -164,6 +166,8 @@ function createDeepSeekDom({ function createKimiDom({ uploadAvailable = true, previewOnChange = true, + previewDelayMs = 0, + replacePreviewOnDelay = false, language = 'zh', remoteErrorPreview = false, existingLoadingPreview = false, @@ -234,7 +238,24 @@ function createKimiDom({ const file = fileInput.files[0]; uploadedNames.push(file.name); if (!previewOnChange) return; - appendThumbnail(remoteErrorPreview ? 'error' : 'success', file.name); + const outcome = remoteErrorPreview ? 'error' : 'success'; + if (previewDelayMs <= 0) { + appendThumbnail(outcome, file.name); + return; + } + + const { thumbnail, image } = appendThumbnail('loading', file.name); + setTimeout(() => { + if (replacePreviewOnDelay) { + thumbnail.remove(); + appendThumbnail(outcome, file.name); + return; + } + thumbnail.classList.replace('loading', outcome); + image.src = outcome === 'success' + ? `https://www.kimi.com/apiv2-files/sign-obj/${file.name}` + : `https://statics.moonshot.cn/kimi-upload-error/${file.name}`; + }, previewDelayMs); }); }); @@ -499,6 +520,47 @@ describe('provider image upload adapters', () => { }, '*'); }); + it('reconciles a Kimi upload that succeeds after the first fill times out', async () => { + window.happyDOM.setURL('https://www.kimi.com/'); + const { uploadedNames } = createKimiDom({ + previewDelayMs: 6500, + replacePreviewOnDelay: true, + }); + + dispatchImageInjection({ requestId: 'fill-request-kimi-late-success' }); + await finishTimedOutInjection(); + + expect(uploadedNames).toEqual(['sample-one.png']); + expect(document.querySelectorAll('.image-thumbnail.success')).toHaveLength(1); + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-kimi-late-success', + provider: 'kimi', + action: 'fill', + status: 'failed', + reason: 'preview-timeout', + }, '*'); + + window.parent.postMessage.mockClear(); + dispatchImageInjection({ + requestId: 'fill-request-kimi-late-success-retry', + retry: true, + }); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png']); + expect(document.querySelectorAll('.image-thumbnail.success')).toHaveLength(1); + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-kimi-late-success-retry', + provider: 'kimi', + action: 'fill', + status: 'succeeded', + }, '*'); + }); + it('keeps Kimi text and does not submit when the attachment preview times out', async () => { window.happyDOM.setURL('https://www.kimi.com/'); const { sendButton } = createKimiDom({ previewOnChange: false }); diff --git a/tests/qwen-content-script.test.js b/tests/qwen-content-script.test.js index 115073b..beca203 100644 --- a/tests/qwen-content-script.test.js +++ b/tests/qwen-content-script.test.js @@ -22,7 +22,7 @@ function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -function createQwenChinaDom() { +function createQwenChinaDom({ dropUploadsWithoutInput = false } = {}) { document.body.innerHTML = `