diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 449d5945b..e27d54efa 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1 +1 @@ -Before you start, you need to read and follow the rules in @../claude.md +Before you start, you need to read and follow the rules in @../CLAUDE.md diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index 6c12b5cec..0b3e5676e 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -60,16 +60,13 @@ Examples: - Preferred: `i-mgc-copy-cute-re`, `i-mgc-external-link-cute-re` - Fallback only: `i-mingcute-copy-line` (only if no mgc equivalent exists) -## Animation with Framer Motion +## Using Framer Motion - **LazyMotion Integration**: Project uses Framer Motion with LazyMotion for optimized bundle size - **Usage Rule**: Always use `m.` instead of `motion.` when creating animated components - **Import**: `import { m } from 'motion/react'` - **Examples**: `m.div`, `m.button`, `m.span` (not `motion.div`, `motion.button`, etc.) - **Benefits**: Reduces bundle size while maintaining all Framer Motion functionality - -**Animation Presets**: - - **Prefer Spring Presets**: Use predefined spring animations from `@follow/components/constants/spring.js` - **Available Presets Constants**: `Spring.presets.smooth`, `Spring.presets.snappy`, `Spring.presets.bouncy` (extracted from Apple's spring parameters) - **Usage Example**: `transition={Spring.presets.smooth}` or `transition={Spring.snappy(0.3, 0.1)}` diff --git a/apps/desktop/layer/renderer/package.json b/apps/desktop/layer/renderer/package.json index 5312810f8..39e515511 100644 --- a/apps/desktop/layer/renderer/package.json +++ b/apps/desktop/layer/renderer/package.json @@ -118,7 +118,7 @@ "@follow/models": "workspace:*", "@follow/types": "workspace:*", "@follow/utils": "workspace:*", - "@folo-services/ai-tools": "0.2.19", + "@folo-services/ai-tools": "0.2.20", "@types/node": "24.0.10", "@vite-pwa/assets-generator": "1.0.0", "fake-indexeddb": "6.0.1", diff --git a/apps/desktop/layer/renderer/src/lib/avatar-upload.ts b/apps/desktop/layer/renderer/src/lib/avatar-upload.ts index 2db9092f8..86788e989 100644 --- a/apps/desktop/layer/renderer/src/lib/avatar-upload.ts +++ b/apps/desktop/layer/renderer/src/lib/avatar-upload.ts @@ -1,6 +1,6 @@ import { toast } from "sonner" -import { apiClient, apiFetch } from "./api-fetch" +import { followApi } from "./api-client" import { getFetchErrorMessage } from "./error-parser" /** @@ -10,19 +10,14 @@ import { getFetchErrorMessage } from "./error-parser" * @returns Promise - The uploaded image URL */ export async function uploadAvatarBlob(blob: Blob): Promise { - const formData = new FormData() - formData.append("file", blob, "avatar.jpg") + const { url } = await followApi.upload + .uploadAvatar({ + file: blob, + }) + .catch((err) => { + toast.error(getFetchErrorMessage(err)) + throw err + }) - const res = await apiFetch<{ - url: string - }>(apiClient.upload.avatar.$url().toString(), { - method: "POST", - - body: formData, - }).catch((err) => { - toast.error(getFetchErrorMessage(err)) - throw err - }) - - return res.url + return url } diff --git a/apps/desktop/layer/renderer/src/modules/activation/NeedActivationToast.tsx b/apps/desktop/layer/renderer/src/modules/activation/NeedActivationToast.tsx index c64af9b58..c9370c41b 100644 --- a/apps/desktop/layer/renderer/src/modules/activation/NeedActivationToast.tsx +++ b/apps/desktop/layer/renderer/src/modules/activation/NeedActivationToast.tsx @@ -3,7 +3,7 @@ import { stopPropagation } from "@follow/utils/dom" import { useCallback } from "react" import { useTranslation } from "react-i18next" -import { useSettingModal } from "../settings/modal/useSettingModal" +import { useSettingModal } from "../settings/modal/use-setting-modal-hack" export const NeedActivationToast = (props: { dimiss: () => void }) => { const settingModalPresent = useSettingModal() diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/file/GlobalFileDropZone.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/file/GlobalFileDropZone.tsx new file mode 100644 index 000000000..8b4342f51 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/file/GlobalFileDropZone.tsx @@ -0,0 +1,199 @@ +import { Spring } from "@follow/components/constants/spring.js" +import { cn } from "@follow/utils" +import { AnimatePresence, m } from "motion/react" +import type { FC, PropsWithChildren } from "react" +import { memo, useCallback, useRef, useState } from "react" + +import { useFileUploadWithDefaults } from "../../hooks/useFileUpload" +import { useAIChatStore } from "../../store/AIChatContext" +import { UploadProgress } from "../ui/UploadProgress" + +interface GlobalFileDropZoneProps extends PropsWithChildren { + className?: string +} + +export const GlobalFileDropZone: FC = memo(({ children, className }) => { + const { handleFileDrop } = useFileUploadWithDefaults() + const [isDragOver, setIsDragOver] = useState(false) + const [isProcessing, setIsProcessing] = useState(false) + const dragCounterRef = useRef(0) + + // Get uploading files from the store + const blocks = useAIChatStore()((s) => s.blocks) + const uploadingFiles = blocks + .filter( + (block): block is Extract => + block.type === "fileAttachment" && block.attachment.uploadStatus === "uploading", + ) + .map((block) => block.attachment) + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + dragCounterRef.current += 1 + + if (e.dataTransfer.types.includes("Files")) { + setIsDragOver(true) + } + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + dragCounterRef.current -= 1 + + if (dragCounterRef.current === 0) { + setIsDragOver(false) + } + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + dragCounterRef.current = 0 + setIsDragOver(false) + + const { files } = e.dataTransfer + if (!files || files.length === 0) return + + setIsProcessing(true) + + try { + await handleFileDrop(files) + } catch (error) { + console.error("Error processing files:", error) + } finally { + setIsProcessing(false) + } + }, + [handleFileDrop], + ) + + return ( +
+ {children} + + {/* Global Drag Overlay */} + + {isDragOver && ( + + {/* Glass morphism backdrop */} + + + {/* Content */} + + {isProcessing ? ( + <> +
+
+

Processing files...

+

+ Please wait while we process your files +

+
+ + ) : ( + <> +
+ + + + +
+
+

Drop files to attach

+

+ Images, PDFs, text files, and audio files are supported +

+
+ + )} + + + )} + + + {/* Upload Progress Overlay */} + + {uploadingFiles.length > 0 && ( + + +
+ + + Uploading {uploadingFiles.length} file{uploadingFiles.length !== 1 ? "s" : ""} + +
+ +
+ {uploadingFiles.map((file) => ( +
+
+ + {file.name} + + + {file.uploadProgress ? Math.round(file.uploadProgress) : 0}% + +
+ +
+ ))} +
+
+
+ )} +
+
+ ) +}) + +GlobalFileDropZone.displayName = "GlobalFileDropZone" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AIChatContextBar.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AIChatContextBar.tsx index 0f15a9e66..9e5311181 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AIChatContextBar.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/AIChatContextBar.tsx @@ -7,7 +7,7 @@ import { stopPropagation } from "@follow/utils" import { cn } from "@follow/utils/utils" import Fuse from "fuse.js" import type { FC } from "react" -import { memo, useMemo, useState } from "react" +import { memo, useCallback, useMemo, useRef, useState } from "react" import { useDebounceCallback } from "usehooks-ts" import { useAISettingValue } from "~/atoms/settings/ai" @@ -26,13 +26,19 @@ import { useAIChatStore } from "~/modules/ai-chat/store/AIChatContext" import type { AIChatContextBlock } from "~/modules/ai-chat/store/types" import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack" +import { useFileUploadWithDefaults } from "../../hooks/useFileUpload" import { useChatBlockActions } from "../../store/hooks" +import { getFileCategoryFromMimeType, getFileIconName } from "../../utils/file-validation" +import { CircularProgress } from "../ui/UploadProgress" +import { ImageThumbnail } from "./ImageThumbnail" export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) => void }> = memo( ({ className, onSendShortcut }) => { const blocks = useAIChatStore()((s) => s.blocks) const blockActions = useChatBlockActions() const { shortcuts } = useAISettingValue() + const fileInputRef = useRef(null) + const { handleFileInputChange } = useFileUploadWithDefaults() // Filter enabled shortcuts const enabledShortcuts = useMemo( @@ -41,6 +47,10 @@ export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) => ) const showSettingModal = useSettingModal() + + const handleAttachFile = useCallback(() => { + fileInputRef.current?.click() + }, []) const contextMenuContent = ( @@ -135,6 +145,26 @@ export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) => {contextMenuContent} + {/* File Upload Button */} + + + {/* Hidden File Input */} + + {/* AI Shortcuts Button */} {enabledShortcuts.length > 0 && ( @@ -231,7 +261,10 @@ const PickerList = ({ const CurrentFeedEntriesPickerList: FC<{ onSelect: (entryId: string) => void }> = ({ onSelect, }) => { - const mainEntryId = useAIChatStore()((s) => s.blocks.find((b) => b.type === "mainEntry")?.value) + const mainEntryId = useAIChatStore()((s) => { + const block = s.blocks.find((b) => b.type === "mainEntry") + return block && block.type === "mainEntry" ? block.value : undefined + }) const feedId = useEntry(mainEntryId, (e) => e?.feedId) const entryIds = useEntryIdsByFeedId(feedId!) @@ -332,7 +365,7 @@ const FeedPickerItem: FC<{ ) } -const ContextBlock: FC<{ block: AIChatContextBlock }> = ({ block }) => { +const ContextBlock: FC<{ block: AIChatContextBlock }> = memo(({ block }) => { const blockActions = useChatBlockActions() const getBlockIcon = () => { @@ -349,6 +382,17 @@ const ContextBlock: FC<{ block: AIChatContextBlock }> = ({ block }) => { case "selectedText": { return "i-mgc-quill-pen-cute-re" } + case "fileAttachment": { + const { type, dataUrl, previewUrl } = block.attachment + const fileCategory = getFileCategoryFromMimeType(type) + + // Don't show icon for images with thumbnails, as the thumbnail serves as the icon + if (fileCategory === "image" && (dataUrl || previewUrl)) { + return null + } + + return getFileIconName(fileCategory) + } default: { return "i-mgc-paper-cute-fi" @@ -368,8 +412,80 @@ const ContextBlock: FC<{ block: AIChatContextBlock }> = ({ block }) => { case "selectedText": { return `"${block.value}"` } + case "fileAttachment": { + const { type, name, dataUrl, previewUrl, uploadStatus, errorMessage, uploadProgress } = + block.attachment + const fileCategory = getFileCategoryFromMimeType(type) + + if (fileCategory === "image" && (dataUrl || previewUrl)) { + return ( +
+
+ + {uploadStatus === "uploading" && uploadProgress !== undefined && ( +
+ +
+ )} + {uploadStatus === "error" && ( +
+ +
+ )} +
+ +
+ {name} + {uploadStatus === "uploading" && uploadProgress !== undefined && ( +
+ Uploading {Math.round(uploadProgress)}% +
+ )} + {uploadStatus === "error" &&
Upload failed
} +
+
+ ) + } + + // For non-image files + return ( +
+ {name} + {uploadStatus === "uploading" && uploadProgress !== undefined && ( +
+ + {Math.round(uploadProgress)}% +
+ )} + {uploadStatus === "error" && ( + + )} +
+ ) + } default: { - return block.value + // This should never happen with proper discriminated union + return "" } } } @@ -388,6 +504,9 @@ const ContextBlock: FC<{ block: AIChatContextBlock }> = ({ block }) => { case "selectedText": { return "Text" } + case "fileAttachment": { + return "File" + } default: { return "" @@ -398,30 +517,43 @@ const ContextBlock: FC<{ block: AIChatContextBlock }> = ({ block }) => { const canRemove = block.type !== "mainEntry" return ( -
-
-
- - {getBlockLabel()} -
+
+
+
+
+ {getBlockIcon() && } + {getBlockLabel()} +
- - {getDisplayContent()} - + + {getDisplayContent()} + +
{canRemove && ( )}
) -} +}) const EntryTitle: FC<{ entryId?: string; fallback: string }> = ({ entryId, fallback }) => { const entryTitle = useEntry(entryId!, (e) => e?.title) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx index cd782d48a..dda33ed26 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInput.tsx @@ -84,7 +84,13 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => { }, []) return ( -
+
{/* Input Area */}
diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx index b4cdce21b..b79d83751 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ChatInterface.tsx @@ -8,7 +8,7 @@ import type { EditorState, LexicalEditor } from "lexical" import { AnimatePresence } from "motion/react" import { nanoid } from "nanoid" import type { FC } from "react" -import { useCallback, useEffect, useState } from "react" +import { Suspense, useCallback, useEffect, useState } from "react" import { useEventCallback } from "usehooks-ts" import { @@ -28,6 +28,7 @@ import { } from "~/modules/ai-chat/store/hooks" import { convertLexicalToMarkdown } from "../../utils/lexical-markdown" +import { GlobalFileDropZone } from "../file/GlobalFileDropZone" import { AIErrorFallback } from "./AIErrorFallback" import { ChatInput } from "./ChatInput" import { CollapsibleError } from "./CollapsibleError" @@ -102,13 +103,29 @@ const ChatInterfaceContent = () => { (message: string | EditorState, editor: LexicalEditor | null) => { resetScrollState() + const blocks = [] as any[] + + for (const block of blockActions.getBlocks()) { + if (block.type === "fileAttachment" && block.attachment.serverUrl) { + blocks.push({ + ...block, + attachment: { + id: block.attachment.id, + name: block.attachment.name, + type: block.attachment.type, + size: block.attachment.size, + serverUrl: block.attachment.serverUrl, + }, + }) + } else { + blocks.push(block) + } + } + const parts: BizUIMessage["parts"] = [ { type: "data-block", - data: blockActions.getBlocks().map((b) => ({ - type: b.type, - value: b.value, - })), + data: blocks, }, ] @@ -145,7 +162,7 @@ const ChatInterfaceContent = () => { const shouldShowScrollToBottom = hasMessages && !isAtBottom && !isLoadingHistory return ( -
+
{!hasMessages && !isLoadingHistory ? ( @@ -203,7 +220,7 @@ const ChatInterfaceContent = () => { {error && }
-
+ ) } @@ -216,5 +233,9 @@ export const ChatInterface = () => ( const Messages: FC = () => { const messages = useMessages() - return messages.map((message) => ) + return messages.map((message) => ( + + + + )) } 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 new file mode 100644 index 000000000..0936a072f --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/ImageThumbnail.tsx @@ -0,0 +1,119 @@ +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/AIChatMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIChatMessage.tsx index 422c848dd..14b9cb827 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIChatMessage.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIChatMessage.tsx @@ -1,5 +1,5 @@ import { createDefaultLexicalEditor } from "@follow/components/ui/lexical-rich-editor/editor.js" -import { stopPropagation } from "@follow/utils" +import { stopPropagation, thenable } from "@follow/utils" import type { UIDataTypes, UIMessage } from "ai" import type { LexicalEditor, SerializedEditorState } from "lexical" import { m } from "motion/react" @@ -31,6 +31,9 @@ interface AIChatMessageProps { } export const AIChatMessage: React.FC = React.memo(({ message }) => { + if (message.parts.length === 0) { + throw thenable + } const chatActions = useChatActions() const messageId = message.id diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIDataBlockItem.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIDataBlockItem.tsx new file mode 100644 index 000000000..7fe266a5a --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIDataBlockItem.tsx @@ -0,0 +1,120 @@ +import { cn } from "@follow/utils/utils" +import * as React from "react" + +import type { AIChatContextBlock } from "~/modules/ai-chat/store/types" + +import { + getBlockIcon, + getBlockLabel, + getBlockStyles, + getFileDisplayContent, + isImageAttachment, +} from "./ai-block-constants" +import { EntryTitle, FeedTitle } from "./BlockTitleComponents" +import { ImageThumbnail } from "./ImageThumbnail" + +interface AIDataBlockItemProps { + block: AIChatContextBlock + index: number +} + +/** + * Gets the display content for a context block + */ +const getDisplayContent = (block: AIChatContextBlock): React.ReactNode => { + switch (block.type) { + case "mainEntry": + case "referEntry": { + return + } + case "referFeed": { + return + } + case "selectedText": { + return `"${block.value}"` + } + case "fileAttachment": { + if (!block.attachment) { + return "[File: Unknown]" + } + return getFileDisplayContent(block.attachment) + } + default: { + return "" + } + } +} + +/** + * Renders the appropriate icon or image thumbnail for a block + */ +const BlockIcon: React.FC<{ + block: AIChatContextBlock + styles: ReturnType +}> = React.memo(({ block, styles }) => { + // Handle image thumbnails for file attachments + if (block.type === "fileAttachment" && block.attachment && isImageAttachment(block)) { + return ( +
+ +
+ ) + } + + const iconClass = getBlockIcon(block) + + return ( +
+ +
+ ) +}) + +BlockIcon.displayName = "BlockIcon" + +/** + * Individual block item component with optimized rendering and animations + */ +export const AIDataBlockItem: React.FC = React.memo(({ block }) => { + const styles = React.useMemo(() => getBlockStyles(block.type), [block.type]) + const label = React.useMemo(() => getBlockLabel(block.type), [block.type]) + const displayContent = React.useMemo(() => getDisplayContent(block), [block]) + + return ( +
+ + + {/* Label and content */} +
+ {label} + · + + {displayContent} + +
+
+ ) +}) + +AIDataBlockItem.displayName = "AIDataBlockItem" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIDataBlockPart.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIDataBlockPart.tsx index 511cc9b3e..edd09fc5f 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIDataBlockPart.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIDataBlockPart.tsx @@ -1,136 +1,21 @@ -import { useEntry } from "@follow/store/entry/hooks" -import { useFeedById } from "@follow/store/feed/hooks" import { cn } from "@follow/utils/utils" -import { m } from "motion/react" import * as React from "react" import type { AIChatContextBlock } from "~/modules/ai-chat/store/types" +import { AIDataBlockItem } from "./AIDataBlockItem" + interface AIDataBlockPartProps { blocks: AIChatContextBlock[] } -// Helper components for displaying titles -const EntryTitle: React.FC<{ entryId?: string; fallback: string }> = ({ entryId, fallback }) => { - const entryTitle = useEntry(entryId!, (e) => e?.title) - - if (!entryId || !entryTitle) { - return {fallback} - } - - return {entryTitle} -} - -const FeedTitle: React.FC<{ feedId?: string; fallback: string }> = ({ feedId, fallback }) => { - const feed = useFeedById(feedId, (feed) => ({ title: feed?.title })) - if (!feedId || !feed) { - return {fallback} - } - - return {feed.title} -} - -// Data Block Component +/** + * Main component for rendering AI chat context blocks + * Displays various types of context (entries, feeds, text, files) with animations + */ export const AIDataBlockPart: React.FC = React.memo(({ blocks }) => { - const getBlockIcon = (type: AIChatContextBlock["type"]) => { - switch (type) { - case "mainEntry": { - return "i-mgc-star-cute-fi" - } - case "referEntry": { - return "i-mgc-paper-cute-fi" - } - case "referFeed": { - return "i-mgc-rss-cute-fi" - } - case "selectedText": { - return "i-mgc-quill-pen-cute-re" - } - default: { - return "i-mgc-paper-cute-fi" - } - } - } - - const getBlockLabel = (type: AIChatContextBlock["type"]) => { - switch (type) { - case "mainEntry": { - return "Current" - } - case "referEntry": { - return "Ref" - } - case "referFeed": { - return "Feed" - } - case "selectedText": { - return "Text" - } - default: { - return "" - } - } - } - - const getBlockStyles = (type: AIChatContextBlock["type"]) => { - switch (type) { - case "mainEntry": { - return { - container: "from-orange/5 to-orange/10 border-orange/20 hover:border-orange/30", - icon: "bg-orange/10 text-orange", - label: "text-orange", - } - } - case "referEntry": { - return { - container: "from-blue/5 to-blue/10 border-blue/20 hover:border-blue/30", - icon: "bg-blue/10 text-blue", - label: "text-blue", - } - } - case "referFeed": { - return { - container: "from-green/5 to-green/10 border-green/20 hover:border-green/30", - icon: "bg-green/10 text-green", - label: "text-green", - } - } - case "selectedText": { - return { - container: "from-purple/5 to-purple/10 border-purple/20 hover:border-purple/30", - icon: "bg-purple/10 text-purple", - label: "text-purple", - } - } - default: { - return { - container: "from-gray/5 to-gray/10 border-gray/20 hover:border-gray/30", - icon: "bg-gray/10 text-gray", - label: "text-gray", - } - } - } - } - - const getDisplayContent = (block: AIChatContextBlock) => { - switch (block.type) { - case "mainEntry": - case "referEntry": { - return - } - case "referFeed": { - return - } - case "selectedText": { - return `"${block.value}"` - } - default: { - return block.value - } - } - } - - if (!blocks || blocks.length === 0) { + // Early return for empty blocks + if (!blocks?.length) { return null } @@ -148,50 +33,10 @@ export const AIDataBlockPart: React.FC = React.memo(({ blo Context:
- {/* Blocks */} - {blocks.map((block, index) => { - const styles = getBlockStyles(block.type) - - return ( - - {/* Icon */} -
- -
- - {/* Label and content */} -
- - {getBlockLabel(block.type)} - - · - - {getDisplayContent(block)} - -
-
- ) - })} + {/* Render individual block items */} + {blocks.map((block, index) => ( + + ))}
) }) diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx index c48dbcdb3..0875fa900 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIMessageParts.tsx @@ -2,7 +2,6 @@ import "@xyflow/react/dist/style.css" import type { ToolUIPart } from "ai" import type { SerializedEditorState } from "lexical" -import { m } from "motion/react" import * as React from "react" import type { @@ -34,30 +33,8 @@ const LazyAIDisplayFlowPart = React.lazy(() => interface MessagePartsProps { message: BizUIMessage } -const ThinkingIndicator: React.FC = () => { - return ( -
- - - Thinking... - - -
- ) -} export const AIMessageParts: React.FC = React.memo(({ message }) => { - if (!message.parts || message.parts.length === 0) { - // In AI SDK v5, messages should always have parts - if (message.role === "assistant") { - return - } - return null - } const isUser = message.role === "user" return message.parts.map((part, index) => { diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIRichTextMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIRichTextMessage.tsx index 3227f698b..e639b0d60 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIRichTextMessage.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/AIRichTextMessage.tsx @@ -25,15 +25,17 @@ interface AIRichTextMessageProps { export const AIRichTextMessage: React.FC = React.memo( ({ data, className }) => { - const initialConfig: InitialConfigType = { - namespace: "AIRichTextDisplay", - theme: defaultLexicalTheme, - onError, - editable: false, // Read-only mode - editorState: JSON.stringify(data.state), - nodes: [...LexicalRichEditorNodes, MentionNode], + let initialConfig: InitialConfigType = null! + if (!initialConfig) { + initialConfig = { + namespace: "AIRichTextDisplay", + theme: defaultLexicalTheme, + onError, + editable: false, // Read-only mode + editorState: JSON.stringify(data.state), + nodes: [...LexicalRichEditorNodes, MentionNode], + } } - return (
diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/BlockTitleComponents.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/BlockTitleComponents.tsx new file mode 100644 index 000000000..27e93f959 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/BlockTitleComponents.tsx @@ -0,0 +1,45 @@ +import { useEntry } from "@follow/store/entry/hooks" +import { useFeedById } from "@follow/store/feed/hooks" +import * as React from "react" + +interface TitleProps { + fallback: string +} + +interface EntryTitleProps extends TitleProps { + entryId?: string +} + +interface FeedTitleProps extends TitleProps { + feedId?: string +} + +/** + * Displays entry title with fallback handling + */ +export const EntryTitle: React.FC = React.memo(({ entryId, fallback }) => { + const entryTitle = useEntry(entryId!, (e) => e?.title) + + if (!entryId || !entryTitle) { + return {fallback} + } + + return {entryTitle} +}) + +EntryTitle.displayName = "EntryTitle" + +/** + * Displays feed title with fallback handling + */ +export const FeedTitle: React.FC = React.memo(({ feedId, fallback }) => { + const feed = useFeedById(feedId, (feed) => ({ title: feed?.title })) + + if (!feedId || !feed?.title) { + return {fallback} + } + + return {feed.title} +}) + +FeedTitle.displayName = "FeedTitle" 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 new file mode 100644 index 000000000..b3326b453 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ImageThumbnail.tsx @@ -0,0 +1,70 @@ +import { cn } from "@follow/utils/utils" +import * as React from "react" + +import type { FileAttachment } from "~/modules/ai-chat/store/types" + +import { getImageUrl } from "./ai-block-constants" + +interface ImageThumbnailProps { + attachment: FileAttachment + className?: string + fallbackIcon?: string +} + +/** + * A robust image thumbnail component with proper error handling and fallback states + */ +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) + + const imageUrl = React.useMemo(() => getImageUrl(attachment), [attachment]) + + 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 + } + + return ( +
+ {attachment.name} + {isLoading && ( +
+ +
+ )} +
+ ) + }, +) + +ImageThumbnail.displayName = "ImageThumbnail" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ThinkingIndicator.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ThinkingIndicator.tsx new file mode 100644 index 000000000..2a8d71027 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ThinkingIndicator.tsx @@ -0,0 +1,17 @@ +import { m } from "motion/react" + +export const ThinkingIndicator: React.FC = () => { + return ( +
+ + + Thinking... + + +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ai-block-constants.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ai-block-constants.ts new file mode 100644 index 000000000..83fca3284 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/ai-block-constants.ts @@ -0,0 +1,128 @@ +import type { AIChatContextBlock, FileAttachment } from "~/modules/ai-chat/store/types" +import { + getFileCategoryFromMimeType, + getFileIconName, +} from "~/modules/ai-chat/utils/file-validation" + +/** + * Block style configurations for different context block types + */ +export const BLOCK_STYLES = { + mainEntry: { + container: "from-orange/5 to-orange/10 border-orange/20 hover:border-orange/30", + icon: "bg-orange/10 text-orange", + label: "text-orange", + }, + referEntry: { + container: "from-blue/5 to-blue/10 border-blue/20 hover:border-blue/30", + icon: "bg-blue/10 text-blue", + label: "text-blue", + }, + referFeed: { + container: "from-green/5 to-green/10 border-green/20 hover:border-green/30", + icon: "bg-green/10 text-green", + label: "text-green", + }, + selectedText: { + container: "from-purple/5 to-purple/10 border-purple/20 hover:border-purple/30", + icon: "bg-purple/10 text-purple", + label: "text-purple", + }, + fileAttachment: { + container: "from-pink/5 to-pink/10 border-pink/20 hover:border-pink/30", + icon: "bg-pink/10 text-pink", + label: "text-pink", + }, +} as const + +/** + * Default fallback styles for unknown block types + */ +export const DEFAULT_BLOCK_STYLES = { + container: "from-gray/5 to-gray/10 border-gray/20 hover:border-gray/30", + icon: "bg-gray/10 text-gray", + label: "text-gray", +} as const + +/** + * Block icons for different context block types + */ +export const BLOCK_ICONS = { + mainEntry: "i-mgc-star-cute-fi", + referEntry: "i-mgc-paper-cute-fi", + referFeed: "i-mgc-rss-cute-fi", + selectedText: "i-mgc-quill-pen-cute-re", + fileAttachment: "i-mgc-file-upload-cute-re", +} as const + +/** + * Block labels for different context block types + */ +export const BLOCK_LABELS = { + mainEntry: "Current", + referEntry: "Ref", + referFeed: "Feed", + selectedText: "Text", + fileAttachment: "File", +} as const + +/** + * File upload status labels + */ +export const FILE_STATUS_LABELS = { + uploading: "Uploading...", + error: "Failed", + processing: "Processing...", + completed: "", +} as const + +/** + * Gets the appropriate styles for a block type + */ +export function getBlockStyles(type: AIChatContextBlock["type"]) { + return BLOCK_STYLES[type] || DEFAULT_BLOCK_STYLES +} + +/** + * Gets the appropriate icon for a block + */ +export function getBlockIcon(block: AIChatContextBlock): string { + if (block.type === "fileAttachment" && block.attachment) { + const fileCategory = getFileCategoryFromMimeType(block.attachment.type) + return getFileIconName(fileCategory) + } + return BLOCK_ICONS[block.type] || BLOCK_ICONS.fileAttachment +} + +/** + * Gets the appropriate label for a block type + */ +export function getBlockLabel(type: AIChatContextBlock["type"]): string { + return BLOCK_LABELS[type] || "" +} + +/** + * Gets the best available image URL for a file attachment + */ +export function getImageUrl(attachment: FileAttachment): string | null { + return attachment.previewUrl || attachment.dataUrl || attachment.serverUrl || null +} + +/** + * Checks if a block represents an image attachment + */ +export function isImageAttachment(block: AIChatContextBlock): boolean { + return ( + block.type === "fileAttachment" && + !!block.attachment && + getFileCategoryFromMimeType(block.attachment.type) === "image" + ) +} + +/** + * Gets display content for file attachments based on upload status + */ +export function getFileDisplayContent(attachment: FileAttachment): string { + const statusLabel = FILE_STATUS_LABELS[attachment.uploadStatus] + return statusLabel || attachment.name +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/components/ui/UploadProgress.tsx b/apps/desktop/layer/renderer/src/modules/ai-chat/components/ui/UploadProgress.tsx new file mode 100644 index 000000000..8ddf0f395 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/components/ui/UploadProgress.tsx @@ -0,0 +1,154 @@ +import { cn } from "@follow/utils" +import { m } from "motion/react" +import type { FC } from "react" +import { memo } from "react" + +export interface UploadProgressProps { + /** Progress percentage (0-100) */ + progress: number + /** Progress bar size variant */ + size?: "sm" | "md" | "lg" + /** Show percentage text */ + showPercentage?: boolean + /** Custom className */ + className?: string + /** Progress bar color */ + variant?: "default" | "success" | "error" +} + +export const UploadProgress: FC = memo( + ({ progress, size = "md", showPercentage = false, className, variant = "default" }) => { + const progressValue = Math.max(0, Math.min(100, progress)) + + const sizeClasses = { + sm: "h-1", + md: "h-2", + lg: "h-3", + } + + const colorClasses = { + default: "bg-blue", + success: "bg-green", + error: "bg-red", + } + + return ( +
+ {/* Progress Bar */} +
+ +
+ + {/* Percentage Text */} + {showPercentage && ( +
+ {Math.round(progressValue)}% +
+ )} +
+ ) + }, +) + +UploadProgress.displayName = "UploadProgress" + +export interface CircularProgressProps { + /** Progress percentage (0-100) */ + progress: number + /** Circle size */ + size?: number + /** Stroke width */ + strokeWidth?: number + /** Show percentage text in center */ + showPercentage?: boolean + /** Custom className */ + className?: string + /** Progress color */ + variant?: "default" | "success" | "error" +} + +export const CircularProgress: FC = memo( + ({ + progress, + size = 20, + strokeWidth = 2, + showPercentage = false, + className, + variant = "default", + }) => { + const progressValue = Math.max(0, Math.min(100, progress)) + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + const strokeDashoffset = circumference - (progressValue / 100) * circumference + + const colorClasses = { + default: "text-blue", + success: "text-green", + error: "text-red", + } + + return ( +
+ + {/* Background circle */} + + + {/* Progress circle */} + + + + {/* Percentage text */} + {showPercentage && ( +
+ {Math.round(progressValue)}% +
+ )} +
+ ) + }, +) + +CircularProgress.displayName = "CircularProgress" diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionBlockSync.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionBlockSync.ts index f58d78eb4..e0a98af81 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionBlockSync.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/editor/plugins/mention/hooks/useMentionBlockSync.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from "react" import { useAIChatStore } from "~/modules/ai-chat/store/AIChatContext" import { useChatBlockActions } from "~/modules/ai-chat/store/hooks" -import type { AIChatContextBlock } from "~/modules/ai-chat/store/types" +import type { AIChatContextBlock, ValueContextBlock } from "~/modules/ai-chat/store/types" import { $isMentionNode, MentionNode } from "../MentionNode" import type { MentionData } from "../types" @@ -18,7 +18,7 @@ interface MentionBlockReference { const getResourceId = (type: string, value: string) => `${type}:${value}` -const getBlockType = (mentionType: string): AIChatContextBlock["type"] => { +const getBlockType = (mentionType: string): ValueContextBlock["type"] => { return mentionType === "feed" ? "referFeed" : "referEntry" } @@ -105,7 +105,7 @@ export const useMentionBlockSync = () => { const blockType = getBlockType(mentionData.type) // Generate block ID (mimicking the block slice logic) - const newBlock: Omit = { + const newBlock: Omit = { type: blockType, value: mentionData.value as string, } @@ -115,7 +115,8 @@ export const useMentionBlockSync = () => { // Use current blocks state directly from store instead of stale closure const currentBlocks = blockActions.getBlocks() const addedBlock = currentBlocks.find( - (block) => block.type === blockType && block.value === mentionData.value, + (block): block is Extract => + block.type === blockType && block.value === mentionData.value, ) if (addedBlock) { 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 new file mode 100644 index 000000000..5022b0653 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/hooks/useFileUpload.ts @@ -0,0 +1,159 @@ +import { useCallback } from "react" +import { toast } from "sonner" + +import { useChatBlockActions } from "../store/hooks" +import type { ProcessFileOptions, ProcessFileResult } from "../utils/file-processing" +import { processAndUploadFile } from "../utils/file-processing" + +export interface UseFileUploadOptions extends ProcessFileOptions { + /** + * Show success toast on successful upload + */ + showSuccessToast?: boolean + /** + * Show error toast on upload failure + */ + showErrorToast?: boolean + /** + * Custom success message for toast + */ + successMessage?: string + /** + * Custom error message prefix for toast + */ + errorMessagePrefix?: string +} + +export interface FileUploadHandlers { + /** + * Upload a single file with progress tracking + */ + uploadFile: (file: File) => Promise + /** + * Upload multiple files with progress tracking + */ + uploadFiles: (files: File[] | FileList) => Promise + /** + * Handle file input change event + */ + handleFileInputChange: (event: React.ChangeEvent) => Promise + /** + * Handle drag and drop files + */ + handleFileDrop: (files: FileList) => Promise +} + +/** + * Hook for handling file uploads with progress tracking and block management + */ +export function useFileUpload(options: UseFileUploadOptions = {}): FileUploadHandlers { + const { + showSuccessToast = false, + showErrorToast = true, + successMessage = "File uploaded successfully", + errorMessagePrefix = "File upload error", + ...processOptions + } = options + + const blockActions = useChatBlockActions() + + const uploadFile = useCallback( + async (file: File): Promise => { + try { + const result = await processAndUploadFile(file, processOptions, (updatedAttachment) => { + // Update block during upload progress + blockActions.updateFileAttachment(updatedAttachment.id, updatedAttachment) + }) + + if (result.success && result.fileAttachment) { + // Add the completed file attachment to blocks + blockActions.addFileAttachment(result.fileAttachment) + + if (showSuccessToast) { + toast.success(`${successMessage}: ${file.name}`) + } + } else if (showErrorToast && result.error) { + toast.error(`${errorMessagePrefix}: ${result.error}`) + console.error("File upload error:", result.error) + } + + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error" + + if (showErrorToast) { + toast.error(`${errorMessagePrefix}: ${errorMessage}`) + } + + console.error("File upload failed:", error) + + return { + success: false, + error: errorMessage, + } + } + }, + [ + blockActions, + processOptions, + showSuccessToast, + showErrorToast, + successMessage, + errorMessagePrefix, + ], + ) + + const uploadFiles = useCallback( + async (files: File[] | FileList): Promise => { + const results: ProcessFileResult[] = [] + const fileArray = Array.from(files) + + // Process files sequentially to avoid overwhelming the server + for (const file of fileArray) { + const result = await uploadFile(file) + results.push(result) + } + + return results + }, + [uploadFile], + ) + + const handleFileInputChange = useCallback( + async (event: React.ChangeEvent) => { + const { files } = event.target + if (files && files.length > 0) { + await uploadFiles(files) + } + // Reset file input + event.target.value = "" + }, + [uploadFiles], + ) + + const handleFileDrop = useCallback( + async (files: FileList) => { + if (files && files.length > 0) { + await uploadFiles(files) + } + }, + [uploadFiles], + ) + + return { + uploadFile, + uploadFiles, + handleFileInputChange, + handleFileDrop, + } +} + +/** + * Convenience hook for file upload with default settings + */ +export function useFileUploadWithDefaults(): FileUploadHandlers { + return useFileUpload({ + showErrorToast: true, + showSuccessToast: false, + }) +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/block.slice.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/block.slice.ts index 7f0162dfd..0ca5df28f 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/block.slice.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/slices/block.slice.ts @@ -3,7 +3,8 @@ import { produce } from "immer" import { nanoid } from "nanoid" import type { StateCreator } from "zustand" -import type { AIChatContextBlock } from "../types" +import { cleanupFileAttachment } from "../../utils/file-processing" +import type { AIChatContextBlock, AIChatContextBlockInput, FileAttachment } from "../types" export interface BlockSlice { blocks: AIChatContextBlock[] @@ -39,7 +40,7 @@ export class BlockSliceAction { get get() { return this.params[1] } - addBlock(block: Omit) { + addBlock(block: AIChatContextBlockInput) { const currentBlocks = this.get().blocks // Only allow one SPECIAL_TYPES @@ -60,6 +61,10 @@ export class BlockSliceAction { removeBlock(id: string) { this.set( produce((state: BlockSlice) => { + const blockToRemove = state.blocks.find((block) => block.id === id) + if (blockToRemove && blockToRemove.type === "fileAttachment") { + cleanupFileAttachment(blockToRemove.attachment) + } state.blocks = state.blocks.filter((block) => block.id !== id) }), ) @@ -68,9 +73,18 @@ export class BlockSliceAction { updateBlock(id: string, updates: Partial) { this.set( produce((state: BlockSlice) => { - state.blocks = state.blocks.map((block) => - block.id === id ? { ...block, ...updates } : block, - ) + state.blocks = state.blocks.map((block) => { + if (block.id !== id) return block + + // Handle discriminated union updates carefully + if (updates.type && updates.type !== block.type) { + // Type change - need to replace the entire block + return { ...updates, id } as AIChatContextBlock + } else { + // Same type - safe to spread + return { ...block, ...updates } as AIChatContextBlock + } + }) }), ) } @@ -87,6 +101,12 @@ export class BlockSliceAction { clearBlocks() { this.set( produce((state: BlockSlice) => { + // Clean up file attachments before clearing + state.blocks.forEach((block) => { + if (block.type === "fileAttachment") { + cleanupFileAttachment(block.attachment) + } + }) state.blocks = [] }), ) @@ -95,6 +115,12 @@ export class BlockSliceAction { resetContext() { this.set( produce((state: BlockSlice) => { + // Clean up file attachments before resetting + state.blocks.forEach((block) => { + if (block.type === "fileAttachment") { + cleanupFileAttachment(block.attachment) + } + }) state.blocks = [] }), ) @@ -103,4 +129,55 @@ export class BlockSliceAction { getBlocks() { return this.get().blocks } + + // File attachment specific methods + addFileAttachment(fileAttachment: FileAttachment) { + const fileBlock: AIChatContextBlock = { + id: fileAttachment.id, + type: "fileAttachment", + attachment: fileAttachment, + } + this.addBlock(fileBlock) + } + + updateFileAttachment(fileId: string, updatedAttachment: FileAttachment) { + this.set( + produce((state: BlockSlice) => { + const block = state.blocks.find((b) => b.id === fileId) + if (block && block.type === "fileAttachment") { + block.attachment = updatedAttachment + } + }), + ) + } + + updateFileAttachmentStatus( + fileId: string, + status: FileAttachment["uploadStatus"], + errorMessage?: string, + ) { + this.set( + produce((state: BlockSlice) => { + const block = state.blocks.find((b) => b.id === fileId) + if (block && block.type === "fileAttachment") { + block.attachment.uploadStatus = status + if (errorMessage) { + block.attachment.errorMessage = errorMessage + } + } + }), + ) + } + + removeFileAttachment(fileId: string) { + this.removeBlock(fileId) + } + + getFileAttachments() { + return this.get().blocks.filter((block) => block.type === "fileAttachment") + } + + hasFileAttachments() { + return this.get().blocks.some((block) => block.type === "fileAttachment") + } } diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts index b2d9a5adc..79567486f 100644 --- a/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/store/types.ts @@ -1,11 +1,40 @@ import type { BizUITools, ToolWithState } from "@folo-services/ai-tools" -export interface AIChatContextBlock { +export interface FileAttachment { id: string + name: string + type: string + size: number + dataUrl: string + previewUrl?: string + uploadStatus: "processing" | "uploading" | "completed" | "error" + serverUrl?: string + errorMessage?: string + /** Upload progress percentage (0-100) */ + uploadProgress?: number +} + +interface BaseContextBlock { + id: string +} + +export interface ValueContextBlock extends BaseContextBlock { type: "mainEntry" | "referEntry" | "referFeed" | "selectedText" value: string } +export interface FileAttachmentContextBlock extends BaseContextBlock { + type: "fileAttachment" + attachment: FileAttachment +} + +export type AIChatContextBlock = ValueContextBlock | FileAttachmentContextBlock + +// Helper type for creating new blocks without id +export type AIChatContextBlockInput = + | Omit + | Omit + export interface AIChatStoreInitial { blocks: AIChatContextBlock[] } 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 new file mode 100644 index 000000000..0d5bbd22d --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-processing.ts @@ -0,0 +1,292 @@ +import { nanoid } from "nanoid" + +import { followApi } from "~/lib/api-client" + +import type { FileAttachment } from "../store/types" +import type { FileValidationResult } from "./file-validation" +import { validateFile } from "./file-validation" + +export interface ProcessFileOptions { + maxImageWidth?: number + maxImageHeight?: number + imageQuality?: number +} + +export interface ProcessFileResult { + success: boolean + fileAttachment?: FileAttachment + error?: string +} + +export async function processFile( + file: File, + options: ProcessFileOptions = {}, +): Promise { + const { maxImageWidth = 1920, maxImageHeight = 1080, imageQuality = 0.85 } = options + + // Validate file + const validation: FileValidationResult = validateFile(file) + if (!validation.isValid) { + return { + success: false, + error: validation.error?.message || "File validation failed", + } + } + + try { + const fileId = nanoid() + let dataUrl: string + let previewUrl: string | undefined + + if (validation.fileInfo?.category === "image") { + // Process image: compress and generate preview + const processedImage = await processImage(file, { + maxWidth: maxImageWidth, + maxHeight: maxImageHeight, + quality: imageQuality, + }) + + dataUrl = processedImage.dataUrl + previewUrl = processedImage.previewUrl + } else { + // For non-images, just convert to data URL + dataUrl = await fileToDataUrl(file) + } + + const fileAttachment: FileAttachment = { + id: fileId, + name: file.name, + type: file.type, + size: file.size, + dataUrl, + previewUrl, + uploadStatus: "completed", + } + + return { + success: true, + fileAttachment, + } + } catch (error) { + return { + success: false, + error: `Failed to process file: ${error instanceof Error ? error.message : "Unknown error"}`, + } + } +} + +interface ProcessImageResult { + dataUrl: string + previewUrl: string +} + +async function processImage( + file: File, + options: { maxWidth: number; maxHeight: number; quality: number }, +): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d") + + if (!ctx) { + reject(new Error("Could not get canvas context")) + return + } + + img.onload = () => { + // Calculate new dimensions + let { width, height } = img + const { maxWidth, maxHeight, quality } = options + + if (width > maxWidth || height > maxHeight) { + const ratio = Math.min(maxWidth / width, maxHeight / height) + width *= ratio + height *= ratio + } + + // Set canvas dimensions + canvas.width = width + canvas.height = height + + // Draw and compress image + ctx.drawImage(img, 0, 0, width, height) + + const dataUrl = canvas.toDataURL(file.type, quality) + + // Create smaller preview (thumbnail) + const previewCanvas = document.createElement("canvas") + const previewCtx = previewCanvas.getContext("2d") + + if (previewCtx) { + const previewSize = 150 + const previewRatio = Math.min(previewSize / width, previewSize / height) + previewCanvas.width = width * previewRatio + previewCanvas.height = height * previewRatio + + previewCtx.drawImage(img, 0, 0, previewCanvas.width, previewCanvas.height) + const previewUrl = previewCanvas.toDataURL(file.type, 0.7) + + resolve({ dataUrl, previewUrl }) + } else { + resolve({ dataUrl, previewUrl: dataUrl }) + } + } + + img.onerror = () => { + reject(new Error("Failed to load image")) + } + + img.src = URL.createObjectURL(file) + }) +} + +function fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = () => { + resolve(reader.result as string) + } + + reader.onerror = () => { + reject(new Error("Failed to read file")) + } + + reader.readAsDataURL(file) + }) +} + +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:")) { + URL.revokeObjectURL(fileAttachment.previewUrl) + } +} + +export async function uploadFileAttachment( + fileAttachment: FileAttachment, + onProgressUpdate?: (attachment: FileAttachment) => void, +): Promise { + try { + // Update status to uploading with 0% progress + let currentAttachment: FileAttachment = { + ...fileAttachment, + uploadStatus: "uploading" as const, + uploadProgress: 0, + } + onProgressUpdate?.(currentAttachment) + + const { dataUrl } = fileAttachment + const blob = await fetch(dataUrl).then((r) => r.blob()) + + // TODO: Replace with real progress tracking when followApi supports it + // Currently followApi.upload.uploadChatAttachment doesn't provide progress callbacks + // Future implementation could use XMLHttpRequest or a custom fetch wrapper + + // Simulate realistic progress updates during upload + // This mimics a realistic upload progression pattern + const progressInterval = setInterval(() => { + if (currentAttachment.uploadProgress! < 85) { + // Start faster, then slow down (realistic network behavior) + const currentProgress = currentAttachment.uploadProgress || 0 + const increment = + currentProgress < 50 + ? Math.random() * 20 + 5 // Fast initial progress + : Math.random() * 8 + 2 // Slower progress as it approaches completion + + currentAttachment = { + ...currentAttachment, + uploadProgress: Math.min(85, currentProgress + increment), + } + onProgressUpdate?.(currentAttachment) + } + }, 150) + + try { + // Actual upload + const response = await followApi.upload.uploadChatAttachment({ file: blob }) + const serverUrl = response.data.url + + // Clear progress interval + clearInterval(progressInterval) + + // Update to 100% and completed status + const completedAttachment: FileAttachment = { + ...fileAttachment, + serverUrl, + uploadStatus: "completed", + uploadProgress: 100, + errorMessage: undefined, + } + + // Show 100% briefly before final callback + onProgressUpdate?.(completedAttachment) + + return completedAttachment + } catch (uploadError) { + clearInterval(progressInterval) + throw uploadError + } + } catch (error) { + // Return attachment with error status + const errorAttachment: FileAttachment = { + ...fileAttachment, + uploadStatus: "error", + uploadProgress: undefined, + errorMessage: error instanceof Error ? error.message : "Upload failed", + } + + return errorAttachment + } +} + +export async function processAndUploadFile( + file: File, + options: ProcessFileOptions = {}, + onProgressUpdate?: (attachment: FileAttachment) => void, +): Promise { + // First process the file locally + const localResult = await processFile(file, options) + + if (!localResult.success || !localResult.fileAttachment) { + return localResult + } + + // Then upload to server + const uploadedAttachment = await uploadFileAttachment( + localResult.fileAttachment, + onProgressUpdate, + ) + + return { + success: uploadedAttachment.uploadStatus === "completed", + fileAttachment: uploadedAttachment, + error: + uploadedAttachment.uploadStatus === "error" ? uploadedAttachment.errorMessage : undefined, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-validation.ts b/apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-validation.ts new file mode 100644 index 000000000..04d53fcc2 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai-chat/utils/file-validation.ts @@ -0,0 +1,129 @@ +const MAX_IMAGE_ALLOWED_SIZE = 3 * 1024 * 1024 +const MAX_DOCUMENT_ALLOWED_SIZE = 1 * 1024 * 1024 +export const SUPPORTED_FILE_TYPES = { + // Images + "image/png": { extension: "png", category: "image", maxSize: MAX_IMAGE_ALLOWED_SIZE }, + "image/jpeg": { extension: "jpg", category: "image", maxSize: MAX_IMAGE_ALLOWED_SIZE }, + "image/jpg": { extension: "jpg", category: "image", maxSize: MAX_IMAGE_ALLOWED_SIZE }, + "image/webp": { extension: "webp", category: "image", maxSize: MAX_IMAGE_ALLOWED_SIZE }, + "image/gif": { extension: "gif", category: "image", maxSize: MAX_IMAGE_ALLOWED_SIZE }, + + // Documents + "application/pdf": { extension: "pdf", category: "document", maxSize: MAX_DOCUMENT_ALLOWED_SIZE }, + "text/plain": { extension: "txt", category: "text", maxSize: MAX_DOCUMENT_ALLOWED_SIZE }, + "text/markdown": { extension: "md", category: "text", maxSize: MAX_DOCUMENT_ALLOWED_SIZE }, +} as const + +export type SupportedFileType = keyof typeof SUPPORTED_FILE_TYPES +export type FileCategory = (typeof SUPPORTED_FILE_TYPES)[SupportedFileType]["category"] + +export interface FileValidationError { + type: "unsupported" | "too_large" | "invalid" + message: string +} + +export interface FileValidationResult { + isValid: boolean + error?: FileValidationError + fileInfo?: { + type: SupportedFileType + category: FileCategory + extension: string + maxSize: number + } +} + +export function validateFile(file: File): FileValidationResult { + const fileType = file.type as SupportedFileType + const fileInfo = SUPPORTED_FILE_TYPES[fileType] + + if (!fileInfo) { + return { + isValid: false, + error: { + type: "unsupported", + message: `File type "${file.type}" is not supported. Supported types: images, PDFs, text files.`, + }, + } + } + + if (file.size > fileInfo.maxSize) { + const maxSizeMB = Math.round(fileInfo.maxSize / (1024 * 1024)) + const fileSizeMB = Math.round((file.size / (1024 * 1024)) * 100) / 100 + return { + isValid: false, + error: { + type: "too_large", + message: `File size (${fileSizeMB}MB) exceeds the maximum allowed size of ${maxSizeMB}MB for ${fileInfo.category} files.`, + }, + } + } + + if (file.size === 0) { + return { + isValid: false, + error: { + type: "invalid", + message: "File appears to be empty or corrupted.", + }, + } + } + + return { + isValid: true, + fileInfo: { + type: fileType, + category: fileInfo.category, + extension: fileInfo.extension, + maxSize: fileInfo.maxSize, + }, + } +} + +export function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes" + + const k = 1024 + const sizes = ["Bytes", "KB", "MB", "GB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` +} + +export function getFileCategoryFromMimeType(mimeType: string): FileCategory { + // Images + if (mimeType.startsWith("image/")) { + return "image" + } + + // Documents + if (mimeType === "application/pdf") { + return "document" + } + + // Text files + if (mimeType.startsWith("text/")) { + return "text" + } + + // Default fallback + return "image" +} + +export function getFileIconName(category: FileCategory): string { + switch (category) { + case "image": { + return "i-mgc-pic-cute-re" + } + case "document": { + return "i-mgc-file-cute-re" + } + case "text": { + return "i-mgc-document-cute-re" + } + + default: { + return "i-mgc-attachment-cute-re" + } + } +} diff --git a/apps/mobile/src/icons/add_cute_fi.tsx b/apps/mobile/src/icons/add_cute_fi.tsx index d52502e93..71bd6081c 100644 --- a/apps/mobile/src/icons/add_cute_fi.tsx +++ b/apps/mobile/src/icons/add_cute_fi.tsx @@ -13,10 +13,11 @@ export const AddCuteFiIcon = ({ color = "#10161F", }: AddCuteFiIconProps) => { return ( - + ) diff --git a/apps/mobile/src/icons/ai_cute_fi.tsx b/apps/mobile/src/icons/ai_cute_fi.tsx new file mode 100644 index 000000000..262005ab2 --- /dev/null +++ b/apps/mobile/src/icons/ai_cute_fi.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface AiCuteFiIconProps { + width?: number + height?: number + color?: string +} + +export const AiCuteFiIcon = ({ width = 24, height = 24, color = "#10161F" }: AiCuteFiIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/arrow_up_circle_cute_fi.tsx b/apps/mobile/src/icons/arrow_up_circle_cute_fi.tsx new file mode 100644 index 000000000..33e7b137f --- /dev/null +++ b/apps/mobile/src/icons/arrow_up_circle_cute_fi.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface ArrowUpCircleCuteFiIconProps { + width?: number + height?: number + color?: string +} + +export const ArrowUpCircleCuteFiIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: ArrowUpCircleCuteFiIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/at_cute_re.tsx b/apps/mobile/src/icons/at_cute_re.tsx new file mode 100644 index 000000000..29da143a6 --- /dev/null +++ b/apps/mobile/src/icons/at_cute_re.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface AtCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const AtCuteReIcon = ({ width = 24, height = 24, color = "#10161F" }: AtCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/attachment_cute_re.tsx b/apps/mobile/src/icons/attachment_cute_re.tsx new file mode 100644 index 000000000..e350c551c --- /dev/null +++ b/apps/mobile/src/icons/attachment_cute_re.tsx @@ -0,0 +1,25 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface AttachmentCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const AttachmentCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: AttachmentCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/bookmark_cute_re.tsx b/apps/mobile/src/icons/bookmark_cute_re.tsx new file mode 100644 index 000000000..628e2f187 --- /dev/null +++ b/apps/mobile/src/icons/bookmark_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface BookmarkCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const BookmarkCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: BookmarkCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/comment_2_cute_re.tsx b/apps/mobile/src/icons/comment_2_cute_re.tsx new file mode 100644 index 000000000..5cb0c6a65 --- /dev/null +++ b/apps/mobile/src/icons/comment_2_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface Comment2CuteReIconProps { + width?: number + height?: number + color?: string +} + +export const Comment2CuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: Comment2CuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/comment_cute_fi.tsx b/apps/mobile/src/icons/comment_cute_fi.tsx new file mode 100644 index 000000000..b551b9f4b --- /dev/null +++ b/apps/mobile/src/icons/comment_cute_fi.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface CommentCuteFiIconProps { + width?: number + height?: number + color?: string +} + +export const CommentCuteFiIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: CommentCuteFiIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/comment_cute_li.tsx b/apps/mobile/src/icons/comment_cute_li.tsx new file mode 100644 index 000000000..5d3a74c45 --- /dev/null +++ b/apps/mobile/src/icons/comment_cute_li.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface CommentCuteLiIconProps { + width?: number + height?: number + color?: string +} + +export const CommentCuteLiIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: CommentCuteLiIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/comment_cute_re.tsx b/apps/mobile/src/icons/comment_cute_re.tsx new file mode 100644 index 000000000..18b4ede3b --- /dev/null +++ b/apps/mobile/src/icons/comment_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface CommentCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const CommentCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: CommentCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/documents_cute_re.tsx b/apps/mobile/src/icons/documents_cute_re.tsx new file mode 100644 index 000000000..5ee84333f --- /dev/null +++ b/apps/mobile/src/icons/documents_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface DocumentsCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const DocumentsCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: DocumentsCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/history_cute_re.tsx b/apps/mobile/src/icons/history_cute_re.tsx new file mode 100644 index 000000000..3a48d5c53 --- /dev/null +++ b/apps/mobile/src/icons/history_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface HistoryCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const HistoryCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: HistoryCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/mic_cute_re.tsx b/apps/mobile/src/icons/mic_cute_re.tsx new file mode 100644 index 000000000..d135189e1 --- /dev/null +++ b/apps/mobile/src/icons/mic_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface MicCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const MicCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: MicCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/mind_map_cute_re.tsx b/apps/mobile/src/icons/mind_map_cute_re.tsx new file mode 100644 index 000000000..b20c5ac48 --- /dev/null +++ b/apps/mobile/src/icons/mind_map_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface MindMapCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const MindMapCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: MindMapCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/pic_cute_re.tsx b/apps/mobile/src/icons/pic_cute_re.tsx new file mode 100644 index 000000000..2272821a1 --- /dev/null +++ b/apps/mobile/src/icons/pic_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface PicCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const PicCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: PicCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/save_cute_re.tsx b/apps/mobile/src/icons/save_cute_re.tsx new file mode 100644 index 000000000..57e985004 --- /dev/null +++ b/apps/mobile/src/icons/save_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface SaveCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const SaveCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: SaveCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/search_cute_re.tsx b/apps/mobile/src/icons/search_cute_re.tsx new file mode 100644 index 000000000..3b5816352 --- /dev/null +++ b/apps/mobile/src/icons/search_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface SearchCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const SearchCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: SearchCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/send_plane_cute_fi.tsx b/apps/mobile/src/icons/send_plane_cute_fi.tsx new file mode 100644 index 000000000..4bbb772d1 --- /dev/null +++ b/apps/mobile/src/icons/send_plane_cute_fi.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface SendPlaneCuteFiIconProps { + width?: number + height?: number + color?: string +} + +export const SendPlaneCuteFiIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: SendPlaneCuteFiIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/send_plane_cute_re.tsx b/apps/mobile/src/icons/send_plane_cute_re.tsx new file mode 100644 index 000000000..bdad54ca5 --- /dev/null +++ b/apps/mobile/src/icons/send_plane_cute_re.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface SendPlaneCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const SendPlaneCuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: SendPlaneCuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/up_cute_re.tsx b/apps/mobile/src/icons/up_cute_re.tsx new file mode 100644 index 000000000..0c31fa2df --- /dev/null +++ b/apps/mobile/src/icons/up_cute_re.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface UpCuteReIconProps { + width?: number + height?: number + color?: string +} + +export const UpCuteReIcon = ({ width = 24, height = 24, color = "#10161F" }: UpCuteReIconProps) => { + return ( + + + + ) +} diff --git a/icons/mgc/attachment_cute_re.svg b/icons/mgc/attachment_cute_re.svg new file mode 100644 index 000000000..e0e6a58ae --- /dev/null +++ b/icons/mgc/attachment_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a6f780cb..3466db906 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@follow-app/client-sdk': - specifier: 0.3.27 - version: 0.3.27 + specifier: 0.3.29 + version: 0.3.29 typescript: specifier: 5.8.3 version: 5.8.3 @@ -462,7 +462,7 @@ importers: version: 3.0.2(electron@37.2.0) '@follow-app/client-sdk': specifier: 'catalog:' - version: 0.3.27(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) + version: 0.3.29(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) '@follow/database': specifier: workspace:* version: link:../../../../packages/internal/database @@ -762,8 +762,8 @@ importers: specifier: workspace:* version: link:../../../../packages/internal/utils '@folo-services/ai-tools': - specifier: 0.2.19 - version: 0.2.19(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) + specifier: 0.2.20 + version: 0.2.20(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) '@types/node': specifier: 24.0.10 version: 24.0.10 @@ -1670,7 +1670,7 @@ importers: dependencies: '@follow-app/client-sdk': specifier: 'catalog:' - version: 0.3.27(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) + version: 0.3.29(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) '@follow/configs': specifier: workspace:* version: link:../../configs @@ -1682,7 +1682,7 @@ importers: dependencies: '@follow-app/client-sdk': specifier: 'catalog:' - version: 0.3.27(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) + version: 0.3.29(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) '@follow/constants': specifier: workspace:* version: link:../constants @@ -1760,7 +1760,7 @@ importers: dependencies: '@follow-app/client-sdk': specifier: 'catalog:' - version: 0.3.27(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) + version: 0.3.29(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) '@follow/constants': specifier: workspace:* version: link:../constants @@ -1824,7 +1824,7 @@ importers: dependencies: '@follow-app/client-sdk': specifier: 'catalog:' - version: 0.3.27(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) + version: 0.3.29(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) '@follow/configs': specifier: workspace:* version: link:../../configs @@ -4048,23 +4048,23 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@follow-app/client-sdk@0.3.27': - resolution: {integrity: sha512-Zr4XtFpZ+Gx+CFxcqJwNTSqv0UaKfb5WKMxbHrJ/a9dzdL1MvkAnvO50sx5ViOCLur+bimgz9Epx8+4qGq5VTg==} + '@follow-app/client-sdk@0.3.29': + resolution: {integrity: sha512-bQgY0S6xrWO0iLO4eeptU4vxcmQ36iSrLl6omNscK6SCXzlebCYRmqQy7dsFHU4+bditADjjLulCGWmbDX9IJQ==} - '@folo-services/ai-tools@0.2.19': - resolution: {integrity: sha512-izmFV5zz7HvlyvMGQQS2xNZS/H6QjH+Uvw0n7AywTwkBsvAR1qHsuOGfNTEhb0mRr3EZumk0g9NlA73nmG6v2g==} + '@folo-services/ai-tools@0.2.20': + resolution: {integrity: sha512-1AJI48H2g64JhuTruSqQKUrn4Tc353hMFdKAbohh65AxTAMN/1pIw9w17Dtjm+g5BVlZUXYKaiIvaN9tHsXLNA==} - '@folo-services/constants@0.1.17': - resolution: {integrity: sha512-IR+X76A/ScNj9jvgKdHv9Q1ozGlRo6ayEOpEAG3mwy1adlJdyG2eDlwHsUSMj9kiQUTwDF2JryG0GF32KkmUMw==} + '@folo-services/constants@0.1.18': + resolution: {integrity: sha512-3LDxX4Ebvr6GLdL5Ce2ZkAJwfNjfsdqOLpuFRZ3bq2gVwQZbIGsln+qYnInmR5+1i3DtebglrAj/IktnT3BsIg==} - '@folo-services/drizzle@0.1.12': - resolution: {integrity: sha512-CZmid9EyOi2DDGW3s/lQWNTNt1wPHUqTSDDPZqpC3mlou/m/N4e+kZxpDJ+LY1cmXyP7cPQO9H1tDlXesmItiw==} + '@folo-services/drizzle@0.1.13': + resolution: {integrity: sha512-h6tndGnTLrAmfvxK5XsXGdZaMi2GBmFZBFiBqtabC7Y05A1ZSOaWg/d9f60Ydh586LmVonz5VGrhvzTdn5ukhg==} - '@folo-services/exceptions@0.1.10': - resolution: {integrity: sha512-54ShG+RYYnXarjdxPyC1SSqIeAIbQimB/iBBRgt+LzHp9jMTenjU2CfP7RiYpgwCrS5tNz6eH+cNjZdWa6jTag==} + '@folo-services/exceptions@0.1.11': + resolution: {integrity: sha512-OMKat8KcIxjw9pBfG3pC1kRcIs0YiuaYmMDA2MRy8AEGcki8F9u5e5QH7OKgP/LlNR7Fs2Csz1XetvcC4nB+RQ==} - '@folo-services/shared@0.0.7': - resolution: {integrity: sha512-Tb94/bSFU2vXwg/JUSjeGFoDhPyv9NANa19P0L/ohNsEvymSXyjdmkpKowkw4+CQEhNiSkROTDjPJylO6nIm8g==} + '@folo-services/shared@0.0.8': + resolution: {integrity: sha512-rfl1vvGMxUnchckGSKOkeT5hS1NAzZTJ8gPF6yFR+TuSe2SZzq1a10/66nK9OxmqaDFaHdOpQfNXz2vp0gRJpg==} '@fontsource/sn-pro@5.2.5': resolution: {integrity: sha512-rBdBv/0ygj6bkO7xDMMFpwobLdSrcQ2Jncb6DIwdeYGoAgeWkQRwVYhGDKasfLjEYCNYxrqr6wsXsM9+aU39RA==} @@ -18921,12 +18921,12 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@follow-app/client-sdk@0.3.27(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)': + '@follow-app/client-sdk@0.3.29(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)': dependencies: - '@folo-services/constants': 0.1.17 - '@folo-services/drizzle': 0.1.12(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2) - '@folo-services/exceptions': 0.1.10 - '@folo-services/shared': 0.0.7(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) + '@folo-services/constants': 0.1.18 + '@folo-services/drizzle': 0.1.13(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2) + '@folo-services/exceptions': 0.1.11 + '@folo-services/shared': 0.0.8(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) zod: 3.25.76 transitivePeerDependencies: - '@aws-sdk/client-rds-data' @@ -18960,10 +18960,10 @@ snapshots: - sql.js - sqlite3 - '@folo-services/ai-tools@0.2.19(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)': + '@folo-services/ai-tools@0.2.20(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)': dependencies: '@ai-sdk/openai': 2.0.2(zod@3.25.76) - '@folo-services/drizzle': 0.1.12(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2) + '@folo-services/drizzle': 0.1.13(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2) ai: 5.0.4(zod@3.25.76) drizzle-orm: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) zod: 3.25.76 @@ -18999,13 +18999,13 @@ snapshots: - sql.js - sqlite3 - '@folo-services/constants@0.1.17': + '@folo-services/constants@0.1.18': dependencies: zod: 3.25.76 - '@folo-services/drizzle@0.1.12(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)': + '@folo-services/drizzle@0.1.13(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)': dependencies: - '@folo-services/exceptions': 0.1.10 + '@folo-services/exceptions': 0.1.11 drizzle-orm: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) drizzle-zod: 0.7.1(drizzle-orm@0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3))(zod@3.25.76) nanoid: 5.1.5 @@ -19042,11 +19042,11 @@ snapshots: - sql.js - sqlite3 - '@folo-services/exceptions@0.1.10': {} + '@folo-services/exceptions@0.1.11': {} - '@folo-services/shared@0.0.7(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)': + '@folo-services/shared@0.0.8(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)': dependencies: - '@folo-services/drizzle': 0.1.12(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2) + '@folo-services/drizzle': 0.1.13(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2) drizzle-orm: 0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3) drizzle-zod: 0.7.1(drizzle-orm@0.44.3(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3))(zod@3.25.76) zod: 3.25.76 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1abc59877..aa757362d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,4 +58,4 @@ overrides: catalog: typescript: "5.8.3" - "@follow-app/client-sdk": "0.3.27" + "@follow-app/client-sdk": "0.3.29"