diff --git a/templates/components/ui/shadcn/chat/chat-actions.tsx b/templates/components/ui/shadcn/chat/chat-actions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..151ef61a945c49cacfd44c77cbdf7287b5967861 --- /dev/null +++ b/templates/components/ui/shadcn/chat/chat-actions.tsx @@ -0,0 +1,28 @@ +import { PauseCircle, RefreshCw } from "lucide-react"; + +import { Button } from "../button"; +import { ChatHandler } from "./chat.interface"; + +export default function ChatActions( + props: Pick<ChatHandler, "stop" | "reload"> & { + showReload?: boolean; + showStop?: boolean; + }, +) { + return ( + <div className="space-x-4"> + {props.showStop && ( + <Button variant="outline" size="sm" onClick={props.stop}> + <PauseCircle className="mr-2 h-4 w-4" /> + Stop generating + </Button> + )} + {props.showReload && ( + <Button variant="outline" size="sm" onClick={props.reload}> + <RefreshCw className="mr-2 h-4 w-4" /> + Regenerate + </Button> + )} + </div> + ); +} diff --git a/templates/components/ui/shadcn/chat/chat-input.tsx b/templates/components/ui/shadcn/chat/chat-input.tsx index 86b079e0bc4b14d5c72d3f24c3a7641fddd2e153..1a0cc3e0cc6d92178bafce5e8d573586e024e3f6 100644 --- a/templates/components/ui/shadcn/chat/chat-input.tsx +++ b/templates/components/ui/shadcn/chat/chat-input.tsx @@ -1,20 +1,17 @@ -import * as React from "react"; - import { Button } from "../button"; import { Input } from "../input"; +import { ChatHandler } from "./chat.interface"; -export interface ChatInputProps { - handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void; - handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void; - input: string; - isLoading: boolean; -} - -export default function ChatInput(props: ChatInputProps) { +export default function ChatInput( + props: Pick< + ChatHandler, + "isLoading" | "handleSubmit" | "handleInputChange" | "input" + >, +) { return ( <form onSubmit={props.handleSubmit} - className="mx-auto flex w-full max-w-5xl items-start justify-between gap-4 rounded-xl bg-white p-4 shadow-xl" + className="flex w-full items-start justify-between gap-4 rounded-xl bg-white p-4 shadow-xl" > <Input autoFocus @@ -24,7 +21,7 @@ export default function ChatInput(props: ChatInputProps) { value={props.input} onChange={props.handleInputChange} /> - <Button disabled={props.isLoading} type="submit"> + <Button type="submit" disabled={props.isLoading}> Send message </Button> </form> diff --git a/templates/components/ui/shadcn/chat/chat-message.tsx b/templates/components/ui/shadcn/chat/chat-message.tsx index 62f7e75dd64f8f95b753a69e6934a72e7ab64bca..9ada08a3d7498a204ef3403b2148007f302936bf 100644 --- a/templates/components/ui/shadcn/chat/chat-message.tsx +++ b/templates/components/ui/shadcn/chat/chat-message.tsx @@ -1,36 +1,25 @@ import { Check, Copy } from "lucide-react"; -import { useState } from "react"; import { Button } from "../button"; import ChatAvatar from "./chat-avatar"; - -export interface Message { - id: string; - content: string; - role: string; -} +import { Message } from "./chat.interface"; +import Markdown from "./markdown"; +import { useCopyToClipboard } from "./use-copy-to-clipboard"; export default function ChatMessage(chatMessage: Message) { - const [isCopied, setIsCopied] = useState(false); - - const copyToClipboard = () => { - navigator.clipboard.writeText(chatMessage.content); - setIsCopied(true); - setTimeout(() => { - setIsCopied(false); - }, 2000); - }; - + const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }); return ( - <div className="flex items-start gap-4 pt-5"> + <div className="flex items-start gap-4 pr-5 pt-5"> <ChatAvatar role={chatMessage.role} /> <div className="group flex flex-1 justify-between gap-2"> - <p className="break-words">{chatMessage.content}</p> + <div className="flex-1"> + <Markdown content={chatMessage.content} /> + </div> <Button - onClick={copyToClipboard} + onClick={() => copyToClipboard(chatMessage.content)} size="icon" variant="ghost" - className="hidden h-8 w-8 group-hover:flex" + className="h-8 w-8 opacity-0 group-hover:opacity-100" > {isCopied ? ( <Check className="h-4 w-4" /> diff --git a/templates/components/ui/shadcn/chat/chat-messages.tsx b/templates/components/ui/shadcn/chat/chat-messages.tsx index d5162768c7e1db4d9963a6a6e50131e1700d1a1e..dd0a442b6cd567094f6393bf2ccf221ee1eb178d 100644 --- a/templates/components/ui/shadcn/chat/chat-messages.tsx +++ b/templates/components/ui/shadcn/chat/chat-messages.tsx @@ -1,9 +1,15 @@ import { useEffect, useRef } from "react"; -import ChatMessage, { Message } from "./chat-message"; +import ChatActions from "./chat-actions"; +import ChatMessage from "./chat-message"; +import { ChatHandler } from "./chat.interface"; -export default function ChatMessages({ messages }: { messages: Message[] }) { +export default function ChatMessages( + props: Pick<ChatHandler, "messages" | "isLoading" | "reload" | "stop">, +) { const scrollableChatContainerRef = useRef<HTMLDivElement>(null); + const messageLength = props.messages.length; + const lastMessage = props.messages[messageLength - 1]; const scrollToBottom = () => { if (scrollableChatContainerRef.current) { @@ -12,20 +18,34 @@ export default function ChatMessages({ messages }: { messages: Message[] }) { } }; + const isLastMessageFromAssistant = + messageLength > 0 && lastMessage?.role !== "user"; + const showReload = + props.reload && !props.isLoading && isLastMessageFromAssistant; + const showStop = props.stop && props.isLoading; + useEffect(() => { scrollToBottom(); - }, [messages.length]); + }, [messageLength, lastMessage]); return ( - <div className="mx-auto w-full max-w-5xl rounded-xl bg-white p-4 shadow-xl"> + <div className="w-full rounded-xl bg-white p-4 shadow-xl pb-0"> <div - className="flex h-[50vh] flex-col gap-5 divide-y overflow-auto" + className="flex h-[50vh] flex-col gap-5 divide-y overflow-y-auto pb-4" ref={scrollableChatContainerRef} > - {messages.map((m) => ( + {props.messages.map((m) => ( <ChatMessage key={m.id} {...m} /> ))} </div> + <div className="flex justify-end py-4"> + <ChatActions + reload={props.reload} + stop={props.stop} + showReload={showReload} + showStop={showStop} + /> + </div> </div> ); } diff --git a/templates/components/ui/shadcn/chat/chat.interface.ts b/templates/components/ui/shadcn/chat/chat.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..3256f7f031b42114f192f3375632654dc21f78d8 --- /dev/null +++ b/templates/components/ui/shadcn/chat/chat.interface.ts @@ -0,0 +1,15 @@ +export interface Message { + id: string; + content: string; + role: string; +} + +export interface ChatHandler { + messages: Message[]; + input: string; + isLoading: boolean; + handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void; + handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void; + reload?: () => void; + stop?: () => void; +} diff --git a/templates/components/ui/shadcn/chat/codeblock.tsx b/templates/components/ui/shadcn/chat/codeblock.tsx new file mode 100644 index 0000000000000000000000000000000000000000..10598223b811a77c09a53a27857cd433a71dcfef --- /dev/null +++ b/templates/components/ui/shadcn/chat/codeblock.tsx @@ -0,0 +1,139 @@ +"use client" + +import React, { FC, memo } from "react" +import { Check, Copy, Download } from "lucide-react" +import { Prism, SyntaxHighlighterProps } from "react-syntax-highlighter" +import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism" + +import { Button } from "../button" +import { useCopyToClipboard } from "./use-copy-to-clipboard" + +// TODO: Remove this when @type/react-syntax-highlighter is updated +const SyntaxHighlighter = Prism as unknown as FC<SyntaxHighlighterProps> + +interface Props { + language: string + value: string +} + +interface languageMap { + [key: string]: string | undefined +} + +export const programmingLanguages: languageMap = { + javascript: ".js", + python: ".py", + java: ".java", + c: ".c", + cpp: ".cpp", + "c++": ".cpp", + "c#": ".cs", + ruby: ".rb", + php: ".php", + swift: ".swift", + "objective-c": ".m", + kotlin: ".kt", + typescript: ".ts", + go: ".go", + perl: ".pl", + rust: ".rs", + scala: ".scala", + haskell: ".hs", + lua: ".lua", + shell: ".sh", + sql: ".sql", + html: ".html", + css: ".css", + // add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component +} + +export const generateRandomString = (length: number, lowercase = false) => { + const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789" // excluding similar looking characters like Z, 2, I, 1, O, 0 + let result = "" + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return lowercase ? result.toLowerCase() : result +} + +const CodeBlock: FC<Props> = memo(({ language, value }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 }) + + const downloadAsFile = () => { + if (typeof window === "undefined") { + return + } + const fileExtension = programmingLanguages[language] || ".file" + const suggestedFileName = `file-${generateRandomString( + 3, + true + )}${fileExtension}` + const fileName = window.prompt("Enter file name" || "", suggestedFileName) + + if (!fileName) { + // User pressed cancel on prompt. + return + } + + const blob = new Blob([value], { type: "text/plain" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.download = fileName + link.href = url + link.style.display = "none" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + const onCopy = () => { + if (isCopied) return + copyToClipboard(value) + } + + return ( + <div className="codeblock relative w-full bg-zinc-950 font-sans"> + <div className="flex w-full items-center justify-between bg-zinc-800 px-6 py-2 pr-4 text-zinc-100"> + <span className="text-xs lowercase">{language}</span> + <div className="flex items-center space-x-1"> + <Button variant="ghost" onClick={downloadAsFile} size="icon"> + <Download /> + <span className="sr-only">Download</span> + </Button> + <Button variant="ghost" size="icon" onClick={onCopy}> + {isCopied ? ( + <Check className="h-4 w-4" /> + ) : ( + <Copy className="h-4 w-4" /> + )} + <span className="sr-only">Copy code</span> + </Button> + </div> + </div> + <SyntaxHighlighter + language={language} + style={coldarkDark} + PreTag="div" + showLineNumbers + customStyle={{ + width: "100%", + background: "transparent", + padding: "1.5rem 1rem", + borderRadius: "0.5rem", + }} + codeTagProps={{ + style: { + fontSize: "0.9rem", + fontFamily: "var(--font-mono)", + }, + }} + > + {value} + </SyntaxHighlighter> + </div> + ) +}) +CodeBlock.displayName = "CodeBlock" + +export { CodeBlock } diff --git a/templates/components/ui/shadcn/chat/index.ts b/templates/components/ui/shadcn/chat/index.ts index d50251ffd6184259c6916a74951d88e0f598c6c5..0b8104960cff17adb4bb2068d0cb7dcd1f8a93ae 100644 --- a/templates/components/ui/shadcn/chat/index.ts +++ b/templates/components/ui/shadcn/chat/index.ts @@ -1,6 +1,5 @@ import ChatInput from "./chat-input"; import ChatMessages from "./chat-messages"; -export type { ChatInputProps } from "./chat-input"; -export type { Message } from "./chat-message"; +export { type ChatHandler, type Message } from "./chat.interface"; export { ChatMessages, ChatInput }; diff --git a/templates/components/ui/shadcn/chat/markdown.tsx b/templates/components/ui/shadcn/chat/markdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31b78242d257db674676d4c69a076b26785d0033 --- /dev/null +++ b/templates/components/ui/shadcn/chat/markdown.tsx @@ -0,0 +1,59 @@ +import { FC, memo } from "react" +import ReactMarkdown, { Options } from "react-markdown" +import remarkGfm from "remark-gfm" +import remarkMath from "remark-math" + +import { CodeBlock } from "./codeblock" + +const MemoizedReactMarkdown: FC<Options> = memo( + ReactMarkdown, + (prevProps, nextProps) => + prevProps.children === nextProps.children && + prevProps.className === nextProps.className +) + +export default function Markdown({ content }: { content: string }) { + return ( + <MemoizedReactMarkdown + className="prose dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 break-words" + remarkPlugins={[remarkGfm, remarkMath]} + components={{ + p({ children }) { + return <p className="mb-2 last:mb-0">{children}</p> + }, + code({ node, inline, className, children, ...props }) { + if (children.length) { + if (children[0] == "▍") { + return ( + <span className="mt-1 animate-pulse cursor-default">▍</span> + ) + } + + children[0] = (children[0] as string).replace("`▍`", "▍") + } + + const match = /language-(\w+)/.exec(className || "") + + if (inline) { + return ( + <code className={className} {...props}> + {children} + </code> + ) + } + + return ( + <CodeBlock + key={Math.random()} + language={(match && match[1]) || ""} + value={String(children).replace(/\n$/, "")} + {...props} + /> + ) + }, + }} + > + {content} + </MemoizedReactMarkdown> + ) +} diff --git a/templates/components/ui/shadcn/chat/use-copy-to-clipboard.tsx b/templates/components/ui/shadcn/chat/use-copy-to-clipboard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..62f7156dca246c46b213151af003a3a177977ccf --- /dev/null +++ b/templates/components/ui/shadcn/chat/use-copy-to-clipboard.tsx @@ -0,0 +1,33 @@ +'use client' + +import * as React from 'react' + +export interface useCopyToClipboardProps { + timeout?: number +} + +export function useCopyToClipboard({ + timeout = 2000 +}: useCopyToClipboardProps) { + const [isCopied, setIsCopied] = React.useState<Boolean>(false) + + const copyToClipboard = (value: string) => { + if (typeof window === 'undefined' || !navigator.clipboard?.writeText) { + return + } + + if (!value) { + return + } + + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true) + + setTimeout(() => { + setIsCopied(false) + }, timeout) + }) + } + + return { isCopied, copyToClipboard } +} diff --git a/templates/index.ts b/templates/index.ts index 3c6f63b1e7896a9ecae9b0bcce804575bafdc08a..6cc8b14e38219b8fcd7a95077ef39e92079d7521 100644 --- a/templates/index.ts +++ b/templates/index.ts @@ -137,6 +137,17 @@ const installTSTemplate = async ({ "@radix-ui/react-slot": "^1", "class-variance-authority": "^0.7", "lucide-react": "^0.291", + remark: "^14.0.3", + "remark-code-import": "^1.2.0", + "remark-gfm": "^3.0.1", + "remark-math": "^5.1.1", + "react-markdown": "^8.0.7", + "react-syntax-highlighter": "^15.5.0", + }; + + packageJson.devDependencies = { + ...packageJson.devDependencies, + "@types/react-syntax-highlighter": "^15.5.6", }; } diff --git a/templates/types/simple/nextjs/app/components/chat-section.tsx b/templates/types/simple/nextjs/app/components/chat-section.tsx index 246d46e08caca8b09be62c195b9dfb3af4a81718..133a0a884bb82fb1ed3c78553852b8ae9b169cd5 100644 --- a/templates/types/simple/nextjs/app/components/chat-section.tsx +++ b/templates/types/simple/nextjs/app/components/chat-section.tsx @@ -2,11 +2,11 @@ import { nanoid } from "nanoid"; import { useState } from "react"; -import { ChatInput, ChatMessages, Message } from "./ui/chat"; +import { ChatInput, ChatInputProps, ChatMessages, Message } from "./ui/chat"; -export default function ChatSection() { +function useChat(): ChatInputProps & { messages: Message[] } { const [messages, setMessages] = useState<Message[]>([]); - const [loading, setLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [input, setInput] = useState(""); const getAssistantMessage = async (messages: Message[]) => { @@ -30,8 +30,10 @@ export default function ChatSection() { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); if (!input) return; + + setIsLoading(true); + try { - setLoading(true); const newMessages = [ ...messages, { id: nanoid(), content: input, role: "user" }, @@ -40,9 +42,11 @@ export default function ChatSection() { setInput(""); const assistantMessage = await getAssistantMessage(newMessages); setMessages([...newMessages, { ...assistantMessage, id: nanoid() }]); - setLoading(false); } catch (error: any) { - alert(JSON.stringify(error)); + console.log(error); + alert(error.message); + } finally { + setIsLoading(false); } }; @@ -50,15 +54,27 @@ export default function ChatSection() { setInput(e.target.value); }; + return { + messages, + isLoading, + input, + handleSubmit, + handleInputChange, + }; +} + +export default function ChatSection() { + const { messages, isLoading, input, handleSubmit, handleInputChange } = + useChat(); return ( - <> + <div className="space-y-4 max-w-5xl w-full"> <ChatMessages messages={messages} /> <ChatInput handleSubmit={handleSubmit} - isLoading={loading} + isLoading={isLoading} input={input} handleInputChange={handleInputChange} /> - </> + </div> ); } diff --git a/templates/types/streaming/nextjs/app/components/chat-section.tsx b/templates/types/streaming/nextjs/app/components/chat-section.tsx index fd771fb3dd5221e3c63259ffcf154691cd13190e..04098fcdf41a4e686459f0ce0d6f3ca311895a9e 100644 --- a/templates/types/streaming/nextjs/app/components/chat-section.tsx +++ b/templates/types/streaming/nextjs/app/components/chat-section.tsx @@ -4,18 +4,30 @@ import { useChat } from "ai/react"; import { ChatInput, ChatMessages } from "./ui/chat"; export default function ChatSection() { - const { messages, input, isLoading, handleSubmit, handleInputChange } = - useChat({ api: process.env.NEXT_PUBLIC_CHAT_API }); + const { + messages, + input, + isLoading, + handleSubmit, + handleInputChange, + reload, + stop, + } = useChat({ api: process.env.NEXT_PUBLIC_CHAT_API }); return ( - <> - <ChatMessages messages={messages} /> + <div className="space-y-4 max-w-5xl w-full"> + <ChatMessages + messages={messages} + isLoading={isLoading} + reload={reload} + stop={stop} + /> <ChatInput input={input} handleSubmit={handleSubmit} handleInputChange={handleInputChange} isLoading={isLoading} /> - </> + </div> ); } diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx index 65eacabbfb1b7bc99950b43e7fd07ba56b3ecdb0..0e978394015bd985af40646e87fa6620e9001a2f 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx @@ -9,7 +9,17 @@ export interface Message { role: string; } -export default function ChatMessages({ messages }: { messages: Message[] }) { +export default function ChatMessages({ + messages, + isLoading, + reload, + stop, +}: { + messages: Message[]; + isLoading?: boolean; + stop?: () => void; + reload?: () => void; +}) { const scrollableChatContainerRef = useRef<HTMLDivElement>(null); const scrollToBottom = () => {