mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-12-04 18:13:44 +00:00
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:
parent
5850e3473f
commit
95b0493fae
156 changed files with 20 additions and 20 deletions
407
src/llama_stack_ui/components/chat-playground/chat-message.tsx
Normal file
407
src/llama_stack_ui/components/chat-playground/chat-message.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
357
src/llama_stack_ui/components/chat-playground/chat.tsx
Normal file
357
src/llama_stack_ui/components/chat-playground/chat.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
565
src/llama_stack_ui/components/chat-playground/conversations.tsx
Normal file
565
src/llama_stack_ui/components/chat-playground/conversations.tsx
Normal 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));
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
459
src/llama_stack_ui/components/chat-playground/message-input.tsx
Normal file
459
src/llama_stack_ui/components/chat-playground/message-input.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'll be able to upload
|
||||
documents and use it for knowledge search in your agent conversations.
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue