diff --git a/lightrag_webui/src/features/RetrievalTesting.tsx b/lightrag_webui/src/features/RetrievalTesting.tsx index eac38c17..5abeaf63 100644 --- a/lightrag_webui/src/features/RetrievalTesting.tsx +++ b/lightrag_webui/src/features/RetrievalTesting.tsx @@ -11,6 +11,8 @@ import QuerySettings from '@/components/retrieval/QuerySettings' import { ChatMessage, MessageWithError } from '@/components/retrieval/ChatMessage' import { EraserIcon, SendIcon, CopyIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { copyToClipboard } from '@/utils/clipboard' import type { QueryMode } from '@/api/lightrag' // Helper function to generate unique IDs with browser compatibility @@ -587,7 +589,7 @@ export default function RetrievalTesting() { useSettingsStore.getState().setRetrievalHistory([]) }, [setMessages]) - // Handle copying message content + // Handle copying message content with robust clipboard support const handleCopyMessage = useCallback(async (message: MessageWithError) => { let contentToCopy = ''; @@ -602,12 +604,50 @@ export default function RetrievalTesting() { contentToCopy = finalDisplayContent; } - if (contentToCopy.trim()) { - try { - await navigator.clipboard.writeText(contentToCopy) - } catch (err) { - console.error(t('chat.copyError'), err) + if (!contentToCopy.trim()) { + toast.error(t('retrievePanel.chatMessage.copyEmpty', 'No content to copy')); + return; + } + + try { + const result = await copyToClipboard(contentToCopy); + + if (result.success) { + // Show success message with method used + const methodMessages: Record = { + 'clipboard-api': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard'), + 'execCommand': t('retrievePanel.chatMessage.copySuccessLegacy', 'Content copied (legacy method)'), + 'manual-select': t('retrievePanel.chatMessage.copySuccessManual', 'Content copied (manual method)'), + 'fallback': t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard') + }; + + toast.success(methodMessages[result.method] || t('retrievePanel.chatMessage.copySuccess', 'Content copied to clipboard')); + } else { + // Show error with fallback instructions + if (result.method === 'fallback') { + toast.error( + result.error || t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'), + { + description: t('retrievePanel.chatMessage.copyManualInstruction', 'Please select and copy the text manually') + } + ); + } else { + toast.error( + t('retrievePanel.chatMessage.copyFailed', 'Failed to copy content'), + { + description: result.error + } + ); + } } + } catch (err) { + console.error('Clipboard operation failed:', err); + toast.error( + t('retrievePanel.chatMessage.copyError', 'Copy operation failed'), + { + description: err instanceof Error ? err.message : 'Unknown error occurred' + } + ); } }, [t]) diff --git a/lightrag_webui/src/utils/clipboard.ts b/lightrag_webui/src/utils/clipboard.ts new file mode 100644 index 00000000..ca45982b --- /dev/null +++ b/lightrag_webui/src/utils/clipboard.ts @@ -0,0 +1,198 @@ +/** + * Robust clipboard utility with multiple fallback strategies + * Handles various browser environments and security contexts + */ + +export interface CopyResult { + success: boolean; + method: 'clipboard-api' | 'execCommand' | 'manual-select' | 'fallback'; + error?: string; +} + +/** + * Copy text to clipboard with multiple fallback strategies + * @param text - Text to copy to clipboard + * @returns Promise - Result object with success status and method used + */ +export async function copyToClipboard(text: string): Promise { + if (!text || text.trim() === '') { + return { + success: false, + method: 'fallback', + error: 'No text provided' + }; + } + + // Strategy 1: Modern Clipboard API (preferred) + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + try { + await navigator.clipboard.writeText(text); + return { + success: true, + method: 'clipboard-api' + }; + } catch (error) { + console.warn('Clipboard API failed:', error); + // Continue to fallback methods + } + } + + // Strategy 2: Legacy execCommand (for older browsers) + try { + const result = await copyWithExecCommand(text); + if (result.success) { + return result; + } + } catch (error) { + console.warn('execCommand failed:', error); + // Continue to fallback methods + } + + // Strategy 3: Manual text selection (most compatible) + try { + const result = await copyWithManualSelection(text); + if (result.success) { + return result; + } + } catch (error) { + console.warn('Manual selection failed:', error); + } + + // Strategy 4: Complete fallback - return error + return { + success: false, + method: 'fallback', + error: 'All copy methods failed. Please copy the text manually.' + }; +} + +/** + * Copy using legacy execCommand method + */ +async function copyWithExecCommand(text: string): Promise { + return new Promise((resolve) => { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + textarea.style.opacity = '0'; + textarea.setAttribute('readonly', ''); + + document.body.appendChild(textarea); + + try { + textarea.select(); + textarea.setSelectionRange(0, text.length); + + const successful = document.execCommand('copy'); + + if (successful) { + resolve({ + success: true, + method: 'execCommand' + }); + } else { + resolve({ + success: false, + method: 'execCommand', + error: 'execCommand returned false' + }); + } + } catch (error) { + resolve({ + success: false, + method: 'execCommand', + error: error instanceof Error ? error.message : 'execCommand failed' + }); + } finally { + document.body.removeChild(textarea); + } + }); +} + +/** + * Copy using manual text selection method + */ +async function copyWithManualSelection(text: string): Promise { + return new Promise((resolve) => { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + textarea.style.opacity = '0'; + textarea.style.pointerEvents = 'none'; + textarea.setAttribute('readonly', ''); + textarea.setAttribute('tabindex', '-1'); + + document.body.appendChild(textarea); + + try { + // Focus and select the text + textarea.focus(); + textarea.select(); + textarea.setSelectionRange(0, text.length); + + // Try to trigger copy event + const copyEvent = new ClipboardEvent('copy', { + clipboardData: new DataTransfer() + }); + + if (copyEvent.clipboardData) { + copyEvent.clipboardData.setData('text/plain', text); + document.dispatchEvent(copyEvent); + + resolve({ + success: true, + method: 'manual-select' + }); + } else { + // Fallback: keep text selected for manual copy + resolve({ + success: false, + method: 'manual-select', + error: 'Manual selection prepared, but automatic copy failed' + }); + } + } catch (error) { + resolve({ + success: false, + method: 'manual-select', + error: error instanceof Error ? error.message : 'Manual selection failed' + }); + } finally { + // Clean up after a short delay to allow copy operation + setTimeout(() => { + if (document.body.contains(textarea)) { + document.body.removeChild(textarea); + } + }, 100); + } + }); +} + +/** + * Check if clipboard functionality is available + */ +export function isClipboardSupported(): boolean { + return !!( + (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') || + typeof document !== 'undefined' + ); +} + +/** + * Get the best available clipboard method + */ +export function getBestClipboardMethod(): 'clipboard-api' | 'execCommand' | 'manual-select' | 'none' { + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + return 'clipboard-api'; + } + + if (typeof document !== 'undefined') { + return 'execCommand'; + } + + return 'none'; +}