md slash commands, text/img gen

This commit is contained in:
lovebird 2026-01-30 19:59:13 +01:00
parent 1fd6ef704e
commit ba9fba49cb
9 changed files with 498 additions and 223 deletions

View File

@ -7,7 +7,7 @@ import { User, MessageCircle, Heart, MoreHorizontal, Mic, MicOff, Loader2 } from
import { toast } from "sonner"; import { toast } from "sonner";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import MarkdownRenderer from "@/components/MarkdownRenderer"; import MarkdownRenderer from "@/components/MarkdownRenderer";
import MarkdownEditor from "@/components/MarkdownEditor";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -52,7 +52,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
const [userProfiles, setUserProfiles] = useState<Map<string, UserProfile>>(new Map()); const [userProfiles, setUserProfiles] = useState<Map<string, UserProfile>>(new Map());
const [editingComment, setEditingComment] = useState<string | null>(null); const [editingComment, setEditingComment] = useState<string | null>(null);
const [editText, setEditText] = useState(""); const [editText, setEditText] = useState("");
// Microphone recording state // Microphone recording state
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false); const [isTranscribing, setIsTranscribing] = useState(false);
@ -81,12 +81,12 @@ const Comments = ({ pictureId }: CommentsProps) => {
.from('comment_likes') .from('comment_likes')
.select('comment_id') .select('comment_id')
.eq('user_id', user.id); .eq('user_id', user.id);
if (!likesError && likesData) { if (!likesError && likesData) {
userLikes = likesData.map(like => like.comment_id); userLikes = likesData.map(like => like.comment_id);
} }
} }
setLikedComments(new Set(userLikes)); setLikedComments(new Set(userLikes));
// Fetch user profiles for all comment authors // Fetch user profiles for all comment authors
@ -119,14 +119,14 @@ const Comments = ({ pictureId }: CommentsProps) => {
// Second pass: organize hierarchy with depth limit // Second pass: organize hierarchy with depth limit
data.forEach(comment => { data.forEach(comment => {
const commentWithReplies = commentsMap.get(comment.id)!; const commentWithReplies = commentsMap.get(comment.id)!;
if (comment.parent_comment_id) { if (comment.parent_comment_id) {
const parent = commentsMap.get(comment.parent_comment_id); const parent = commentsMap.get(comment.parent_comment_id);
if (parent) { if (parent) {
// Calculate depth: parent depth + 1, but max 2 (0, 1, 2 = 3 levels) // Calculate depth: parent depth + 1, but max 2 (0, 1, 2 = 3 levels)
const newDepth = Math.min(parent.depth + 1, 2); const newDepth = Math.min(parent.depth + 1, 2);
commentWithReplies.depth = newDepth; commentWithReplies.depth = newDepth;
// If we're at max depth, flatten to parent's level instead of nesting deeper // If we're at max depth, flatten to parent's level instead of nesting deeper
if (parent.depth >= 2) { if (parent.depth >= 2) {
// Find the root ancestor to add this comment to // Find the root ancestor to add this comment to
@ -233,7 +233,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
try { try {
const { error } = await supabase const { error } = await supabase
.from('comments') .from('comments')
.update({ .update({
content: editText.trim(), content: editText.trim(),
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}) })
@ -269,7 +269,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
} }
const isLiked = likedComments.has(commentId); const isLiked = likedComments.has(commentId);
try { try {
if (isLiked) { if (isLiked) {
// Unlike the comment // Unlike the comment
@ -285,7 +285,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
const newLikedComments = new Set(likedComments); const newLikedComments = new Set(likedComments);
newLikedComments.delete(commentId); newLikedComments.delete(commentId);
setLikedComments(newLikedComments); setLikedComments(newLikedComments);
// Update comments state // Update comments state
const updateCommentsLikes = (comments: Comment[]): Comment[] => { const updateCommentsLikes = (comments: Comment[]): Comment[] => {
return comments.map(comment => { return comments.map(comment => {
@ -314,7 +314,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
const newLikedComments = new Set(likedComments); const newLikedComments = new Set(likedComments);
newLikedComments.add(commentId); newLikedComments.add(commentId);
setLikedComments(newLikedComments); setLikedComments(newLikedComments);
// Update comments state // Update comments state
const updateCommentsLikes = (comments: Comment[]): Comment[] => { const updateCommentsLikes = (comments: Comment[]): Comment[] => {
return comments.map(comment => { return comments.map(comment => {
@ -340,15 +340,15 @@ const Comments = ({ pictureId }: CommentsProps) => {
const commentDate = new Date(dateString); const commentDate = new Date(dateString);
const diffInMs = now.getTime() - commentDate.getTime(); const diffInMs = now.getTime() - commentDate.getTime();
const diffInHours = diffInMs / (1000 * 60 * 60); const diffInHours = diffInMs / (1000 * 60 * 60);
// If more than 24 hours ago, show regular date // If more than 24 hours ago, show regular date
if (diffInHours >= 24) { if (diffInHours >= 24) {
return commentDate.toLocaleDateString(); return commentDate.toLocaleDateString();
} }
// Show relative time for recent comments // Show relative time for recent comments
const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
if (diffInMinutes < 1) { if (diffInMinutes < 1) {
return 'just now'; return 'just now';
} else if (diffInMinutes < 60) { } else if (diffInMinutes < 60) {
@ -375,16 +375,16 @@ const Comments = ({ pictureId }: CommentsProps) => {
} }
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const options = { mimeType: 'audio/webm' }; const options = { mimeType: 'audio/webm' };
let mediaRecorder: MediaRecorder; let mediaRecorder: MediaRecorder;
try { try {
mediaRecorder = new MediaRecorder(stream, options); mediaRecorder = new MediaRecorder(stream, options);
} catch (e) { } catch (e) {
mediaRecorder = new MediaRecorder(stream); mediaRecorder = new MediaRecorder(stream);
} }
mediaRecorderRef.current = mediaRecorder; mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = []; audioChunksRef.current = [];
setRecordingFor(type); setRecordingFor(type);
@ -397,14 +397,14 @@ const Comments = ({ pictureId }: CommentsProps) => {
mediaRecorder.onstop = async () => { mediaRecorder.onstop = async () => {
setIsTranscribing(true); setIsTranscribing(true);
try { try {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' }); const audioFile = new File([audioBlob], 'recording.webm', { type: 'audio/webm' });
toast.info(translate('Transcribing audio...')); toast.info(translate('Transcribing audio...'));
const transcribedText = await transcribeAudio(audioFile); const transcribedText = await transcribeAudio(audioFile);
if (transcribedText) { if (transcribedText) {
if (type === 'new') { if (type === 'new') {
setNewComment(prev => { setNewComment(prev => {
@ -435,7 +435,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
mediaRecorder.start(); mediaRecorder.start();
setIsRecording(true); setIsRecording(true);
toast.info(translate('Recording... Click mic again to stop')); toast.info(translate('Recording... Click mic again to stop'));
} catch (error: any) { } catch (error: any) {
console.error('Error accessing microphone:', error); console.error('Error accessing microphone:', error);
if (error.name === 'NotAllowedError') { if (error.name === 'NotAllowedError') {
@ -455,14 +455,14 @@ const Comments = ({ pictureId }: CommentsProps) => {
return ( return (
<div key={comment.id} className="space-y-3"> <div key={comment.id} className="space-y-3">
<div className="flex space-x-3" style={{ marginLeft: `${marginLeft}px` }}> <div className="flex space-x-3" style={{ marginLeft: `${marginLeft}px` }}>
<Link <Link
to={`/user/${comment.user_id}`} to={`/user/${comment.user_id}`}
className="w-8 h-8 bg-gradient-primary rounded-full flex items-center justify-center flex-shrink-0 hover:scale-110 transition-transform overflow-hidden" className="w-8 h-8 bg-gradient-primary rounded-full flex items-center justify-center flex-shrink-0 hover:scale-110 transition-transform overflow-hidden"
> >
{userProfile?.avatar_url ? ( {userProfile?.avatar_url ? (
<img <img
src={userProfile.avatar_url} src={userProfile.avatar_url}
alt={userProfile.display_name || 'User avatar'} alt={userProfile.display_name || 'User avatar'}
className="w-full h-full rounded-full object-cover" className="w-full h-full rounded-full object-cover"
/> />
) : ( ) : (
@ -473,7 +473,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
<div className="bg-card rounded-lg p-3"> <div className="bg-card rounded-lg p-3">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Link <Link
to={`/user/${comment.user_id}`} to={`/user/${comment.user_id}`}
className="text-sm font-semibold hover:underline" className="text-sm font-semibold hover:underline"
> >
@ -491,12 +491,12 @@ const Comments = ({ pictureId }: CommentsProps) => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
onClick={() => startEditingComment(comment)} onClick={() => startEditingComment(comment)}
> >
<T>Edit</T> <T>Edit</T>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleDeleteComment(comment.id)} onClick={() => handleDeleteComment(comment.id)}
className="text-destructive" className="text-destructive"
> >
@ -516,14 +516,14 @@ const Comments = ({ pictureId }: CommentsProps) => {
autoFocus autoFocus
/> />
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button
size="sm" size="sm"
onClick={() => handleEditComment(comment.id)} onClick={() => handleEditComment(comment.id)}
disabled={!editText.trim()} disabled={!editText.trim()}
> >
<T>Save</T> <T>Save</T>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={cancelEditingComment} onClick={cancelEditingComment}
@ -538,28 +538,26 @@ const Comments = ({ pictureId }: CommentsProps) => {
</div> </div>
</div> </div>
<div className="flex items-center space-x-4 mt-2"> <div className="flex items-center space-x-4 mt-2">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleToggleLike(comment.id)} onClick={() => handleToggleLike(comment.id)}
className={`h-6 px-2 text-xs ${ className={`h-6 px-2 text-xs ${likedComments.has(comment.id)
likedComments.has(comment.id) ? 'text-red-500 hover:text-red-600'
? 'text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground' : 'text-muted-foreground hover:text-foreground'
}`} }`}
> >
<Heart <Heart
className={`h-3 w-3 mr-1 ${ className={`h-3 w-3 mr-1 ${likedComments.has(comment.id) ? 'fill-current' : ''
likedComments.has(comment.id) ? 'fill-current' : '' }`}
}`}
/> />
{comment.likes_count > 0 && <span className="mr-1">{comment.likes_count}</span>} {comment.likes_count > 0 && <span className="mr-1">{comment.likes_count}</span>}
<T>Like</T> <T>Like</T>
</Button> </Button>
{comment.depth < 2 && ( {comment.depth < 2 && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setReplyingTo(replyingTo === comment.id ? null : comment.id)} onClick={() => setReplyingTo(replyingTo === comment.id ? null : comment.id)}
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground" className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
> >
@ -568,7 +566,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
</Button> </Button>
)} )}
</div> </div>
{replyingTo === comment.id && ( {replyingTo === comment.id && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div className="relative"> <div className="relative">
@ -588,11 +586,10 @@ const Comments = ({ pictureId }: CommentsProps) => {
<button <button
onClick={() => handleMicrophone('reply')} onClick={() => handleMicrophone('reply')}
disabled={isTranscribing} disabled={isTranscribing}
className={`absolute right-2 bottom-2 p-1.5 rounded-md transition-colors ${ className={`absolute right-2 bottom-2 p-1.5 rounded-md transition-colors ${isRecording && recordingFor === 'reply'
isRecording && recordingFor === 'reply' ? 'bg-red-100 text-red-600 hover:bg-red-200'
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`} }`}
title={isRecording && recordingFor === 'reply' ? 'Stop recording' : 'Record audio'} title={isRecording && recordingFor === 'reply' ? 'Stop recording' : 'Record audio'}
> >
{isTranscribing && recordingFor === 'reply' ? ( {isTranscribing && recordingFor === 'reply' ? (
@ -605,16 +602,16 @@ const Comments = ({ pictureId }: CommentsProps) => {
</button> </button>
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <Button
size="sm" size="sm"
onClick={() => handleAddReply(comment.id)} onClick={() => handleAddReply(comment.id)}
disabled={!replyText.trim()} disabled={!replyText.trim()}
> >
<T>Reply</T> <T>Reply</T>
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setReplyingTo(null); setReplyingTo(null);
setReplyText(""); setReplyText("");
@ -627,7 +624,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
)} )}
</div> </div>
</div> </div>
{comment.replies && comment.replies.length > 0 && ( {comment.replies && comment.replies.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
{comment.replies.map(reply => renderComment(reply))} {comment.replies.map(reply => renderComment(reply))}
@ -652,18 +649,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
<div className="space-y-3 pb-4 border-b"> <div className="space-y-3 pb-4 border-b">
<div className="relative"> <div className="relative">
{useMarkdown ? ( {useMarkdown ? (
<MarkdownEditor <div />
value={newComment}
onChange={setNewComment}
placeholder={translate('Add a comment...')}
className="min-h-[60px]"
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleAddComment();
}
}}
/>
) : ( ) : (
<Textarea <Textarea
value={newComment} value={newComment}
@ -682,11 +668,10 @@ const Comments = ({ pictureId }: CommentsProps) => {
<button <button
onClick={() => handleMicrophone('new')} onClick={() => handleMicrophone('new')}
disabled={isTranscribing} disabled={isTranscribing}
className={`absolute right-2 bottom-2 p-1.5 rounded-md transition-colors ${ className={`absolute right-2 bottom-2 p-1.5 rounded-md transition-colors ${isRecording && recordingFor === 'new'
isRecording && recordingFor === 'new' ? 'bg-red-100 text-red-600 hover:bg-red-200'
? 'bg-red-100 text-red-600 hover:bg-red-200'
: 'text-muted-foreground hover:text-foreground hover:bg-accent' : 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`} }`}
title={isRecording && recordingFor === 'new' ? 'Stop recording' : 'Record audio'} title={isRecording && recordingFor === 'new' ? 'Stop recording' : 'Record audio'}
> >
{isTranscribing && recordingFor === 'new' ? ( {isTranscribing && recordingFor === 'new' ? (
@ -699,7 +684,7 @@ const Comments = ({ pictureId }: CommentsProps) => {
</button> </button>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
onClick={handleAddComment} onClick={handleAddComment}
disabled={!newComment.trim()} disabled={!newComment.trim()}
> >

View File

@ -238,20 +238,7 @@ export const CreationWizardPopup: React.FC<CreationWizardPopupProps> = ({
title: img.title || 'Untitled Link', title: img.title || 'Untitled Link',
description: img.description || null, description: img.description || null,
image_url: img.src, // Use the preview image as main URL? Or should we store the link URL? image_url: img.src, // Use the preview image as main URL? Or should we store the link URL?
// Wait, "image_url" usually stores the image. // image_url: img.path, // The generic URL
// For pages, we should probably store the link in `meta` or `url` if exists?
// The `pictures` table has `image_url` and `video_url`.
// Let's store the LINK in `image_url` (since it's the primary content) or specific logic?
// Actually, for PAGE type, `image_url` is often the link, or we use a separate field?
// Looking at `mediaUtils`: for `PAGE`, `url` is passed through.
// But `PAGE` usually implies internal.
// Let's check `MediaCard`. It uses `url` prop.
// For `PAGE_EXTERNAL`, the `url` should be the external link.
// But where do we store the thumbnail?
// `pictures` table has `thumbnail_url`.
// So: image_url = external_link, thumbnail_url = preview_image.
image_url: img.path, // The generic URL
thumbnail_url: img.src, // The preview image thumbnail_url: img.src, // The preview image
organization_id: organizationId, organization_id: organizationId,
type: 'page-external', type: 'page-external',

View File

@ -27,7 +27,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{showStepNumber && <span className="text-xs text-muted-foreground mr-2">1.</span>} {showStepNumber && <span className="text-xs text-muted-foreground mr-2">1.</span>}
<T>{label}</T> <T>{label}</T>
</label> </label>
<select <select
value={selectedModel} value={selectedModel}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
@ -44,7 +44,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</option> </option>
))} ))}
</optgroup> </optgroup>
{/* Replicate Models */} {/* Replicate Models */}
<optgroup label="Replicate"> <optgroup label="Replicate">
{AVAILABLE_MODELS.filter(m => m.provider === 'replicate').map((model) => ( {AVAILABLE_MODELS.filter(m => m.provider === 'replicate').map((model) => (
@ -56,7 +56,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</option> </option>
))} ))}
</optgroup> </optgroup>
{/* Bria.ai Models (Commercial Safe) */} {/* Bria.ai Models (Commercial Safe) */}
<optgroup label="Bria.ai (Commercial Safe)"> <optgroup label="Bria.ai (Commercial Safe)">
{AVAILABLE_MODELS.filter(m => m.provider === 'bria').map((model) => ( {AVAILABLE_MODELS.filter(m => m.provider === 'bria').map((model) => (
@ -68,7 +68,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
</option> </option>
))} ))}
</optgroup> </optgroup>
{/* AIML API Models (Multi-Model Gateway) */} {/* AIML API Models (Multi-Model Gateway) */}
<optgroup label="AIML API (Multi-Model Gateway)"> <optgroup label="AIML API (Multi-Model Gateway)">
{AVAILABLE_MODELS.filter(m => m.provider === 'aimlapi').map((model) => ( {AVAILABLE_MODELS.filter(m => m.provider === 'aimlapi').map((model) => (
@ -81,7 +81,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
))} ))}
</optgroup> </optgroup>
</select> </select>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
<T>Select the AI model for image generation</T> <T>Select the AI model for image generation</T>
</p> </p>

View File

@ -4,7 +4,7 @@ import { supabase } from '@/integrations/supabase/client';
// Lazy load the heavy editor component // Lazy load the heavy editor component
const MilkdownEditorInternal = React.lazy(() => import('@/components/lazy-editors/MilkdownEditorInternal')); //const MilkdownEditorInternal = React.lazy(() => import('@/components/lazy-editors/MilkdownEdito'));
interface MarkdownEditorProps { interface MarkdownEditorProps {
value: string; value: string;
@ -99,11 +99,7 @@ const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
</div> </div>
{activeTab === 'editor' && ( {activeTab === 'editor' && (
<React.Suspense fallback={<div className="p-3 text-muted-foreground">Loading editor...</div>}> <React.Suspense fallback={<div className="p-3 text-muted-foreground">Loading editor...</div>}>
<MilkdownEditorInternal <div>Remove MilkdownEditorInternal</div>
value={value}
onChange={onChange}
className={className}
/>
</React.Suspense> </React.Suspense>
)} )}
{activeTab === 'raw' && ( {activeTab === 'raw' && (

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { COMMAND_PRIORITY_NORMAL, KEY_DOWN_COMMAND, $getSelection, $isRangeSelection, $createParagraphNode, $insertNodes, $getRoot, $createTextNode } from 'lexical'; import { COMMAND_PRIORITY_NORMAL, KEY_DOWN_COMMAND, $getSelection, $isRangeSelection, $createParagraphNode, $insertNodes, $getRoot, $createTextNode, createCommand, LexicalCommand } from 'lexical';
import { mergeRegister } from '@lexical/utils'; import { mergeRegister } from '@lexical/utils';
import { RealmPlugin, addComposerChild$, usePublisher, insertMarkdown$ } from '@mdxeditor/editor'; import { RealmPlugin, addComposerChild$, usePublisher, insertMarkdown$ } from '@mdxeditor/editor';
import { AIPromptPopup } from './AIPromptPopup'; import { AIPromptPopup } from './AIPromptPopup';
@ -9,6 +9,9 @@ import { toast } from 'sonner';
import { getUserSecrets } from '@/components/ImageWizard/db'; import { getUserSecrets } from '@/components/ImageWizard/db';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { formatTextGenPrompt } from '@/constants';
export const OPEN_AI_TEXT_GEN_COMMAND: LexicalCommand<void> = createCommand('OPEN_AI_TEXT_GEN_COMMAND');
// Hook to access secrets helper // Hook to access secrets helper
const useProviderApiKey = () => { const useProviderApiKey = () => {
@ -56,6 +59,23 @@ const AIGenerationComponent = () => {
const [contextData, setContextData] = useState<{ selection: string, content: string }>({ selection: '', content: '' }); const [contextData, setContextData] = useState<{ selection: string, content: string }>({ selection: '', content: '' });
const insertMarkdown = usePublisher(insertMarkdown$); const insertMarkdown = usePublisher(insertMarkdown$);
const openPopup = useCallback(() => {
// 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);
}, [editor]);
useEffect(() => { useEffect(() => {
return mergeRegister( return mergeRegister(
editor.registerCommand( editor.registerCommand(
@ -64,45 +84,36 @@ const AIGenerationComponent = () => {
// Check for Ctrl+Space // Check for Ctrl+Space
if (event.ctrlKey && event.code === 'Space') { if (event.ctrlKey && event.code === 'Space') {
event.preventDefault(); event.preventDefault();
openPopup();
// 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 true;
} }
return false; return false;
}, },
COMMAND_PRIORITY_NORMAL COMMAND_PRIORITY_NORMAL
),
editor.registerCommand(
OPEN_AI_TEXT_GEN_COMMAND,
() => {
openPopup();
return true;
},
COMMAND_PRIORITY_NORMAL
) )
); );
}, [editor]); }, [editor, openPopup]);
const handleGenerate = async (prompt: string, provider: string, model: string, contextMode: 'selection' | 'content' | 'none', applicationMode: 'replace' | 'insert' | 'append') => { const handleGenerate = async (prompt: string, provider: string, model: string, contextMode: 'selection' | 'content' | 'none', applicationMode: 'replace' | 'insert' | 'append') => {
try { try {
const apiKey = await getApiKey(provider); 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) { // Construct prompt with context
finalPrompt = `CONTEXT:\n\`\`\`\n${contextData.content}\n\`\`\`\n\nREQUEST: ${prompt}${instructions}`; const finalPrompt = formatTextGenPrompt(prompt, {
} else { selection: contextMode === 'selection' ? contextData.selection : undefined,
finalPrompt = `${prompt}${instructions}`; content: contextMode === 'content' ? contextData.content : undefined
} });
// Generate text // Generate text
// Note: We're not using tools or streaming here yet for simplicity, just text generation // Note: We're not using tools or streaming here yet for simplicity, just text generation
@ -156,7 +167,10 @@ const AIGenerationComponent = () => {
return ( return (
<AIPromptPopup <AIPromptPopup
isOpen={isPopupOpen} isOpen={isPopupOpen}
onClose={() => setIsPopupOpen(false)} onClose={() => {
setIsPopupOpen(false);
editor.focus();
}}
onGenerate={handleGenerate} onGenerate={handleGenerate}
hasSelection={!!contextData.selection} hasSelection={!!contextData.selection}
hasContent={!!contextData.content} hasContent={!!contextData.content}
@ -173,3 +187,4 @@ export const aiGenerationPlugin = (): RealmPlugin => {
} }
} }
} }

View File

@ -3,11 +3,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { $createParagraphNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, createCommand, LexicalCommand, $insertNodes } from 'lexical'; import { $createParagraphNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, createCommand, LexicalCommand, $insertNodes } from 'lexical';
import { RealmPlugin, addComposerChild$, $createImageNode } from '@mdxeditor/editor'; import { RealmPlugin, addComposerChild$, $createImageNode } from '@mdxeditor/editor';
import { AIImagePromptPopup } from './AIImagePromptPopup'; import { AIImagePromptPopup } from './AIImagePromptPopup';
import { createImage } from '@/lib/image-router'; import { createImage, editImage } from '@/lib/image-router';
import { uploadFileToStorage } from '@/lib/db'; import { uploadFileToStorage } from '@/lib/db';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatImageGenPrompt } from '@/constants';
export const OPEN_IMAGE_GEN_COMMAND: LexicalCommand<void> = createCommand('OPEN_IMAGE_GEN_COMMAND'); export const OPEN_IMAGE_GEN_COMMAND: LexicalCommand<void> = createCommand('OPEN_IMAGE_GEN_COMMAND');
const AIImagePromptPopupWrapper = () => { const AIImagePromptPopupWrapper = () => {
@ -35,12 +37,16 @@ const AIImagePromptPopupWrapper = () => {
); );
}, [editor]); }, [editor]);
const handleGenerate = async (prompt: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none') => { const handleGenerate = async (prompt: string, provider: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none', referenceImages?: string[], applicationMode: 'replace' | 'insert' | 'append' = 'insert', resolution?: string, searchGrounding?: boolean) => {
const modelString = `${provider}/${model}`;
if (!user?.id) { if (!user?.id) {
toast.error("You must be logged in to generate images."); toast.error("You must be logged in to generate images.");
return; return;
} }
// ...
let fullPrompt = prompt; let fullPrompt = prompt;
// Augment prompt with context if requested // Augment prompt with context if requested
if (contextMode !== 'none') { if (contextMode !== 'none') {
@ -58,21 +64,45 @@ const AIImagePromptPopupWrapper = () => {
resolve(text); resolve(text);
}); });
}); });
fullPrompt = formatImageGenPrompt(prompt, contextText);
if (contextText) {
// Truncate context if too long (arbitrary limit for prompt safety)
const safeContext = contextText.slice(0, 1000);
fullPrompt = `${prompt}\n\nContext: ${safeContext}`;
}
} }
try { try {
const result = await createImage( let result;
fullPrompt,
model, if (referenceImages && referenceImages.length > 0) {
undefined, // apiKey handled internally/server-side usually or locally // Image-to-Image Generation (Reference Images)
aspectRatio
); // Fetch images first
const imageFiles = await Promise.all(referenceImages.map(async (url) => {
const response = await fetch(url);
const blob = await response.blob();
// Extract filename from URL or default
const filename = url.split('/').pop() || 'reference.png';
return new File([blob], filename, { type: blob.type });
}));
result = await editImage(
fullPrompt,
imageFiles,
modelString,
undefined, // apiKey
aspectRatio,
resolution,
searchGrounding
);
} else {
// Text-to-Image Generation
result = await createImage(
fullPrompt,
modelString,
undefined, // apiKey handled internally/server-side usually or locally
aspectRatio,
resolution,
searchGrounding
);
}
if (!result || !result.imageData) { if (!result || !result.imageData) {
toast.error("Failed to generate image."); toast.error("Failed to generate image.");
@ -93,13 +123,23 @@ const AIImagePromptPopupWrapper = () => {
altText: prompt, altText: prompt,
title: prompt title: prompt
}); });
// Insert at current selection
const selection = $getSelection(); // Handle application modes
if ($isRangeSelection(selection)) { if (applicationMode === 'append') {
selection.insertNodes([imageNode]); // Append to end of document
const root = $getRoot();
root.append($createParagraphNode().append(imageNode));
} else if (applicationMode === 'replace') {
// Replace selection
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertNodes([imageNode]);
} else {
// Fallback to insert if no selection
$insertNodes([imageNode]);
}
} else { } else {
// Fallback append // Insert at cursor (default)
// Use $insertNodes which handles selection fallback usually, but if no selection...
$insertNodes([imageNode]); $insertNodes([imageNode]);
} }
}); });
@ -117,7 +157,10 @@ const AIImagePromptPopupWrapper = () => {
return ( return (
<AIImagePromptPopup <AIImagePromptPopup
isOpen={isOpen} isOpen={isOpen}
onClose={() => setIsOpen(false)} onClose={() => {
setIsOpen(false);
editor.focus();
}}
onGenerate={handleGenerate} onGenerate={handleGenerate}
hasSelection={hasSelection} hasSelection={hasSelection}
hasContent={hasContent} hasContent={hasContent}

View File

@ -5,13 +5,19 @@ import { Loader2, Sparkles, X, Image as ImageIcon } from 'lucide-react';
import { T, translate } from '@/i18n'; import { T, translate } from '@/i18n';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { ModelSelector } from '@/components/ImageWizard/components/ModelSelector'; import { ModelSelector } from '@/components/ImageWizard/components/ModelSelector';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ProviderSelector } from '@/components/filters/ProviderSelector';
import { ImagePickerDialog } from '@/components/widgets/ImagePickerDialog';
import { Switch } from '@/components/ui/switch';
import { Plus } from 'lucide-react';
interface AIImagePromptPopupProps { interface AIImagePromptPopupProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onGenerate: (prompt: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none') => Promise<void>; onGenerate: (prompt: string, provider: string, model: string, aspectRatio: string, contextMode: 'selection' | 'content' | 'none', referenceImages?: string[], applicationMode?: 'replace' | 'insert' | 'append', resolution?: string, searchGrounding?: boolean) => Promise<void>;
initialProvider?: string;
initialModel?: string; initialModel?: string;
hasSelection: boolean; hasSelection: boolean;
hasContent: boolean; hasContent: boolean;
@ -21,23 +27,51 @@ export const AIImagePromptPopup: React.FC<AIImagePromptPopupProps> = ({
isOpen, isOpen,
onClose, onClose,
onGenerate, onGenerate,
initialModel = 'google/gemini-3-pro-image-preview', initialProvider = 'google',
initialModel = 'gemini-3-pro-image-preview',
hasSelection, hasSelection,
hasContent hasContent
}) => { }) => {
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
// Initialize model from local storage or props // Initialize model from local storage or props
// Note: Provider is now implicit in the model string for ModelSelector
const [model, setModel] = useState(() => { const [model, setModel] = useState(() => {
return localStorage.getItem('ai_image_last_model') || initialModel; const storedModel = localStorage.getItem('ai_image_last_model');
// If stored model already has provider prefix (modern format)
if (storedModel && storedModel.includes('/')) return storedModel;
// Migration: Check for legacy separate provider/model
const storedProvider = localStorage.getItem('ai_image_last_provider');
if (storedModel && storedProvider) {
return `${storedProvider}/${storedModel}`;
}
return `${initialProvider}/${initialModel}`;
}); });
const [aspectRatio, setAspectRatio] = useState(() => { const [aspectRatio, setAspectRatio] = useState(() => {
return localStorage.getItem('ai_image_last_aspect_ratio') || '1:1'; return localStorage.getItem('ai_image_last_aspect_ratio') || '1:1';
}); });
const [resolution, setResolution] = useState(() => {
return localStorage.getItem('ai_image_last_resolution') || '1K';
});
const [searchGrounding, setSearchGrounding] = useState(() => {
return localStorage.getItem('ai_image_last_grounding') === 'true';
});
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [contextMode, setContextMode] = useState<'selection' | 'content' | 'none'>('none'); const [contextMode, setContextMode] = useState<'selection' | 'content' | 'none'>('none');
const [applicationMode, setApplicationMode] = useState<'replace' | 'insert' | 'append'>('append');
// History state
const [history, setHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// Reference Images State
const [referenceImages, setReferenceImages] = useState<any[]>([]);
const [showImagePicker, setShowImagePicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -50,19 +84,39 @@ export const AIImagePromptPopup: React.FC<AIImagePromptPopupProps> = ({
if (aspectRatio) localStorage.setItem('ai_image_last_aspect_ratio', aspectRatio); if (aspectRatio) localStorage.setItem('ai_image_last_aspect_ratio', aspectRatio);
}, [aspectRatio]); }, [aspectRatio]);
useEffect(() => {
if (resolution) localStorage.setItem('ai_image_last_resolution', resolution);
}, [resolution]);
useEffect(() => {
localStorage.setItem('ai_image_last_grounding', String(searchGrounding));
}, [searchGrounding]);
// Initial context mode selection // Initial context mode selection
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (hasSelection) { if (hasSelection) {
setContextMode('selection'); setContextMode('selection');
setApplicationMode('replace');
} else if (hasContent) { } else if (hasContent) {
setContextMode('content'); setContextMode('content');
setApplicationMode('append');
} else { } else {
setContextMode('none'); setContextMode('none');
setApplicationMode('insert');
}
// Load history from local storage
const savedHistory = localStorage.getItem('ai_image_prompt_history');
if (savedHistory) {
try {
setHistory(JSON.parse(savedHistory));
} catch (e) { console.error('Failed to parse history', e); }
} }
} }
}, [isOpen, hasSelection, hasContent]); }, [isOpen, hasSelection, hasContent]);
// Auto-focus textarea when opened // Auto-focus textarea when opened
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@ -87,16 +141,41 @@ export const AIImagePromptPopup: React.FC<AIImagePromptPopupProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, isGenerating]); }, [isOpen, onClose, isGenerating]);
const addToHistory = (text: string) => {
const newHistory = [text, ...history.filter(h => h !== text)].slice(0, 50);
setHistory(newHistory);
localStorage.setItem('ai_image_prompt_history', JSON.stringify(newHistory));
setHistoryIndex(-1);
};
const handleGenerate = async () => { const handleGenerate = async () => {
if (!prompt.trim()) return; if (!prompt.trim()) return;
setIsGenerating(true); setIsGenerating(true);
addToHistory(prompt);
try { try {
await onGenerate(prompt, model, aspectRatio, contextMode); // Split provider/model from the model string if possible, or pass as is depending on backend expectation
// The previous code passed explicit provider "google" or "replicate" etc.
// ModelSelector returns "provider/modelname".
// We need to parse it for the callback which expects separate args, OR update callback.
// The callback signature in props is: (prompt, provider, model, ...)
const [providerName, modelName] = model.includes('/') ? model.split(/\/(.+)/) : ['google', model];
await onGenerate(
prompt,
providerName,
modelName,
aspectRatio,
contextMode,
referenceImages.map(img => img.image_url || img.src),
applicationMode,
resolution,
searchGrounding
);
onClose(); onClose();
setPrompt(''); setPrompt('');
} catch (error) { } catch (error) {
console.error("Image generation failed", error); console.error("Generation failed", error);
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }
@ -106,9 +185,30 @@ export const AIImagePromptPopup: React.FC<AIImagePromptPopupProps> = ({
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleGenerate(); handleGenerate();
} else if (e.key === 'ArrowUp' && e.ctrlKey) {
e.preventDefault();
const nextIndex = Math.min(historyIndex + 1, history.length - 1);
if (nextIndex !== historyIndex && history[nextIndex]) {
setHistoryIndex(nextIndex);
setPrompt(history[nextIndex]);
}
} else if (e.key === 'ArrowDown' && e.ctrlKey) {
e.preventDefault();
const prevIndex = Math.max(historyIndex - 1, -1);
if (prevIndex !== historyIndex) {
setHistoryIndex(prevIndex);
setPrompt(prevIndex === -1 ? '' : history[prevIndex]);
}
} }
}; };
console.log(model);
// Helper to check if model supports advanced options
// All Google models in our router support these parameters
const isGoogleModel = model.startsWith('google/');
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@ -144,99 +244,217 @@ export const AIImagePromptPopup: React.FC<AIImagePromptPopupProps> = ({
className="min-h-[100px] resize-none bg-background/50 focus:bg-background transition-colors text-base" className="min-h-[100px] resize-none bg-background/50 focus:bg-background transition-colors text-base"
disabled={isGenerating} disabled={isGenerating}
/> />
{history.length > 0 && (
<div className="absolute top-2 right-2 text-[10px] text-muted-foreground opacity-50 pointer-events-none">
Ctrl+ for history
</div>
)}
</div> </div>
{/* Context Toggles */} {/* Context Toggles */}
<div className="flex gap-2"> <div className="flex flex-col gap-1.5">
<Button <Label className="text-xs text-muted-foreground">Context</Label>
variant={contextMode === 'selection' ? 'secondary' : 'ghost'} <RadioGroup
size="sm" value={contextMode}
className="text-xs h-7" onValueChange={(val) => setContextMode(val as any)}
onClick={() => setContextMode('selection')} className="flex flex-row gap-4"
disabled={!hasSelection || isGenerating}
title="Use selected text as context"
>
Selection {hasSelection && '✓'}
</Button>
<Button
variant={contextMode === 'content' ? 'secondary' : 'ghost'}
size="sm"
className="text-xs h-7"
onClick={() => setContextMode('content')}
disabled={!hasContent || isGenerating}
title="Use entire document as context"
>
Content {hasContent && '✓'}
</Button>
<Button
variant={contextMode === 'none' ? 'secondary' : 'ghost'}
size="sm"
className="text-xs h-7"
onClick={() => setContextMode('none')}
disabled={isGenerating} disabled={isGenerating}
> >
No Context <div className="flex items-center space-x-2">
</Button> <RadioGroupItem value="selection" id="img-c-selection" disabled={!hasSelection} />
<Label htmlFor="img-c-selection" className={`text-sm font-normal cursor-pointer ${!hasSelection ? 'opacity-50' : ''}`}>
Selection {hasSelection && '✓'}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="content" id="img-c-content" disabled={!hasContent} />
<Label htmlFor="img-c-content" className={`text-sm font-normal cursor-pointer ${!hasContent ? 'opacity-50' : ''}`}>
Content {hasContent && '✓'}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="none" id="img-c-none" />
<Label htmlFor="img-c-none" className="text-sm font-normal cursor-pointer">No Context</Label>
</div>
</RadioGroup>
</div> </div>
{/* Controls Row: Aspect Ratio & Model */} {/* Controls Row: Aspect Ratio & Model & Mode */}
<div className="grid grid-cols-2 gap-4"> <div className="flex flex-col gap-3">
<div className="space-y-2"> {/* Insertion Mode */}
<Label className="text-xs text-muted-foreground">Aspect Ratio</Label> <div className="flex flex-col gap-1.5">
<Select value={aspectRatio} onValueChange={setAspectRatio} disabled={isGenerating}> <Label className="text-xs text-muted-foreground">Insertion Mode</Label>
<SelectTrigger> <RadioGroup
<SelectValue placeholder="Select ratio" /> value={applicationMode}
</SelectTrigger> onValueChange={(val) => setApplicationMode(val as any)}
<SelectContent> className="flex flex-row gap-4"
<SelectItem value="1:1">Square (1:1)</SelectItem> disabled={isGenerating}
<SelectItem value="16:9">Landscape (16:9)</SelectItem> >
<SelectItem value="4:3">Standard (4:3)</SelectItem> <div className="flex items-center space-x-2">
<SelectItem value="3:4">Portrait (3:4)</SelectItem> <RadioGroupItem value="replace" id="img-r-replace" />
<SelectItem value="9:16">Mobile (9:16)</SelectItem> <Label htmlFor="img-r-replace" className="text-sm font-normal cursor-pointer">Replace</Label>
</SelectContent> </div>
</Select> <div className="flex items-center space-x-2">
<RadioGroupItem value="insert" id="img-r-insert" />
<Label htmlFor="img-r-insert" className="text-sm font-normal cursor-pointer">Insert</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="append" id="img-r-append" />
<Label htmlFor="img-r-append" className="text-sm font-normal cursor-pointer">Append</Label>
</div>
</RadioGroup>
</div> </div>
{/* Model Selector */}
<div className="space-y-2"> <div className="space-y-2">
<ModelSelector <ModelSelector
selectedModel={model} selectedModel={model}
onChange={setModel} onChange={setModel}
label="AI Model"
showStepNumber={false} showStepNumber={false}
label="Model"
/> />
</div> </div>
</div>
{/* Generate Button */} {/* Advanced Options (Conditionally Rendered) */}
<div className="flex justify-end pt-2"> {isGoogleModel && (
<Button <div className="grid grid-cols-2 gap-4">
onClick={handleGenerate} <div className="space-y-2">
disabled={!prompt.trim() || isGenerating} <Label className="text-xs text-muted-foreground">Aspect Ratio</Label>
className={`w-full ${isGenerating ? 'opacity-80' : ''} bg-purple-600 hover:bg-purple-700 text-white`} <Select value={aspectRatio} onValueChange={setAspectRatio} disabled={isGenerating}>
> <SelectTrigger>
{isGenerating ? ( <SelectValue placeholder="Select ratio" />
<> </SelectTrigger>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <SelectContent>
<T>Generating...</T> <SelectItem value="1:1">Square (1:1)</SelectItem>
</> <SelectItem value="16:9">Landscape (16:9)</SelectItem>
<SelectItem value="4:3">Standard (4:3)</SelectItem>
<SelectItem value="3:4">Portrait (3:4)</SelectItem>
<SelectItem value="9:16">Mobile (9:16)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Resolution</Label>
<Select value={resolution} onValueChange={setResolution} disabled={isGenerating}>
<SelectTrigger>
<SelectValue placeholder="Resolution" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1K">1K</SelectItem>
<SelectItem value="2K">2K</SelectItem>
<SelectItem value="4K">4K</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* Google Search Grounding */}
{isGoogleModel && (
<div className="flex items-center justify-between pt-1">
<Label htmlFor="search-grounding" className="flex flex-col space-y-0.5 pointer-events-none">
<span className="text-xs font-medium"><T>Grounding with Google Search</T></span>
</Label>
<Switch
id="search-grounding"
checked={searchGrounding}
onCheckedChange={setSearchGrounding}
disabled={isGenerating}
className="scale-75 origin-right"
/>
</div>
)}
{/* Reference Images Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Reference Images</Label>
<span className="text-xs text-muted-foreground">{referenceImages.length} selected</span>
</div>
{referenceImages.length === 0 ? (
<Button
variant="outline"
className="w-full h-16 border-dashed flex flex-col items-center justify-center text-muted-foreground hover:text-primary hover:border-primary/50 hover:bg-accent/50 transition-all font-normal"
onClick={() => setShowImagePicker(true)}
disabled={isGenerating}
>
<ImageIcon className="h-4 w-4 mb-1 opacity-50" />
<span className="text-xs"><T>Add Reference Image</T></span>
</Button>
) : ( ) : (
<> <div className="grid grid-cols-4 gap-2">
<Sparkles className="w-4 h-4 mr-2" /> {referenceImages.map((img) => (
<T>Generate Image</T> <div key={img.id} className="relative aspect-square rounded-md overflow-hidden border group bg-muted/30">
</> <img
src={img.image_url || img.src}
alt={img.title}
className="w-full h-full object-cover"
/>
<button
onClick={() => setReferenceImages(prev => prev.filter(i => i.id !== img.id))}
className="absolute top-0.5 right-0.5 p-0.5 bg-black/50 hover:bg-destructive text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
))}
<Button
variant="outline"
className="aspect-square flex flex-col items-center justify-center p-0 border-dashed hover:border-primary hover:text-primary hover:bg-accent/50 transition-all"
onClick={() => setShowImagePicker(true)}
disabled={isGenerating}
>
<Plus className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
)} )}
</Button>
</div>
</div>
{/* Footer / Status */} <ImagePickerDialog
{isGenerating && ( isOpen={showImagePicker}
<div className="px-4 pb-3"> onClose={() => setShowImagePicker(false)}
<div className="h-1 w-full bg-muted overflow-hidden rounded-full"> multiple={true}
<div className="h-full bg-purple-500 animate-progress-indeterminateOrigin" /> currentValues={referenceImages.map(img => img.id)}
onMultiSelectPictures={(pictures) => {
setReferenceImages(pictures);
setShowImagePicker(false);
}}
/>
</div>
{/* Generate Button */}
<div className="flex justify-end pt-2">
<Button
onClick={handleGenerate}
disabled={!prompt.trim() || isGenerating}
className={`w-full ${isGenerating ? 'opacity-80' : ''} bg-purple-600 hover:bg-purple-700 text-white`}
>
{isGenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<T>Generating...</T>
</>
) : (
<>
<Sparkles className="w-4 h-4 mr-2" />
<T>Generate Image</T>
</>
)}
</Button>
</div> </div>
</div> </div>
)}
{/* Footer / Status */}
{isGenerating && (
<div className="px-4 pb-3">
<div className="h-1 w-full bg-muted overflow-hidden rounded-full">
<div className="h-full bg-purple-500 animate-progress-indeterminateOrigin" />
</div>
</div>
)}
</div>
</Card> </Card>
</div> </div>
); );

View File

@ -16,10 +16,12 @@ import {
Quote, Quote,
Image, Image,
Code, Code,
Sparkles Sparkles,
Bot
} from 'lucide-react'; } from 'lucide-react';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import { OPEN_IMAGE_GEN_COMMAND } from './AIImageGenerationPlugin'; import { OPEN_IMAGE_GEN_COMMAND } from './AIImageGenerationPlugin';
import { OPEN_AI_TEXT_GEN_COMMAND } from './AIGenerationPlugin';
// Import from MDXEditor to hook into its plugin system and access ImageNode creation // Import from MDXEditor to hook into its plugin system and access ImageNode creation
import { RealmPlugin, addComposerChild$, $createImageNode } from '@mdxeditor/editor'; import { RealmPlugin, addComposerChild$, $createImageNode } from '@mdxeditor/editor';
@ -102,6 +104,9 @@ function SlashCommandMenu({
new SlashCommandOption('Generate Image', <Sparkles size={16} />, ['ai', 'image', 'generate', 'gen'], () => { new SlashCommandOption('Generate Image', <Sparkles size={16} />, ['ai', 'image', 'generate', 'gen'], () => {
editor.dispatchCommand(OPEN_IMAGE_GEN_COMMAND, undefined); editor.dispatchCommand(OPEN_IMAGE_GEN_COMMAND, undefined);
}), }),
new SlashCommandOption('AI Assistant', <Bot size={16} />, ['ai', 'text', 'generate', 'ask', 'gpt'], () => {
editor.dispatchCommand(OPEN_AI_TEXT_GEN_COMMAND, undefined);
}),
// Special handling for Image if handler provided // Special handling for Image if handler provided
...(onRequestImage ? [ ...(onRequestImage ? [
new SlashCommandOption('Image', <Image size={16} />, ['image', 'picture', 'photo'], () => { new SlashCommandOption('Image', <Image size={16} />, ['image', 'picture', 'photo'], () => {

View File

@ -104,4 +104,30 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
{ id: '3', name: "Cyberpunk Style", prompt: "Transform this into cyberpunk style with neon colors", icon: "🌆" }, { id: '3', name: "Cyberpunk Style", prompt: "Transform this into cyberpunk style with neon colors", icon: "🌆" },
{ id: '4', name: "Fantasy", prompt: "Transform this into a fantasy art style", icon: "🧙‍♂️" }, { id: '4', name: "Fantasy", prompt: "Transform this into a fantasy art style", icon: "🧙‍♂️" },
{ id: '5', name: "Portrait", prompt: "Transform this into a professional portrait", icon: "👤" }, { id: '5', name: "Portrait", prompt: "Transform this into a professional portrait", icon: "👤" },
]; ];
/**
* AI Text Generation Constants
*/
export const AI_TEXT_GEN_INSTRUCTION = `\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.`;
export const formatTextGenPrompt = (prompt: string, context?: { selection?: string, content?: string }): string => {
if (context?.selection) {
return `CONTEXT:\n\`\`\`\n${context.selection}\n\`\`\`\n\nREQUEST: ${prompt}${AI_TEXT_GEN_INSTRUCTION}`;
} else if (context?.content) {
return `CONTEXT:\n\`\`\`\n${context.content}\n\`\`\`\n\nREQUEST: ${prompt}${AI_TEXT_GEN_INSTRUCTION}`;
}
return `${prompt}${AI_TEXT_GEN_INSTRUCTION}`;
};
/**
* AI Image Generation Constants
*/
export const formatImageGenPrompt = (prompt: string, context?: string): string => {
if (context) {
// Truncate context if too long (arbitrary limit for prompt safety)
const safeContext = context.slice(0, 1000);
return `${prompt}\n\nContext: ${safeContext}`;
}
return prompt;
};