chore: move src/llama_stack/ui to src/llama_stack_ui (#4068)

# What does this PR do?
This better separates UI from backend code, which was a point of
confusion often for our beloved AI friends.


## Test Plan
CI
This commit is contained in:
ehhuang 2025-11-04 15:21:49 -08:00 committed by GitHub
parent 5850e3473f
commit 95b0493fae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
156 changed files with 20 additions and 20 deletions

View file

@ -0,0 +1,407 @@
"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]: unknown;
};
}
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?: unknown;
}
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
? new Date(createdAt).toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
})
: undefined;
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={new Date(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={new Date(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={new Date(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>
);
}

View file

@ -0,0 +1,357 @@
"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/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;
onRateResponse?: (
messageId: string,
rating: "thumbs-up" | "thumbs-down"
) => void;
setMessages?: (messages: Message[]) => void;
transcribeAudio?: (blob: Blob) => Promise<string>;
onRAGFileUpload?: (file: File) => Promise<void>;
}
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,
onRAGFileUpload,
}: 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: {
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;
}
);
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 ? (
<>
<div className="border-r pr-1">
<CopyButton
content={message.content}
copyMessage="Copied response to clipboard!"
/>
</div>
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={() => onRateResponse(message.id, "thumbs-up")}
>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
onClick={() => onRateResponse(message.id, "thumbs-down")}
>
<ThumbsDown className="h-4 w-4" />
</Button>
</>
) : (
<CopyButton
content={message.content}
copyMessage="Copied response to clipboard!"
/>
),
}),
[onRateResponse]
);
return (
<ChatContainer className={className}>
<div className="flex-1 flex flex-col">
{isEmpty && append && suggestions ? (
<div className="flex-1 flex items-center justify-center">
<PromptSuggestions
label="Try these prompts ✨"
append={append}
suggestions={suggestions}
/>
</div>
) : null}
{messages.length > 0 ? (
<ChatMessages messages={messages}>
<MessageList
messages={messages}
isTyping={isTyping}
messageOptions={messageOptions}
/>
</ChatMessages>
) : null}
</div>
<div className="mt-auto border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container max-w-4xl py-4">
<ChatForm
isPending={isGenerating || isTyping}
handleSubmit={handleSubmit}
>
{() => (
<MessageInput
value={input}
onChange={handleInputChange}
allowAttachments={true}
files={null}
setFiles={() => {}}
stop={handleStop}
isGenerating={isGenerating}
transcribeAudio={transcribeAudio}
onRAGFileUpload={onRAGFileUpload}
/>
)}
</ChatForm>
</div>
</div>
</ChatContainer>
);
}
Chat.displayName = "Chat";
export function ChatMessages({
messages,
children,
}: React.PropsWithChildren<{
messages: Message[];
}>) {
const {
containerRef,
scrollToBottom,
handleScroll,
shouldAutoScroll,
handleTouchStart,
} = useAutoScroll([messages]);
return (
<div
className="grid grid-cols-1 overflow-y-auto pb-4"
ref={containerRef}
onScroll={handleScroll}
onTouchStart={handleTouchStart}
>
<div className="max-w-full [grid-column:1/1] [grid-row:1/1]">
{children}
</div>
{!shouldAutoScroll && (
<div className="pointer-events-none flex flex-1 items-end justify-end [grid-column:1/1] [grid-row:1/1]">
<div className="sticky bottom-0 left-0 flex w-full justify-end">
<Button
onClick={scrollToBottom}
className="pointer-events-auto h-8 w-8 rounded-full ease-in-out animate-in fade-in-0 slide-in-from-bottom-1"
size="icon"
variant="ghost"
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
export const ChatContainer = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn("flex flex-col max-h-full w-full", className)}
{...props}
/>
);
});
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<React.SetStateAction<File[] | null>>;
}) => ReactElement;
}
export const ChatForm = forwardRef<HTMLFormElement, ChatFormProps>(
({ children, handleSubmit, isPending, className }, ref) => {
const [files, setFiles] = useState<File[] | null>(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 (
<form ref={ref} onSubmit={onSubmit} className={className}>
{children({ files, setFiles })}
</form>
);
}
);
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;
}

View file

@ -0,0 +1,345 @@
import React from "react";
import { render, screen, waitFor, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { Conversations, SessionUtils } from "./conversations";
import type { Message } from "@/components/chat-playground/chat-message";
interface ChatSession {
id: string;
name: string;
messages: Message[];
selectedModel: string;
systemMessage: string;
agentId: string;
createdAt: number;
updatedAt: number;
}
const mockOnSessionChange = jest.fn();
const mockOnNewSession = jest.fn();
// Mock the auth client
const mockClient = {
agents: {
session: {
list: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
retrieve: jest.fn(),
},
},
};
// Mock the useAuthClient hook
jest.mock("@/hooks/use-auth-client", () => ({
useAuthClient: jest.fn(() => mockClient),
}));
// Mock additional SessionUtils methods that are now being used
jest.mock("./conversations", () => {
const actual = jest.requireActual("./conversations");
return {
...actual,
SessionUtils: {
...actual.SessionUtils,
saveSessionData: jest.fn(),
loadSessionData: jest.fn(),
saveAgentConfig: jest.fn(),
loadAgentConfig: jest.fn(),
clearAgentCache: jest.fn(),
},
};
});
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});
// Mock crypto.randomUUID for test environment
let uuidCounter = 0;
Object.defineProperty(globalThis, "crypto", {
value: {
randomUUID: jest.fn(() => `test-uuid-${++uuidCounter}`),
},
writable: true,
});
describe("SessionManager", () => {
const mockSession: ChatSession = {
id: "session_123",
name: "Test Session",
messages: [
{
id: "msg_1",
role: "user",
content: "Hello",
createdAt: new Date(),
},
],
selectedModel: "test-model",
systemMessage: "You are a helpful assistant.",
agentId: "agent_123",
createdAt: 1710000000,
updatedAt: 1710001000,
};
const mockAgentSessions = [
{
session_id: "session_123",
session_name: "Test Session",
started_at: "2024-01-01T00:00:00Z",
turns: [],
},
{
session_id: "session_456",
session_name: "Another Session",
started_at: "2024-01-01T01:00:00Z",
turns: [],
},
];
beforeEach(() => {
jest.clearAllMocks();
localStorageMock.getItem.mockReturnValue(null);
localStorageMock.setItem.mockImplementation(() => {});
mockClient.agents.session.list.mockResolvedValue({
data: mockAgentSessions,
});
mockClient.agents.session.create.mockResolvedValue({
session_id: "new_session_123",
});
mockClient.agents.session.delete.mockResolvedValue(undefined);
mockClient.agents.session.retrieve.mockResolvedValue({
session_id: "test-session",
session_name: "Test Session",
started_at: new Date().toISOString(),
turns: [],
});
uuidCounter = 0; // Reset UUID counter for consistent test behavior
});
describe("Component Rendering", () => {
test("does not render when no agent is selected", async () => {
const { container } = await act(async () => {
return render(
<Conversations
selectedAgentId=""
currentSession={null}
onSessionChange={mockOnSessionChange}
onNewSession={mockOnNewSession}
/>
);
});
expect(container.firstChild).toBeNull();
});
test("renders loading state initially", async () => {
mockClient.agents.session.list.mockImplementation(
() => new Promise(() => {}) // Never resolves to simulate loading
);
await act(async () => {
render(
<Conversations
selectedAgentId="agent_123"
currentSession={null}
onSessionChange={mockOnSessionChange}
onNewSession={mockOnNewSession}
/>
);
});
expect(screen.getByText("Select Session")).toBeInTheDocument();
// When loading, the "+ New" button should be disabled
expect(screen.getByText("+ New")).toBeDisabled();
});
test("renders session selector when agent sessions are loaded", async () => {
await act(async () => {
render(
<Conversations
selectedAgentId="agent_123"
currentSession={null}
onSessionChange={mockOnSessionChange}
onNewSession={mockOnNewSession}
/>
);
});
await waitFor(() => {
expect(screen.getByText("Select Session")).toBeInTheDocument();
});
});
test("renders current session name when session is selected", async () => {
await act(async () => {
render(
<Conversations
selectedAgentId="agent_123"
currentSession={mockSession}
onSessionChange={mockOnSessionChange}
onNewSession={mockOnNewSession}
/>
);
});
await waitFor(() => {
expect(screen.getByText("Test Session")).toBeInTheDocument();
});
});
});
describe("Agent API Integration", () => {
test("loads sessions from agent API on mount", async () => {
await act(async () => {
render(
<Conversations
selectedAgentId="agent_123"
currentSession={mockSession}
onSessionChange={mockOnSessionChange}
onNewSession={mockOnNewSession}
/>
);
});
await waitFor(() => {
expect(mockClient.agents.session.list).toHaveBeenCalledWith(
"agent_123"
);
});
});
test("handles API errors gracefully", async () => {
mockClient.agents.session.list.mockRejectedValue(new Error("API Error"));
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
await act(async () => {
render(
<Conversations
selectedAgentId="agent_123"
currentSession={mockSession}
onSessionChange={mockOnSessionChange}
onNewSession={mockOnNewSession}
/>
);
});
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
"Error loading agent sessions:",
expect.any(Error)
);
});
consoleSpy.mockRestore();
});
});
describe("Error Handling", () => {
test("component renders without crashing when API is unavailable", async () => {
mockClient.agents.session.list.mockRejectedValue(
new Error("Network Error")
);
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
await act(async () => {
render(
<Conversations
selectedAgentId="agent_123"
currentSession={mockSession}
onSessionChange={mockOnSessionChange}
onNewSession={mockOnNewSession}
/>
);
});
// Should still render the session manager with the select trigger
expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.getByText("+ New")).toBeInTheDocument();
consoleSpy.mockRestore();
});
});
});
describe("SessionUtils", () => {
beforeEach(() => {
jest.clearAllMocks();
localStorageMock.getItem.mockReturnValue(null);
localStorageMock.setItem.mockImplementation(() => {});
});
describe("saveCurrentSessionId", () => {
test("saves session ID to localStorage", () => {
SessionUtils.saveCurrentSessionId("test-session-id");
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"chat-playground-current-session",
"test-session-id"
);
});
});
describe("createDefaultSession", () => {
test("creates default session with agent ID", () => {
const result = SessionUtils.createDefaultSession("agent_123");
expect(result).toEqual(
expect.objectContaining({
name: "Default Session",
messages: [],
selectedModel: "",
systemMessage: "You are a helpful assistant.",
agentId: "agent_123",
})
);
expect(result.id).toBeTruthy();
expect(result.createdAt).toBeTruthy();
expect(result.updatedAt).toBeTruthy();
});
test("creates default session with inherited model", () => {
const result = SessionUtils.createDefaultSession(
"agent_123",
"inherited-model"
);
expect(result.selectedModel).toBe("inherited-model");
expect(result.agentId).toBe("agent_123");
});
test("creates unique session IDs", () => {
const originalNow = Date.now;
let mockTime = 1710005000;
Date.now = jest.fn(() => ++mockTime);
const session1 = SessionUtils.createDefaultSession("agent_123");
const session2 = SessionUtils.createDefaultSession("agent_123");
expect(session1.id).not.toBe(session2.id);
Date.now = originalNow;
});
test("sets creation and update timestamps", () => {
const result = SessionUtils.createDefaultSession("agent_123");
expect(result.createdAt).toBeTruthy();
expect(result.updatedAt).toBeTruthy();
expect(typeof result.createdAt).toBe("number");
expect(typeof result.updatedAt).toBe("number");
});
});
});

View file

@ -0,0 +1,565 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
import { Trash2 } from "lucide-react";
import type { Message } from "@/components/chat-playground/chat-message";
import { useAuthClient } from "@/hooks/use-auth-client";
import { cleanMessageContent } from "@/lib/message-content-utils";
import type {
Session,
SessionCreateParams,
} from "llama-stack-client/resources/agents";
export interface ChatSession {
id: string;
name: string;
messages: Message[];
selectedModel: string;
systemMessage: string;
agentId: string;
session?: Session;
createdAt: number;
updatedAt: number;
}
interface SessionManagerProps {
currentSession: ChatSession | null;
onSessionChange: (session: ChatSession) => void;
onNewSession: () => void;
selectedAgentId: string;
}
const CURRENT_SESSION_KEY = "chat-playground-current-session";
// ensures this only happens client side
const safeLocalStorage = {
getItem: (key: string): string | null => {
if (typeof window === "undefined") return null;
try {
return localStorage.getItem(key);
} catch (err) {
console.error("Error accessing localStorage:", err);
return null;
}
},
setItem: (key: string, value: string): void => {
if (typeof window === "undefined") return;
try {
localStorage.setItem(key, value);
} catch (err) {
console.error("Error writing to localStorage:", err);
}
},
removeItem: (key: string): void => {
if (typeof window === "undefined") return;
try {
localStorage.removeItem(key);
} catch (err) {
console.error("Error removing from localStorage:", err);
}
},
};
const generateSessionId = (): string => {
return globalThis.crypto.randomUUID();
};
export function Conversations({
currentSession,
onSessionChange,
selectedAgentId,
}: SessionManagerProps) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newSessionName, setNewSessionName] = useState("");
const [loading, setLoading] = useState(false);
const client = useAuthClient();
const loadAgentSessions = useCallback(async () => {
if (!selectedAgentId) return;
setLoading(true);
try {
const response = await client.agents.session.list(selectedAgentId);
console.log("Sessions response:", response);
if (!response.data || !Array.isArray(response.data)) {
console.warn("Invalid sessions response, starting fresh");
setSessions([]);
return;
}
const agentSessions: ChatSession[] = response.data
.filter(sessionData => {
const isValid =
sessionData &&
typeof sessionData === "object" &&
sessionData.session_id &&
sessionData.session_name;
if (!isValid) {
console.warn("Filtering out invalid session:", sessionData);
}
return isValid;
})
.map(sessionData => ({
id: sessionData.session_id,
name: sessionData.session_name,
messages: [],
selectedModel: currentSession?.selectedModel || "",
systemMessage:
currentSession?.systemMessage || "You are a helpful assistant.",
agentId: selectedAgentId,
session: sessionData,
createdAt: sessionData.started_at
? new Date(sessionData.started_at).getTime()
: Date.now(),
updatedAt: sessionData.started_at
? new Date(sessionData.started_at).getTime()
: Date.now(),
}));
setSessions(agentSessions);
} catch (error) {
console.error("Error loading agent sessions:", error);
setSessions([]);
} finally {
setLoading(false);
}
}, [
selectedAgentId,
client,
currentSession?.selectedModel,
currentSession?.systemMessage,
]);
useEffect(() => {
if (selectedAgentId) {
loadAgentSessions();
}
}, [selectedAgentId, loadAgentSessions]);
const createNewSession = async () => {
if (!selectedAgentId) return;
const sessionName =
newSessionName.trim() || `Session ${sessions.length + 1}`;
setLoading(true);
try {
const response = await client.agents.session.create(selectedAgentId, {
session_name: sessionName,
} as SessionCreateParams);
const newSession: ChatSession = {
id: response.session_id,
name: sessionName,
messages: [],
selectedModel: currentSession?.selectedModel || "",
systemMessage:
currentSession?.systemMessage || "You are a helpful assistant.",
agentId: selectedAgentId,
createdAt: Date.now(),
updatedAt: Date.now(),
};
setSessions(prev => [...prev, newSession]);
SessionUtils.saveCurrentSessionId(newSession.id, selectedAgentId);
onSessionChange(newSession);
setNewSessionName("");
setShowCreateForm(false);
} catch (error) {
console.error("Error creating session:", error);
} finally {
setLoading(false);
}
};
const loadSessionMessages = useCallback(
async (agentId: string, sessionId: string): Promise<Message[]> => {
try {
const session = await client.agents.session.retrieve(
agentId,
sessionId
);
if (!session || !session.turns || !Array.isArray(session.turns)) {
return [];
}
const messages: Message[] = [];
for (const turn of session.turns) {
// Add user messages from input_messages
if (turn.input_messages && Array.isArray(turn.input_messages)) {
for (const input of turn.input_messages) {
if (input.role === "user" && input.content) {
messages.push({
id: `${turn.turn_id}-user-${messages.length}`,
role: "user",
content:
typeof input.content === "string"
? input.content
: JSON.stringify(input.content),
createdAt: new Date(turn.started_at || Date.now()),
});
}
}
}
// Add assistant message from output_message
if (turn.output_message && turn.output_message.content) {
messages.push({
id: `${turn.turn_id}-assistant-${messages.length}`,
role: "assistant",
content: cleanMessageContent(turn.output_message.content),
createdAt: new Date(
turn.completed_at || turn.started_at || Date.now()
),
});
}
}
return messages;
} catch (error) {
console.error("Error loading session messages:", error);
return [];
}
},
[client]
);
const switchToSession = useCallback(
async (sessionId: string) => {
const session = sessions.find(s => s.id === sessionId);
if (session) {
setLoading(true);
try {
// Load messages for this session
const messages = await loadSessionMessages(
selectedAgentId,
sessionId
);
const sessionWithMessages = {
...session,
messages,
};
SessionUtils.saveCurrentSessionId(sessionId, selectedAgentId);
onSessionChange(sessionWithMessages);
} catch (error) {
console.error("Error switching to session:", error);
// Fallback to session without messages
SessionUtils.saveCurrentSessionId(sessionId, selectedAgentId);
onSessionChange(session);
} finally {
setLoading(false);
}
}
},
[sessions, selectedAgentId, loadSessionMessages, onSessionChange]
);
const deleteSession = async (sessionId: string) => {
if (!selectedAgentId) {
return;
}
if (
confirm(
"Are you sure you want to delete this session? This action cannot be undone."
)
) {
setLoading(true);
try {
await client.agents.session.delete(selectedAgentId, sessionId);
const updatedSessions = sessions.filter(s => s.id !== sessionId);
setSessions(updatedSessions);
if (currentSession?.id === sessionId) {
const newCurrentSession = updatedSessions[0] || null;
if (newCurrentSession) {
SessionUtils.saveCurrentSessionId(
newCurrentSession.id,
selectedAgentId
);
onSessionChange(newCurrentSession);
} else {
SessionUtils.clearCurrentSession(selectedAgentId);
onNewSession();
}
}
} catch (error) {
console.error("Error deleting session:", error);
} finally {
setLoading(false);
}
}
};
useEffect(() => {
if (currentSession) {
setSessions(prevSessions => {
const updatedSessions = prevSessions.map(session =>
session.id === currentSession.id ? currentSession : session
);
if (!prevSessions.find(s => s.id === currentSession.id)) {
updatedSessions.push(currentSession);
}
return updatedSessions;
});
}
}, [currentSession]);
if (!selectedAgentId) {
return null;
}
return (
<div className="relative">
<div className="flex items-center gap-2">
<Select
value={currentSession?.id || ""}
onValueChange={switchToSession}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Session" />
</SelectTrigger>
<SelectContent>
{sessions.map(session => (
<SelectItem key={session.id} value={session.id}>
{session.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => setShowCreateForm(true)}
variant="outline"
size="sm"
disabled={loading || !selectedAgentId}
>
+ New
</Button>
{currentSession && (
<Button
onClick={() => deleteSession(currentSession.id)}
variant="outline"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
title="Delete current session"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
{showCreateForm && (
<Card className="absolute top-full left-0 mt-2 p-4 space-y-3 w-80 z-50 bg-background border shadow-lg">
<h3 className="text-md font-semibold">Create New Session</h3>
<Input
value={newSessionName}
onChange={e => setNewSessionName(e.target.value)}
placeholder="Session name (optional)"
onKeyDown={e => {
if (e.key === "Enter") {
createNewSession();
} else if (e.key === "Escape") {
setShowCreateForm(false);
setNewSessionName("");
}
}}
/>
<div className="flex gap-2">
<Button
onClick={createNewSession}
className="flex-1"
disabled={loading}
>
{loading ? "Creating..." : "Create"}
</Button>
<Button
variant="outline"
onClick={() => {
setShowCreateForm(false);
setNewSessionName("");
}}
className="flex-1"
>
Cancel
</Button>
</div>
</Card>
)}
{currentSession && sessions.length > 1 && (
<div className="absolute top-full left-0 mt-1 text-xs text-gray-500 whitespace-nowrap">
{sessions.length} sessions Current: {currentSession.name}
{currentSession.messages.length > 0 &&
`${currentSession.messages.length} messages`}
</div>
)}
</div>
);
}
export const SessionUtils = {
loadCurrentSessionId: (agentId?: string): string | null => {
const key = agentId
? `${CURRENT_SESSION_KEY}-${agentId}`
: CURRENT_SESSION_KEY;
return safeLocalStorage.getItem(key);
},
saveCurrentSessionId: (sessionId: string, agentId?: string) => {
const key = agentId
? `${CURRENT_SESSION_KEY}-${agentId}`
: CURRENT_SESSION_KEY;
safeLocalStorage.setItem(key, sessionId);
},
createDefaultSession: (
agentId: string,
inheritModel?: string
): ChatSession => ({
id: generateSessionId(),
name: "Default Session",
messages: [],
selectedModel: inheritModel || "",
systemMessage: "You are a helpful assistant.",
agentId,
createdAt: Date.now(),
updatedAt: Date.now(),
}),
clearCurrentSession: (agentId?: string) => {
const key = agentId
? `${CURRENT_SESSION_KEY}-${agentId}`
: CURRENT_SESSION_KEY;
safeLocalStorage.removeItem(key);
},
loadCurrentAgentId: (): string | null => {
return safeLocalStorage.getItem("chat-playground-current-agent");
},
saveCurrentAgentId: (agentId: string) => {
safeLocalStorage.setItem("chat-playground-current-agent", agentId);
},
// Comprehensive session caching
saveSessionData: (agentId: string, sessionData: ChatSession) => {
const key = `chat-playground-session-data-${agentId}-${sessionData.id}`;
safeLocalStorage.setItem(
key,
JSON.stringify({
...sessionData,
cachedAt: Date.now(),
})
);
},
loadSessionData: (agentId: string, sessionId: string): ChatSession | null => {
const key = `chat-playground-session-data-${agentId}-${sessionId}`;
const cached = safeLocalStorage.getItem(key);
if (!cached) return null;
try {
const data = JSON.parse(cached);
// Check if cache is fresh (less than 1 hour old)
const cacheAge = Date.now() - (data.cachedAt || 0);
if (cacheAge > 60 * 60 * 1000) {
safeLocalStorage.removeItem(key);
return null;
}
// Convert date strings back to Date objects
return {
...data,
messages: data.messages.map(
(msg: { createdAt: string; [key: string]: unknown }) => ({
...msg,
createdAt: new Date(msg.createdAt),
})
),
};
} catch (error) {
console.error("Error parsing cached session data:", error);
safeLocalStorage.removeItem(key);
return null;
}
},
// Agent config caching
saveAgentConfig: (
agentId: string,
config: {
toolgroups?: Array<
string | { name: string; args: Record<string, unknown> }
>;
[key: string]: unknown;
}
) => {
const key = `chat-playground-agent-config-${agentId}`;
safeLocalStorage.setItem(
key,
JSON.stringify({
config,
cachedAt: Date.now(),
})
);
},
loadAgentConfig: (
agentId: string
): {
toolgroups?: Array<
string | { name: string; args: Record<string, unknown> }
>;
[key: string]: unknown;
} | null => {
const key = `chat-playground-agent-config-${agentId}`;
const cached = safeLocalStorage.getItem(key);
if (!cached) return null;
try {
const data = JSON.parse(cached);
// Check if cache is fresh (less than 30 minutes old)
const cacheAge = Date.now() - (data.cachedAt || 0);
if (cacheAge > 30 * 60 * 1000) {
safeLocalStorage.removeItem(key);
return null;
}
return data.config;
} catch (error) {
console.error("Error parsing cached agent config:", error);
safeLocalStorage.removeItem(key);
return null;
}
},
// Clear all cached data for an agent
clearAgentCache: (agentId: string) => {
const keys = Object.keys(localStorage).filter(
key =>
key.includes(`chat-playground-session-data-${agentId}`) ||
key.includes(`chat-playground-agent-config-${agentId}`)
);
keys.forEach(key => safeLocalStorage.removeItem(key));
},
};

View file

@ -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 (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ top: 0, filter: "blur(5px)" }}
animate={{
top: -40,
filter: "blur(0px)",
transition: {
type: "spring",
filter: { type: "tween" },
},
}}
exit={{ top: 0, filter: "blur(5px)" }}
className="absolute left-1/2 flex -translate-x-1/2 overflow-hidden whitespace-nowrap rounded-full border bg-background py-1 text-center text-sm text-muted-foreground"
>
<span className="ml-2.5">Press Enter again to interrupt</span>
<button
className="ml-1 mr-2.5 flex items-center"
type="button"
onClick={close}
aria-label="Close"
>
<X className="h-3 w-3" />
</button>
</motion.div>
)}
</AnimatePresence>
);
}

