diff --git a/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx new file mode 100644 index 00000000..0b0ccd09 --- /dev/null +++ b/packages/ui/src/components/lazy-editors/AIGenerationPlugin.tsx @@ -0,0 +1,175 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { COMMAND_PRIORITY_NORMAL, KEY_DOWN_COMMAND, $getSelection, $isRangeSelection, $createParagraphNode, $insertNodes, $getRoot, $createTextNode } from 'lexical'; +import { mergeRegister } from '@lexical/utils'; +import { RealmPlugin, addComposerChild$, usePublisher, insertMarkdown$ } from '@mdxeditor/editor'; +import { AIPromptPopup } from './AIPromptPopup'; +import { generateText } from '@/lib/openai'; +import { toast } from 'sonner'; +import { getUserSecrets } from '@/components/ImageWizard/db'; +import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from '@/hooks/useAuth'; + +// Hook to access secrets helper +const useProviderApiKey = () => { + const { user } = useAuth(); + + const getApiKey = useCallback(async (provider: string): Promise => { + if (!user) return null; + + if (provider === 'openai') { + try { + const secrets = await getUserSecrets(user.id); + if (secrets && secrets.openai_api_key) { + return secrets.openai_api_key; + } + } catch (error) { + console.error('Error fetching user secrets:', error); + } + } + + try { + const { data: userProvider, error } = await supabase + .from('provider_configs') + .select('settings') + .eq('user_id', user.id) + .eq('name', provider) + .eq('is_active', true) + .single(); + + if (error || !userProvider) return null; + const settings = userProvider.settings as any; + return settings?.apiKey || null; + } catch (error) { + console.error(`Error fetching API key for ${provider}:`, error); + return null; + } + }, [user]); + + return getApiKey; +} + +const AIGenerationComponent = () => { + const [editor] = useLexicalComposerContext(); + const [isPopupOpen, setIsPopupOpen] = useState(false); + const getApiKey = useProviderApiKey(); + const [contextData, setContextData] = useState<{ selection: string, content: string }>({ selection: '', content: '' }); + const insertMarkdown = usePublisher(insertMarkdown$); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + KEY_DOWN_COMMAND, + (event: KeyboardEvent) => { + // Check for Ctrl+Space + if (event.ctrlKey && event.code === 'Space') { + event.preventDefault(); + + // Capture context before opening + editor.getEditorState().read(() => { + const selection = $getSelection(); + const selectionText = selection ? selection.getTextContent() : ''; + const root = $getRoot(); + const contentText = root ? root.getTextContent() : ''; + + setContextData({ + selection: selectionText, + content: contentText + }); + }); + + setIsPopupOpen(true); + return true; + } + return false; + }, + COMMAND_PRIORITY_NORMAL + ) + ); + }, [editor]); + + const handleGenerate = async (prompt: string, provider: string, model: string, contextMode: 'selection' | 'content' | 'none', applicationMode: 'replace' | 'insert' | 'append') => { + try { + const apiKey = await getApiKey(provider); + + // Construct prompt with context + let finalPrompt = prompt; + const instructions = `\n\nINSTRUCTIONS: Return the content formatted as Markdown. You can use code blocks, lists, and other markdown features. Do NOT include preamble or explanations. Just return the content directly.`; + + if (contextMode === 'selection' && contextData.selection) { + finalPrompt = `CONTEXT:\n\`\`\`\n${contextData.selection}\n\`\`\`\n\nREQUEST: ${prompt}${instructions}`; + } else if (contextMode === 'content' && contextData.content) { + finalPrompt = `CONTEXT:\n\`\`\`\n${contextData.content}\n\`\`\`\n\nREQUEST: ${prompt}${instructions}`; + } else { + finalPrompt = `${prompt}${instructions}`; + } + + // Generate text + // Note: We're not using tools or streaming here yet for simplicity, just text generation + const result = await generateText(finalPrompt, model, apiKey || undefined); + + if (result) { + // Clean up markdown wrapper + let cleanedText = result.trim(); + + // Smart cleanup: + // If the entire text is wrapped in ```markdown or ```text or just ``` (no lang), it's likely a metadata wrapper. + // We want to strip these BUT preserve inner code blocks or code blocks with specific languages (e.g. ```javascript). + + const wrapperMatch = cleanedText.match(/^```(markdown|text|txt)?\s*\n/i); + if (wrapperMatch && cleanedText.endsWith('```')) { + // It is wrapped. Strip the wrapper. + cleanedText = cleanedText + .substring(wrapperMatch[0].length, cleanedText.length - 3) + .trim(); + } + + + cleanedText = cleanedText.trim(); + + // Handle 'append' mode by moving selection to end first + if (applicationMode === 'append') { + // We need to move selection to end. + // Since insertMarkdown uses the current selection, updating it via editor.update shoud work. + editor.update(() => { + const root = $getRoot(); + root.selectEnd(); + }); + } + // For 'replace' or 'insert', the current selection is used automatically by insertMarkdown. + + // Insert the markdown + // Note: insertMarkdown$ is a signal that triggers the editor to insert markdown at the current selection. + insertMarkdown(cleanedText); + + toast.success('Generated text inserted'); + } else { + toast.error('No text generated'); + } + } catch (error: any) { + console.error('Generation error:', error); + toast.error(`Error: ${error.message}`); + throw error; // Re-throw to keep popup open or handle error in popup + } + }; + + return ( + setIsPopupOpen(false)} + onGenerate={handleGenerate} + hasSelection={!!contextData.selection} + hasContent={!!contextData.content} + /> + ); +}; + +export const aiGenerationPlugin = (): RealmPlugin => { + return { + init: (realm) => { + realm.pubIn({ + [addComposerChild$]: () => + }) + } + } +} diff --git a/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx b/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx new file mode 100644 index 00000000..f548cbcb --- /dev/null +++ b/packages/ui/src/components/lazy-editors/AIImageGenerationPlugin.tsx @@ -0,0 +1,139 @@ +import React, { useState, useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $createParagraphNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, createCommand, LexicalCommand, $insertNodes } from 'lexical'; +import { RealmPlugin, addComposerChild$, $createImageNode } from '@mdxeditor/editor'; +import { AIImagePromptPopup } from './AIImagePromptPopup'; +import { createImage } from '@/lib/image-router'; +import { uploadFileToStorage } from '@/lib/db'; +import { useAuth } from '@/hooks/useAuth'; +import { toast } from 'sonner'; + +export const OPEN_IMAGE_GEN_COMMAND: LexicalCommand = createCommand('OPEN_IMAGE_GEN_COMMAND'); + +const AIImagePromptPopupWrapper = () => { + const [editor] = useLexicalComposerContext(); + const [isOpen, setIsOpen] = useState(false); + const [hasSelection, setHasSelection] = useState(false); + const [hasContent, setHasContent] = useState(false); + const { user } = useAuth(); + + useEffect(() => { + return editor.registerCommand( + OPEN_IMAGE_GEN_COMMAND, + () => { + // Check selection/content status when opening + editor.getEditorState().read(() => { + const selection = $getSelection(); + const textContent = editor.getEditorState().read(() => $getRoot().getTextContent()); + setHasSelection($isRangeSelection(selection) && !selection.isCollapsed()); + setHasContent(textContent.trim().length > 0); + }); + setIsOpen(true); + return true; + }, + COMMAND_PRIORITY_LOW + ); + }, [editor]); + + const handleGenerate = async (prompt: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none') => { + if (!user?.id) { + toast.error("You must be logged in to generate images."); + return; + } + + let fullPrompt = prompt; + // Augment prompt with context if requested + if (contextMode !== 'none') { + const contextText = await new Promise((resolve) => { + editor.getEditorState().read(() => { + let text = ''; + if (contextMode === 'selection') { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + text = selection.getTextContent(); + } + } else if (contextMode === 'content') { + text = $getRoot().getTextContent(); + } + resolve(text); + }); + }); + + if (contextText) { + // Truncate context if too long (arbitrary limit for prompt safety) + const safeContext = contextText.slice(0, 1000); + fullPrompt = `${prompt}\n\nContext: ${safeContext}`; + } + } + + try { + const result = await createImage( + fullPrompt, + model, + undefined, // apiKey handled internally/server-side usually or locally + aspectRatio + ); + + if (!result || !result.imageData) { + toast.error("Failed to generate image."); + return; + } + + // Convert ArrayBuffer to Blob + const blob = new Blob([result.imageData], { type: 'image/png' }); + + // Upload to Supabase + const fileName = `${user.id}/gen-${Date.now()}.png`; + const publicUrl = await uploadFileToStorage(user.id, blob, fileName); + + if (publicUrl) { + editor.update(() => { + const imageNode = $createImageNode({ + src: publicUrl, + altText: prompt, + title: prompt + }); + // Insert at current selection + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertNodes([imageNode]); + } else { + // Fallback append + // Use $insertNodes which handles selection fallback usually, but if no selection... + $insertNodes([imageNode]); + } + }); + toast.success("Image generated and inserted!"); + } else { + toast.error("Failed to upload generated image."); + } + + } catch (error: any) { + console.error("Image generation error:", error); + toast.error(error.message || "Error generating image"); + } + }; + + return ( + setIsOpen(false)} + onGenerate={handleGenerate} + hasSelection={hasSelection} + hasContent={hasContent} + /> + ); +}; + +export const aiImageGenerationPlugin = (): RealmPlugin => { + return { + init: (realm) => { + realm.pubIn({ + [addComposerChild$]: () => + }); + } + }; +}; + +// Helper to access root node (Lexical hack if import missing) +import { $getRoot } from 'lexical'; diff --git a/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx b/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx new file mode 100644 index 00000000..d11dd575 --- /dev/null +++ b/packages/ui/src/components/lazy-editors/AIImagePromptPopup.tsx @@ -0,0 +1,243 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Loader2, Sparkles, X, Image as ImageIcon } from 'lucide-react'; +import { T, translate } from '@/i18n'; +import { Card } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { ModelSelector } from '@/components/ImageWizard/components/ModelSelector'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +interface AIImagePromptPopupProps { + isOpen: boolean; + onClose: () => void; + onGenerate: (prompt: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none') => Promise; + initialModel?: string; + hasSelection: boolean; + hasContent: boolean; +} + +export const AIImagePromptPopup: React.FC = ({ + isOpen, + onClose, + onGenerate, + initialModel = 'google/gemini-3-pro-image-preview', + hasSelection, + hasContent +}) => { + const [prompt, setPrompt] = useState(''); + + // Initialize model from local storage or props + const [model, setModel] = useState(() => { + return localStorage.getItem('ai_image_last_model') || initialModel; + }); + + const [aspectRatio, setAspectRatio] = useState(() => { + return localStorage.getItem('ai_image_last_aspect_ratio') || '1:1'; + }); + + const [isGenerating, setIsGenerating] = useState(false); + const [contextMode, setContextMode] = useState<'selection' | 'content' | 'none'>('none'); + + const textareaRef = useRef(null); + + // Save model/aspect ratio changes + useEffect(() => { + if (model) localStorage.setItem('ai_image_last_model', model); + }, [model]); + + useEffect(() => { + if (aspectRatio) localStorage.setItem('ai_image_last_aspect_ratio', aspectRatio); + }, [aspectRatio]); + + // Initial context mode selection + useEffect(() => { + if (isOpen) { + if (hasSelection) { + setContextMode('selection'); + } else if (hasContent) { + setContextMode('content'); + } else { + setContextMode('none'); + } + } + }, [isOpen, hasSelection, hasContent]); + + // Auto-focus textarea when opened + useEffect(() => { + if (isOpen) { + setTimeout(() => { + textareaRef.current?.focus(); + }, 50); + } + }, [isOpen]); + + // Handle ESC key to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + if (e.key === 'Escape') { + if (!isGenerating) { + onClose(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose, isGenerating]); + + const handleGenerate = async () => { + if (!prompt.trim()) return; + + setIsGenerating(true); + try { + await onGenerate(prompt, model, aspectRatio, contextMode); + onClose(); + setPrompt(''); + } catch (error) { + console.error("Image generation failed", error); + } finally { + setIsGenerating(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleGenerate(); + } + }; + + if (!isOpen) return null; + + return ( +
+ + {/* Header */} +
+
+ + Generate Image +
+ +
+ + {/* Content */} +
+ {/* Prompt Input */} +
+