diff --git a/llama_stack/ui/app/chat-playground/page.tsx b/llama_stack/ui/app/chat-playground/page.tsx index 9f50a1bdb..40f9a91dc 100644 --- a/llama_stack/ui/app/chat-playground/page.tsx +++ b/llama_stack/ui/app/chat-playground/page.tsx @@ -1,42 +1,34 @@ "use client"; -import { useState, useRef, useEffect } from "react"; -import { ChatMessage } from "@/lib/types"; -import { ChatMessageItem } from "@/components/chat-completions/chat-messasge-item"; -import { Input } from "@/components/ui/input"; +import { useState, useEffect } from "react"; +import { flushSync } from "react-dom"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Send, Loader2, ChevronDown } from "lucide-react"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Chat } from "@/components/ui/chat"; +import { type Message } from "@/components/ui/chat-message"; import { useAuthClient } from "@/hooks/use-auth-client"; import type { CompletionCreateParams } from "llama-stack-client/resources/chat/completions"; import type { Model } from "llama-stack-client/resources/models"; export default function ChatPlaygroundPage() { - const [messages, setMessages] = useState([]); - const [inputMessage, setInputMessage] = useState(""); - const [isLoading, setIsLoading] = useState(false); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(""); const [modelsLoading, setModelsLoading] = useState(true); const [modelsError, setModelsError] = useState(null); - const messagesEndRef = useRef(null); const client = useAuthClient(); + const isModelsLoading = modelsLoading ?? true; - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); useEffect(() => { const fetchModels = async () => { @@ -60,76 +52,119 @@ export default function ChatPlaygroundPage() { fetchModels(); }, [client]); - const extractTextContent = (content: any): string => { + const extractTextContent = (content: unknown): string => { if (typeof content === 'string') { return content; } if (Array.isArray(content)) { return content - .filter(item => item.type === 'text') - .map(item => item.text) + .filter(item => item && typeof item === 'object' && 'type' in item && item.type === 'text') + .map(item => (item && typeof item === 'object' && 'text' in item) ? String(item.text) : '') .join(''); } - if (content && content.type === 'text') { - return content.text || ''; + if (content && typeof content === 'object' && 'type' in content && content.type === 'text' && 'text' in content) { + return String(content.text) || ''; } return ''; }; - const handleSendMessage = async () => { - if (!inputMessage.trim() || isLoading || !selectedModel) return; - - const userMessage: ChatMessage = { - role: "user", - content: inputMessage.trim(), - }; - - setMessages(prev => [...prev, userMessage]); - setInputMessage(""); - setIsLoading(true); - setError(null); - - try { - const messageParams: CompletionCreateParams["messages"] = [...messages, userMessage].map(msg => { - const content = typeof msg.content === 'string' ? msg.content : extractTextContent(msg.content); - if (msg.role === "user") { - return { role: "user" as const, content }; - } else if (msg.role === "assistant") { - return { role: "assistant" as const, content }; - } else { - return { role: "system" as const, content }; - } - }); - - const response = await client.chat.completions.create({ - model: selectedModel, - messages: messageParams, - stream: false, - }); - - if ('choices' in response && response.choices && response.choices.length > 0) { - const choice = response.choices[0]; - if ('message' in choice && choice.message) { - const assistantMessage: ChatMessage = { - role: "assistant", - content: extractTextContent(choice.message.content), - }; - setMessages(prev => [...prev, assistantMessage]); - } - } - } catch (err) { - console.error("Error sending message:", err); - setError("Failed to send message. Please try again."); - } finally { - setIsLoading(false); - } + const handleInputChange = (e: React.ChangeEvent) => { + setInput(e.target.value); }; - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSendMessage(); +const handleSubmit = async (event?: { preventDefault?: () => void }) => { + event?.preventDefault?.(); + if (!input.trim()) return; + + // Add user message to chat + const userMessage: Message = { + id: Date.now().toString(), + role: "user", + content: input.trim(), + createdAt: new Date(), + }; + + setMessages(prev => [...prev, userMessage]); + setInput(""); + + // Use the helper function with the content + await handleSubmitWithContent(userMessage.content); +}; + +const handleSubmitWithContent = async (content: string) => { + setIsGenerating(true); + setError(null); + + try { + const messageParams: CompletionCreateParams["messages"] = [ + ...messages.map(msg => { + const msgContent = typeof msg.content === 'string' ? msg.content : extractTextContent(msg.content); + if (msg.role === "user") { + return { role: "user" as const, content: msgContent }; + } else if (msg.role === "assistant") { + return { role: "assistant" as const, content: msgContent }; + } else { + return { role: "system" as const, content: msgContent }; + } + }), + { role: "user" as const, content } + ]; + + const response = await client.chat.completions.create({ + model: selectedModel, + messages: messageParams, + stream: true, + }); + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: "assistant", + content: "", + createdAt: new Date(), + }; + + setMessages(prev => [...prev, assistantMessage]); + let fullContent = ""; + for await (const chunk of response) { + if (chunk.choices && chunk.choices[0]?.delta?.content) { + const deltaContent = chunk.choices[0].delta.content; + fullContent += deltaContent; + + flushSync(() => { + setMessages(prev => { + const newMessages = [...prev]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage.role === "assistant") { + lastMessage.content = fullContent; + } + return newMessages; + }); + }); + } } + } catch (err) { + console.error("Error sending message:", err); + setError("Failed to send message. Please try again."); + setMessages(prev => prev.slice(0, -1)); + } finally { + setIsGenerating(false); + } +}; + const suggestions = [ + "What is the weather in San Francisco?", + "Explain step-by-step how to solve this math problem: If x² + 6x + 9 = 25, what is x?", + "Design a simple algorithm to find the longest palindrome in a string.", + ]; + + const append = (message: { role: "user"; content: string }) => { + const newMessage: Message = { + id: Date.now().toString(), + role: message.role, + content: message.content, + createdAt: new Date(), + }; + setMessages(prev => [...prev, newMessage]) + handleSubmitWithContent(newMessage.content); }; const clearChat = () => { @@ -141,102 +176,48 @@ export default function ChatPlaygroundPage() {

Chat Playground

-
- - - - - +
+ +
- - - Chat Messages - - -
- {messages.length === 0 ? ( -
-

Start a conversation by typing a message below.

-
- ) : ( - messages.map((message, index) => ( - - )) - )} - {isLoading && ( -
- - Thinking... -
- )} -
-
+ {modelsError && ( +
+

{modelsError}

+
+ )} - {modelsError && ( -
-

{modelsError}

-
- )} + {error && ( +
+

{error}

+
+ )} - {error && ( -
-

{error}

-
- )} - -
- setInputMessage(e.target.value)} - onKeyPress={handleKeyPress} - placeholder="Type your message here..." - disabled={isLoading} - className="flex-1" - /> - -
- - +
); } diff --git a/llama_stack/ui/components/ui/audio-visualizer.tsx b/llama_stack/ui/components/ui/audio-visualizer.tsx new file mode 100644 index 000000000..e1c23c57b --- /dev/null +++ b/llama_stack/ui/components/ui/audio-visualizer.tsx @@ -0,0 +1,198 @@ +"use client" + +import { useEffect, useRef } from "react" + +// Configuration constants for the audio analyzer +const AUDIO_CONFIG = { + FFT_SIZE: 512, + SMOOTHING: 0.8, + MIN_BAR_HEIGHT: 2, + MIN_BAR_WIDTH: 2, + BAR_SPACING: 1, + COLOR: { + MIN_INTENSITY: 100, // Minimum gray value (darker) + MAX_INTENSITY: 255, // Maximum gray value (brighter) + INTENSITY_RANGE: 155, // MAX_INTENSITY - MIN_INTENSITY + }, +} as const + +interface AudioVisualizerProps { + stream: MediaStream | null + isRecording: boolean + onClick: () => void +} + +export function AudioVisualizer({ + stream, + isRecording, + onClick, +}: AudioVisualizerProps) { + // Refs for managing audio context and animation + const canvasRef = useRef(null) + const audioContextRef = useRef(null) + const analyserRef = useRef(null) + const animationFrameRef = useRef() + const containerRef = useRef(null) + + // Cleanup function to stop visualization and close audio context + const cleanup = () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + if (audioContextRef.current) { + audioContextRef.current.close() + } + } + + // Cleanup on unmount + useEffect(() => { + return cleanup + }, []) + + // Start or stop visualization based on recording state + useEffect(() => { + if (stream && isRecording) { + startVisualization() + } else { + cleanup() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stream, isRecording]) + + // Handle window resize + useEffect(() => { + const handleResize = () => { + if (canvasRef.current && containerRef.current) { + const container = containerRef.current + const canvas = canvasRef.current + const dpr = window.devicePixelRatio || 1 + + // Set canvas size based on container and device pixel ratio + const rect = container.getBoundingClientRect() + // Account for the 2px total margin (1px on each side) + canvas.width = (rect.width - 2) * dpr + canvas.height = (rect.height - 2) * dpr + + // Scale canvas CSS size to match container minus margins + canvas.style.width = `${rect.width - 2}px` + canvas.style.height = `${rect.height - 2}px` + } + } + + window.addEventListener("resize", handleResize) + // Initial setup + handleResize() + + return () => window.removeEventListener("resize", handleResize) + }, []) + + // Initialize audio context and start visualization + const startVisualization = async () => { + try { + const audioContext = new AudioContext() + audioContextRef.current = audioContext + + const analyser = audioContext.createAnalyser() + analyser.fftSize = AUDIO_CONFIG.FFT_SIZE + analyser.smoothingTimeConstant = AUDIO_CONFIG.SMOOTHING + analyserRef.current = analyser + + const source = audioContext.createMediaStreamSource(stream!) + source.connect(analyser) + + draw() + } catch (error) { + console.error("Error starting visualization:", error) + } + } + + // Calculate the color intensity based on bar height + const getBarColor = (normalizedHeight: number) => { + const intensity = + Math.floor(normalizedHeight * AUDIO_CONFIG.COLOR.INTENSITY_RANGE) + + AUDIO_CONFIG.COLOR.MIN_INTENSITY + return `rgb(${intensity}, ${intensity}, ${intensity})` + } + + // Draw a single bar of the visualizer + const drawBar = ( + ctx: CanvasRenderingContext2D, + x: number, + centerY: number, + width: number, + height: number, + color: string + ) => { + ctx.fillStyle = color + // Draw upper bar (above center) + ctx.fillRect(x, centerY - height, width, height) + // Draw lower bar (below center) + ctx.fillRect(x, centerY, width, height) + } + + // Main drawing function + const draw = () => { + if (!isRecording) return + + const canvas = canvasRef.current + const ctx = canvas?.getContext("2d") + if (!canvas || !ctx || !analyserRef.current) return + + const dpr = window.devicePixelRatio || 1 + ctx.scale(dpr, dpr) + + const analyser = analyserRef.current + const bufferLength = analyser.frequencyBinCount + const frequencyData = new Uint8Array(bufferLength) + + const drawFrame = () => { + animationFrameRef.current = requestAnimationFrame(drawFrame) + + // Get current frequency data + analyser.getByteFrequencyData(frequencyData) + + // Clear canvas - use CSS pixels for clearing + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr) + + // Calculate dimensions in CSS pixels + const barWidth = Math.max( + AUDIO_CONFIG.MIN_BAR_WIDTH, + canvas.width / dpr / bufferLength - AUDIO_CONFIG.BAR_SPACING + ) + const centerY = canvas.height / dpr / 2 + let x = 0 + + // Draw each frequency bar + for (let i = 0; i < bufferLength; i++) { + const normalizedHeight = frequencyData[i] / 255 // Convert to 0-1 range + const barHeight = Math.max( + AUDIO_CONFIG.MIN_BAR_HEIGHT, + normalizedHeight * centerY + ) + + drawBar( + ctx, + x, + centerY, + barWidth, + barHeight, + getBarColor(normalizedHeight) + ) + + x += barWidth + AUDIO_CONFIG.BAR_SPACING + } + } + + drawFrame() + } + + return ( +
+ +
+ ) +} diff --git a/llama_stack/ui/components/ui/button.tsx b/llama_stack/ui/components/ui/button.tsx index 2adaf00da..a2df8dce6 100644 --- a/llama_stack/ui/components/ui/button.tsx +++ b/llama_stack/ui/components/ui/button.tsx @@ -1,8 +1,8 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", @@ -32,8 +32,8 @@ const buttonVariants = cva( variant: "default", size: "default", }, - }, -); + } +) function Button({ className, @@ -43,9 +43,9 @@ function Button({ ...props }: React.ComponentProps<"button"> & VariantProps & { - asChild?: boolean; + asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; + const Comp = asChild ? Slot : "button" return ( - ); + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/llama_stack/ui/components/ui/chat-message.tsx b/llama_stack/ui/components/ui/chat-message.tsx new file mode 100644 index 000000000..a7678ea83 --- /dev/null +++ b/llama_stack/ui/components/ui/chat-message.tsx @@ -0,0 +1,405 @@ +"use client" + +import React, { useMemo, useState } from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { motion } from "framer-motion" +import { Ban, ChevronRight, Code2, Loader2, Terminal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { FilePreview } from "@/components/ui/file-preview" +import { MarkdownRenderer } from "@/components/ui/markdown-renderer" + +const chatBubbleVariants = cva( + "group/message relative break-words rounded-lg p-3 text-sm sm:max-w-[70%]", + { + variants: { + isUser: { + true: "bg-primary text-primary-foreground", + false: "bg-muted text-foreground", + }, + animation: { + none: "", + slide: "duration-300 animate-in fade-in-0", + scale: "duration-300 animate-in fade-in-0 zoom-in-75", + fade: "duration-500 animate-in fade-in-0", + }, + }, + compoundVariants: [ + { + isUser: true, + animation: "slide", + class: "slide-in-from-right", + }, + { + isUser: false, + animation: "slide", + class: "slide-in-from-left", + }, + { + isUser: true, + animation: "scale", + class: "origin-bottom-right", + }, + { + isUser: false, + animation: "scale", + class: "origin-bottom-left", + }, + ], + } +) + +type Animation = VariantProps["animation"] + +interface Attachment { + name?: string + contentType?: string + url: string +} + +interface PartialToolCall { + state: "partial-call" + toolName: string +} + +interface ToolCall { + state: "call" + toolName: string +} + +interface ToolResult { + state: "result" + toolName: string + result: { + __cancelled?: boolean + [key: string]: any + } +} + +type ToolInvocation = PartialToolCall | ToolCall | ToolResult + +interface ReasoningPart { + type: "reasoning" + reasoning: string +} + +interface ToolInvocationPart { + type: "tool-invocation" + toolInvocation: ToolInvocation +} + +interface TextPart { + type: "text" + text: string +} + +// For compatibility with AI SDK types, not used +interface SourcePart { + type: "source" + source?: any +} + +interface FilePart { + type: "file" + mimeType: string + data: string +} + +interface StepStartPart { + type: "step-start" +} + +type MessagePart = + | TextPart + | ReasoningPart + | ToolInvocationPart + | SourcePart + | FilePart + | StepStartPart + +export interface Message { + id: string + role: "user" | "assistant" | (string & {}) + content: string + createdAt?: Date + experimental_attachments?: Attachment[] + toolInvocations?: ToolInvocation[] + parts?: MessagePart[] +} + +export interface ChatMessageProps extends Message { + showTimeStamp?: boolean + animation?: Animation + actions?: React.ReactNode +} + +export const ChatMessage: React.FC = ({ + role, + content, + createdAt, + showTimeStamp = false, + animation = "scale", + actions, + experimental_attachments, + toolInvocations, + parts, +}) => { + const files = useMemo(() => { + return experimental_attachments?.map((attachment) => { + const dataArray = dataUrlToUint8Array(attachment.url) + const file = new File([dataArray], attachment.name ?? "Unknown", { + type: attachment.contentType, + }) + return file + }) + }, [experimental_attachments]) + + const isUser = role === "user" + + const formattedTime = createdAt?.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + }) + + if (isUser) { + return ( +
+ {files ? ( +
+ {files.map((file, index) => { + return + })} +
+ ) : null} + +
+ {content} +
+ + {showTimeStamp && createdAt ? ( + + ) : null} +
+ ) + } + + if (parts && parts.length > 0) { + return parts.map((part, index) => { + if (part.type === "text") { + return ( +
+
+ {part.text} + {actions ? ( +
+ {actions} +
+ ) : null} +
+ + {showTimeStamp && createdAt ? ( + + ) : null} +
+ ) + } else if (part.type === "reasoning") { + return + } else if (part.type === "tool-invocation") { + return ( + + ) + } + return null + }) + } + + if (toolInvocations && toolInvocations.length > 0) { + return + } + + return ( +
+
+ {content} + {actions ? ( +
+ {actions} +
+ ) : null} +
+ + {showTimeStamp && createdAt ? ( + + ) : null} +
+ ) +} + +function dataUrlToUint8Array(data: string) { + const base64 = data.split(",")[1] + const buf = Buffer.from(base64, "base64") + return new Uint8Array(buf) +} + +const ReasoningBlock = ({ part }: { part: ReasoningPart }) => { + const [isOpen, setIsOpen] = useState(false) + + return ( +
+ +
+ + + +
+ + +
+
+ {part.reasoning} +
+
+
+
+
+
+ ) +} + +function ToolCall({ + toolInvocations, +}: Pick) { + if (!toolInvocations?.length) return null + + return ( +
+ {toolInvocations.map((invocation, index) => { + const isCancelled = + invocation.state === "result" && + invocation.result.__cancelled === true + + if (isCancelled) { + return ( +
+ + + Cancelled{" "} + + {"`"} + {invocation.toolName} + {"`"} + + +
+ ) + } + + switch (invocation.state) { + case "partial-call": + case "call": + return ( +
+ + + Calling{" "} + + {"`"} + {invocation.toolName} + {"`"} + + ... + + +
+ ) + case "result": + return ( +
+
+ + + Result from{" "} + + {"`"} + {invocation.toolName} + {"`"} + + +
+
+                  {JSON.stringify(invocation.result, null, 2)}
+                
+
+ ) + default: + return null + } + })} +
+ ) +} diff --git a/llama_stack/ui/components/ui/chat.tsx b/llama_stack/ui/components/ui/chat.tsx new file mode 100644 index 000000000..21cd3dc01 --- /dev/null +++ b/llama_stack/ui/components/ui/chat.tsx @@ -0,0 +1,349 @@ +"use client" + +import { + forwardRef, + useCallback, + useRef, + useState, + type ReactElement, +} from "react" +import { ArrowDown, ThumbsDown, ThumbsUp } from "lucide-react" + +import { cn } from "@/lib/utils" +import { useAutoScroll } from "@/hooks/use-auto-scroll" +import { Button } from "@/components/ui/button" +import { type Message } from "@/components/ui/chat-message" +import { CopyButton } from "@/components/ui/copy-button" +import { MessageInput } from "@/components/ui/message-input" +import { MessageList } from "@/components/ui/message-list" +import { PromptSuggestions } from "@/components/ui/prompt-suggestions" + +interface ChatPropsBase { + handleSubmit: ( + event?: { preventDefault?: () => void }, + options?: { experimental_attachments?: FileList } + ) => void + messages: Array + input: string + className?: string + handleInputChange: React.ChangeEventHandler + isGenerating: boolean + stop?: () => void + onRateResponse?: ( + messageId: string, + rating: "thumbs-up" | "thumbs-down" + ) => void + setMessages?: (messages: any[]) => void + transcribeAudio?: (blob: Blob) => Promise +} + +interface ChatPropsWithoutSuggestions extends ChatPropsBase { + append?: never + suggestions?: never +} + +interface ChatPropsWithSuggestions extends ChatPropsBase { + append: (message: { role: "user"; content: string }) => void + suggestions: string[] +} + +type ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions + +export function Chat({ + messages, + handleSubmit, + input, + handleInputChange, + stop, + isGenerating, + append, + suggestions, + className, + onRateResponse, + setMessages, + transcribeAudio, +}: ChatProps) { + const lastMessage = messages.at(-1) + const isEmpty = messages.length === 0 + const isTyping = lastMessage?.role === "user" + + const messagesRef = useRef(messages) + messagesRef.current = messages + + // Enhanced stop function that marks pending tool calls as cancelled + const handleStop = useCallback(() => { + stop?.() + + if (!setMessages) return + + const latestMessages = [...messagesRef.current] + const lastAssistantMessage = latestMessages.findLast( + (m) => m.role === "assistant" + ) + + if (!lastAssistantMessage) return + + let needsUpdate = false + let updatedMessage = { ...lastAssistantMessage } + + if (lastAssistantMessage.toolInvocations) { + const updatedToolInvocations = lastAssistantMessage.toolInvocations.map( + (toolInvocation) => { + if (toolInvocation.state === "call") { + needsUpdate = true + return { + ...toolInvocation, + state: "result", + result: { + content: "Tool execution was cancelled", + __cancelled: true, // Special marker to indicate cancellation + }, + } as const + } + return toolInvocation + } + ) + + if (needsUpdate) { + updatedMessage = { + ...updatedMessage, + toolInvocations: updatedToolInvocations, + } + } + } + + if (lastAssistantMessage.parts && lastAssistantMessage.parts.length > 0) { + const updatedParts = lastAssistantMessage.parts.map((part: any) => { + if ( + part.type === "tool-invocation" && + part.toolInvocation && + part.toolInvocation.state === "call" + ) { + needsUpdate = true + return { + ...part, + toolInvocation: { + ...part.toolInvocation, + state: "result", + result: { + content: "Tool execution was cancelled", + __cancelled: true, + }, + }, + } + } + return part + }) + + if (needsUpdate) { + updatedMessage = { + ...updatedMessage, + parts: updatedParts, + } + } + } + + if (needsUpdate) { + const messageIndex = latestMessages.findIndex( + (m) => m.id === lastAssistantMessage.id + ) + if (messageIndex !== -1) { + latestMessages[messageIndex] = updatedMessage + setMessages(latestMessages) + } + } + }, [stop, setMessages, messagesRef]) + + const messageOptions = useCallback( + (message: Message) => ({ + actions: onRateResponse ? ( + <> +
+ +
+ + + + ) : ( + + ), + }), + [onRateResponse] + ) + + return ( + +
+ {isEmpty && append && suggestions ? ( +
+ +
+ ) : null} + + {messages.length > 0 ? ( + + + + ) : null} +
+ +
+
+ + {({ files, setFiles }) => ( + + )} + +
+
+
+ ) +} +Chat.displayName = "Chat" + +export function ChatMessages({ + messages, + children, +}: React.PropsWithChildren<{ + messages: Message[] +}>) { + const { + containerRef, + scrollToBottom, + handleScroll, + shouldAutoScroll, + handleTouchStart, + } = useAutoScroll([messages]) + + return ( +
+
+ {children} +
+ + {!shouldAutoScroll && ( +
+
+ +
+
+ )} +
+ ) +} + +export const ChatContainer = forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + return ( +
+ ) +}) +ChatContainer.displayName = "ChatContainer" + +interface ChatFormProps { + className?: string + isPending: boolean + handleSubmit: ( + event?: { preventDefault?: () => void }, + options?: { experimental_attachments?: FileList } + ) => void + children: (props: { + files: File[] | null + setFiles: React.Dispatch> + }) => ReactElement +} + +export const ChatForm = forwardRef( + ({ children, handleSubmit, isPending, className }, ref) => { + const [files, setFiles] = useState(null) + + const onSubmit = (event: React.FormEvent) => { + // if (isPending) { + // event.preventDefault() + // return + // } + + if (!files) { + handleSubmit(event) + return + } + + const fileList = createFileList(files) + handleSubmit(event, { experimental_attachments: fileList }) + setFiles(null) + } + + return ( +
+ {children({ files, setFiles })} +
+ ) + } +) +ChatForm.displayName = "ChatForm" + +function createFileList(files: File[] | FileList): FileList { + const dataTransfer = new DataTransfer() + for (const file of Array.from(files)) { + dataTransfer.items.add(file) + } + return dataTransfer.files +} diff --git a/llama_stack/ui/components/ui/collapsible.tsx b/llama_stack/ui/components/ui/collapsible.tsx new file mode 100644 index 000000000..ae9fad04a --- /dev/null +++ b/llama_stack/ui/components/ui/collapsible.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/llama_stack/ui/components/ui/copy-button.tsx b/llama_stack/ui/components/ui/copy-button.tsx new file mode 100644 index 000000000..51d2ca2d4 --- /dev/null +++ b/llama_stack/ui/components/ui/copy-button.tsx @@ -0,0 +1,44 @@ +"use client" + +import { Check, Copy } from "lucide-react" + +import { cn } from "@/lib/utils" +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" +import { Button } from "@/components/ui/button" + +type CopyButtonProps = { + content: string + copyMessage?: string +} + +export function CopyButton({ content, copyMessage }: CopyButtonProps) { + const { isCopied, handleCopy } = useCopyToClipboard({ + text: content, + copyMessage, + }) + + return ( + + ) +} diff --git a/llama_stack/ui/components/ui/file-preview.tsx b/llama_stack/ui/components/ui/file-preview.tsx new file mode 100644 index 000000000..8f0ed7da2 --- /dev/null +++ b/llama_stack/ui/components/ui/file-preview.tsx @@ -0,0 +1,153 @@ +"use client" + +import React, { useEffect } from "react" +import { motion } from "framer-motion" +import { FileIcon, X } from "lucide-react" + +interface FilePreviewProps { + file: File + onRemove?: () => void +} + +export const FilePreview = React.forwardRef( + (props, ref) => { + if (props.file.type.startsWith("image/")) { + return + } + + if ( + props.file.type.startsWith("text/") || + props.file.name.endsWith(".txt") || + props.file.name.endsWith(".md") + ) { + return + } + + return + } +) +FilePreview.displayName = "FilePreview" + +const ImageFilePreview = React.forwardRef( + ({ file, onRemove }, ref) => { + return ( + +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Attachment + + {file.name} + +
+ + {onRemove ? ( + + ) : null} +
+ ) + } +) +ImageFilePreview.displayName = "ImageFilePreview" + +const TextFilePreview = React.forwardRef( + ({ file, onRemove }, ref) => { + const [preview, setPreview] = React.useState("") + + useEffect(() => { + const reader = new FileReader() + reader.onload = (e) => { + const text = e.target?.result as string + setPreview(text.slice(0, 50) + (text.length > 50 ? "..." : "")) + } + reader.readAsText(file) + }, [file]) + + return ( + +
+
+
+ {preview || "Loading..."} +
+
+ + {file.name} + +
+ + {onRemove ? ( + + ) : null} +
+ ) + } +) +TextFilePreview.displayName = "TextFilePreview" + +const GenericFilePreview = React.forwardRef( + ({ file, onRemove }, ref) => { + return ( + +
+
+ +
+ + {file.name} + +
+ + {onRemove ? ( + + ) : null} +
+ ) + } +) +GenericFilePreview.displayName = "GenericFilePreview" diff --git a/llama_stack/ui/components/ui/interrupt-prompt.tsx b/llama_stack/ui/components/ui/interrupt-prompt.tsx new file mode 100644 index 000000000..757863c62 --- /dev/null +++ b/llama_stack/ui/components/ui/interrupt-prompt.tsx @@ -0,0 +1,41 @@ +"use client" + +import { AnimatePresence, motion } from "framer-motion" +import { X } from "lucide-react" + +interface InterruptPromptProps { + isOpen: boolean + close: () => void +} + +export function InterruptPrompt({ isOpen, close }: InterruptPromptProps) { + return ( + + {isOpen && ( + + Press Enter again to interrupt + + + )} + + ) +} diff --git a/llama_stack/ui/components/ui/markdown-renderer.tsx b/llama_stack/ui/components/ui/markdown-renderer.tsx new file mode 100644 index 000000000..374f687df --- /dev/null +++ b/llama_stack/ui/components/ui/markdown-renderer.tsx @@ -0,0 +1,195 @@ +import React, { Suspense } from "react" +import Markdown from "react-markdown" +import remarkGfm from "remark-gfm" + +import { cn } from "@/lib/utils" +import { CopyButton } from "@/components/ui/copy-button" + +interface MarkdownRendererProps { + children: string +} + +export function MarkdownRenderer({ children }: MarkdownRendererProps) { + return ( +
+ + {children} + +
+ ) +} + +interface HighlightedPre extends React.HTMLAttributes { + children: string + language: string +} + +const HighlightedPre = React.memo( + async ({ children, language, ...props }: HighlightedPre) => { + const { codeToTokens, bundledLanguages } = await import("shiki") + + if (!(language in bundledLanguages)) { + return
{children}
+ } + + const { tokens } = await codeToTokens(children, { + lang: language as keyof typeof bundledLanguages, + defaultColor: false, + themes: { + light: "github-light", + dark: "github-dark", + }, + }) + + return ( +
+        
+          {tokens.map((line, lineIndex) => (
+            <>
+              
+                {line.map((token, tokenIndex) => {
+                  const style =
+                    typeof token.htmlStyle === "string"
+                      ? undefined
+                      : token.htmlStyle
+
+                  return (
+                    
+                      {token.content}
+                    
+                  )
+                })}
+              
+              {lineIndex !== tokens.length - 1 && "\n"}
+            
+          ))}
+        
+      
+ ) + } +) +HighlightedPre.displayName = "HighlightedCode" + +interface CodeBlockProps extends React.HTMLAttributes { + children: React.ReactNode + className?: string + language: string +} + +const CodeBlock = ({ + children, + className, + language, + ...restProps +}: CodeBlockProps) => { + const code = + typeof children === "string" + ? children + : childrenTakeAllStringContents(children) + + const preClass = cn( + "overflow-x-scroll rounded-md border bg-background/50 p-4 font-mono text-sm [scrollbar-width:none]", + className + ) + + return ( +
+ + {children} + + } + > + + {code} + + + +
+ +
+
+ ) +} + +function childrenTakeAllStringContents(element: any): string { + if (typeof element === "string") { + return element + } + + if (element?.props?.children) { + let children = element.props.children + + if (Array.isArray(children)) { + return children + .map((child) => childrenTakeAllStringContents(child)) + .join("") + } else { + return childrenTakeAllStringContents(children) + } + } + + return "" +} + +const COMPONENTS = { + h1: withClass("h1", "text-2xl font-semibold"), + h2: withClass("h2", "font-semibold text-xl"), + h3: withClass("h3", "font-semibold text-lg"), + h4: withClass("h4", "font-semibold text-base"), + h5: withClass("h5", "font-medium"), + strong: withClass("strong", "font-semibold"), + a: withClass("a", "text-primary underline underline-offset-2"), + blockquote: withClass("blockquote", "border-l-2 border-primary pl-4"), + code: ({ children, className, node, ...rest }: any) => { + const match = /language-(\w+)/.exec(className || "") + return match ? ( + + {children} + + ) : ( + &]:rounded-md [:not(pre)>&]:bg-background/50 [:not(pre)>&]:px-1 [:not(pre)>&]:py-0.5" + )} + {...rest} + > + {children} + + ) + }, + pre: ({ children }: any) => children, + ol: withClass("ol", "list-decimal space-y-2 pl-6"), + ul: withClass("ul", "list-disc space-y-2 pl-6"), + li: withClass("li", "my-1.5"), + table: withClass( + "table", + "w-full border-collapse overflow-y-auto rounded-md border border-foreground/20" + ), + th: withClass( + "th", + "border border-foreground/20 px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right" + ), + td: withClass( + "td", + "border border-foreground/20 px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right" + ), + tr: withClass("tr", "m-0 border-t p-0 even:bg-muted"), + p: withClass("p", "whitespace-pre-wrap"), + hr: withClass("hr", "border-foreground/20"), +} + +function withClass(Tag: keyof JSX.IntrinsicElements, classes: string) { + const Component = ({ node, ...props }: any) => ( + + ) + Component.displayName = Tag + return Component +} + +export default MarkdownRenderer diff --git a/llama_stack/ui/components/ui/message-input.tsx b/llama_stack/ui/components/ui/message-input.tsx new file mode 100644 index 000000000..32761260d --- /dev/null +++ b/llama_stack/ui/components/ui/message-input.tsx @@ -0,0 +1,465 @@ +"use client" + +import React, { useEffect, useRef, useState } from "react" +import { AnimatePresence, motion } from "framer-motion" +import { ArrowUp, Info, Loader2, Mic, Paperclip, Square } from "lucide-react" +import { omit } from "remeda" + +import { cn } from "@/lib/utils" +import { useAudioRecording } from "@/hooks/use-audio-recording" +import { useAutosizeTextArea } from "@/hooks/use-autosize-textarea" +import { AudioVisualizer } from "@/components/ui/audio-visualizer" +import { Button } from "@/components/ui/button" +import { FilePreview } from "@/components/ui/file-preview" +import { InterruptPrompt } from "@/components/ui/interrupt-prompt" + +interface MessageInputBaseProps + extends React.TextareaHTMLAttributes { + value: string + submitOnEnter?: boolean + stop?: () => void + isGenerating: boolean + enableInterrupt?: boolean + transcribeAudio?: (blob: Blob) => Promise +} + +interface MessageInputWithoutAttachmentProps extends MessageInputBaseProps { + allowAttachments?: false +} + +interface MessageInputWithAttachmentsProps extends MessageInputBaseProps { + allowAttachments: true + files: File[] | null + setFiles: React.Dispatch> +} + +type MessageInputProps = + | MessageInputWithoutAttachmentProps + | MessageInputWithAttachmentsProps + +export function MessageInput({ + placeholder = "Ask AI...", + className, + onKeyDown: onKeyDownProp, + submitOnEnter = true, + stop, + isGenerating, + enableInterrupt = true, + transcribeAudio, + ...props +}: MessageInputProps) { + const [isDragging, setIsDragging] = useState(false) + const [showInterruptPrompt, setShowInterruptPrompt] = useState(false) + + const { + isListening, + isSpeechSupported, + isRecording, + isTranscribing, + audioStream, + toggleListening, + stopRecording, + } = useAudioRecording({ + transcribeAudio, + onTranscriptionComplete: (text) => { + props.onChange?.({ target: { value: text } } as any) + }, + }) + + useEffect(() => { + if (!isGenerating) { + setShowInterruptPrompt(false) + } + }, [isGenerating]) + + const addFiles = (files: File[] | null) => { + if (props.allowAttachments) { + props.setFiles((currentFiles) => { + if (currentFiles === null) { + return files + } + + if (files === null) { + return currentFiles + } + + return [...currentFiles, ...files] + }) + } + } + + const onDragOver = (event: React.DragEvent) => { + if (props.allowAttachments !== true) return + event.preventDefault() + setIsDragging(true) + } + + const onDragLeave = (event: React.DragEvent) => { + if (props.allowAttachments !== true) return + event.preventDefault() + setIsDragging(false) + } + + const onDrop = (event: React.DragEvent) => { + setIsDragging(false) + if (props.allowAttachments !== true) return + event.preventDefault() + const dataTransfer = event.dataTransfer + if (dataTransfer.files.length) { + addFiles(Array.from(dataTransfer.files)) + } + } + + const onPaste = (event: React.ClipboardEvent) => { + const items = event.clipboardData?.items + if (!items) return + + const text = event.clipboardData.getData("text") + if (text && text.length > 500 && props.allowAttachments) { + event.preventDefault() + const blob = new Blob([text], { type: "text/plain" }) + const file = new File([blob], "Pasted text", { + type: "text/plain", + lastModified: Date.now(), + }) + addFiles([file]) + return + } + + const files = Array.from(items) + .map((item) => item.getAsFile()) + .filter((file) => file !== null) + + if (props.allowAttachments && files.length > 0) { + addFiles(files) + } + } + + const onKeyDown = (event: React.KeyboardEvent) => { + if (submitOnEnter && event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + + if (isGenerating && stop && enableInterrupt) { + if (showInterruptPrompt) { + stop() + setShowInterruptPrompt(false) + event.currentTarget.form?.requestSubmit() + } else if ( + props.value || + (props.allowAttachments && props.files?.length) + ) { + setShowInterruptPrompt(true) + return + } + } + + event.currentTarget.form?.requestSubmit() + } + + onKeyDownProp?.(event) + } + + const textAreaRef = useRef(null) + const [textAreaHeight, setTextAreaHeight] = useState(0) + + useEffect(() => { + if (textAreaRef.current) { + setTextAreaHeight(textAreaRef.current.offsetHeight) + } + }, [props.value]) + + const showFileList = + props.allowAttachments && props.files && props.files.length > 0 + + + useAutosizeTextArea({ + ref: textAreaRef, + maxHeight: 240, + borderWidth: 1, + dependencies: [props.value, showFileList], + }) + + return ( +
+ {enableInterrupt && ( + setShowInterruptPrompt(false)} + /> + )} + + + +
+
+