mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-08-16 14:38:00 +00:00
feat(UI): adding MVP playground UI (#2828)
# What does this PR do? I've been tinkering a little with a simple chat playground in the UI, so I'm opening the PR with what's kind of a WIP. If you look at the first commit, that includes the big part of the changes. The rest of the files changed come from adding installing the `shadcn` components. Note this is missing a lot; e.g., - sessions - document upload - audio (the shadcn components install these by default from https://shadcn-chatbot-kit.vercel.app/docs/components/chat) I still need to wire up a lot more to make it actually fully functional but it does basic chat using the LS Typescript Client. Basic demo: <img width="1329" height="1430" alt="Image" src="https://github.com/user-attachments/assets/917a2096-36d4-4925-b83b-f1f2cda98698" /> <img width="1319" height="1424" alt="Image" src="https://github.com/user-attachments/assets/fab1583b-1c72-4bf3-baf2-405aee13c6bb" /> <!-- If resolving an issue, uncomment and update the line below --> <!-- Closes #[issue-number] --> ## Test Plan <!-- Describe the tests you ran to verify your changes with result summaries. *Provide clear instructions so the plan can be easily re-executed.* --> --------- Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
This commit is contained in:
parent
d6ae2b0f47
commit
f3d5459647
32 changed files with 4876 additions and 31 deletions
405
llama_stack/ui/components/chat-playground/chat-message.tsx
Normal file
405
llama_stack/ui/components/chat-playground/chat-message.tsx
Normal file
|
@ -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/chat-playground/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<typeof chatBubbleVariants>["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<ChatMessageProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={cn("flex flex-col", isUser ? "items-end" : "items-start")}
|
||||
>
|
||||
{files ? (
|
||||
<div className="mb-1 flex flex-wrap gap-2">
|
||||
{files.map((file, index) => {
|
||||
return <FilePreview file={file} key={index} />
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn(chatBubbleVariants({ isUser, animation }))}>
|
||||
<MarkdownRenderer>{content}</MarkdownRenderer>
|
||||
</div>
|
||||
|
||||
{showTimeStamp && createdAt ? (
|
||||
<time
|
||||
dateTime={createdAt.toISOString()}
|
||||
className={cn(
|
||||
"mt-1 block px-1 text-xs opacity-50",
|
||||
animation !== "none" && "duration-500 animate-in fade-in-0"
|
||||
)}
|
||||
>
|
||||
{formattedTime}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (parts && parts.length > 0) {
|
||||
return parts.map((part, index) => {
|
||||
if (part.type === "text") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
isUser ? "items-end" : "items-start"
|
||||
)}
|
||||
key={`text-${index}`}
|
||||
>
|
||||
<div className={cn(chatBubbleVariants({ isUser, animation }))}>
|
||||
<MarkdownRenderer>{part.text}</MarkdownRenderer>
|
||||
{actions ? (
|
||||
<div className="absolute -bottom-4 right-2 flex space-x-1 rounded-lg border bg-background p-1 text-foreground opacity-0 transition-opacity group-hover/message:opacity-100">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showTimeStamp && createdAt ? (
|
||||
<time
|
||||
dateTime={createdAt.toISOString()}
|
||||
className={cn(
|
||||
"mt-1 block px-1 text-xs opacity-50",
|
||||
animation !== "none" && "duration-500 animate-in fade-in-0"
|
||||
)}
|
||||
>
|
||||
{formattedTime}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
} else if (part.type === "reasoning") {
|
||||
return <ReasoningBlock key={`reasoning-${index}`} part={part} />
|
||||
} else if (part.type === "tool-invocation") {
|
||||
return (
|
||||
<ToolCall
|
||||
key={`tool-${index}`}
|
||||
toolInvocations={[part.toolInvocation]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
if (toolInvocations && toolInvocations.length > 0) {
|
||||
return <ToolCall toolInvocations={toolInvocations} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", isUser ? "items-end" : "items-start")}>
|
||||
<div className={cn(chatBubbleVariants({ isUser, animation }))}>
|
||||
<MarkdownRenderer>{content}</MarkdownRenderer>
|
||||
{actions ? (
|
||||
<div className="absolute -bottom-4 right-2 flex space-x-1 rounded-lg border bg-background p-1 text-foreground opacity-0 transition-opacity group-hover/message:opacity-100">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showTimeStamp && createdAt ? (
|
||||
<time
|
||||
dateTime={createdAt.toISOString()}
|
||||
className={cn(
|
||||
"mt-1 block px-1 text-xs opacity-50",
|
||||
animation !== "none" && "duration-500 animate-in fade-in-0"
|
||||
)}
|
||||
>
|
||||
{formattedTime}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-2 flex flex-col items-start sm:max-w-[70%]">
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className="group w-full overflow-hidden rounded-lg border bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center p-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span>Thinking</span>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<CollapsibleContent forceMount>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={isOpen ? "open" : "closed"}
|
||||
variants={{
|
||||
open: { height: "auto", opacity: 1 },
|
||||
closed: { height: 0, opacity: 0 },
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: [0.04, 0.62, 0.23, 0.98] }}
|
||||
className="border-t"
|
||||
>
|
||||
<div className="p-2">
|
||||
<div className="whitespace-pre-wrap text-xs">
|
||||
{part.reasoning}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolCall({
|
||||
toolInvocations,
|
||||
}: Pick<ChatMessageProps, "toolInvocations">) {
|
||||
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
|
||||
|
||||
if (isCancelled) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
<span>
|
||||
Cancelled{" "}
|
||||
<span className="font-mono">
|
||||
{"`"}
|
||||
{invocation.toolName}
|
||||
{"`"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
switch (invocation.state) {
|
||||
case "partial-call":
|
||||
case "call":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span>
|
||||
Calling{" "}
|
||||
<span className="font-mono">
|
||||
{"`"}
|
||||
{invocation.toolName}
|
||||
{"`"}
|
||||
</span>
|
||||
...
|
||||
</span>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
case "result":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col gap-1.5 rounded-lg border bg-muted/50 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Code2 className="h-4 w-4" />
|
||||
<span>
|
||||
Result from{" "}
|
||||
<span className="font-mono">
|
||||
{"`"}
|
||||
{invocation.toolName}
|
||||
{"`"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap text-foreground">
|
||||
{JSON.stringify(invocation.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue