feat(ai): update ai-tools and optimize chat message components animation

- Bumped version of `@folo-services/ai-tools` from 0.2.31 to 0.2.33 in package.json and pnpm-lock.yaml.
- Refactored `AIDisplayFlowPart` to improve error handling and compatibility with older data structures.
- Enhanced `AIChatMessage` and `AIMessageParts` components to support new props for better message rendering.
- Updated animation styles for chat messages to improve user experience during interactions.

This update improves the overall functionality and visual appeal of the AI chat interface.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-09-02 00:09:42 +08:00
parent 1f54e024b9
commit 79bf7a86e5
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
12 changed files with 286 additions and 287 deletions

View File

@ -121,7 +121,7 @@
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@folo-services/ai-tools": "0.2.31",
"@folo-services/ai-tools": "0.2.33",
"@types/node": "24.0.10",
"@vite-pwa/assets-generator": "1.0.0",
"fake-indexeddb": "6.0.1",

View File

@ -1,13 +1,14 @@
import "@xyflow/react/dist/style.css"
import { useIsDark } from "@follow/hooks"
import { thenable } from "@follow/utils"
import { cn } from "@follow/utils/utils"
import type { ToolWithState } from "@folo-services/ai-tools"
import { Background, Controls, ReactFlow } from "@xyflow/react"
import { useCallback } from "react"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import type { AIDisplayFlowTool } from "../../store/types"
import { toolMemo } from "./share"
const FlowPreviewModal = ({
@ -41,64 +42,63 @@ const FlowPreviewModal = ({
)
}
export const AIDisplayFlowPart = toolMemo(
({ part, loadingElement }: { part: AIDisplayFlowTool; loadingElement: React.ReactNode }) => {
if (!part.input) return loadingElement
if (part.state === "input-streaming") {
return loadingElement
}
export const AIDisplayFlowPart = toolMemo(({ part }: { part: ToolWithState<any> }) => {
if (!part.input) throw thenable
if (part.state === "input-streaming") {
throw thenable
}
const { nodes, edges } = part.input.flowChart as any
const colorMode = useIsDark() ? "dark" : "light"
const { present } = useModalStack()
// `part.input.flowChart` to make compatible with old data
const { nodes, edges } = part.input.flowChart || (part.input.schema.flowChart as any)
const colorMode = useIsDark() ? "dark" : "light"
const { present } = useModalStack()
const handleOpenModal = useCallback(() => {
present({
title: "Flow Chart Preview",
content: () => <FlowPreviewModal nodes={nodes} edges={edges} colorMode={colorMode} />,
max: true,
canClose: true,
clickOutsideToDismiss: false,
modalContentClassName: "p-0 h-full",
modalClassName: "h-[90vh] w-[90vw]",
})
}, [nodes, edges, colorMode, present])
const handleOpenModal = useCallback(() => {
present({
title: "Flow Chart Preview",
content: () => <FlowPreviewModal nodes={nodes} edges={edges} colorMode={colorMode} />,
max: true,
canClose: true,
clickOutsideToDismiss: false,
modalContentClassName: "p-0 h-full",
modalClassName: "h-[90vh] w-[90vw]",
})
}, [nodes, edges, colorMode, present])
return (
<div className="group relative my-2 aspect-[4/3] w-[calc(var(--ai-chat-layout-width,65ch))] max-w-full overflow-hidden rounded-md">
<ReactFlow
colorMode={colorMode}
nodes={nodes}
edges={edges}
fitView
nodesDraggable={false}
nodesConnectable={false}
nodesFocusable={false}
edgesFocusable={false}
elementsSelectable={false}
preventScrolling={false}
>
<Background />
<Controls />
</ReactFlow>
return (
<div className="group relative my-2 aspect-[4/3] w-[calc(var(--ai-chat-layout-width,65ch))] max-w-full overflow-hidden rounded-md">
<ReactFlow
colorMode={colorMode}
nodes={nodes}
edges={edges}
fitView
nodesDraggable={false}
nodesConnectable={false}
nodesFocusable={false}
edgesFocusable={false}
elementsSelectable={false}
preventScrolling={false}
>
<Background />
<Controls />
</ReactFlow>
{/* Expand/Preview button */}
<button
type="button"
onClick={handleOpenModal}
className={cn(
"absolute right-2 top-2 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5",
"bg-material-thick text-text-secondary text-sm font-medium",
"opacity-0 transition-all duration-200 group-hover:opacity-100",
"hover:bg-material-medium hover:text-text focus:opacity-100",
"focus:ring-blue focus:outline-none focus:ring-2 focus:ring-offset-1",
)}
title="Open in full screen"
>
<i className="i-mgc-external-link-cute-re size-4" />
<span>Preview</span>
</button>
</div>
)
},
)
{/* Expand/Preview button */}
<button
type="button"
onClick={handleOpenModal}
className={cn(
"absolute right-2 top-2 flex items-center gap-1.5 rounded-lg px-2.5 py-1.5",
"bg-material-thick text-text-secondary text-sm font-medium",
"opacity-0 transition-all duration-200 group-hover:opacity-100",
"hover:bg-material-medium hover:text-text focus:opacity-100",
"focus:ring-blue focus:outline-none focus:ring-2 focus:ring-offset-1",
)}
title="Open in full screen"
>
<i className="i-mgc-external-link-cute-re size-4" />
<span>Preview</span>
</button>
</div>
)
})

View File

@ -1,17 +1,12 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { cn } from "@follow/utils/utils"
import { memo, useCallback, useMemo, useRef } from "react"
import { toast } from "sonner"
import { useAISettingValue } from "~/atoms/settings/ai"
import { DropdownMenu, DropdownMenuTrigger } from "~/components/ui/dropdown-menu/dropdown-menu"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useFileUploadWithDefaults } from "~/modules/ai-chat/hooks/useFileUpload"
import { useAIChatStore } from "~/modules/ai-chat/store/AIChatContext"
import { SUPPORTED_MIME_ACCEPT } from "~/modules/ai-chat/utils/file-validation"
import { useCanCreateNewAITask } from "~/modules/ai-task/query"
import { AITaskModal } from "../../../ai-task/components/ai-task-modal"
import { ContextBlock } from "../context-bar/blocks"
import { ContextMenuContent, ShortcutsMenuContent } from "../context-bar/menus"
@ -21,8 +16,6 @@ export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) =>
const { shortcuts } = useAISettingValue()
const fileInputRef = useRef<HTMLInputElement>(null)
const { handleFileInputChange } = useFileUploadWithDefaults()
const { present } = useModalStack()
const canCreateNewTask = useCanCreateNewAITask()
// Filter enabled shortcuts
const enabledShortcuts = useMemo(
@ -34,18 +27,6 @@ export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) =>
fileInputRef.current?.click()
}, [])
const handleScheduleActionClick = () => {
if (!canCreateNewTask) {
toast.error("Please remove an existing task before creating a new one.")
return
}
present({
title: "New AI Task",
canClose: true,
content: () => <AITaskModal showSettingsTip />,
})
}
return (
<div className={cn("flex flex-wrap items-center gap-2 px-4 py-3", className)}>
{/* Add Context Button */}
@ -97,14 +78,6 @@ export const AIChatContextBar: Component<{ onSendShortcut?: (prompt: string) =>
</DropdownMenu>
)}
<ActionButton
className="bg-material-medium hover:bg-material-thin border-border text-text-secondary hover:text-text-secondary flex size-7 items-center justify-center rounded-md border transition-colors"
tooltip="Schedule Action"
onClick={handleScheduleActionClick}
>
<i className="i-mgc-calendar-time-add-cute-re size-3.5" />
</ActionButton>
{/* Context Blocks */}
{blocks.map((block) => (
<ContextBlock key={block.id} block={block} />

View File

@ -41,7 +41,7 @@ const ChatHeaderLayout = ({
[chatActions],
)
const maskImage = `linear-gradient(to bottom, black 0%, black 75%, transparent 100%)`
const maskImage = `linear-gradient(to bottom, black 0%, black 90%, transparent 100%)`
return (
<div className="absolute inset-x-0 top-0 z-[1] h-12">
<div

View File

@ -253,15 +253,18 @@ const Messages: FC = () => {
return (
<div className="relative flex min-w-0 flex-1 flex-col">
{messages.map((message) => (
<Suspense key={message.id}>
{message.role === "user" ? (
<UserChatMessage message={message} />
) : (
<AIChatMessage message={message} />
)}
</Suspense>
))}
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1
return (
<Suspense key={message.id}>
{message.role === "user" ? (
<UserChatMessage message={message} />
) : (
<AIChatMessage message={message} isLastMessage={isLastMessage} />
)}
</Suspense>
)
})}
</div>
)
}

View File

@ -1,7 +1,6 @@
import { Spring } from "@follow/components/constants/spring.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { cn } from "@follow/utils"
import { ExceptionCodeMap } from "@follow-app/client-sdk"
import { m } from "motion/react"
import * as React from "react"
import { useI18n } from "~/hooks/common/useI18n"
@ -12,6 +11,7 @@ interface CollapsibleErrorProps {
className?: string
collapsedHeight?: string
icon?: string
expandedMaxHeight?: string
}
interface ErrorData {
@ -27,6 +27,7 @@ export const CollapsibleError: React.FC<CollapsibleErrorProps> = ({
className,
collapsedHeight = "48px",
icon = "i-mgc-alert-cute-fi",
expandedMaxHeight = "240px",
}) => {
const [isExpanded, setIsExpanded] = React.useState(false)
const t = useI18n()
@ -159,56 +160,42 @@ export const CollapsibleError: React.FC<CollapsibleErrorProps> = ({
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
>
<m.div
initial={false}
animate={{
height: isExpanded ? "auto" : collapsedHeight,
}}
transition={Spring.presets.snappy}
className="overflow-hidden"
<div
className={
"border-red/20 shadow-red/5 dark:shadow-red/10 backdrop-blur-background bg-mix-red-20 relative overflow-hidden rounded-xl transition-all duration-200"
}
>
{/* Collapsed Content */}
<div
className={
"border-red/20 shadow-red/5 dark:shadow-red/10 backdrop-blur-background bg-mix-red-20 relative overflow-hidden rounded-xl transition-all duration-200"
}
className="relative z-10 flex items-center gap-3 p-3"
style={{ minHeight: collapsedHeight }}
>
{/* Collapsed Content */}
<div className="relative z-10 flex items-center gap-3 p-3">
<m.div
transition={{ duration: 0.2 }}
className="bg-red/20 flex size-6 flex-shrink-0 items-center justify-center rounded-full"
>
<i className={cn(icon, "text-red size-3")} />
</m.div>
<div className="min-w-0 flex-1">
<div className="text-red text-sm font-medium">{getErrorTitle()}</div>
</div>
<m.span
animate={{
opacity: isExpanded ? 0 : 1,
scale: isExpanded ? 0.8 : 1,
}}
transition={{ duration: 0.15 }}
className="text-text-tertiary text-xs"
>
hover to expand
</m.span>
<div className="bg-red/20 flex size-6 flex-shrink-0 items-center justify-center rounded-full transition-colors duration-200">
<i className={cn(icon, "text-red size-3")} />
</div>
{/* Expanded Content */}
<m.div
animate={{
opacity: isExpanded ? 1 : 0,
height: isExpanded ? "auto" : 0,
<div className="min-w-0 flex-1">
<div className="text-red text-sm font-medium">{getErrorTitle()}</div>
</div>
<span
className="text-text-tertiary text-xs transition-all duration-150"
style={{
opacity: isExpanded ? 0 : 1,
transform: isExpanded ? "scale(0.95)" : "scale(1)",
}}
transition={{
duration: 0.2,
ease: "easeOut",
}}
className="overflow-hidden"
>
hover to expand
</span>
</div>
{/* Expanded Content */}
<div
className="overflow-hidden transition-[max-height,opacity] duration-200 ease-out"
style={{ maxHeight: isExpanded ? expandedMaxHeight : "0px", opacity: isExpanded ? 1 : 0 }}
aria-hidden={!isExpanded}
>
<ScrollArea focusable={false} viewportProps={{ style: { maxHeight: expandedMaxHeight } }}>
<div className="border-red/20 bg-red/5 border-t px-3 pb-3">
<div className="text-red/80 bg-red/10 mt-2 rounded-md p-3 text-xs leading-relaxed">
<div className="text-red/80 bg-red/10 mt-2 cursor-text select-text break-all rounded-md p-3 text-xs leading-relaxed">
{displayMessage as string}
</div>
{contextualInfo && (
@ -217,9 +204,9 @@ export const CollapsibleError: React.FC<CollapsibleErrorProps> = ({
</div>
)}
</div>
</m.div>
</ScrollArea>
</div>
</m.div>
</div>
</div>
)
}

View File

@ -10,15 +10,17 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import { useDialog, useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useChatActions, useCurrentChatId } from "~/modules/ai-chat/store/hooks"
import { AIChatSessionService } from "~/modules/ai-chat-session"
import {
useAIChatSessionListQuery,
useDeleteAIChatSessionMutation,
} from "~/modules/ai-chat-session/query"
import { AITaskModal, useCanCreateNewAITask } from "~/modules/ai-task"
import { AIPersistService } from "../../services"
@ -77,7 +79,7 @@ const SessionItem = ({ session, onClick, onDelete, isLoading }: SessionItemProps
const EmptyState = () => {
return (
<div className="flex flex-col items-center py-8 text-center">
<i className="i-mgc-inbox-cute-re text-text-secondary mb-2 block size-8" />
<i className="i-mgc-calendar-time-add-cute-re text-text-secondary mb-2 block size-8" />
<p className="text-text-secondary text-sm">All task reports read</p>
</div>
)
@ -100,6 +102,20 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
const hasUnreadSessions = unreadSessions.length > 0
const { present } = useModalStack()
const canCreateNewTask = useCanCreateNewAITask()
const handleScheduleActionClick = () => {
if (!canCreateNewTask) {
toast.error("Please remove an existing task before creating a new one.")
return
}
present({
title: "New AI Task",
canClose: true,
content: () => <AITaskModal showSettingsTip />,
})
}
const handleSessionSelect = useCallback(
async (session: AIChatSession) => {
if (session.chatId === currentChatId) return
@ -153,7 +169,7 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
const defaultTrigger = (
<ActionButton tooltip="Task Reports" className="relative">
<i className="i-mgc-inbox-cute-re text-text-secondary size-5" />
<i className="i-mgc-calendar-time-add-cute-re text-text-secondary size-5" />
{hasUnreadSessions && (
<span
className="bg-accent absolute right-1 top-1 block size-2 rounded-full shadow-[0_0_0_2px_var(--color-bg-default)] dark:shadow-[0_0_0_2px_var(--color-bg-default)]"
@ -195,6 +211,16 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
) : (
<EmptyState />
)}
{canCreateNewTask && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleScheduleActionClick}>
<i className="i-mgc-add-cute-re mr-2 size-4" />
New Task
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)

View File

@ -23,6 +23,7 @@ export interface ChatMessage {
interface AIChatMessageProps {
message: BizUIMessage
isLastMessage: boolean
}
// Utility function for converting message to markdown
@ -58,61 +59,64 @@ const useMessageMarkdownFormat = (message: BizUIMessage) => {
}
// AI message component
export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(({ message }) => {
if (message.parts.length === 0) {
throw thenable
}
const getMessageMarkdownFormat = useMessageMarkdownFormat(message)
const handleCopy = React.useCallback(async () => {
const messageContent = getMessageMarkdownFormat()
try {
await copyToClipboard(messageContent)
toast.success("Message copied to clipboard")
} catch {
toast.error("Failed to copy message")
export const AIChatMessage: React.FC<AIChatMessageProps> = React.memo(
({ message, isLastMessage }) => {
if (message.parts.length === 0) {
throw thenable
}
}, [getMessageMarkdownFormat])
return (
<div onContextMenu={stopPropagation} className="group flex justify-start">
<div className="text-text relative flex max-w-full flex-col gap-2">
{/* Normal message display */}
<div className="text-text">
<div className="flex select-text flex-col gap-2 text-sm">
<AIMessageParts message={message} />
const getMessageMarkdownFormat = useMessageMarkdownFormat(message)
const handleCopy = React.useCallback(async () => {
const messageContent = getMessageMarkdownFormat()
try {
await copyToClipboard(messageContent)
toast.success("Message copied to clipboard")
} catch {
toast.error("Failed to copy message")
}
}, [getMessageMarkdownFormat])
return (
<div onContextMenu={stopPropagation} className="group flex justify-start">
<div className="text-text relative flex max-w-full flex-col gap-2">
{/* Normal message display */}
<div className="text-text">
<div className="flex select-text flex-col gap-2 text-sm">
<AIMessageParts message={message} isLastMessage={isLastMessage} />
</div>
</div>
</div>
{/* Action buttons */}
<div className="absolute -left-2 bottom-1 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<button
type="button"
onClick={handleCopy}
className="text-text-secondary hover:bg-fill-tertiary flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
title="Copy message"
>
<i className="i-mgc-copy-2-cute-re size-3" />
<span>Copy</span>
</button>
{message.metadata && (
<TokenUsagePill metadata={message.metadata}>
<button
type="button"
className="text-text-secondary hover:bg-fill-tertiary flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
>
<i className="i-mgc-information-cute-re size-3" />
</button>
</TokenUsagePill>
)}
</div>
{/* Action buttons */}
<div className="absolute -left-2 bottom-1 right-0 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<button
type="button"
onClick={handleCopy}
className="text-text-secondary hover:bg-fill-tertiary flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
title="Copy message"
>
<i className="i-mgc-copy-2-cute-re size-3" />
<span>Copy</span>
</button>
<div className="h-6" />
{message.metadata && (
<TokenUsagePill metadata={message.metadata}>
<button
type="button"
className="text-text-secondary hover:bg-fill-tertiary absolute right-0 flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors"
>
<i className="i-mgc-information-cute-re size-3" />
</button>
</TokenUsagePill>
)}
</div>
<div className="h-6" />
</div>
</div>
</div>
)
})
)
},
)
export const AIChatWaitingIndicator: React.FC = () => {
return (

View File

@ -1,5 +1,7 @@
import "@xyflow/react/dist/style.css"
import { alwaysFalse } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import type { ToolUIPart } from "ai"
import * as React from "react"
@ -29,98 +31,100 @@ const LazyAIDisplayFlowPart = React.lazy(() =>
interface AIMessagePartsProps {
message: BizUIMessage
isLastMessage: boolean
}
export const AIMessageParts: React.FC<AIMessagePartsProps> = React.memo(({ message }) => {
const [shouldStreamingAnimation, setShouldStreamingAnimation] = React.useState(false)
const chatStatus = useChatStatus()
export const AIMessageParts: React.FC<AIMessagePartsProps> = React.memo(
({ message, isLastMessage }) => {
const [shouldStreamingAnimation, setShouldStreamingAnimation] = React.useState(false)
const chatStatus = useChatStatus()
React.useEffect(() => {
// Delay 2s to set shouldStreamingAnimation
const timerId = setTimeout(() => {
setShouldStreamingAnimation(true)
}, 2000)
return () => clearTimeout(timerId)
}, [])
React.useEffect(() => {
// Delay 2s to set shouldStreamingAnimation
const timerId = setTimeout(() => {
setShouldStreamingAnimation(true)
}, 2000)
return () => clearTimeout(timerId)
}, [])
const shouldMessageAnimation = React.useMemo(() => {
return chatStatus === "streaming" && shouldStreamingAnimation
}, [chatStatus, shouldStreamingAnimation])
const shouldMessageAnimation = React.useMemo(() => {
return chatStatus === "streaming" && shouldStreamingAnimation && isLastMessage
}, [chatStatus, isLastMessage, shouldStreamingAnimation])
return message.parts.map((part, index) => {
const partKey = `${message.id}-${index}`
return message.parts.map((part, index) => {
const partKey = `${message.id}-${index}`
switch (part.type) {
case "text": {
return (
<AIMarkdownStreamingMessage
key={partKey}
text={part.text}
className={"text-text"}
isStreaming={shouldMessageAnimation}
/>
)
}
switch (part.type) {
case "text": {
return (
<AIMarkdownStreamingMessage
key={partKey}
text={part.text}
className={"text-text"}
isStreaming={shouldMessageAnimation}
/>
)
}
case "reasoning": {
return (
<AIReasoningPart
key={partKey}
text={part.text}
isStreaming={part.state === "streaming"}
/>
)
}
case "reasoning": {
return (
<AIReasoningPart
key={partKey}
text={part.text}
isStreaming={part.state === "streaming"}
/>
)
}
case "tool-displayAnalytics": {
return <AIDisplayAnalyticsPart key={partKey} part={part as AIDisplayAnalyticsTool} />
}
case "tool-displayEntries": {
return <AIDisplayEntriesPart key={partKey} part={part as AIDisplayEntriesTool} />
}
case "tool-displaySubscriptions": {
return (
<AIDisplaySubscriptionsPart key={partKey} part={part as AIDisplaySubscriptionsTool} />
)
}
case "tool-displayFeeds": {
return <AIDisplayFeedsPart key={partKey} part={part as AIDisplayFeedsTool} />
}
case "tool-displayAnalytics": {
return <AIDisplayAnalyticsPart key={partKey} part={part as AIDisplayAnalyticsTool} />
}
case "tool-displayEntries": {
return <AIDisplayEntriesPart key={partKey} part={part as AIDisplayEntriesTool} />
}
case "tool-displaySubscriptions": {
return (
<AIDisplaySubscriptionsPart key={partKey} part={part as AIDisplaySubscriptionsTool} />
)
}
case "tool-displayFeeds": {
return <AIDisplayFeedsPart key={partKey} part={part as AIDisplayFeedsTool} />
}
case "tool-displayFlowChart": {
const loadingElement = (
<div className="bg-material-medium my-2 flex aspect-[4/3] w-[calc(var(--ai-chat-layout-width,65ch))] max-w-full items-center justify-center rounded">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
<i className="i-mgc-loading-3-cute-re text-text-secondary size-4 animate-spin" />
<span className="text-text-secondary text-sm font-medium">
Generating Flow Chart...
</span>
case "tool-displayFlowChart": {
const loadingElement = (
<div className="bg-material-medium my-2 flex aspect-[4/3] w-[calc(var(--ai-chat-layout-width,65ch))] max-w-full items-center justify-center rounded">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
<i className="i-mgc-loading-3-cute-re text-text-secondary size-4 animate-spin" />
<span className="text-text-secondary text-sm font-medium">
Generating Flow Chart...
</span>
</div>
</div>
</div>
</div>
)
return (
<React.Suspense fallback={loadingElement} key={partKey}>
<LazyAIDisplayFlowPart
part={part as AIDisplayFlowTool}
loadingElement={loadingElement}
/>
</React.Suspense>
)
}
default: {
if (part.type.startsWith("tool-")) {
if (part.type.startsWith("tool-chunkBreak")) {
return null
}
return <ToolInvocationComponent key={partKey} part={part as ToolUIPart} />
)
return (
<ErrorBoundary key={partKey} beforeCapture={alwaysFalse}>
<React.Suspense fallback={loadingElement}>
<LazyAIDisplayFlowPart part={part as AIDisplayFlowTool} />
</React.Suspense>
</ErrorBoundary>
)
}
default: {
if (part.type.startsWith("tool-")) {
if (part.type.startsWith("tool-chunkBreak")) {
return null
}
return <ToolInvocationComponent key={partKey} part={part as ToolUIPart} />
}
return null
}
return null
}
}
})
})
})
},
)
AIMessageParts.displayName = "AIMessageParts"

View File

@ -1,4 +1,4 @@
export const DEFAULT_ANIMATION = "ft-typeIn 0.5s ease-in-out"
export const DEFAULT_ANIMATION = "mask-left-to-right 0.5s ease-in-out"
export const ANIMATION_STYLE = {
animation: DEFAULT_ANIMATION,
}

View File

@ -2,3 +2,5 @@ export const noop = () => {}
// eslint-disable-next-line unicorn/no-thenable
export const thenable: any = { then: noop }
export const emptyObject = {}
export const alwaysFalse = () => false

View File

@ -774,8 +774,8 @@ importers:
specifier: workspace:*
version: link:../../../../packages/internal/utils
'@folo-services/ai-tools':
specifier: 0.2.31
version: 0.2.31(@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.33
version: 0.2.33(@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
@ -4060,18 +4060,18 @@ packages:
'@follow-app/client-sdk@0.3.50':
resolution: {integrity: sha512-qc8gI5li/v62h1oR8p0bkLh+tlNaEP8jogppoP92v10XktERtd0rrx5grvSQ1pw0mM3Fws4CknEJZeGH9leDaw==}
'@folo-services/ai-tools@0.2.31':
resolution: {integrity: sha512-oqTVMBc6UCLOt/3M9GX4I+DJ8WT2U8Hlok1MN/Sruw8sQu0ZvtoDvkuiJEtssOQAGDifzHm7z8AVmOgNoGrKUg==}
'@folo-services/ai-tools@0.2.33':
resolution: {integrity: sha512-5Q35xDIT5dt/l1Dn0ApadD8l7NHK8rVJYqemHuENYvaUDPNJ8f7SXfXY5HwaMv/LpV20ln5DwMEHXwjtMdP75g==}
'@folo-services/constants@0.1.29':
resolution: {integrity: sha512-7YH5UQyUoT0STsoAKXTJgdAlvW88RKe5L/yqq4nbovSf2UJm1yEfl+opOYMeYEKBzX3OnixIipPL/1oKQPeh0w==}
'@folo-services/drizzle@0.1.22':
resolution: {integrity: sha512-n4L0bBA5AxqJn5K3k+BTzmtuZh90LAO7G6zALCo3H+BOsnMsQ1OCsQH/xFg7m6ICQ80LJVbHdUN6b4ZKUoQJ/g==}
'@folo-services/drizzle@0.1.23':
resolution: {integrity: sha512-Y4oGzfcYtsh9jdaDZvMh0CMO2tD1c2VSt1gtaZbq9uF17sv45empMI72OdGfWEJBJse7h/P+YGydX7iK63aoIQ==}
'@folo-services/drizzle@0.1.24':
resolution: {integrity: sha512-C2B32AuAIoPihwkowEzQVSCq9jz5gUJOXWSTsh46ICCXIQ+FeDLclGuOS13+3FQ79mGBslUqrRu5chpoeDLE/g==}
'@folo-services/exceptions@0.1.16':
resolution: {integrity: sha512-ThmLLe1Dv2CFsDIgcuY6kJhYJnjiQ7UEsewaqc25MkuImDabhzWnnad+3zRnJGLpveaMeEZ69uqUsTbKC+fR7g==}
@ -19018,10 +19018,10 @@ snapshots:
- sql.js
- sqlite3
'@folo-services/ai-tools@0.2.31(@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.33(@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.22(zod@3.25.76)
'@folo-services/drizzle': 0.1.22(@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.24(@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.26(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
@ -19061,7 +19061,7 @@ snapshots:
dependencies:
zod: 3.25.76
'@folo-services/drizzle@0.1.22(@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.23(@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.16
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)
@ -19100,7 +19100,7 @@ snapshots:
- sql.js
- sqlite3
'@folo-services/drizzle@0.1.23(@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.24(@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.16
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)