View file

@ -0,0 +1,240 @@
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";
interface MarkdownRendererProps {
children: string;
}
export function MarkdownRenderer({ children }: MarkdownRendererProps) {
return (
<div className="space-y-3">
<Markdown remarkPlugins={[remarkGfm]} components={COMPONENTS}>
{children}
</Markdown>
</div>
);
}
interface HighlightedPre extends React.HTMLAttributes<HTMLPreElement> {
children: string;
language: string;
}
const HighlightedPre = React.memo(
({ children, language, ...props }: HighlightedPre) => {
const [tokens, setTokens] = useState<unknown[] | null>(null);
const [isSupported, setIsSupported] = useState(false);
useEffect(() => {
let mounted = true;
const loadAndHighlight = async () => {
try {
const { codeToTokens, bundledLanguages } = await import("shiki");
if (!mounted) return;
if (!(language in bundledLanguages)) {
setIsSupported(false);
return;
}
setIsSupported(true);
const { tokens: highlightedTokens } = await codeToTokens(children, {
lang: language as keyof typeof bundledLanguages,
defaultColor: false,
themes: {
light: "github-light",
dark: "github-dark",
},
});
if (mounted) {
setTokens(highlightedTokens);
}
} catch {
if (mounted) {
setIsSupported(false);
}
}
};
loadAndHighlight();
return () => {
mounted = false;
};
}, [children, language]);
if (!isSupported) {
return <pre {...props}>{children}</pre>;
}
if (!tokens) {
return <pre {...props}>{children}</pre>;
}
return (
<pre {...props}>
<code>
{tokens.map((line, lineIndex) => (
<React.Fragment key={lineIndex}>
<span>
{line.map((token, tokenIndex) => {
const style =
typeof token.htmlStyle === "string"
? undefined
: token.htmlStyle;
return (
<span
key={tokenIndex}
className="text-shiki-light bg-shiki-light-bg dark:text-shiki-dark dark:bg-shiki-dark-bg"
style={style}
>
{token.content}
</span>
);
})}
</span>
{lineIndex !== tokens.length - 1 && "\n"}
</React.Fragment>
))}
</code>
</pre>
);
}
);
HighlightedPre.displayName = "HighlightedCode";
interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {
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 (
<div className="group/code relative mb-4">
<Suspense
fallback={
<pre className={preClass} {...restProps}>
{children}
</pre>
}
>
<HighlightedPre language={language} className={preClass}>
{code}
</HighlightedPre>
</Suspense>
<div className="invisible absolute right-2 top-2 flex space-x-1 rounded-lg p-1 opacity-0 transition-all duration-200 group-hover/code:visible group-hover/code:opacity-100">
<CopyButton content={code} copyMessage="Copied code to clipboard" />
</div>
</div>
);
};
function childrenTakeAllStringContents(element: unknown): string {
if (typeof element === "string") {
return element;
}
if (element?.props?.children) {
const 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,
...rest
}: {
children: React.ReactNode;
className?: string;
}) => {
const match = /language-(\w+)/.exec(className || "");
return match ? (
<CodeBlock className={className} language={match[1]} {...rest}>
{children}
</CodeBlock>
) : (
<code
className={cn(
"font-mono [:not(pre)>&]:rounded-md [:not(pre)>&]:bg-background/50 [:not(pre)>&]:px-1 [:not(pre)>&]:py-0.5"
)}
{...rest}
>
{children}
</code>
);
},
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"),
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 = ({ ...props }: Record<string, unknown>) => (
<Tag className={classes} {...props} />
);
Component.displayName = Tag;
return Component;
}
export default MarkdownRenderer;

View file

@ -0,0 +1,49 @@
import React from "react";
export interface MessageBlockProps {
label: string;
labelDetail?: string;
content: React.ReactNode;
className?: string;
contentClassName?: string;
}
export const MessageBlock: React.FC<MessageBlockProps> = ({
label,
labelDetail,
content,
className = "",
contentClassName = "",
}) => {
return (
<div className={`mb-4 ${className}`}>
<p className="py-1 font-semibold text-muted-foreground mb-1">
{label}
{labelDetail && (
<span className="text-xs text-muted-foreground font-normal ml-1">
{labelDetail}
</span>
)}
</p>
<div className={`py-1 whitespace-pre-wrap ${contentClassName}`}>
{content}
</div>
</div>
);
};
export interface ToolCallBlockProps {
children: React.ReactNode;
className?: string;
}
export const ToolCallBlock = ({ children, className }: ToolCallBlockProps) => {
const baseClassName =
"p-3 bg-slate-50 border border-slate-200 rounded-md text-sm";
return (
<div className={`${baseClassName} ${className || ""}`}>
<pre className="whitespace-pre-wrap text-xs">{children}</pre>
</div>
);
};

View file

@ -0,0 +1,459 @@
"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/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>;
onRAGFileUpload?: (file: File) => Promise<void>;
}
interface MessageInputWithoutAttachmentProps extends MessageInputBaseProps {
allowAttachments?: false;
}
interface MessageInputWithAttachmentsProps extends MessageInputBaseProps {
allowAttachments: true;
files: File[] | null;
setFiles: React.Dispatch<React.SetStateAction<File[] | null>>;
}
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 React.ChangeEvent<HTMLTextAreaElement>);
},
});
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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>(null);
const [textAreaHeight, setTextAreaHeight] = useState<number>(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 (
<div
className="relative flex w-full"
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{enableInterrupt && (
<InterruptPrompt
isOpen={showInterruptPrompt}
close={() => setShowInterruptPrompt(false)}
/>
)}
<RecordingPrompt
isVisible={isRecording}
onStopRecording={stopRecording}
/>
<div className="relative flex w-full items-center space-x-2">
<div className="relative flex-1">
<textarea
aria-label="Write your prompt here"
placeholder={placeholder}
ref={textAreaRef}
onPaste={onPaste}
onKeyDown={onKeyDown}
className={cn(
"z-10 w-full grow resize-none rounded-xl border border-input bg-background p-3 pr-24 text-sm ring-offset-background transition-[border] placeholder:text-muted-foreground focus-visible:border-primary focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
showFileList && "pb-16",
className
)}
{...(props.allowAttachments
? omit(props, [
"allowAttachments",
"files",
"setFiles",
"onRAGFileUpload",
])
: omit(props, ["allowAttachments", "onRAGFileUpload"]))}
/>
{props.allowAttachments && (
<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 => {
return (
<FilePreview
key={file.name + String(file.lastModified)}
file={file}
onRemove={() => {
props.setFiles(files => {
if (!files) return null;
const filtered = Array.from(files).filter(
f => f !== file
);
if (filtered.length === 0) return null;
return filtered;
});
}}
/>
);
})}
</AnimatePresence>
</div>
</div>
)}
</div>
</div>
<div className="absolute right-3 top-3 z-20 flex gap-2">
{props.allowAttachments && (
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label="Upload file to RAG"
disabled={false}
onClick={async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".pdf,.txt,.md,.html,.csv,.json";
input.onchange = async e => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file && props.onRAGFileUpload) {
await props.onRAGFileUpload(file);
}
};
input.click();
}}
>
<Paperclip className="h-4 w-4" />
</Button>
)}
{isSpeechSupported && (
<Button
type="button"
variant="outline"
className={cn("h-8 w-8", isListening && "text-primary")}
aria-label="Voice input"
size="icon"
onClick={toggleListening}
>
<Mic className="h-4 w-4" />
</Button>
)}
{isGenerating && stop ? (
<Button
type="button"
size="icon"
className="h-8 w-8"
aria-label="Stop generating"
onClick={stop}
>
<Square className="h-3 w-3 animate-pulse" fill="currentColor" />
</Button>
) : (
<Button
type="submit"
size="icon"
className="h-8 w-8 transition-opacity"
aria-label="Send message"
disabled={props.value === "" || isGenerating}
>
<ArrowUp className="h-5 w-5" />
</Button>
)}
</div>
{props.allowAttachments && <FileUploadOverlay isDragging={isDragging} />}
<RecordingControls
isRecording={isRecording}
isTranscribing={isTranscribing}
audioStream={audioStream}
textAreaHeight={textAreaHeight}
onStopRecording={stopRecording}
/>
</div>
);
}
MessageInput.displayName = "MessageInput";
interface FileUploadOverlayProps {
isDragging: boolean;
}
function FileUploadOverlay({ isDragging }: FileUploadOverlayProps) {
return (
<AnimatePresence>
{isDragging && (
<motion.div
className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center space-x-2 rounded-xl border border-dashed border-border bg-background text-sm text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
aria-hidden
>
<Paperclip className="h-4 w-4" />
<span>Drop your files here to attach them.</span>
</motion.div>
)}
</AnimatePresence>
);
}
function TranscribingOverlay() {
return (
<motion.div
className="flex h-full w-full flex-col items-center justify-center rounded-xl bg-background/80 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="relative">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<motion.div
className="absolute inset-0 h-8 w-8 animate-pulse rounded-full bg-primary/20"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1.2, opacity: 1 }}
transition={{
duration: 1,
repeat: Infinity,
repeatType: "reverse",
ease: "easeInOut",
}}
/>
</div>
<p className="mt-4 text-sm font-medium text-muted-foreground">
Transcribing audio...
</p>
</motion.div>
);
}
interface RecordingPromptProps {
isVisible: boolean;
onStopRecording: () => void;
}
function RecordingPrompt({ isVisible, onStopRecording }: RecordingPromptProps) {
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ top: 0, filter: "blur(5px)" }}
animate={{
top: -40,
filter: "blur(0px)",
transition: {
type: "spring",
filter: { type: "tween" },
},
}}
exit={{ top: 0, filter: "blur(5px)" }}
className="absolute left-1/2 flex -translate-x-1/2 cursor-pointer overflow-hidden whitespace-nowrap rounded-full border bg-background py-1 text-center text-sm text-muted-foreground"
onClick={onStopRecording}
>
<span className="mx-2.5 flex items-center">
<Info className="mr-2 h-3 w-3" />
Click to finish recording
</span>
</motion.div>
)}
</AnimatePresence>
);
}
interface RecordingControlsProps {
isRecording: boolean;
isTranscribing: boolean;
audioStream: MediaStream | null;
textAreaHeight: number;
onStopRecording: () => void;
}
function RecordingControls({
isRecording,
isTranscribing,
audioStream,
textAreaHeight,
onStopRecording,
}: RecordingControlsProps) {
if (isRecording) {
return (
<div
className="absolute inset-[1px] z-50 overflow-hidden rounded-xl"
style={{ height: textAreaHeight - 2 }}
>
<AudioVisualizer
stream={audioStream}
isRecording={isRecording}
onClick={onStopRecording}
/>
</div>
);
}
if (isTranscribing) {
return (
<div
className="absolute inset-[1px] z-50 overflow-hidden rounded-xl"
style={{ height: textAreaHeight - 2 }}
>
<TranscribingOverlay />
</div>
);
}
return null;
}

