feat(UI): Adding linter and prettier for UI (#3156)

This commit is contained in:
Francisco Arceo 2025-08-14 15:58:43 -06:00 committed by GitHub
parent 61582f327c
commit e69acbafbf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 1452 additions and 1226 deletions

View file

@ -1,18 +1,18 @@
"use client"
"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 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 { cn } from "@/lib/utils";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { FilePreview } from "@/components/ui/file-preview"
import { MarkdownRenderer } from "@/components/chat-playground/markdown-renderer"
} from "@/components/ui/collapsible";
import { FilePreview } from "@/components/ui/file-preview";
import { MarkdownRenderer } from "@/components/chat-playground/markdown-renderer";
const chatBubbleVariants = cva(
"group/message relative break-words rounded-lg p-3 text-sm sm:max-w-[70%]",
@ -52,66 +52,66 @@ const chatBubbleVariants = cva(
},
],
}
)
);
type Animation = VariantProps<typeof chatBubbleVariants>["animation"]
type Animation = VariantProps<typeof chatBubbleVariants>["animation"];
interface Attachment {
name?: string
contentType?: string
url: string
name?: string;
contentType?: string;
url: string;
}
interface PartialToolCall {
state: "partial-call"
toolName: string
state: "partial-call";
toolName: string;
}
interface ToolCall {
state: "call"
toolName: string
state: "call";
toolName: string;
}
interface ToolResult {
state: "result"
toolName: string
state: "result";
toolName: string;
result: {
__cancelled?: boolean
[key: string]: any
}
__cancelled?: boolean;
[key: string]: unknown;
};
}
type ToolInvocation = PartialToolCall | ToolCall | ToolResult
type ToolInvocation = PartialToolCall | ToolCall | ToolResult;
interface ReasoningPart {
type: "reasoning"
reasoning: string
type: "reasoning";
reasoning: string;
}
interface ToolInvocationPart {
type: "tool-invocation"
toolInvocation: ToolInvocation
type: "tool-invocation";
toolInvocation: ToolInvocation;
}
interface TextPart {
type: "text"
text: string
type: "text";
text: string;
}
// For compatibility with AI SDK types, not used
interface SourcePart {
type: "source"
source?: any
type: "source";
source?: unknown;
}
interface FilePart {
type: "file"
mimeType: string
data: string
type: "file";
mimeType: string;
data: string;
}
interface StepStartPart {
type: "step-start"
type: "step-start";
}
type MessagePart =
@ -120,22 +120,22 @@ type MessagePart =
| ToolInvocationPart
| SourcePart
| FilePart
| StepStartPart
| StepStartPart;
export interface Message {
id: string
role: "user" | "assistant" | (string & {})
content: string
createdAt?: Date
experimental_attachments?: Attachment[]
toolInvocations?: ToolInvocation[]
parts?: MessagePart[]
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
showTimeStamp?: boolean;
animation?: Animation;
actions?: React.ReactNode;
}
export const ChatMessage: React.FC<ChatMessageProps> = ({
@ -150,21 +150,21 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
parts,
}) => {
const files = useMemo(() => {
return experimental_attachments?.map((attachment) => {
const dataArray = dataUrlToUint8Array(attachment.url)
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])
});
return file;
});
}, [experimental_attachments]);
const isUser = role === "user"
const isUser = role === "user";
const formattedTime = createdAt?.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
})
});
if (isUser) {
return (
@ -174,7 +174,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
{files ? (
<div className="mb-1 flex flex-wrap gap-2">
{files.map((file, index) => {
return <FilePreview file={file} key={index} />
return <FilePreview file={file} key={index} />;
})}
</div>
) : null}
@ -195,7 +195,7 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
</time>
) : null}
</div>
)
);
}
if (parts && parts.length > 0) {
@ -230,23 +230,23 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
</time>
) : null}
</div>
)
);
} else if (part.type === "reasoning") {
return <ReasoningBlock key={`reasoning-${index}`} part={part} />
return <ReasoningBlock key={`reasoning-${index}`} part={part} />;
} else if (part.type === "tool-invocation") {
return (
<ToolCall
key={`tool-${index}`}
toolInvocations={[part.toolInvocation]}
/>
)
);
}
return null
})
return null;
});
}
if (toolInvocations && toolInvocations.length > 0) {
return <ToolCall toolInvocations={toolInvocations} />
return <ToolCall toolInvocations={toolInvocations} />;
}
return (
@ -272,17 +272,17 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
</time>
) : null}
</div>
)
}
);
};
function dataUrlToUint8Array(data: string) {
const base64 = data.split(",")[1]
const buf = Buffer.from(base64, "base64")
return new Uint8Array(buf)
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)
const [isOpen, setIsOpen] = useState(false);
return (
<div className="mb-2 flex flex-col items-start sm:max-w-[70%]">
@ -319,20 +319,20 @@ const ReasoningBlock = ({ part }: { part: ReasoningPart }) => {
</CollapsibleContent>
</Collapsible>
</div>
)
}
);
};
function ToolCall({
toolInvocations,
}: Pick<ChatMessageProps, "toolInvocations">) {
if (!toolInvocations?.length) return null
if (!toolInvocations?.length) return null;
return (
<div className="flex flex-col items-start gap-2">
{toolInvocations.map((invocation, index) => {
const isCancelled =
invocation.state === "result" &&
invocation.result.__cancelled === true
invocation.result.__cancelled === true;
if (isCancelled) {
return (
@ -350,7 +350,7 @@ function ToolCall({
</span>
</span>
</div>
)
);
}
switch (invocation.state) {
@ -373,7 +373,7 @@ function ToolCall({
</span>
<Loader2 className="h-3 w-3 animate-spin" />
</div>
)
);
case "result":
return (
<div
@ -395,11 +395,11 @@ function ToolCall({
{JSON.stringify(invocation.result, null, 2)}
</pre>
</div>
)
);
default:
return null
return null;
}
})}
</div>
)
);
}

View file

@ -1,4 +1,4 @@
"use client"
"use client";
import {
forwardRef,
@ -6,48 +6,48 @@ import {
useRef,
useState,
type ReactElement,
} from "react"
import { ArrowDown, ThumbsDown, ThumbsUp } from "lucide-react"
} 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/chat-playground/chat-message"
import { CopyButton } from "@/components/ui/copy-button"
import { MessageInput } from "@/components/chat-playground/message-input"
import { MessageList } from "@/components/chat-playground/message-list"
import { PromptSuggestions } from "@/components/chat-playground/prompt-suggestions"
import { cn } from "@/lib/utils";
import { useAutoScroll } from "@/hooks/use-auto-scroll";
import { Button } from "@/components/ui/button";
import { type Message } from "@/components/chat-playground/chat-message";
import { CopyButton } from "@/components/ui/copy-button";
import { MessageInput } from "@/components/chat-playground/message-input";
import { MessageList } from "@/components/chat-playground/message-list";
import { PromptSuggestions } from "@/components/chat-playground/prompt-suggestions";
interface ChatPropsBase {
handleSubmit: (
event?: { preventDefault?: () => void },
options?: { experimental_attachments?: FileList }
) => void
messages: Array<Message>
input: string
className?: string
handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement>
isGenerating: boolean
stop?: () => void
) => void;
messages: Array<Message>;
input: string;
className?: string;
handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement>;
isGenerating: boolean;
stop?: () => void;
onRateResponse?: (
messageId: string,
rating: "thumbs-up" | "thumbs-down"
) => void
setMessages?: (messages: any[]) => void
transcribeAudio?: (blob: Blob) => Promise<string>
) => void;
setMessages?: (messages: Message[]) => void;
transcribeAudio?: (blob: Blob) => Promise<string>;
}
interface ChatPropsWithoutSuggestions extends ChatPropsBase {
append?: never
suggestions?: never
append?: never;
suggestions?: never;
}
interface ChatPropsWithSuggestions extends ChatPropsBase {
append: (message: { role: "user"; content: string }) => void
suggestions: string[]
append: (message: { role: "user"; content: string }) => void;
suggestions: string[];
}
type ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions
type ChatProps = ChatPropsWithoutSuggestions | ChatPropsWithSuggestions;
export function Chat({
messages,
@ -63,34 +63,34 @@ export function Chat({
setMessages,
transcribeAudio,
}: ChatProps) {
const lastMessage = messages.at(-1)
const isEmpty = messages.length === 0
const isTyping = lastMessage?.role === "user"
const lastMessage = messages.at(-1);
const isEmpty = messages.length === 0;
const isTyping = lastMessage?.role === "user";
const messagesRef = useRef(messages)
messagesRef.current = messages
const messagesRef = useRef(messages);
messagesRef.current = messages;
// Enhanced stop function that marks pending tool calls as cancelled
const handleStop = useCallback(() => {
stop?.()
stop?.();
if (!setMessages) return
if (!setMessages) return;
const latestMessages = [...messagesRef.current]
const latestMessages = [...messagesRef.current];
const lastAssistantMessage = latestMessages.findLast(
(m) => m.role === "assistant"
)
m => m.role === "assistant"
);
if (!lastAssistantMessage) return
if (!lastAssistantMessage) return;
let needsUpdate = false
let updatedMessage = { ...lastAssistantMessage }
let needsUpdate = false;
let updatedMessage = { ...lastAssistantMessage };
if (lastAssistantMessage.toolInvocations) {
const updatedToolInvocations = lastAssistantMessage.toolInvocations.map(
(toolInvocation) => {
toolInvocation => {
if (toolInvocation.state === "call") {
needsUpdate = true
needsUpdate = true;
return {
...toolInvocation,
state: "result",
@ -98,61 +98,66 @@ export function Chat({
content: "Tool execution was cancelled",
__cancelled: true, // Special marker to indicate cancellation
},
} as const
} as const;
}
return toolInvocation
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,
const updatedParts = lastAssistantMessage.parts.map(
(part: {
type: string;
toolInvocation?: { state: string; toolName: string };
}) => {
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;
}
return part
})
);
if (needsUpdate) {
updatedMessage = {
...updatedMessage,
parts: updatedParts,
}
};
}
}
if (needsUpdate) {
const messageIndex = latestMessages.findIndex(
(m) => m.id === lastAssistantMessage.id
)
m => m.id === lastAssistantMessage.id
);
if (messageIndex !== -1) {
latestMessages[messageIndex] = updatedMessage
setMessages(latestMessages)
latestMessages[messageIndex] = updatedMessage;
setMessages(latestMessages);
}
}
}, [stop, setMessages, messagesRef])
}, [stop, setMessages, messagesRef]);
const messageOptions = useCallback(
(message: Message) => ({
@ -189,7 +194,7 @@ export function Chat({
),
}),
[onRateResponse]
)
);
return (
<ChatContainer className={className}>
@ -237,15 +242,15 @@ export function Chat({
</div>
</div>
</ChatContainer>
)
);
}
Chat.displayName = "Chat"
Chat.displayName = "Chat";
export function ChatMessages({
messages,
children,
}: React.PropsWithChildren<{
messages: Message[]
messages: Message[];
}>) {
const {
containerRef,
@ -253,7 +258,7 @@ export function ChatMessages({
handleScroll,
shouldAutoScroll,
handleTouchStart,
} = useAutoScroll([messages])
} = useAutoScroll([messages]);
return (
<div
@ -281,7 +286,7 @@ export function ChatMessages({
</div>
)}
</div>
)
);
}
export const ChatContainer = forwardRef<
@ -294,56 +299,56 @@ export const ChatContainer = forwardRef<
className={cn("flex flex-col max-h-full w-full", className)}
{...props}
/>
)
})
ChatContainer.displayName = "ChatContainer"
);
});
ChatContainer.displayName = "ChatContainer";
interface ChatFormProps {
className?: string
isPending: boolean
className?: string;
isPending: boolean;
handleSubmit: (
event?: { preventDefault?: () => void },
options?: { experimental_attachments?: FileList }
) => void
) => void;
children: (props: {
files: File[] | null
setFiles: React.Dispatch<React.SetStateAction<File[] | null>>
}) => ReactElement
files: File[] | null;
setFiles: React.Dispatch<React.SetStateAction<File[] | null>>;
}) => ReactElement;
}
export const ChatForm = forwardRef<HTMLFormElement, ChatFormProps>(
({ children, handleSubmit, isPending, className }, ref) => {
const [files, setFiles] = useState<File[] | null>(null)
const [files, setFiles] = useState<File[] | null>(null);
const onSubmit = (event: React.FormEvent) => {
// if (isPending) {
// event.preventDefault()
// return
// }
if (!files) {
handleSubmit(event)
return
if (isPending) {
event.preventDefault();
return;
}
const fileList = createFileList(files)
handleSubmit(event, { experimental_attachments: fileList })
setFiles(null)
}
if (!files) {
handleSubmit(event);
return;
}
const fileList = createFileList(files);
handleSubmit(event, { experimental_attachments: fileList });
setFiles(null);
};
return (
<form ref={ref} onSubmit={onSubmit} className={className}>
{children({ files, setFiles })}
</form>
)
);
}
)
ChatForm.displayName = "ChatForm"
);
ChatForm.displayName = "ChatForm";
function createFileList(files: File[] | FileList): FileList {
const dataTransfer = new DataTransfer()
const dataTransfer = new DataTransfer();
for (const file of Array.from(files)) {
dataTransfer.items.add(file)
dataTransfer.items.add(file);
}
return dataTransfer.files
return dataTransfer.files;
}

View file

@ -1,11 +1,11 @@
"use client"
"use client";
import { AnimatePresence, motion } from "framer-motion"
import { X } from "lucide-react"
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
interface InterruptPromptProps {
isOpen: boolean
close: () => void
isOpen: boolean;
close: () => void;
}
export function InterruptPrompt({ isOpen, close }: InterruptPromptProps) {
@ -37,5 +37,5 @@ export function InterruptPrompt({ isOpen, close }: InterruptPromptProps) {
</motion.div>
)}
</AnimatePresence>
)
);
}

View file

@ -1,12 +1,12 @@
import React, { Suspense, useEffect, useState } from "react"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"
import React, { Suspense, useEffect, useState } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils"
import { CopyButton } from "@/components/ui/copy-button"
import { cn } from "@/lib/utils";
import { CopyButton } from "@/components/ui/copy-button";
interface MarkdownRendererProps {
children: string
children: string;
}
export function MarkdownRenderer({ children }: MarkdownRendererProps) {
@ -16,34 +16,34 @@ export function MarkdownRenderer({ children }: MarkdownRendererProps) {
{children}
</Markdown>
</div>
)
);
}
interface HighlightedPre extends React.HTMLAttributes<HTMLPreElement> {
children: string
language: string
children: string;
language: string;
}
const HighlightedPre = React.memo(
({ children, language, ...props }: HighlightedPre) => {
const [tokens, setTokens] = useState<any[] | null>(null)
const [isSupported, setIsSupported] = useState(false)
const [tokens, setTokens] = useState<unknown[] | null>(null);
const [isSupported, setIsSupported] = useState(false);
useEffect(() => {
let mounted = true
let mounted = true;
const loadAndHighlight = async () => {
try {
const { codeToTokens, bundledLanguages } = await import("shiki")
const { codeToTokens, bundledLanguages } = await import("shiki");
if (!mounted) return
if (!mounted) return;
if (!(language in bundledLanguages)) {
setIsSupported(false)
return
setIsSupported(false);
return;
}
setIsSupported(true)
setIsSupported(true);
const { tokens: highlightedTokens } = await codeToTokens(children, {
lang: language as keyof typeof bundledLanguages,
@ -52,31 +52,31 @@ const HighlightedPre = React.memo(
light: "github-light",
dark: "github-dark",
},
})
});
if (mounted) {
setTokens(highlightedTokens)
setTokens(highlightedTokens);
}
} catch (error) {
} catch {
if (mounted) {
setIsSupported(false)
setIsSupported(false);
}
}
}
};
loadAndHighlight()
loadAndHighlight();
return () => {
mounted = false
}
}, [children, language])
mounted = false;
};
}, [children, language]);
if (!isSupported) {
return <pre {...props}>{children}</pre>
return <pre {...props}>{children}</pre>;
}
if (!tokens) {
return <pre {...props}>{children}</pre>
return <pre {...props}>{children}</pre>;
}
return (
@ -89,7 +89,7 @@ const HighlightedPre = React.memo(
const style =
typeof token.htmlStyle === "string"
? undefined
: token.htmlStyle
: token.htmlStyle;
return (
<span
@ -99,7 +99,7 @@ const HighlightedPre = React.memo(
>
{token.content}
</span>
)
);
})}
</span>
{lineIndex !== tokens.length - 1 && "\n"}
@ -107,15 +107,15 @@ const HighlightedPre = React.memo(
))}
</code>
</pre>
)
);
}
)
HighlightedPre.displayName = "HighlightedCode"
);
HighlightedPre.displayName = "HighlightedCode";
interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {
children: React.ReactNode
className?: string
language: string
children: React.ReactNode;
className?: string;
language: string;
}
const CodeBlock = ({
@ -127,12 +127,12 @@ const CodeBlock = ({
const code =
typeof children === "string"
? children
: childrenTakeAllStringContents(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 (
<div className="group/code relative mb-4">
@ -152,27 +152,27 @@ const CodeBlock = ({
<CopyButton content={code} copyMessage="Copied code to clipboard" />
</div>
</div>
)
}
);
};
function childrenTakeAllStringContents(element: any): string {
function childrenTakeAllStringContents(element: unknown): string {
if (typeof element === "string") {
return element
return element;
}
if (element?.props?.children) {
let children = element.props.children
const children = element.props.children;
if (Array.isArray(children)) {
return children
.map((child) => childrenTakeAllStringContents(child))
.join("")
.map(child => childrenTakeAllStringContents(child))
.join("");
} else {
return childrenTakeAllStringContents(children)
return childrenTakeAllStringContents(children);
}
}
return ""
return "";
}
const COMPONENTS = {
@ -184,8 +184,14 @@ const COMPONENTS = {
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 || "")
code: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
const match = /language-(\w+)/.exec(className || "");
return match ? (
<CodeBlock className={className} language={match[1]} {...rest}>
{children}
@ -199,9 +205,9 @@ const COMPONENTS = {
>
{children}
</code>
)
);
},
pre: ({ children }: any) => children,
pre: ({ children }: { children: React.ReactNode }) => 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"),
@ -220,14 +226,14 @@ const COMPONENTS = {
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) => (
const Component = ({ ...props }: Record<string, unknown>) => (
<Tag className={classes} {...props} />
)
Component.displayName = Tag
return Component
);
Component.displayName = Tag;
return Component;
}
export default MarkdownRenderer
export default MarkdownRenderer;

View file

@ -1,41 +1,41 @@
"use client"
"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 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/chat-playground/interrupt-prompt"
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/chat-playground/interrupt-prompt";
interface MessageInputBaseProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
value: string
submitOnEnter?: boolean
stop?: () => void
isGenerating: boolean
enableInterrupt?: boolean
transcribeAudio?: (blob: Blob) => Promise<string>
value: string;
submitOnEnter?: boolean;
stop?: () => void;
isGenerating: boolean;
enableInterrupt?: boolean;
transcribeAudio?: (blob: Blob) => Promise<string>;
}
interface MessageInputWithoutAttachmentProps extends MessageInputBaseProps {
allowAttachments?: false
allowAttachments?: false;
}
interface MessageInputWithAttachmentsProps extends MessageInputBaseProps {
allowAttachments: true
files: File[] | null
setFiles: React.Dispatch<React.SetStateAction<File[] | null>>
allowAttachments: true;
files: File[] | null;
setFiles: React.Dispatch<React.SetStateAction<File[] | null>>;
}
type MessageInputProps =
| MessageInputWithoutAttachmentProps
| MessageInputWithAttachmentsProps
| MessageInputWithAttachmentsProps;
export function MessageInput({
placeholder = "Ask AI...",
@ -48,8 +48,8 @@ export function MessageInput({
transcribeAudio,
...props
}: MessageInputProps) {
const [isDragging, setIsDragging] = useState(false)
const [showInterruptPrompt, setShowInterruptPrompt] = useState(false)
const [isDragging, setIsDragging] = useState(false);
const [showInterruptPrompt, setShowInterruptPrompt] = useState(false);
const {
isListening,
@ -61,123 +61,124 @@ export function MessageInput({
stopRecording,
} = useAudioRecording({
transcribeAudio,
onTranscriptionComplete: (text) => {
props.onChange?.({ target: { value: text } } as any)
onTranscriptionComplete: text => {
props.onChange?.({
target: { value: text },
} as React.ChangeEvent<HTMLTextAreaElement>);
},
})
});
useEffect(() => {
if (!isGenerating) {
setShowInterruptPrompt(false)
setShowInterruptPrompt(false);
}
}, [isGenerating])
}, [isGenerating]);
const addFiles = (files: File[] | null) => {
if (props.allowAttachments) {
props.setFiles((currentFiles) => {
props.setFiles(currentFiles => {
if (currentFiles === null) {
return files
return files;
}
if (files === null) {
return currentFiles
return currentFiles;
}
return [...currentFiles, ...files]
})
return [...currentFiles, ...files];
});
}
}
};
const onDragOver = (event: React.DragEvent) => {
if (props.allowAttachments !== true) return
event.preventDefault()
setIsDragging(true)
}
if (props.allowAttachments !== true) return;
event.preventDefault();
setIsDragging(true);
};
const onDragLeave = (event: React.DragEvent) => {
if (props.allowAttachments !== true) return
event.preventDefault()
setIsDragging(false)
}
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
setIsDragging(false);
if (props.allowAttachments !== true) return;
event.preventDefault();
const dataTransfer = event.dataTransfer;
if (dataTransfer.files.length) {
addFiles(Array.from(dataTransfer.files))
addFiles(Array.from(dataTransfer.files));
}
}
};
const onPaste = (event: React.ClipboardEvent) => {
const items = event.clipboardData?.items
if (!items) return
const items = event.clipboardData?.items;
if (!items) return;
const text = event.clipboardData.getData("text")
const text = event.clipboardData.getData("text");
if (text && text.length > 500 && props.allowAttachments) {
event.preventDefault()
const blob = new Blob([text], { type: "text/plain" })
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
});
addFiles([file]);
return;
}
const files = Array.from(items)
.map((item) => item.getAsFile())
.filter((file) => file !== null)
.map(item => item.getAsFile())
.filter(file => file !== null);
if (props.allowAttachments && files.length > 0) {
addFiles(files)
addFiles(files);
}
}
};
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (submitOnEnter && event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
event.preventDefault();
if (isGenerating && stop && enableInterrupt) {
if (showInterruptPrompt) {
stop()
setShowInterruptPrompt(false)
event.currentTarget.form?.requestSubmit()
stop();
setShowInterruptPrompt(false);
event.currentTarget.form?.requestSubmit();
} else if (
props.value ||
(props.allowAttachments && props.files?.length)
) {
setShowInterruptPrompt(true)
return
setShowInterruptPrompt(true);
return;
}
}
event.currentTarget.form?.requestSubmit()
event.currentTarget.form?.requestSubmit();
}
onKeyDownProp?.(event)
}
onKeyDownProp?.(event);
};
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [textAreaHeight, setTextAreaHeight] = useState<number>(0)
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [textAreaHeight, setTextAreaHeight] = useState<number>(0);
useEffect(() => {
if (textAreaRef.current) {
setTextAreaHeight(textAreaRef.current.offsetHeight)
setTextAreaHeight(textAreaRef.current.offsetHeight);
}
}, [props.value])
}, [props.value]);
const showFileList =
props.allowAttachments && props.files && props.files.length > 0
props.allowAttachments && props.files && props.files.length > 0;
useAutosizeTextArea({
ref: textAreaRef,
maxHeight: 240,
borderWidth: 1,
dependencies: [props.value, showFileList],
})
});
return (
<div
@ -220,24 +221,24 @@ export function MessageInput({
<div className="absolute inset-x-3 bottom-0 z-20 overflow-x-scroll py-3">
<div className="flex space-x-3">
<AnimatePresence mode="popLayout">
{props.files?.map((file) => {
{props.files?.map(file => {
return (
<FilePreview
key={file.name + String(file.lastModified)}
file={file}
onRemove={() => {
props.setFiles((files) => {
if (!files) return null
props.setFiles(files => {
if (!files) return null;
const filtered = Array.from(files).filter(
(f) => f !== file
)
if (filtered.length === 0) return null
return filtered
})
f => f !== file
);
if (filtered.length === 0) return null;
return filtered;
});
}}
/>
)
);
})}
</AnimatePresence>
</div>
@ -256,8 +257,8 @@ export function MessageInput({
aria-label="Attach a file"
disabled={true}
onClick={async () => {
const files = await showFileUploadDialog()
addFiles(files)
const files = await showFileUploadDialog();
addFiles(files);
}}
>
<Paperclip className="h-4 w-4" />
@ -308,12 +309,12 @@ export function MessageInput({
onStopRecording={stopRecording}
/>
</div>
)
);
}
MessageInput.displayName = "MessageInput"
MessageInput.displayName = "MessageInput";
interface FileUploadOverlayProps {
isDragging: boolean
isDragging: boolean;
}
function FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {
@ -333,29 +334,29 @@ function FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {
</motion.div>
)}
</AnimatePresence>
)
);
}
function showFileUploadDialog() {
const input = document.createElement("input")
const input = document.createElement("input");
input.type = "file"
input.multiple = true
input.accept = "*/*"
input.click()
input.type = "file";
input.multiple = true;
input.accept = "*/*";
input.click();
return new Promise<File[] | null>((resolve) => {
input.onchange = (e) => {
const files = (e.currentTarget as HTMLInputElement).files
return new Promise<File[] | null>(resolve => {
input.onchange = e => {
const files = (e.currentTarget as HTMLInputElement).files;
if (files) {
resolve(Array.from(files))
return
resolve(Array.from(files));
return;
}
resolve(null)
}
})
resolve(null);
};
});
}
function TranscribingOverlay() {
@ -385,12 +386,12 @@ function TranscribingOverlay() {
Transcribing audio...
</p>
</motion.div>
)
);
}
interface RecordingPromptProps {
isVisible: boolean
onStopRecording: () => void
isVisible: boolean;
onStopRecording: () => void;
}
function RecordingPrompt({ isVisible, onStopRecording }: RecordingPromptProps) {
@ -418,15 +419,15 @@ function RecordingPrompt({ isVisible, onStopRecording }: RecordingPromptProps) {
</motion.div>
)}
</AnimatePresence>
)
);
}
interface RecordingControlsProps {
isRecording: boolean
isTranscribing: boolean
audioStream: MediaStream | null
textAreaHeight: number
onStopRecording: () => void
isRecording: boolean;
isTranscribing: boolean;
audioStream: MediaStream | null;
textAreaHeight: number;
onStopRecording: () => void;
}
function RecordingControls({
@ -448,7 +449,7 @@ function RecordingControls({
onClick={onStopRecording}
/>
</div>
)
);
}
if (isTranscribing) {
@ -459,8 +460,8 @@ function RecordingControls({
>
<TranscribingOverlay />
</div>
)
);
}
return null
return null;
}

