diff --git a/src/components/chat-mention-input.tsx b/src/components/chat-mention-input.tsx index e11382c31..d54bd1da7 100644 --- a/src/components/chat-mention-input.tsx +++ b/src/components/chat-mention-input.tsx @@ -366,6 +366,22 @@ export function ChatMentionInputSuggestion({ label = "table"; description = "Create a table"; break; + case DefaultToolName.CreateTimeline: + label = "timeline"; + description = "Create a timeline visualization"; + break; + case DefaultToolName.CreateSteps: + label = "steps"; + description = "Create a step-by-step guide"; + break; + case DefaultToolName.CreateImageGallery: + label = "image-gallery"; + description = "Create an image gallery"; + break; + case DefaultToolName.CreateCarousel: + label = "carousel"; + description = "Create a carousel"; + break; case DefaultToolName.WebSearch: label = "web-search"; description = "Search the web"; diff --git a/src/components/default-tool-icon.tsx b/src/components/default-tool-icon.tsx index 6676e4d13..b8092996d 100644 --- a/src/components/default-tool-icon.tsx +++ b/src/components/default-tool-icon.tsx @@ -10,6 +10,10 @@ import { CodeIcon, HammerIcon, TableOfContents, + LayoutList, + ListChecks, + Images, + GalleryHorizontalEnd, } from "lucide-react"; import { useMemo } from "react"; @@ -38,6 +42,22 @@ export function DefaultToolIcon({ ); } + if (name === DefaultToolName.CreateTimeline) { + return ; + } + if (name === DefaultToolName.CreateSteps) { + return ; + } + if (name === DefaultToolName.CreateImageGallery) { + return ; + } + if (name === DefaultToolName.CreateCarousel) { + return ( + + ); + } if (name === DefaultToolName.WebSearch) { return ; } diff --git a/src/components/message-parts.tsx b/src/components/message-parts.tsx index fedec6317..847e0281f 100644 --- a/src/components/message-parts.tsx +++ b/src/components/message-parts.tsx @@ -695,6 +695,47 @@ const InteractiveTable = dynamic( }, ); +const Timeline = dynamic( + () => import("./tool-invocation/timeline").then((mod) => mod.Timeline), + { + ssr: false, + loading, + }, +); + +const StepsInvocation = dynamic( + () => + import("./tool-invocation/steps-invocation").then( + (mod) => mod.StepsInvocation, + ), + { + ssr: false, + loading, + }, +); + +const GalleryInvocation = dynamic( + () => + import("./tool-invocation/gallery-invocation").then( + (mod) => mod.GalleryInvocation, + ), + { + ssr: false, + loading, + }, +); + +const CarouselInvocation = dynamic( + () => + import("./tool-invocation/carousel-invocation").then( + (mod) => mod.CarouselInvocation, + ), + { + ssr: false, + loading, + }, +); + const WebSearchToolInvocation = dynamic( () => import("./tool-invocation/web-search").then( @@ -926,6 +967,31 @@ export const ToolMessagePart = memo( {...(input as any)} /> ); + case DefaultToolName.CreateTimeline: + return ( + + ); + case DefaultToolName.CreateSteps: + return ( + + ); + case DefaultToolName.CreateImageGallery: + return ( + + ); + case DefaultToolName.CreateCarousel: + return ( + + ); } } return null; diff --git a/src/components/tool-invocation/carousel-invocation.tsx b/src/components/tool-invocation/carousel-invocation.tsx new file mode 100644 index 000000000..a9866485a --- /dev/null +++ b/src/components/tool-invocation/carousel-invocation.tsx @@ -0,0 +1,70 @@ +"use client"; + +import * as React from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; +import { JsonViewPopup } from "../json-view-popup"; +import { Markdown } from "@/components/markdown"; + +// Carousel invocation props interface matching tool schema +export interface CarouselInvocationProps { + title: string; + description?: string; + items: Array<{ + content: React.ReactNode; + id?: string; + }>; + itemsToScroll?: number; +} + +export function CarouselInvocation(props: CarouselInvocationProps) { + const { title, description, items, itemsToScroll = 1 } = props; + + return ( + + + + Carousel - {title} +
+ +
+
+ {description && {description}} +
+ + + + {items.map((item, index) => ( + +
+ {typeof item.content === "string" ? ( + {item.content} + ) : ( + item.content + )} +
+
+ ))} +
+ + +
+
+
+ ); +} diff --git a/src/components/tool-invocation/gallery-invocation.tsx b/src/components/tool-invocation/gallery-invocation.tsx new file mode 100644 index 000000000..5ca1b8e22 --- /dev/null +++ b/src/components/tool-invocation/gallery-invocation.tsx @@ -0,0 +1,40 @@ +"use client"; + +import * as React from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ImageGallery, type ImageItem } from "@/components/ui/image-gallery"; +import { JsonViewPopup } from "../json-view-popup"; + +// Gallery invocation props interface matching tool schema +export interface GalleryInvocationProps { + title: string; + description?: string; + images: ImageItem[]; +} + +export function GalleryInvocation(props: GalleryInvocationProps) { + const { title, description, images } = props; + + return ( + + + + Gallery - {title} +
+ +
+
+ {description && {description}} +
+ + + +
+ ); +} diff --git a/src/components/tool-invocation/steps-invocation.tsx b/src/components/tool-invocation/steps-invocation.tsx new file mode 100644 index 000000000..d11dfed13 --- /dev/null +++ b/src/components/tool-invocation/steps-invocation.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Steps, StepsItem } from "@/components/ui/steps"; +import { JsonViewPopup } from "../json-view-popup"; + +// Steps invocation props interface matching tool schema +export interface StepsInvocationProps { + title: string; + description?: string; + steps: Array<{ + title: string; + details: string; + number?: number; + }>; +} + +export function StepsInvocation(props: StepsInvocationProps) { + const { title, description, steps } = props; + + return ( + + + + Steps - {title} +
+ +
+
+ {description && {description}} +
+ + + {steps.map((step, index) => ( + + ))} + + +
+ ); +} diff --git a/src/components/tool-invocation/timeline.tsx b/src/components/tool-invocation/timeline.tsx new file mode 100644 index 000000000..a24487b35 --- /dev/null +++ b/src/components/tool-invocation/timeline.tsx @@ -0,0 +1,186 @@ +"use client"; + +import * as React from "react"; +import { motion } from "framer-motion"; +import { format, parseISO, formatDistanceToNow } from "date-fns"; +import * as LucideIcons from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { JsonViewPopup } from "../json-view-popup"; + +// Timeline component props interface matching tool schema +export interface TimelineProps { + title: string; + description?: string; + events: Array<{ + title: string; + description?: string; + timestamp: string; + status: "pending" | "in-progress" | "complete"; + icon?: string; + }>; +} + +// Status configurations +const statusConfig = { + complete: { + color: "bg-green-500", + ringColor: "ring-green-500/20", + textColor: "text-green-600 dark:text-green-400", + label: "Complete", + badgeVariant: "default" as const, + }, + "in-progress": { + color: "bg-blue-500", + ringColor: "ring-blue-500/20", + textColor: "text-blue-600 dark:text-blue-400", + label: "In Progress", + badgeVariant: "secondary" as const, + }, + pending: { + color: "bg-transparent border-2 border-gray-400 dark:border-gray-500", + ringColor: "ring-gray-400/20", + textColor: "text-gray-600 dark:text-gray-400", + label: "Pending", + badgeVariant: "outline" as const, + }, +}; + +// Animation variants for stagger effect +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.1, // 100ms delay between items + }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, x: -20 }, + visible: { + opacity: 1, + x: 0, + transition: { + duration: 0.4, + ease: "easeOut", + }, + }, +}; + +// Helper to format timestamp +function formatTimestamp(timestamp: string): string { + try { + const date = parseISO(timestamp); + const distance = formatDistanceToNow(date, { addSuffix: true }); + return distance; + } catch (_error) { + // If not ISO format, return as-is (e.g., "Yesterday", "2 hours ago") + return timestamp; + } +} + +// Helper to get Lucide icon component +function getIconComponent(iconName?: string) { + if (!iconName) return null; + + // Convert iconName to PascalCase if needed and get from lucide-react + const IconComponent = (LucideIcons as any)[iconName]; + return IconComponent || null; +} + +export function Timeline(props: TimelineProps) { + const { title, description, events } = props; + + return ( + + + + Timeline - {title} +
+ +
+
+ {description && {description}} +
+ + + {/* Vertical connecting line */} +
+ + {/* Timeline events */} +
+ {events.map((event, index) => { + const config = statusConfig[event.status]; + const IconComponent = getIconComponent(event.icon); + const _isLast = index === events.length - 1; + + return ( + + {/* Status indicator dot */} +
+ {IconComponent && ( + + )} +
+ + {/* Event content */} +
+
+

+ {event.title} +

+ + {config.label} + +
+ + {event.description && ( +

+ {event.description} +

+ )} + +

+ {formatTimestamp(event.timestamp)} +

+
+
+ ); + })} +
+ + + + ); +} diff --git a/src/components/ui/image-gallery.tsx b/src/components/ui/image-gallery.tsx new file mode 100644 index 000000000..1867c59c3 --- /dev/null +++ b/src/components/ui/image-gallery.tsx @@ -0,0 +1,123 @@ +"use client"; + +import * as React from "react"; +import { useState, useMemo, useCallback } from "react"; +import { motion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import { ImageGalleryModal } from "./image-gallery-modal"; + +export interface ImageItem { + src: string; + alt?: string; + details?: string; +} + +export interface ImageGalleryProps { + images: ImageItem[]; + className?: string; +} + +// Animation variants +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.08, + }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, scale: 0.95 }, + visible: { + opacity: 1, + scale: 1, + transition: { + duration: 0.3, + ease: "easeOut" as const, + }, + }, +}; + +// Get grid layout class based on image count +function getLayoutClassName(imageCount: number): string { + switch (imageCount) { + case 1: + return "grid-cols-1"; + case 2: + return "grid-cols-2"; + case 3: + return "grid-cols-2 grid-rows-2"; + case 4: + return "grid-cols-2 grid-rows-2"; + default: + return "grid-cols-3"; + } +} + +export const ImageGallery: React.FC = ({ + images, + className, +}) => { + const [selectedImageIndex, setSelectedImageIndex] = useState( + null, + ); + + const layoutClassName = useMemo( + () => getLayoutClassName(images.length), + [images.length], + ); + + const handleImageClick = useCallback((index: number) => { + setSelectedImageIndex(index); + }, []); + + const handleCloseModal = useCallback(() => { + setSelectedImageIndex(null); + }, []); + + return ( + <> + +
+ {images.map((image, index) => ( + handleImageClick(index)} + > + {image.alt + + ))} +
+
+ + {selectedImageIndex !== null && ( + + )} + + ); +}; + +ImageGallery.displayName = "ImageGallery"; diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index 233683d7a..d92425e8f 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -10,6 +10,10 @@ export enum DefaultToolName { CreateBarChart = "createBarChart", CreateLineChart = "createLineChart", CreateTable = "createTable", + CreateTimeline = "createTimeline", + CreateSteps = "createSteps", + CreateImageGallery = "createImageGallery", + CreateCarousel = "createCarousel", WebSearch = "webSearch", WebContent = "webContent", Http = "http", diff --git a/src/lib/ai/tools/tool-kit.ts b/src/lib/ai/tools/tool-kit.ts index 22623a8e6..cde3df7ef 100644 --- a/src/lib/ai/tools/tool-kit.ts +++ b/src/lib/ai/tools/tool-kit.ts @@ -2,6 +2,10 @@ import { createPieChartTool } from "./visualization/create-pie-chart"; import { createBarChartTool } from "./visualization/create-bar-chart"; import { createLineChartTool } from "./visualization/create-line-chart"; import { createTableTool } from "./visualization/create-table"; +import { createTimelineTool } from "./visualization/create-timeline"; +import { createStepsTool } from "./visualization/create-steps"; +import { createImageGalleryTool } from "./visualization/create-image-gallery"; +import { createCarouselTool } from "./visualization/create-carousel"; import { exaSearchTool, exaContentsTool } from "./web/web-search"; import { AppDefaultToolkit, DefaultToolName } from "."; import { Tool } from "ai"; @@ -18,6 +22,10 @@ export const APP_DEFAULT_TOOL_KIT: Record< [DefaultToolName.CreateBarChart]: createBarChartTool, [DefaultToolName.CreateLineChart]: createLineChartTool, [DefaultToolName.CreateTable]: createTableTool, + [DefaultToolName.CreateTimeline]: createTimelineTool, + [DefaultToolName.CreateSteps]: createStepsTool, + [DefaultToolName.CreateImageGallery]: createImageGalleryTool, + [DefaultToolName.CreateCarousel]: createCarouselTool, }, [AppDefaultToolkit.WebSearch]: { [DefaultToolName.WebSearch]: exaSearchTool, diff --git a/src/lib/ai/tools/visualization/create-carousel.ts b/src/lib/ai/tools/visualization/create-carousel.ts new file mode 100644 index 000000000..4cfe9883c --- /dev/null +++ b/src/lib/ai/tools/visualization/create-carousel.ts @@ -0,0 +1,42 @@ +import { tool as createTool } from "ai"; +import { z } from "zod"; + +export const createCarouselTool = createTool({ + description: + "Create a horizontal scrolling carousel for displaying multiple items side-by-side. Supports markdown formatting including images, links, and text formatting. Use for product showcases, feature highlights, testimonials, pricing tiers, or any content where users benefit from browsing items horizontally with smooth scrolling and navigation buttons.", + inputSchema: z.object({ + title: z + .string() + .describe( + "Title for the carousel (e.g., 'Featured Products', 'Customer Reviews')", + ), + description: z + .string() + .optional() + .describe("Optional description or context for the carousel"), + items: z + .array( + z.object({ + content: z + .string() + .describe( + "Content for this carousel item. Supports markdown including images (![alt](url)), links ([text](url)), bold, italic, etc.", + ), + id: z + .string() + .optional() + .describe("Optional unique identifier for the item"), + }), + ) + .describe("Array of items to display in the carousel"), + itemsToScroll: z + .number() + .optional() + .describe( + "Optional: how many items to scroll when clicking prev/next buttons (defaults to 1)", + ), + }), + execute: async () => { + return "Success"; + }, +}); diff --git a/src/lib/ai/tools/visualization/create-image-gallery.ts b/src/lib/ai/tools/visualization/create-image-gallery.ts new file mode 100644 index 000000000..75d611571 --- /dev/null +++ b/src/lib/ai/tools/visualization/create-image-gallery.ts @@ -0,0 +1,38 @@ +import { tool as createTool } from "ai"; +import { z } from "zod"; + +export const createImageGalleryTool = createTool({ + description: + "Create an image gallery with responsive grid layouts and modal lightbox. Use for displaying collections of images like product photos, portfolios, team members, before/after comparisons, or any visual content that benefits from being viewable in detail. Users can click images to view full-size with keyboard navigation.", + inputSchema: z.object({ + title: z + .string() + .describe( + "Title for the gallery (e.g., 'Product Variations', 'Architecture Examples')", + ), + description: z + .string() + .optional() + .describe("Optional description or context for the gallery"), + images: z + .array( + z.object({ + src: z.string().describe("Image URL (must be publicly accessible)"), + alt: z + .string() + .optional() + .describe("Alt text describing the image for accessibility"), + details: z + .string() + .optional() + .describe( + "Optional caption or details shown in the lightbox modal", + ), + }), + ) + .describe("Array of images to display in the gallery"), + }), + execute: async () => { + return "Success"; + }, +}); diff --git a/src/lib/ai/tools/visualization/create-steps.ts b/src/lib/ai/tools/visualization/create-steps.ts new file mode 100644 index 000000000..2f25aea25 --- /dev/null +++ b/src/lib/ai/tools/visualization/create-steps.ts @@ -0,0 +1,33 @@ +import { tool as createTool } from "ai"; +import { z } from "zod"; + +export const createStepsTool = createTool({ + description: + "Create a vertical steps visualization showing sequential instructions or procedures. Use for recipes, tutorials, setup guides, workflows, or any ordered list of instructions where users need to follow steps in sequence.", + inputSchema: z.object({ + title: z + .string() + .describe("Title for the steps (e.g., 'How to Deploy App')"), + description: z + .string() + .optional() + .describe("Optional description or context for the steps"), + steps: z + .array( + z.object({ + title: z.string().describe("Step title or heading"), + details: z.string().describe("Detailed instructions for this step"), + number: z + .number() + .optional() + .describe( + "Optional custom step number (defaults to auto-numbering)", + ), + }), + ) + .describe("Array of steps in sequential order"), + }), + execute: async () => { + return "Success"; + }, +}); diff --git a/src/lib/ai/tools/visualization/create-timeline.ts b/src/lib/ai/tools/visualization/create-timeline.ts new file mode 100644 index 000000000..30e2fec9a --- /dev/null +++ b/src/lib/ai/tools/visualization/create-timeline.ts @@ -0,0 +1,45 @@ +import { tool as createTool } from "ai"; + +import { z } from "zod"; + +export const createTimelineTool = createTool({ + description: + "Create a timeline visualization showing chronological events with status indicators. Use for project milestones, event chronologies, process steps, audit trails, or any time-based sequential data with status tracking (pending/in-progress/complete).", + inputSchema: z.object({ + title: z.string().describe("Title for the timeline"), + description: z + .string() + .optional() + .describe("Optional description or context for the timeline"), + events: z + .array( + z.object({ + title: z.string().describe("Event title"), + description: z + .string() + .optional() + .describe("Optional detailed description of the event"), + timestamp: z + .string() + .describe( + "ISO 8601 timestamp or relative time string (e.g., '2024-01-15T10:00:00Z' or 'Yesterday')", + ), + status: z + .enum(["pending", "in-progress", "complete"]) + .describe( + "Event status: pending (gray outline), in-progress (blue), complete (green)", + ), + icon: z + .string() + .optional() + .describe( + "Optional Lucide icon name (e.g., 'CheckCircle', 'Clock', 'Zap')", + ), + }), + ) + .describe("Array of timeline events in chronological order"), + }), + execute: async () => { + return "Success"; + }, +});