mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
feat(ui): adopt vercel-style chat composer for playground
Replace the compact single-line input with a PromptInput-style composer: taller auto-growing textarea, rounded card shell, footer tools, and stop button while a request is in flight
This commit is contained in:
parent
d635f44683
commit
74b400d5be
2 changed files with 219 additions and 105 deletions
|
|
@ -0,0 +1,184 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { ArrowUp, Code2, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface ChatComposerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel?: () => void;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
submitDisabled?: boolean;
|
||||
tools?: React.ReactNode;
|
||||
body?: React.ReactNode;
|
||||
suggestions?: string[];
|
||||
showSuggestions?: boolean;
|
||||
onSuggestionSelect?: (suggestion: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
submitDisabled = false,
|
||||
tools,
|
||||
body,
|
||||
suggestions = [],
|
||||
showSuggestions = false,
|
||||
onSuggestionSelect,
|
||||
className,
|
||||
}: ChatComposerProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el || body) {
|
||||
return;
|
||||
}
|
||||
el.style.height = "0px";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 192)}px`;
|
||||
}, [value, body]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
if (!submitDisabled && !isLoading) {
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex w-full flex-col gap-3", className)}>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div
|
||||
className="flex w-full gap-2 overflow-x-auto pb-1 sm:grid sm:grid-cols-2 sm:overflow-visible"
|
||||
data-testid="chat-suggested-actions"
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="min-w-[200px] shrink-0 rounded-xl border border-border/50 bg-card/30 px-4 py-3 text-left text-[12px] leading-relaxed text-muted-foreground transition-all duration-200 hover:-translate-y-0.5 hover:bg-card/60 hover:text-foreground sm:min-w-0 sm:whitespace-normal sm:p-4 sm:text-[13px]"
|
||||
onClick={() => onSuggestionSelect?.(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="w-full"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!submitDisabled && !isLoading) {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"h-auto min-h-[7.5rem] flex-col overflow-hidden rounded-2xl border border-border/40 bg-card shadow-sm",
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30",
|
||||
)}
|
||||
>
|
||||
{body ? (
|
||||
<div className="max-h-48 min-h-24 w-full overflow-y-auto px-3 pt-3">{body}</div>
|
||||
) : (
|
||||
<InputGroupTextarea
|
||||
ref={textareaRef}
|
||||
data-testid="chat-composer-input"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className="min-h-24 max-h-48 resize-none border-0 bg-transparent px-4 pt-3.5 pb-1.5 text-[13px] leading-relaxed shadow-none placeholder:text-muted-foreground/50 focus-visible:ring-0"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputGroupAddon align="block-end" className="justify-between gap-2 px-3 pb-3 pt-1">
|
||||
<div className="flex min-w-0 items-center gap-1">{tools}</div>
|
||||
|
||||
{isLoading && onCancel ? (
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
aria-label="Stop request"
|
||||
data-testid="chat-stop-button"
|
||||
className="size-8 rounded-xl bg-foreground text-background hover:bg-foreground/90"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</InputGroupButton>
|
||||
) : (
|
||||
<InputGroupButton
|
||||
type="submit"
|
||||
size="icon-sm"
|
||||
aria-label="Send message"
|
||||
data-testid="chat-send-button"
|
||||
disabled={submitDisabled || isLoading}
|
||||
className={cn(
|
||||
"size-8 rounded-xl transition-all duration-200",
|
||||
!submitDisabled && !isLoading
|
||||
? "bg-foreground text-background hover:opacity-90 active:scale-95"
|
||||
: "cursor-not-allowed bg-muted text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeInterpreterToggleProps {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function CodeInterpreterToggle({ enabled, onToggle }: CodeInterpreterToggleProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-8 rounded-lg border border-border/40",
|
||||
enabled
|
||||
? "border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label={enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Code2 className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatComposer;
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUp,
|
||||
Bot,
|
||||
Code2,
|
||||
Database,
|
||||
|
|
@ -47,6 +46,7 @@ import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api
|
|||
import { makeInteractionsRequest } from "../../llm_calls/interactions_api";
|
||||
import AdditionalModelSettings from "./AdditionalModelSettings";
|
||||
import { OPEN_AI_VOICE_SELECT_OPTIONS, OpenAIVoice } from "./chatConstants";
|
||||
import ChatComposer, { CodeInterpreterToggle } from "./ChatComposer";
|
||||
import ChatImageUpload from "./ChatImageUpload";
|
||||
import { createChatDisplayMessage, createChatMultimodalMessage } from "./ChatImageUtils";
|
||||
import CodeInterpreterTool from "./CodeInterpreterTool";
|
||||
|
|
@ -72,7 +72,6 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import {
|
||||
|
|
@ -506,14 +505,6 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
}
|
||||
}, [chatHistory]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault(); // Prevent default to avoid newline
|
||||
handleSendMessage();
|
||||
}
|
||||
// If Shift+Enter is pressed, the default behavior (inserting a newline) will occur
|
||||
};
|
||||
|
||||
const handleCancelRequest = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
|
|
@ -1968,27 +1959,24 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{chatHistory.length === 0 && !isLoading && endpointType !== EndpointType.MCP && (
|
||||
<div className="mb-3 flex items-center gap-2 overflow-x-auto">
|
||||
{(endpointType === EndpointType.A2A_AGENTS
|
||||
<ChatComposer
|
||||
value={inputMessage}
|
||||
onChange={setInputMessage}
|
||||
onSubmit={handleSendMessage}
|
||||
onCancel={handleCancelRequest}
|
||||
placeholder={inputPlaceholder}
|
||||
disabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
submitDisabled={sendDisabled}
|
||||
showSuggestions={chatHistory.length === 0 && !isLoading && endpointType !== EndpointType.MCP}
|
||||
suggestions={
|
||||
endpointType === EndpointType.A2A_AGENTS
|
||||
? ["What can you help me with?", "Tell me about yourself", "What tasks can you perform?"]
|
||||
: ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"]
|
||||
).map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
type="button"
|
||||
className="shrink-0 cursor-pointer rounded-full border border-gray-200 px-3 py-1 text-xs font-medium text-gray-600 transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||
onClick={() => setInputMessage(prompt)} // lgtm[js/xss-through-dom]
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-h-[44px] flex-1 items-center rounded-xl border border-gray-300 bg-white px-3 py-1">
|
||||
<div className="mr-2 flex shrink-0 items-center gap-1">
|
||||
}
|
||||
onSuggestionSelect={setInputMessage}
|
||||
tools={
|
||||
<>
|
||||
{endpointType === EndpointType.RESPONSES && !responsesUploadedImage && (
|
||||
<ResponsesImageUpload
|
||||
responsesUploadedImage={responsesUploadedImage}
|
||||
|
|
@ -2006,47 +1994,24 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
/>
|
||||
)}
|
||||
{endpointType === EndpointType.RESPONSES && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 transition-colors ${
|
||||
codeInterpreter.enabled
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: "text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
}`}
|
||||
aria-label={
|
||||
codeInterpreter.enabled
|
||||
? "Code Interpreter enabled (click to disable)"
|
||||
: "Enable Code Interpreter"
|
||||
}
|
||||
onClick={() => {
|
||||
codeInterpreter.toggle();
|
||||
if (!codeInterpreter.enabled) {
|
||||
NotificationsManager.success("Code Interpreter enabled!");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CodeInterpreterToggle
|
||||
enabled={codeInterpreter.enabled}
|
||||
onToggle={() => {
|
||||
codeInterpreter.toggle();
|
||||
if (!codeInterpreter.enabled) {
|
||||
NotificationsManager.success("Code Interpreter enabled!");
|
||||
}
|
||||
>
|
||||
<Code2 className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{codeInterpreter.enabled
|
||||
? "Code Interpreter enabled (click to disable)"
|
||||
: "Enable Code Interpreter"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{endpointType === EndpointType.MCP &&
|
||||
</>
|
||||
}
|
||||
body={
|
||||
endpointType === EndpointType.MCP &&
|
||||
selectedMCPServers.length === 1 &&
|
||||
selectedMCPServers[0] !== "__all__" &&
|
||||
selectedMCPDirectTool ? (
|
||||
<div className="max-h-48 min-h-[44px] flex-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50/50 p-2">
|
||||
{(() => {
|
||||
selectedMCPDirectTool
|
||||
? (() => {
|
||||
const rawSel = selectedMCPServers[0];
|
||||
let toolPool: { name: string }[] = [];
|
||||
if (rawSel.startsWith("toolset:")) {
|
||||
|
|
@ -2069,45 +2034,10 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
Loading tool schema...
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={inputMessage}
|
||||
onChange={(event) => setInputMessage(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={inputPlaceholder}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
className="min-h-0 flex-1 resize-none border-0 bg-transparent px-0 py-1 text-sm shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
onClick={handleSendMessage}
|
||||
disabled={sendDisabled}
|
||||
className="ml-2 size-8 shrink-0 rounded-full bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-300 disabled:text-gray-500"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-200 bg-red-50 text-red-600 hover:bg-red-100"
|
||||
onClick={handleCancelRequest}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
})()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue