md slash commands, text/img gen
This commit is contained in:
parent
1fd6ef704e
commit
ba9fba49cb
@ -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()}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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' && (
|
||||||
|
|||||||
@ -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 => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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'], () => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user