View file

@ -0,0 +1,45 @@
import {
ChatMessage,
type ChatMessageProps,
type Message,
} from "@/components/chat-playground/chat-message";
import { TypingIndicator } from "@/components/chat-playground/typing-indicator";
type AdditionalMessageOptions = Omit<ChatMessageProps, keyof Message>;
interface MessageListProps {
messages: Message[];
showTimeStamps?: boolean;
isTyping?: boolean;
messageOptions?:
| AdditionalMessageOptions
| ((message: Message) => AdditionalMessageOptions);
}
export function MessageList({
messages,
showTimeStamps = true,
isTyping = false,
messageOptions,
}: MessageListProps) {
return (
<div className="space-y-4 overflow-visible">
{messages.map((message, index) => {
const additionalOptions =
typeof messageOptions === "function"
? messageOptions(message)
: messageOptions;
return (
<ChatMessage
key={index}
showTimeStamp={showTimeStamps}
{...message}
{...additionalOptions}
/>
);
})}
{isTyping && <TypingIndicator />}
</div>
);
}

View file

@ -0,0 +1,28 @@
interface PromptSuggestionsProps {
label: string;
append: (message: { role: "user"; content: string }) => void;
suggestions: string[];
}
export function PromptSuggestions({
label,
append,
suggestions,
}: PromptSuggestionsProps) {
return (
<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 => (
<button
key={suggestion}
onClick={() => append({ role: "user", content: suggestion })}
className="h-max flex-1 rounded-xl border bg-background p-4 hover:bg-muted"
>
<p>{suggestion}</p>
</button>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,15 @@
import { Dot } from "lucide-react";
export function TypingIndicator() {
return (
<div className="justify-left flex space-x-1">
<div className="rounded-lg bg-muted p-3">
<div className="flex -space-x-2.5">
<Dot className="h-5 w-5 animate-typing-dot-1" />
<Dot className="h-5 w-5 animate-typing-dot-2" />
<Dot className="h-5 w-5 animate-typing-dot-3" />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,243 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuthClient } from "@/hooks/use-auth-client";
import type { Model } from "llama-stack-client/resources/models";
interface VectorDBCreatorProps {
models: Model[];
onVectorDBCreated?: (vectorDbId: string) => void;
onCancel?: () => void;
}
interface VectorDBProvider {
api: string;
provider_id: string;
provider_type: string;
}
export function VectorDBCreator({
models,
onVectorDBCreated,
onCancel,
}: VectorDBCreatorProps) {
const [vectorDbName, setVectorDbName] = useState("");
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState("");
const [selectedProvider, setSelectedProvider] = useState("faiss");
const [availableProviders, setAvailableProviders] = useState<
VectorDBProvider[]
>([]);
const [isCreating, setIsCreating] = useState(false);
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
const [error, setError] = useState<string | null>(null);
const client = useAuthClient();
const embeddingModels = models.filter(
model => model.model_type === "embedding"
);
useEffect(() => {
const fetchProviders = async () => {
setIsLoadingProviders(true);
try {
const providersResponse = await client.providers.list();
const vectorIoProviders = providersResponse.filter(
(provider: VectorDBProvider) => provider.api === "vector_io"
);
setAvailableProviders(vectorIoProviders);
if (vectorIoProviders.length > 0) {
const faissProvider = vectorIoProviders.find(
(p: VectorDBProvider) => p.provider_id === "faiss"
);
setSelectedProvider(
faissProvider?.provider_id || vectorIoProviders[0].provider_id
);
}
} catch (err) {
console.error("Error fetching providers:", err);
setAvailableProviders([
{
api: "vector_io",
provider_id: "faiss",
provider_type: "inline::faiss",
},
]);
} finally {
setIsLoadingProviders(false);
}
};
fetchProviders();
}, [client]);
const handleCreate = async () => {
if (!vectorDbName.trim() || !selectedEmbeddingModel) {
setError("Please provide a name and select an embedding model");
return;
}
setIsCreating(true);
setError(null);
try {
const embeddingModel = embeddingModels.find(
m => m.identifier === selectedEmbeddingModel
);
if (!embeddingModel) {
throw new Error("Selected embedding model not found");
}
const embeddingDimension = embeddingModel.metadata
?.embedding_dimension as number;
if (!embeddingDimension) {
throw new Error("Embedding dimension not available for selected model");
}
const vectorDbId = vectorDbName.trim() || `vector_db_${Date.now()}`;
const response = await client.vectorDBs.register({
vector_db_id: vectorDbId,
embedding_model: selectedEmbeddingModel,
embedding_dimension: embeddingDimension,
provider_id: selectedProvider,
});
onVectorDBCreated?.(response.identifier || vectorDbId);
} catch (err) {
console.error("Error creating vector DB:", err);
setError(
err instanceof Error ? err.message : "Failed to create vector DB"
);
} finally {
setIsCreating(false);
}
};
return (
<Card className="p-6 space-y-4">
<h3 className="text-lg font-semibold">Create Vector Database</h3>
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-2">
Vector DB Name
</label>
<Input
value={vectorDbName}
onChange={e => setVectorDbName(e.target.value)}
placeholder="My Vector Database"
/>
</div>
<div>
<label className="text-sm font-medium block mb-2">
Embedding Model
</label>
<Select
value={selectedEmbeddingModel}
onValueChange={setSelectedEmbeddingModel}
>
<SelectTrigger>
<SelectValue placeholder="Select Embedding Model" />
</SelectTrigger>
<SelectContent>
{embeddingModels.map(model => (
<SelectItem key={model.identifier} value={model.identifier}>
{model.identifier}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedEmbeddingModel && (
<p className="text-xs text-muted-foreground mt-1">
Dimension:{" "}
{embeddingModels.find(
m => m.identifier === selectedEmbeddingModel
)?.metadata?.embedding_dimension || "Unknown"}
</p>
)}
</div>
<div>
<label className="text-sm font-medium block mb-2">
Vector Database Provider
</label>
<Select
value={selectedProvider}
onValueChange={setSelectedProvider}
disabled={isLoadingProviders}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingProviders
? "Loading providers..."
: "Select Provider"
}
/>
</SelectTrigger>
<SelectContent>
{availableProviders.map(provider => (
<SelectItem
key={provider.provider_id}
value={provider.provider_id}
>
{provider.provider_id}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedProvider && (
<p className="text-xs text-muted-foreground mt-1">
Selected provider: {selectedProvider}
</p>
)}
</div>
{error && (
<div className="text-destructive text-sm bg-destructive/10 p-2 rounded">
{error}
</div>
)}
<div className="flex gap-2 pt-2">
<Button
onClick={handleCreate}
disabled={
isCreating || !vectorDbName.trim() || !selectedEmbeddingModel
}
className="flex-1"
>
{isCreating ? "Creating..." : "Create Vector DB"}
</Button>
{onCancel && (
<Button variant="outline" onClick={onCancel} className="flex-1">
Cancel
</Button>
)}
</div>
</div>
<div className="text-xs text-muted-foreground bg-muted/50 p-3 rounded">
<strong>Note:</strong> This will create a new vector database that can
be used with RAG tools. After creation, you&apos;ll be able to upload
documents and use it for knowledge search in your agent conversations.
</div>
</Card>
);
}