diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/blocks/ContextBlock.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/blocks/ContextBlock.tsx index 34d416c4d..acd4dafa2 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/blocks/ContextBlock.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/context-bar/blocks/ContextBlock.tsx @@ -5,7 +5,7 @@ import { memo } from "react" import { useTranslation } from "react-i18next" import { ROUTE_FEED_IN_FOLDER } from "~/constants" -import { ImageThumbnail } from "~/modules/ai-chat/components/layouts/ImageThumbnail" +import { ImageThumbnail } from "~/modules/ai-chat/components/message/ImageThumbnail" import { CircularProgress } from "~/modules/ai-chat/components/ui/UploadProgress" import { useChatBlockActions } from "~/modules/ai-chat/store/hooks" import type { AIChatContextBlock } from "~/modules/ai-chat/store/types" @@ -93,16 +93,12 @@ export const ContextBlock: FC<{ block: AIChatContextBlock }> = memo(({ block }) const fileCategory = getFileCategoryFromMimeType(type) if (fileCategory === "image" && (dataUrl || previewUrl)) { - const validPreviewUrl = (dataUrl || previewUrl)! return (
{uploadStatus === "uploading" && uploadProgress !== undefined && (
diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ImageThumbnail.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ImageThumbnail.tsx deleted file mode 100644 index d19c3b9dc..000000000 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ImageThumbnail.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { cn } from "@follow/utils" -import * as HoverCard from "@radix-ui/react-hover-card" -import { m } from "motion/react" -import type { FC } from "react" -import * as React from "react" - -import { useModalStack } from "~/components/ui/modal/stacked/hooks" - -export const ImageThumbnail: FC<{ - previewUrl: string - originalUrl: string - alt: string - filename: string - className?: string -}> = ({ previewUrl, originalUrl, alt, filename, className }) => { - const [imageError, setImageError] = React.useState(false) - const { present } = useModalStack() - - return ( - - - present({ - max: true, - title: "Preview Image", - content: () => ( -
- -
- ), - }) - } - > - -
- - -
- {imageError ? ( -
-
- - {filename} -
-
- ) : ( - setImageError(true)} - initial={{ opacity: 0, scale: 0.95 }} - animate={{ opacity: 1, scale: 1 }} - transition={{ duration: 0.2 }} - className="max-h-[300px] max-w-[400px] rounded-md" - /> - )} -
-
-
-
- ) -} - -export const ImageThumbnailInner: React.FC<{ - src: string - alt: string - className?: string -}> = ({ src, alt, className }) => { - const [imageError, setImageError] = React.useState(false) - const [imageLoaded, setImageLoaded] = React.useState(false) - - const handleError = React.useCallback(() => { - setImageError(true) - }, []) - - const handleLoad = React.useCallback(() => { - setImageLoaded(true) - }, []) - - if (imageError) { - return ( -
- -
- ) - } - - return ( -
- {!imageLoaded && ( -
- -
- )} - -
- ) -} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageIdContext.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageIdContext.tsx new file mode 100644 index 000000000..e57924eab --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageIdContext.tsx @@ -0,0 +1,11 @@ +import { createContext, use } from "react" + +export const AIMessageIdContext = createContext(null) + +export const useAIMessageId = () => { + const ctx = use(AIMessageIdContext) + if (!ctx && import.meta.env.DEV) { + throw new Error("useAIMessageId must be used within a AIMessageIdContext") + } + return ctx +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ImageThumbnail.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ImageThumbnail.tsx index b3326b453..1e3f1d98d 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ImageThumbnail.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ImageThumbnail.tsx @@ -1,70 +1,160 @@ import { cn } from "@follow/utils/utils" +import * as HoverCard from "@radix-ui/react-hover-card" +import { m } from "motion/react" import * as React from "react" +import { useModalStack } from "~/components/ui/modal/stacked/hooks" import type { FileAttachment } from "~/modules/ai-chat/store/types" import { getImageUrl } from "./ai-block-constants" -interface ImageThumbnailProps { +type AttachmentImageProps = { attachment: FileAttachment className?: string fallbackIcon?: string } +type ImageThumbnailProps = AttachmentImageProps + /** - * A robust image thumbnail component with proper error handling and fallback states + * Unified ImageThumbnail with hover preview, click-to-modal, loading and error fallbacks */ -export const ImageThumbnail: React.FC = React.memo( - ({ - attachment, - className = "size-3 rounded object-cover", - fallbackIcon = "i-mgc-pic-cute-re", - }) => { - const [hasError, setHasError] = React.useState(false) - const [isLoading, setIsLoading] = React.useState(true) +export const ImageThumbnail: React.FC = React.memo((props) => { + const { present } = useModalStack() - const imageUrl = React.useMemo(() => getImageUrl(attachment), [attachment]) + const [contentImageError, setContentImageError] = React.useState(false) - const handleError = React.useCallback(() => { - setHasError(true) - setIsLoading(false) - }, []) - - const handleLoad = React.useCallback(() => { - setIsLoading(false) - }, []) - - // Reset error state when imageUrl changes - React.useEffect(() => { - if (imageUrl) { - setHasError(false) - setIsLoading(true) - } - }, [imageUrl]) - - // If no image URL or there was an error, show fallback icon - if (!imageUrl || hasError) { - return + const computed = React.useMemo(() => { + const { attachment, className, fallbackIcon } = props + const imageUrl = getImageUrl(attachment) + const { name, serverUrl } = attachment + const original = serverUrl || imageUrl || null + return { + previewUrl: imageUrl, + originalUrl: original, + alt: name, + filename: name, + className: className ?? "size-3 rounded object-cover", + fallbackIcon: fallbackIcon ?? "i-mgc-pic-cute-re", } + }, [props]) - return ( -
- {attachment.name} { + setContentImageError(false) + }, [computed.previewUrl]) + + // If no preview URL available, show fallback icon + if (!computed.previewUrl) { + return + } + + return ( + + + computed.originalUrl && + present({ + max: true, + title: "Preview Image", + clickOutsideToDismiss: true, + content: () => ( +
+ +
+ ), + }) + } + > + - {isLoading && ( -
- + + + +
+ {contentImageError ? ( +
+
+ + {computed.filename} +
+
+ ) : ( + setContentImageError(true)} + initial={{ opacity: 0, scale: 0.95 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ duration: 0.2 }} + className="max-h-[300px] max-w-[400px] rounded-md" + /> + )}
- )} -
- ) - }, -) + + +
+ ) +}) ImageThumbnail.displayName = "ImageThumbnail" + +const ImageThumbnailInner: React.FC<{ src: string; alt: string; className?: string }> = ({ + src, + alt, + className, +}) => { + const [imageError, setImageError] = React.useState(false) + const [imageLoaded, setImageLoaded] = React.useState(false) + + const handleError = React.useCallback(() => { + setImageError(true) + }, []) + + const handleLoad = React.useCallback(() => { + setImageLoaded(true) + }, []) + + if (imageError) { + return ( +
+ +
+ ) + } + + return ( +
+ {!imageLoaded && ( +
+ +
+ )} + +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx index 39c3fee40..2f65e0468 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx @@ -11,6 +11,7 @@ import type { AIChatContextBlock, BizUIMessage } from "~/modules/ai-chat/store/t import type { RichTextPart } from "../../types/ChatSession" import { convertLexicalToMarkdown } from "../../utils/lexical-markdown" import { AIDataBlockPart } from "./AIDataBlockPart" +import { AIMessageIdContext } from "./AIMessageIdContext" import { EditableMessage } from "./EditableMessage" import { UserMessageParts } from "./UserMessageParts" @@ -94,117 +95,119 @@ export const UserChatMessage: React.FC = React.memo(({ mes }, [chatActions, messageId]) return ( -
- {/* Render data-block parts separately, outside the chat bubble */} - {dataBlockParts.length > 0 && ( -
-
- {dataBlockParts.map((part) => { - if (part.type === "data-block" && "data" in part) { - const blocks = part.data as AIChatContextBlock[] - return ( - b.id).join("-")}`} - blocks={blocks} - /> - ) - } - return null - })} -
-
- )} - - {/* Main chat message */} - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
- {/* Normal message display - always rendered to maintain layout */} -
-
- + +
+ {/* Render data-block parts separately, outside the chat bubble */} + {dataBlockParts.length > 0 && ( +
+
+ {dataBlockParts.map((part) => { + if (part.type === "data-block" && "data" in part) { + const blocks = part.data as AIChatContextBlock[] + return ( + b.id).join("-")}`} + blocks={blocks} + /> + ) + } + return null + })}
+ )} - {/* Action buttons - only show when not editing */} - {!isEditing && ( + {/* Main chat message */} + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+ {/* Normal message display - always rendered to maintain layout */} +
+
+ +
+
+ + {/* Action buttons - only show when not editing */} + {!isEditing && ( + + + + + )} + +
+
+ + + {/* Full-width edit overlay - positioned at the top level to span entire container */} + + {isEditing && ( 0 ? `${dataBlockHeight}px` : 0, }} - transition={{ duration: 0.2, ease: "easeOut" }} + initial={{ opacity: 0, scale: 0.98 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.98 }} + transition={{ duration: 0.15, ease: "easeOut" }} > - - +
+ +
)} - -
-
- - - {/* Full-width edit overlay - positioned at the top level to span entire container */} - - {isEditing && ( - 0 ? `${dataBlockHeight}px` : 0, - }} - initial={{ opacity: 0, scale: 0.98 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.98 }} - transition={{ duration: 0.15, ease: "easeOut" }} - > -
- -
-
- )} -
-
+ +
+
) }) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserMessageParts.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserMessageParts.tsx index 90c001176..b2d62695a 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserMessageParts.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserMessageParts.tsx @@ -3,7 +3,7 @@ import * as React from "react" import type { BizUIMessage } from "~/modules/ai-chat/store/types" import { AIMarkdownStreamingMessage } from "./AIMarkdownMessage" -import { AIRichTextMessage } from "./AIRichTextMessage" +import { UserRichTextMessage } from "./UserRichTextMessage" interface UserMessagePartsProps { message: BizUIMessage @@ -25,7 +25,7 @@ export const UserMessageParts: React.FC = React.memo(({ m case "data-rich-text": { return ( - = React.memo( +export const UserRichTextMessage: React.FC = React.memo( ({ data, className }) => { let initialConfig: InitialConfigType = null! if (!initialConfig) { diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/FileAttachmentNode.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/FileAttachmentNode.tsx index adefffe4f..82d354ab2 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/FileAttachmentNode.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/file-upload/FileAttachmentNode.tsx @@ -9,9 +9,10 @@ import type { } from "lexical" import { DecoratorNode } from "lexical" import * as React from "react" -import { useShallow } from "zustand/shallow" -import { useAIChatStore } from "~/modules/ai-chat/store/AIChatContext" +import { useAIMessageId } from "~/modules/ai-chat/components/message/AIMessageIdContext" +import { useMessageByIdSelector } from "~/modules/ai-chat/store/hooks" +import { findFileAttachmentBlock, isDataBlockPart } from "~/modules/ai-chat/utils/extractor" export type SerializedFileAttachmentNode = Spread< { @@ -112,14 +113,18 @@ interface FileAttachmentComponentProps { function FileAttachmentComponent({ node }: FileAttachmentComponentProps) { const attachmentId = node.getAttachmentId() - const fileAttachment = useAIChatStore()( - useShallow((state) => { - const block = state.blocks.find( - (block) => block.type === "fileAttachment" && block.attachment.id === attachmentId, - ) - return block?.type === "fileAttachment" ? block.attachment : null - }), - ) + const messageId = useAIMessageId()! + + const fileAttachment = useMessageByIdSelector(messageId, (message) => { + for (const part of message.parts) { + if (!isDataBlockPart(part)) continue + + const block = findFileAttachmentBlock(part, attachmentId) + if (block) { + return block.attachment + } + } + }) if (!fileAttachment) { return ( diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useFileUpload.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useFileUpload.ts index 610d35ab4..7f4b06e79 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useFileUpload.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useFileUpload.ts @@ -48,7 +48,9 @@ export interface FileUploadHandlers { /** * Hook for handling file uploads with progress tracking and block management */ -export function useFileUpload(options: UseFileUploadOptions = {}): FileUploadHandlers { +export function useFileUpload( + options: Omit = {}, +): FileUploadHandlers { const { showSuccessToast = false, showErrorToast = true, @@ -62,6 +64,7 @@ export function useFileUpload(options: UseFileUploadOptions = {}): FileUploadHan const uploadFile = useCallback( async (file: File, id?: string): Promise => { // Create initial file attachment for immediate UI feedback + const initialFileAttachment: FileAttachment = { id: id || nanoid(), name: file.name, @@ -76,14 +79,18 @@ export function useFileUpload(options: UseFileUploadOptions = {}): FileUploadHan blockActions.addFileAttachment(initialFileAttachment) try { - const result = await processAndUploadFile(file, processOptions, (updatedAttachment) => { - // Update the attachment with the same ID to maintain consistency - const syncedAttachment = { - ...updatedAttachment, - id: initialFileAttachment.id, // Keep the same ID - } - blockActions.updateFileAttachment(initialFileAttachment.id, syncedAttachment) - }) + const result = await processAndUploadFile( + file, + { ...processOptions, nonce: initialFileAttachment.id }, + (updatedAttachment) => { + // Update the attachment with the same ID to maintain consistency + const syncedAttachment = { + ...updatedAttachment, + id: initialFileAttachment.id, // Keep the same ID + } + blockActions.updateFileAttachment(initialFileAttachment.id, syncedAttachment) + }, + ) if (result.success && result.fileAttachment) { // Update the final completed state with the same ID diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/hooks.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/hooks.ts index 70ea45817..e48419ac8 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/hooks.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/hooks.ts @@ -1,3 +1,6 @@ +import type { BizUIMessage } from "@folo-services/ai-tools" +import { useShallow } from "zustand/shallow" + import { useAIChatStore } from "./AIChatContext" /** @@ -40,6 +43,19 @@ export const useMessages = () => { return store((state) => state.messages) } +export const useMessageByIdSelector = ( + messageId: string, + selector: (message: BizUIMessage) => T, +): T | undefined => { + const store = useAIChatStore() + return store( + useShallow((state) => { + const message = state.messages.find((message) => message.id === messageId) + return message ? selector(message) : undefined + }), + ) +} + /** * Hook to check if the chat has messages */ diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/utils/extractor.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/utils/extractor.ts new file mode 100644 index 000000000..7db37c2f9 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/utils/extractor.ts @@ -0,0 +1,30 @@ +import type { AIChatContextBlock, FileAttachmentContextBlock } from "../store/types" + +type AIMessageDataBlockPart = { + type: "data-block" + data: AIChatContextBlock[] +} +export const isDataBlockPart = (part: unknown): part is AIMessageDataBlockPart => { + return !!part && typeof part === "object" && "type" in part && part.type === "data-block" +} + +// Narrow a context block to the file attachment block +export const isFileAttachmentBlock = ( + block: AIChatContextBlock, +): block is FileAttachmentContextBlock => { + return block.type === "fileAttachment" +} + +export const findFileAttachmentBlock = ( + part: AIMessageDataBlockPart, + attachmentId: string, +): FileAttachmentContextBlock | undefined => { + if (!isDataBlockPart(part)) return + + for (const block of part.data) { + if (isFileAttachmentBlock(block) && block.attachment.id === attachmentId) { + return block + } + } + return +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-processing.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-processing.ts index 34b9c36af..d96857b0a 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-processing.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-processing.ts @@ -1,5 +1,3 @@ -import { nanoid } from "nanoid" - import { followApi } from "~/lib/api-client" import type { FileAttachment } from "../store/types" @@ -10,6 +8,8 @@ export interface ProcessFileOptions { maxImageWidth?: number maxImageHeight?: number imageQuality?: number + + nonce: string } export interface ProcessFileResult { @@ -20,7 +20,7 @@ export interface ProcessFileResult { export async function processFile( file: File, - options: ProcessFileOptions = {}, + options: ProcessFileOptions, ): Promise { const { maxImageWidth = 1920, maxImageHeight = 1080, imageQuality = 0.85 } = options @@ -34,7 +34,7 @@ export async function processFile( } try { - const fileId = nanoid() + const { nonce: fileId } = options let dataUrl: string let previewUrl: string | undefined @@ -157,30 +157,6 @@ function fileToDataUrl(file: File): Promise { }) } -export async function processFileList( - files: FileList, - options?: ProcessFileOptions, -): Promise { - const results: ProcessFileResult[] = [] - - for (const file of files) { - if (file) { - const result = await processFile(file, options) - results.push(result) - } - } - - return results -} - -export function createFileAttachmentBlock(fileAttachment: FileAttachment) { - return { - id: fileAttachment.id, - type: "fileAttachment" as const, - attachment: fileAttachment, - } -} - // Utility to clean up object URLs to prevent memory leaks export function cleanupFileAttachment(fileAttachment: FileAttachment) { if (fileAttachment.previewUrl?.startsWith("blob:")) { @@ -267,7 +243,7 @@ export async function uploadFileAttachment( export async function processAndUploadFile( file: File, - options: ProcessFileOptions = {}, + options: ProcessFileOptions, onProgressUpdate?: (attachment: FileAttachment) => void, ): Promise { // First process the file locally