diff --git a/content-scripts/text-injection-all-providers.js b/content-scripts/text-injection-all-providers.js index 6a4acbc..aaee005 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'; @@ -18,9 +20,17 @@ 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; + const pendingKimiImageUploads = new Map(); // Provider-specific selectors const PROVIDER_SELECTORS = { @@ -101,7 +111,7 @@ gemini: true, grok: true, deepseek: true, - kimi: true, // Kimi supports images + kimi: true, doubao: true, 'qwen-cn': true, 'qwen-global': true, @@ -115,9 +125,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 +141,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"]' ], @@ -483,6 +487,31 @@ }, '*'); } + 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 (Array.isArray(result?.succeededImageIds)) { + message.succeededImageIds = result.succeededImageIds; + } + + 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; @@ -1447,8 +1476,24 @@ // ===== Image Injection Functions ===== // Helper function to inject text into provider's input field - function injectText(provider, text, autoSubmit, providerMode = null) { + function inputEndsWithText(element, text) { + const isFormControl = element?.tagName === 'TEXTAREA' || element?.tagName === 'INPUT'; + const currentText = isFormControl ? element.value : element?.textContent; + return typeof currentText === 'string' && currentText.endsWith(text); + } + + function injectText( + provider, + text, + autoSubmit, + providerMode = null, + { skipIfAlreadyPresent = false } = {} + ) { if (provider === 'google') { + const input = findGoogleInput(providerMode); + if (skipIfAlreadyPresent && inputEndsWithText(input, text)) { + return true; + } return handleGoogleTextInjection(text, autoSubmit, providerMode); } @@ -1461,6 +1506,9 @@ for (const selector of selectors) { const element = findTextInputElement(selector); if (element) { + if (skipIfAlreadyPresent && inputEndsWithText(element, text)) { + return true; + } const success = injectTextIntoElement(element, text, provider); if (success) { console.log('[Text Injection] Text injected via injectText helper for', provider); @@ -1486,7 +1534,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) @@ -1494,7 +1542,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 +1550,7 @@ if (text && text.trim()) { handleGoogleTextInjection(text, autoSubmit, providerMode); } - return; + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.UNSUPPORTED, []); } if (!PROVIDER_IMAGE_SUPPORT[provider]) { @@ -1511,12 +1559,24 @@ if (text) { injectText(provider, text, autoSubmit, providerMode); } - return; + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.UNSUPPORTED, []); } if (!images || images.length === 0) { + if (retry && text && text.trim()) { + const textInjected = injectText( + provider, + text, + autoSubmit, + providerMode, + { skipIfAlreadyPresent: true } + ); + return textInjected + ? createImageInjectionSuccess([]) + : createImageInjectionFailure(IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND, []); + } 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}`); @@ -1532,16 +1592,26 @@ startChatgptSendTracking(requestId); } + const succeededImageIds = []; const imageInjectionResults = []; // Inject images first for (const image of images) { - imageInjectionResults.push(await injectSingleImage(provider, image)); + const result = await injectSingleImage(provider, image, { retry }); + imageInjectionResults.push(result); + if (result.ok) { + if (image.id && typeof image.id === 'string') { + succeededImageIds.push(image.id); + } + } else { + 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); } @@ -1550,56 +1620,535 @@ 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, + { skipIfAlreadyPresent: retry } + ); } else if (autoSubmit) { if (!allImagesInjected) { console.warn('[Image Injection] Skipping auto-submit because image injection failed for:', provider); - return; + const firstFailure = imageInjectionResults.find(result => !result.ok); + return createImageInjectionFailure( + firstFailure?.reason || IMAGE_INJECTION_REASONS.INJECTION_ERROR, + succeededImageIds + ); } // If no text but autoSubmit is true, click send button await sleep(300); clickSendButton(provider, providerMode); } + + if (!allImagesInjected) { + const firstFailure = imageInjectionResults.find(result => !result.ok); + return createImageInjectionFailure( + firstFailure?.reason || IMAGE_INJECTION_REASONS.INJECTION_ERROR, + succeededImageIds + ); + } + + if (!textInjected) { + return createImageInjectionFailure( + IMAGE_INJECTION_REASONS.CONTROL_NOT_FOUND, + succeededImageIds + ); + } + + return createImageInjectionSuccess(succeededImageIds); } catch (error) { console.error('[Image Injection] Error:', error); + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR, []); + } + } + + function createImageInjectionSuccess(succeededImageIds = []) { + return { ok: true, succeededImageIds }; + } + + function createImageInjectionFailure(reason, succeededImageIds = []) { + return { ok: false, reason, succeededImageIds }; + } + + 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) { + async function injectSingleImage(provider, imageData, { retry = false } = {}) { 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': + result = await injectImageToGrok(imageData); + break; case 'deepseek': - // These work with drag-drop - return await tryDragDropUpload(provider, imageData); + result = await injectImageToDeepSeek(imageData); + break; + case 'kimi': + result = await injectImageToKimi(imageData, { retry }); + break; 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; + } + + function getKimiImageKey(imageData) { + if (imageData && imageData.id && typeof imageData.id === 'string') { + return imageData.id; + } + 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(); + + 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, { 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) { + if (pendingKimiImageUploads.has(imageKey)) { + const pendingAccepted = await waitForKimiPendingPreview(composer, previousCount); + return pendingAccepted + ? createImageInjectionSuccess() + : createImageInjectionFailure(IMAGE_INJECTION_REASONS.PREVIEW_TIMEOUT); + } + return 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); + const previousThumbnails = new Set(composer.querySelectorAll('.image-thumbnail')); + if (!assignFilesToInput(fileInput, [file])) { + return createImageInjectionFailure(IMAGE_INJECTION_REASONS.INJECTION_ERROR); + } + dispatchFileInputEvents(fileInput); + + const accepted = await waitForPreviewIncrease( + () => countKimiAttachmentPreviews(composer), + previousCount + ); + 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); } } @@ -1838,15 +2387,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])) { @@ -2113,7 +2670,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]; @@ -2368,7 +2925,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..eea5fee --- /dev/null +++ b/modules/panel-action-results.js @@ -0,0 +1,336 @@ +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') + ); +} + +/** + * Calculates the action result timeout based on image count. + * Formula: 8000ms for 0-1 images, plus 6500ms for each additional image. + * + * @param {number} imageCount - Number of pending images for this panel attempt. + * @returns {number} Timeout in milliseconds. + */ +export function getPanelActionResultTimeoutMs(imageCount) { + const count = Math.max(0, Number(imageCount) || 0); + if (count <= 1) { + return PANEL_ACTION_RESULT_TIMEOUT_MS; + } + return PANEL_ACTION_RESULT_TIMEOUT_MS + (count - 1) * 6500; +} + +/** + * Normalizes succeeded image IDs from an ACK message against expected IDs. + * + * @param {Array} [succeededImageIds] - Image IDs reported in ACK. + * @param {Array} [expectedImageIds=[]] - Image IDs expected in request payload. + * @param {boolean} [isSuccess=true] - Whether the overall status was succeeded. + * @returns {Array} Filtered, unique, valid image IDs. + */ +export function normalizeSucceededImageIds(succeededImageIds, expectedImageIds = [], isSuccess = true) { + const expectedSet = new Set((expectedImageIds || []).filter(id => typeof id === 'string')); + if (!Array.isArray(succeededImageIds)) { + return isSuccess ? Array.from(expectedSet) : []; + } + const result = []; + const seen = new Set(); + for (const id of succeededImageIds) { + if (typeof id === 'string' && expectedSet.has(id) && !seen.has(id)) { + seen.add(id); + result.push(id); + } + } + return result; +} + +/** + * Calculates pending image IDs remaining to be uploaded. + * + * @param {Array} [expectedImageIds=[]] - IDs attempted in this request. + * @param {Array} [succeededImageIds=[]] - IDs verified as succeeded. + * @returns {Array} Remaining image IDs that still need upload. + */ +export function calculatePendingImageIds(expectedImageIds = [], succeededImageIds = []) { + const succeededSet = new Set(succeededImageIds || []); + return (expectedImageIds || []).filter(id => !succeededSet.has(id)); +} + +/** + * Cleans up stale panel IDs from retry tracking structures. + * + * @param {Array} panels - Current active panels. + * @param {Map>} pendingFillImageIdsByPanel - Pending image IDs by panel. + * @param {Set} failedPanelIds - Failed panel IDs. + */ +export function cleanStalePanelRetryState(panels, pendingFillImageIdsByPanel, failedPanelIds) { + const activeIds = new Set((panels || []).map(p => p.id)); + if (failedPanelIds) { + for (const id of Array.from(failedPanelIds)) { + if (!activeIds.has(id)) { + failedPanelIds.delete(id); + } + } + } + if (pendingFillImageIdsByPanel && pendingFillImageIdsByPanel instanceof Map) { + for (const key of Array.from(pendingFillImageIdsByPanel.keys())) { + if (!activeIds.has(key)) { + pendingFillImageIdsByPanel.delete(key); + } + } + } +} + +/** + * Creates a single-instance broadcast gate to prevent concurrent operations. + * + * @returns {{tryAcquire: () => boolean, release: () => void, isActive: () => boolean}} + */ +export function createBroadcastGate() { + let active = false; + return { + tryAcquire() { + if (active) return false; + active = true; + return true; + }, + release() { + active = false; + }, + isActive() { + return active; + } + }; +} + +/** + * 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 {Array} [options.expectedImageIds=[]] - Expected image IDs for this panel attempt. + * @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, + expectedImageIds = [], + 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, + succeededImageIds: normalizeSucceededImageIds(event.data.succeededImageIds, expectedImageIds, true) + }); + return; + } + + settle({ + ...createFailure(panel, event.data.reason), + succeededImageIds: normalizeSucceededImageIds(event.data.succeededImageIds, expectedImageIds, false) + }); + }; + + const promise = new Promise(resolve => { + resolvePromise = resolve; + target.addEventListener('message', handleMessage); + timeoutId = setTimeout(() => { + settle({ + ...createFailure(panel, 'preview-timeout'), + succeededImageIds: normalizeSucceededImageIds(undefined, expectedImageIds, false) + }); + }, timeoutMs); + }); + + return { + promise, + cancel(reason = 'injection-error') { + settle({ + ...createFailure(panel, reason), + succeededImageIds: normalizeSucceededImageIds(undefined, expectedImageIds, false) + }); + } + }; +} + +/** + * 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); +} + +/** + * Normalizes Promise.allSettled panel results into an array of result objects. + * + * @param {Array} settledResults - Promise.allSettled results. + * @param {Array} targetPanels - Array of panel objects corresponding to settledResults. + * @returns {Array} Normalized result objects. + */ +export function normalizePanelResults(settledResults, targetPanels) { + return (settledResults || []).map((res, i) => { + const panel = targetPanels[i]; + if (res.status === 'fulfilled') { + return res.value; + } + return { + ok: false, + panelId: panel?.id, + provider: panel?.providerId, + reason: 'injection-error', + succeededImageIds: [] + }; + }); +} + +/** + * Determines action parameters for a panel broadcast operation. + * + * @param {object} options + * @param {boolean} options.hasImages - Whether uploaded images are present. + * @param {boolean} options.hasFailedPanels - Whether any panel has a pending failed fill. + * @param {boolean} [options.autoSubmit=true] - Requested autoSubmit behavior. + * @returns {{isFillAction: boolean, shouldAutoSubmit: boolean, messageType: string, waitForActionResult: boolean}} + */ +export function getPanelBroadcastActionParams({ hasImages = false, hasFailedPanels = false, autoSubmit = true } = {}) { + const isFillAction = Boolean(hasImages || hasFailedPanels); + return { + isFillAction, + shouldAutoSubmit: isFillAction ? false : autoSubmit, + messageType: isFillAction ? 'INJECT_TEXT_WITH_IMAGES' : 'INJECT_TEXT', + waitForActionResult: isFillAction + }; +} + +/** + * Returns the message type string based on fill action status. + * + * @param {object} options + * @param {boolean} options.isFillAction - Whether the operation is a fill action. + * @returns {string} Message type ('INJECT_TEXT_WITH_IMAGES' or 'INJECT_TEXT'). + */ +export function determinePanelMessageType({ isFillAction = false } = {}) { + return isFillAction ? 'INJECT_TEXT_WITH_IMAGES' : 'INJECT_TEXT'; +} diff --git a/multi-panel/multi-panel.js b/multi-panel/multi-panel.js index 62e2b27..8298121 100644 --- a/multi-panel/multi-panel.js +++ b/multi-panel/multi-panel.js @@ -22,6 +22,20 @@ import { import { saveSetting } from '../modules/settings.js'; import { applyTheme } from '../modules/theme-manager.js'; import { t, initializeLanguage } from '../modules/i18n.js'; +import { + calculatePendingImageIds, + cleanStalePanelRetryState, + createBroadcastGate, + createPanelActionResultWaiter, + determinePanelMessageType, + getFillTargetPanels, + getPanelActionResultTimeoutMs, + getPanelBroadcastActionParams, + normalizePanelResults, + normalizeSucceededImageIds, + shouldClearFillPayload, + summarizeFillResults +} from '../modules/panel-action-results.js'; import { getAllPrompts, searchPrompts, @@ -39,6 +53,11 @@ 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 pendingFillImageIdsByPanel = new Map(); +const broadcastGate = createBroadcastGate(); +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 +1329,7 @@ async function addPanel(providerId) { currentUrl: null, state: 'loading' }); + resetFillRetryState(); bindPanelHeaderActions(panelId); @@ -1333,6 +1353,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 +1388,8 @@ async function switchPanelProvider(panelId, newProviderId) { const panelEl = document.getElementById(panelId); if (!panelEl) return; + resetFillRetryState(); + if (isGoogleProvider(newProviderId)) { syncGoogleModeControls(); } @@ -1457,53 +1480,204 @@ function toggleToolbar() { } // ===== Message Broadcasting ===== -async function broadcastMessage(text, autoSubmit = true) { +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; + pendingFillImageIdsByPanel = new Map(); + if (failedFillPanelIds.size === 0) { + updateFillRetryButton(); + return; + } + + failedFillPanelIds = new Set(); + updateFillRetryButton(); +} + +function setFillRetryState(panelIds) { + failedFillPanelIds = new Set(panelIds); + updateFillRetryButton(); +} + +export async function broadcastMessage(text, autoSubmit = true) { + if (!broadcastGate.tryAcquire()) { + return; + } + const sendBtn = document.getElementById('send-all-btn'); const fillBtn = document.getElementById('fill-input-btn'); const statusEl = document.getElementById('send-status'); const hasImages = uploadedImages.length > 0; + const hasFailedPanels = failedFillPanelIds.size > 0; + const broadcastParams = getPanelBroadcastActionParams({ + hasImages, + hasFailedPanels, + autoSubmit + }); + const { isFillAction, shouldAutoSubmit, waitForActionResult } = broadcastParams; - if (!text.trim() && !hasImages) { - // If input is empty and autoSubmit is true, just trigger send buttons - // (this happens when user clicks Fill first, then Send All) - if (autoSubmit) { - await triggerSendButtons(); + try { + if (!text.trim() && !hasImages) { + // If input is empty and autoSubmit is true, just trigger send buttons + if (autoSubmit) { + await triggerSendButtons(); + return; + } + showToast('Please enter a message or upload an image'); return; } - showToast('Please enter a message or upload an image'); - return; - } - // When images are present, always fill first without auto-submit - // User needs to click "Send All" again to actually send - // This gives users a chance to verify content before sending - const shouldAutoSubmit = hasImages ? false : autoSubmit; - const sendFocusRequestId = shouldAutoSubmit - ? restoreUnifiedInputFocusAfterSend(getChatgptPanelsWithFrames()) - : null; + const sendFocusRequestId = shouldAutoSubmit + ? restoreUnifiedInputFocusAfterSend(getChatgptPanelsWithFrames()) + : null; + const fillActionRequestId = isFillAction ? createFillActionRequestId() : null; + const requestId = fillActionRequestId || sendFocusRequestId; + const payloadRevisionAtStart = fillPayloadRevision; - try { // Disable buttons during send sendBtn.disabled = true; fillBtn.disabled = true; statusEl.textContent = shouldAutoSubmit ? 'Sending...' : 'Filling...'; statusEl.className = 'send-status'; - // Prepare images payload - const imagesPayload = uploadedImages.map(img => ({ - dataUrl: img.dataUrl, - name: img.name, - type: img.type - })); + cleanStalePanelRetryState(panels, pendingFillImageIdsByPanel, failedFillPanelIds); + + const targetPanels = hasImages + ? getFillTargetPanels(panels, failedFillPanelIds) + : panels; + const previousFailedPanelIds = new Set(failedFillPanelIds); + const attemptedImagesByPanel = new Map(); - // Send to all panels + // 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 => { + const isPanelRetry = previousFailedPanelIds.has(panel.id); + let panelImages = []; + if (hasImages) { + if (isPanelRetry && pendingFillImageIdsByPanel.has(panel.id)) { + const pendingIds = new Set(pendingFillImageIdsByPanel.get(panel.id)); + panelImages = uploadedImages.filter(img => pendingIds.has(img.id)); + } else { + panelImages = uploadedImages; + } + } + attemptedImagesByPanel.set(panel.id, panelImages); + + const imageCount = panelImages.length; + const panelTimeoutMs = isFillAction + ? getPanelActionResultTimeoutMs(imageCount) + : 8000; + + return sendToPanel( + panel, + text, + panelImages, + shouldAutoSubmit, + requestId, + isPanelRetry, + panelTimeoutMs, + { + isFillAction, + waitForActionResult + } + ); + }) ); + 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; + } + + normalizedResults.forEach(result => { + if (!result?.panelId) return; + const attempted = attemptedImagesByPanel.get(result.panelId) || uploadedImages; + const attemptedIds = attempted.map(img => img.id); + + if (result.ok) { + failedFillPanelIds.delete(result.panelId); + pendingFillImageIdsByPanel.delete(result.panelId); + } else { + failedFillPanelIds.add(result.panelId); + const pendingIds = calculatePendingImageIds(attemptedIds, result.succeededImageIds); + pendingFillImageIdsByPanel.set(result.panelId, pendingIds); + } + }); + + 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([]); + pendingFillImageIdsByPanel.clear(); + } else if (successfulCount > 0) { + statusEl.textContent = `Filled ${successfulCount}/${totalCount}; ${failedCount} failed`; + statusEl.className = 'send-status partial'; + setFillRetryState(summary.failedPanelIds); + } else { + statusEl.textContent = `Fill failed: ${failedCount}/${totalCount} panels 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; @@ -1549,42 +1723,93 @@ async function broadcastMessage(text, autoSubmit = true) { statusEl.className = 'send-status'; }, 3000); } finally { - // Always re-enable buttons, even if there was an error + // Always re-enable buttons and release broadcast gate sendBtn.disabled = false; fillBtn.disabled = false; + broadcastGate.release(); } } -async function sendToPanel(panel, text, images = [], autoSubmit = true, requestId = null) { - return new Promise((resolve) => { - try { - if (!panel.iframe || !panel.iframe.contentWindow) { - resolve(false); - return; - } +export async function sendToPanel( + panel, + text, + images = [], + autoSubmit = true, + requestId = null, + isRetry = false, + timeoutMs = 8000, + options = {} +) { + if (!panel.iframe || !panel.iframe.contentWindow) { + return { + ok: false, + panelId: panel.id, + provider: panel.providerId, + reason: 'control-not-found', + succeededImageIds: [] + }; + } - // Determine message type based on whether images are included - const messageType = images.length > 0 ? 'INJECT_TEXT_WITH_IMAGES' : 'INJECT_TEXT'; + const isFillAction = options.isFillAction === true; + const waitForActionResult = options.waitForActionResult === true; + const expectedImageIds = images.map(img => img.id).filter(Boolean); - // 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 - }, '*'); + const waiter = waitForActionResult + ? createPanelActionResultWaiter({ + target: window, + panel, + requestId, + expectedImageIds, + action: 'fill', + timeoutMs + }) + : null; - // Assume success (we can't easily verify) - resolve(true); - } catch (error) { - console.error(`Error sending to ${panel.providerId}:`, error); - resolve(false); + try { + const messageType = determinePanelMessageType({ isFillAction }); + + const imagesPayload = images.map(img => ({ + id: img.id, + dataUrl: img.dataUrl, + name: img.name, + type: img.type + })); + + panel.iframe.contentWindow.postMessage({ + type: messageType, + text, + images: imagesPayload, + autoSubmit, + requestId, + action: waitForActionResult ? 'fill' : undefined, + retry: waitForActionResult ? isRetry : undefined, + providerMode: getPanelProviderMode(panel), + context: 'multi-panel' + }, '*'); + + if (waiter) { + return await waiter.promise; } - }); + + return { + ok: true, + panelId: panel.id, + provider: panel.providerId, + succeededImageIds: expectedImageIds + }; + } 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', + succeededImageIds: [] + }; + } } // Clear all input boxes (unified input + all panels) @@ -1645,6 +1870,7 @@ async function addImage(file) { type: file.type, dataUrl: dataUrl }); + resetFillRetryState(); // Render preview renderImagePreviews(); @@ -1667,11 +1893,13 @@ function fileToDataUrl(file) { function removeImage(imageId) { uploadedImages = uploadedImages.filter(img => img.id !== imageId); + resetFillRetryState(); renderImagePreviews(); } function clearAllImages() { uploadedImages = []; + resetFillRetryState(); renderImagePreviews(); } @@ -2030,6 +2258,7 @@ function applyVariables() { function applyPromptToInput(content) { const input = document.getElementById('unified-input'); input.value = content; + resetFillRetryState(); resizeTextarea(); input.focus(); } @@ -2045,9 +2274,12 @@ async function searchPromptLibrary(query) { // ===== Event Listeners ===== function setupEventListeners() { + const layoutBtn = document.getElementById('layout-btn'); + if (!layoutBtn) return; + // Layout button - document.getElementById('layout-btn').addEventListener('click', openLayoutModal); - document.getElementById('close-layout-modal').addEventListener('click', closeLayoutModal); + layoutBtn.addEventListener('click', openLayoutModal); + document.getElementById('close-layout-modal')?.addEventListener('click', closeLayoutModal); // Layout options document.querySelectorAll('.layout-option').forEach(btn => { @@ -2218,7 +2450,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/chatgpt-content-script.test.js b/tests/chatgpt-content-script.test.js index 736175d..0c2b6a7 100644 --- a/tests/chatgpt-content-script.test.js +++ b/tests/chatgpt-content-script.test.js @@ -140,7 +140,6 @@ describe('chatgpt content script provider status', () => { context: 'multi-panel', }); - await wait(100); composer.insertAdjacentHTML('beforeend', ''); expect(await waitForProviderStatusCall(postMessageSpy, 'PANELIZE_PROVIDER_BUSY')).toHaveLength(1); getStopButton()?.remove(); diff --git a/tests/panel-action-results.test.js b/tests/panel-action-results.test.js new file mode 100644 index 0000000..c72198b --- /dev/null +++ b/tests/panel-action-results.test.js @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + PANEL_ACTION_RESULT_CONTEXT, + PANELIZE_ACTION_RESULT, + calculatePendingImageIds, + cleanStalePanelRetryState, + createBroadcastGate, + createPanelActionResultWaiter, + determinePanelMessageType, + getFillTargetPanels, + getPanelActionResultTimeoutMs, + getPanelBroadcastActionParams, + isMatchingPanelActionResult, + normalizeSucceededImageIds, + 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', + succeededImageIds: [], + }); + }); + + 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', + succeededImageIds: [], + }); + }); + + 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', + succeededImageIds: [], + }); + }); +}); + +describe('image fill ACK normalization and timeout budget', () => { + it('calculates timeout budget for 0, 1, 2, 10 images correctly', () => { + expect(getPanelActionResultTimeoutMs(0)).toBe(8000); + expect(getPanelActionResultTimeoutMs(1)).toBe(8000); + expect(getPanelActionResultTimeoutMs(2)).toBe(14500); + expect(getPanelActionResultTimeoutMs(10)).toBe(66500); + }); + + it('normalizes success ACK missing image list to all expected IDs', () => { + const expected = ['img-1', 'img-2']; + expect(normalizeSucceededImageIds(undefined, expected, true)).toEqual(['img-1', 'img-2']); + }); + + it('normalizes failure ACK missing image list to empty array', () => { + const expected = ['img-1', 'img-2']; + expect(normalizeSucceededImageIds(undefined, expected, false)).toEqual([]); + }); + + it('filters unknown, duplicate, and non-string image IDs in ACK', () => { + const expected = ['img-1', 'img-2', 'img-3']; + const received = ['img-1', 'img-1', 123, 'img-unknown', null, 'img-2']; + expect(normalizeSucceededImageIds(received, expected, false)).toEqual(['img-1', 'img-2']); + }); + + it('calculates pending image IDs when failed ACK carries partial succeededImageIds', () => { + const expected = ['img-1', 'img-2']; + const succeeded = ['img-1']; + const pending = calculatePendingImageIds(expected, succeeded); + expect(pending).toEqual(['img-2']); + }); + + it('returns empty pending array when all images succeeded but text failed', () => { + const expected = ['img-1', 'img-2']; + const succeeded = ['img-1', 'img-2']; + const pending = calculatePendingImageIds(expected, succeeded); + expect(pending).toEqual([]); + }); +}); + +describe('fill retry state & stale ID cleanup', () => { + 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('cleans up stale panel IDs when a panel is deleted or provider switched', () => { + const failedPanelIds = new Set(['panel-deepseek', 'panel-stale']); + const pendingMap = new Map([ + ['panel-deepseek', ['img-2']], + ['panel-stale', ['img-1', 'img-2']], + ]); + + cleanStalePanelRetryState(panels, pendingMap, failedPanelIds); + + expect([...failedPanelIds]).toEqual(['panel-deepseek']); + expect(pendingMap.has('panel-stale')).toBe(false); + expect(pendingMap.get('panel-deepseek')).toEqual(['img-2']); + }); + + 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); + }); +}); + +describe('broadcast gate', () => { + it('prevents concurrent acquire and allows acquire after release', () => { + const gate = createBroadcastGate(); + expect(gate.isActive()).toBe(false); + + expect(gate.tryAcquire()).toBe(true); + expect(gate.isActive()).toBe(true); + expect(gate.tryAcquire()).toBe(false); + + gate.release(); + expect(gate.isActive()).toBe(false); + expect(gate.tryAcquire()).toBe(true); + }); +}); + +describe('getPanelBroadcastActionParams & determinePanelMessageType', () => { + it('returns plain text parameters for plain text Send All', () => { + const params = getPanelBroadcastActionParams({ + hasImages: false, + hasFailedPanels: false, + autoSubmit: true, + }); + expect(params).toEqual({ + isFillAction: false, + shouldAutoSubmit: true, + messageType: 'INJECT_TEXT', + waitForActionResult: false, + }); + expect(determinePanelMessageType({ isFillAction: params.isFillAction })).toBe('INJECT_TEXT'); + }); + + it('returns plain text parameters for plain text Fill Input Boxes', () => { + const params = getPanelBroadcastActionParams({ + hasImages: false, + hasFailedPanels: false, + autoSubmit: false, + }); + expect(params).toEqual({ + isFillAction: false, + shouldAutoSubmit: false, + messageType: 'INJECT_TEXT', + waitForActionResult: false, + }); + expect(determinePanelMessageType({ isFillAction: params.isFillAction })).toBe('INJECT_TEXT'); + }); + + it('returns image fill parameters for image fill operations', () => { + const params = getPanelBroadcastActionParams({ + hasImages: true, + hasFailedPanels: false, + autoSubmit: true, + }); + expect(params).toEqual({ + isFillAction: true, + shouldAutoSubmit: false, + messageType: 'INJECT_TEXT_WITH_IMAGES', + waitForActionResult: true, + }); + expect(determinePanelMessageType({ isFillAction: params.isFillAction })).toBe('INJECT_TEXT_WITH_IMAGES'); + }); + + it('returns fill action parameters during text-only retry when hasFailedPanels is true', () => { + const params = getPanelBroadcastActionParams({ + hasImages: false, + hasFailedPanels: true, + autoSubmit: false, + }); + expect(params).toEqual({ + isFillAction: true, + shouldAutoSubmit: false, + messageType: 'INJECT_TEXT_WITH_IMAGES', + waitForActionResult: true, + }); + expect(determinePanelMessageType({ isFillAction: params.isFillAction })).toBe('INJECT_TEXT_WITH_IMAGES'); + }); +}); diff --git a/tests/parent-broadcast-workflow.test.js b/tests/parent-broadcast-workflow.test.js new file mode 100644 index 0000000..753608c --- /dev/null +++ b/tests/parent-broadcast-workflow.test.js @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { broadcastMessage, sendToPanel } from '../multi-panel/multi-panel.js'; +import { getPanelBroadcastActionParams } from '../modules/panel-action-results.js'; + +describe('parent page sendToPanel & broadcastMessage production implementation workflow', () => { + let panel; + let iframe; + + beforeEach(() => { + document.body.innerHTML = ` + + + +
+ `; + + iframe = { contentWindow: { postMessage: vi.fn() } }; + panel = { id: 'panel-1', providerId: 'grok', iframe }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('sendToPanel production export', () => { + it('posts INJECT_TEXT and resolves immediately without waiting for ACK for plain text Send All', async () => { + const broadcastParams = getPanelBroadcastActionParams({ + hasImages: false, + hasFailedPanels: false, + autoSubmit: true, + }); + + const sendFocusRequestId = 'send-focus-123'; + + const result = await sendToPanel( + panel, + 'Plain text prompt', + [], + broadcastParams.shouldAutoSubmit, + sendFocusRequestId, + false, + 8000, + { + isFillAction: broadcastParams.isFillAction, + waitForActionResult: broadcastParams.waitForActionResult, + } + ); + + expect(result).toEqual({ + ok: true, + panelId: 'panel-1', + provider: 'grok', + succeededImageIds: [], + }); + + expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith( + { + type: 'INJECT_TEXT', + text: 'Plain text prompt', + images: [], + autoSubmit: true, + requestId: 'send-focus-123', + action: undefined, + retry: undefined, + providerMode: null, + context: 'multi-panel', + }, + '*' + ); + }); + + it('posts INJECT_TEXT for plain text Enter trigger', async () => { + const broadcastParams = getPanelBroadcastActionParams({ + hasImages: false, + hasFailedPanels: false, + autoSubmit: true, + }); + + const result = await sendToPanel( + panel, + 'Enter prompt', + [], + broadcastParams.shouldAutoSubmit, + 'send-focus-456', + false, + 8000, + { + isFillAction: broadcastParams.isFillAction, + waitForActionResult: broadcastParams.waitForActionResult, + } + ); + + expect(result.ok).toBe(true); + expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'INJECT_TEXT', + text: 'Enter prompt', + images: [], + autoSubmit: true, + }), + '*' + ); + }); + + it('posts INJECT_TEXT_WITH_IMAGES and waits for ACK completion during text-only retry after image fill', async () => { + const broadcastParams = getPanelBroadcastActionParams({ + hasImages: false, + hasFailedPanels: true, + autoSubmit: false, + }); + + const fillActionRequestId = 'fill-action-789'; + + const sendPromise = sendToPanel( + panel, + 'Text retry content', + [], + broadcastParams.shouldAutoSubmit, + fillActionRequestId, + true, + 8000, + { + isFillAction: broadcastParams.isFillAction, + waitForActionResult: broadcastParams.waitForActionResult, + } + ); + + expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith( + { + type: 'INJECT_TEXT_WITH_IMAGES', + text: 'Text retry content', + images: [], + autoSubmit: false, + requestId: 'fill-action-789', + action: 'fill', + retry: true, + providerMode: null, + context: 'multi-panel', + }, + '*' + ); + + // Simulate content script sending matching ACK back to window + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-action-789', + provider: 'grok', + action: 'fill', + status: 'succeeded', + succeededImageIds: [], + }, + source: iframe.contentWindow, + }) + ); + + const result = await sendPromise; + + expect(result).toEqual({ + ok: true, + panelId: 'panel-1', + provider: 'grok', + succeededImageIds: [], + }); + }); + }); + + describe('broadcastMessage production export smoke tests', () => { + it('executes broadcastMessage for plain text prompt without ReferenceError or error status', async () => { + const statusEl = document.getElementById('send-status'); + + await broadcastMessage('Test plain text broadcast', true); + + // Verify buttons are re-enabled in finally block + expect(document.getElementById('send-all-btn').disabled).toBe(false); + expect(document.getElementById('fill-input-btn').disabled).toBe(false); + // Status should be Sent to... or Filled..., not "Error occurred" + expect(statusEl.textContent).not.toBe('Error occurred'); + }); + + it('handles empty input gracefully without throwing ReferenceError', async () => { + const statusEl = document.getElementById('send-status'); + + await broadcastMessage('', true); + + expect(document.getElementById('send-all-btn').disabled).toBe(false); + expect(statusEl.textContent).not.toBe('Error occurred'); + }); + }); +}); 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..d3c38f6 --- /dev/null +++ b/tests/provider-image-upload-content-script.test.js @@ -0,0 +1,717 @@ +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 = [ + { + id: 'sample-one-id', + name: 'sample-one.png', + type: 'image/png', + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2Z2ioAAAAASUVORK5CYII=', + }, + { + id: 'sample-two-id', + 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, + requestId, + retry = false, +} = {}) { + window.dispatchEvent(new MessageEvent('message', { + data: { + type: 'INJECT_TEXT_WITH_IMAGES', + context: 'multi-panel', + images, + text, + autoSubmit, + requestId, + retry, + }, + })); +} + +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, + previewDelayMs = 0, + replacePreviewOnDelay = false, + 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; + 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); + }); + }); + + 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), + }); + Object.defineProperty(window, 'parent', { + configurable: true, + value: { postMessage: vi.fn() }, + }); + }); + + 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', + succeededImageIds: ['sample-one-id'], + }, '*'); + }); + + 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', + succeededImageIds: ['sample-one-id'], + }, '*'); + }); + + 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', + succeededImageIds: [], + }, '*'); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + + + 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', + succeededImageIds: [], + }, '*'); + }); + + 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', + text: 'keep this text', + }); + await finishTimedOutInjection(); + + expect(uploadedNames).toEqual(['sample-one.png']); + expect(document.querySelectorAll('.image-thumbnail.success')).toHaveLength(1); + expect(document.querySelector('.chat-input-editor').textContent).toBe('keep this text'); + 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', + succeededImageIds: [], + }, '*'); + + window.parent.postMessage.mockClear(); + dispatchImageInjection({ + requestId: 'fill-request-kimi-late-success-retry', + retry: true, + text: 'keep this text', + }); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png']); + expect(document.querySelectorAll('.image-thumbnail.success')).toHaveLength(1); + expect(document.querySelector('.chat-input-editor').textContent).toBe('keep this text'); + 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', + succeededImageIds: ['sample-one-id'], + }, '*'); + }); + + it('isolates Kimi unrelated existing upload and retries current image after completion', async () => { + window.happyDOM.setURL('https://www.kimi.com/'); + const { uploadedNames } = createKimiDom({ + existingLoadingPreview: true, + existingPreviewOutcome: 'success', + existingPreviewDelayMs: 500, + }); + + dispatchImageInjection({ + requestId: 'fill-request-kimi-unrelated-1', + images: [SAMPLE_IMAGES[0]], + retry: false, + }); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual([]); + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-kimi-unrelated-1', + provider: 'kimi', + action: 'fill', + status: 'failed', + reason: 'preview-timeout', + succeededImageIds: [], + }, '*'); + + // Wait for existing.png to finish uploading + await vi.advanceTimersByTimeAsync(600); + + dispatchImageInjection({ + requestId: 'fill-request-kimi-unrelated-2', + images: [SAMPLE_IMAGES[0]], + retry: true, + }); + await finishSuccessfulInjection(); + + expect(uploadedNames).toEqual(['sample-one.png']); + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'fill-request-kimi-unrelated-2', + provider: 'kimi', + action: 'fill', + status: 'succeeded', + succeededImageIds: ['sample-one-id'], + }, '*'); + }); + + 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(); + }); + + 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', + succeededImageIds: ['sample-one-id'], + }, '*'); + }); + + 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', + succeededImageIds: [], + }, '*'); + }); + + 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', + succeededImageIds: [], + }, '*'); + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('handles text-only retry when images array is empty during retry', async () => { + window.happyDOM.setURL('https://grok.com/'); + createGrokDom(); + + dispatchImageInjection({ + requestId: 'text-only-retry-req', + images: [], + text: 'retry text only', + retry: true, + }); + await finishSuccessfulInjection(); + + expect(document.querySelector('.tiptap').textContent).toBe('retry text only'); + expect(window.parent.postMessage).toHaveBeenCalledWith({ + type: 'PANELIZE_ACTION_RESULT', + context: 'multi-panel-action-result', + requestId: 'text-only-retry-req', + provider: 'grok', + action: 'fill', + status: 'succeeded', + succeededImageIds: [], + }, '*'); + }); +}); diff --git a/tests/qwen-content-script.test.js b/tests/qwen-content-script.test.js index 115073b..22cc0ad 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 = `