View file

@ -2,18 +2,18 @@ import {
ChatMessage,
type ChatMessageProps,
type Message,
} from "@/components/chat-playground/chat-message"
import { TypingIndicator } from "@/components/chat-playground/typing-indicator"
} from "@/components/chat-playground/chat-message";
import { TypingIndicator } from "@/components/chat-playground/typing-indicator";
type AdditionalMessageOptions = Omit<ChatMessageProps, keyof Message>
type AdditionalMessageOptions = Omit<ChatMessageProps, keyof Message>;
interface MessageListProps {
messages: Message[]
showTimeStamps?: boolean
isTyping?: boolean
messages: Message[];
showTimeStamps?: boolean;
isTyping?: boolean;
messageOptions?:
| AdditionalMessageOptions
| ((message: Message) => AdditionalMessageOptions)
| ((message: Message) => AdditionalMessageOptions);
}
export function MessageList({
@ -28,7 +28,7 @@ export function MessageList({
const additionalOptions =
typeof messageOptions === "function"
? messageOptions(message)
: messageOptions
: messageOptions;
return (
<ChatMessage
@ -37,9 +37,9 @@ export function MessageList({
{...message}
{...additionalOptions}
/>
)
);
})}
{isTyping && <TypingIndicator />}
</div>
)
);
}

View file

@ -1,7 +1,7 @@
interface PromptSuggestionsProps {
label: string
append: (message: { role: "user"; content: string }) => void
suggestions: string[]
label: string;
append: (message: { role: "user"; content: string }) => void;
suggestions: string[];
}
export function PromptSuggestions({
@ -13,7 +13,7 @@ export function PromptSuggestions({
<div className="space-y-6">
<h2 className="text-center text-2xl font-bold">{label}</h2>
<div className="flex gap-6 text-sm">
{suggestions.map((suggestion) => (
{suggestions.map(suggestion => (
<button
key={suggestion}
onClick={() => append({ role: "user", content: suggestion })}
@ -24,5 +24,5 @@ export function PromptSuggestions({
))}
</div>
</div>
)
);
}

View file

@ -1,4 +1,4 @@
import { Dot } from "lucide-react"
import { Dot } from "lucide-react";
export function TypingIndicator() {
return (
@ -11,5 +11,5 @@ export function TypingIndicator() {
</div>
</div>
</div>
)
);
}