mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-07-27 06:28:50 +00:00
have messages working but streaming still not fixed yet
Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
This commit is contained in:
parent
fc3286be7e
commit
f7c9651ca7
23 changed files with 4749 additions and 184 deletions
|
@ -1,42 +1,34 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ChatMessage } from "@/lib/types";
|
import { flushSync } from "react-dom";
|
||||||
import { ChatMessageItem } from "@/components/chat-completions/chat-messasge-item";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Send, Loader2, ChevronDown } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
Select,
|
||||||
DropdownMenuContent,
|
SelectContent,
|
||||||
DropdownMenuItem,
|
SelectItem,
|
||||||
DropdownMenuTrigger,
|
SelectTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Chat } from "@/components/ui/chat";
|
||||||
|
import { type Message } from "@/components/ui/chat-message";
|
||||||
import { useAuthClient } from "@/hooks/use-auth-client";
|
import { useAuthClient } from "@/hooks/use-auth-client";
|
||||||
import type { CompletionCreateParams } from "llama-stack-client/resources/chat/completions";
|
import type { CompletionCreateParams } from "llama-stack-client/resources/chat/completions";
|
||||||
import type { Model } from "llama-stack-client/resources/models";
|
import type { Model } from "llama-stack-client/resources/models";
|
||||||
|
|
||||||
export default function ChatPlaygroundPage() {
|
export default function ChatPlaygroundPage() {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [inputMessage, setInputMessage] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [models, setModels] = useState<Model[]>([]);
|
const [models, setModels] = useState<Model[]>([]);
|
||||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||||
const [modelsLoading, setModelsLoading] = useState(true);
|
const [modelsLoading, setModelsLoading] = useState(true);
|
||||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const client = useAuthClient();
|
const client = useAuthClient();
|
||||||
|
|
||||||
const isModelsLoading = modelsLoading ?? true;
|
const isModelsLoading = modelsLoading ?? true;
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchModels = async () => {
|
const fetchModels = async () => {
|
||||||
|
@ -60,76 +52,119 @@ export default function ChatPlaygroundPage() {
|
||||||
fetchModels();
|
fetchModels();
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
const extractTextContent = (content: any): string => {
|
const extractTextContent = (content: unknown): string => {
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return content
|
return content
|
||||||
.filter(item => item.type === 'text')
|
.filter(item => item && typeof item === 'object' && 'type' in item && item.type === 'text')
|
||||||
.map(item => item.text)
|
.map(item => (item && typeof item === 'object' && 'text' in item) ? String(item.text) : '')
|
||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
if (content && content.type === 'text') {
|
if (content && typeof content === 'object' && 'type' in content && content.type === 'text' && 'text' in content) {
|
||||||
return content.text || '';
|
return String(content.text) || '';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
if (!inputMessage.trim() || isLoading || !selectedModel) return;
|
setInput(e.target.value);
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
|
||||||
role: "user",
|
|
||||||
content: inputMessage.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages(prev => [...prev, userMessage]);
|
|
||||||
setInputMessage("");
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const messageParams: CompletionCreateParams["messages"] = [...messages, userMessage].map(msg => {
|
|
||||||
const content = typeof msg.content === 'string' ? msg.content : extractTextContent(msg.content);
|
|
||||||
if (msg.role === "user") {
|
|
||||||
return { role: "user" as const, content };
|
|
||||||
} else if (msg.role === "assistant") {
|
|
||||||
return { role: "assistant" as const, content };
|
|
||||||
} else {
|
|
||||||
return { role: "system" as const, content };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await client.chat.completions.create({
|
|
||||||
model: selectedModel,
|
|
||||||
messages: messageParams,
|
|
||||||
stream: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if ('choices' in response && response.choices && response.choices.length > 0) {
|
|
||||||
const choice = response.choices[0];
|
|
||||||
if ('message' in choice && choice.message) {
|
|
||||||
const assistantMessage: ChatMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content: extractTextContent(choice.message.content),
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, assistantMessage]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error sending message:", err);
|
|
||||||
setError("Failed to send message. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
const handleSubmit = async (event?: { preventDefault?: () => void }) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
event?.preventDefault?.();
|
||||||
e.preventDefault();
|
if (!input.trim()) return;
|
||||||
handleSendMessage();
|
|
||||||
|
// Add user message to chat
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: "user",
|
||||||
|
content: input.trim(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMessage]);
|
||||||
|
setInput("");
|
||||||
|
|
||||||
|
// Use the helper function with the content
|
||||||
|
await handleSubmitWithContent(userMessage.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitWithContent = async (content: string) => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageParams: CompletionCreateParams["messages"] = [
|
||||||
|
...messages.map(msg => {
|
||||||
|
const msgContent = typeof msg.content === 'string' ? msg.content : extractTextContent(msg.content);
|
||||||
|
if (msg.role === "user") {
|
||||||
|
return { role: "user" as const, content: msgContent };
|
||||||
|
} else if (msg.role === "assistant") {
|
||||||
|
return { role: "assistant" as const, content: msgContent };
|
||||||
|
} else {
|
||||||
|
return { role: "system" as const, content: msgContent };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ role: "user" as const, content }
|
||||||
|
];
|
||||||
|
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: selectedModel,
|
||||||
|
messages: messageParams,
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: (Date.now() + 1).toString(),
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, assistantMessage]);
|
||||||
|
let fullContent = "";
|
||||||
|
for await (const chunk of response) {
|
||||||
|
if (chunk.choices && chunk.choices[0]?.delta?.content) {
|
||||||
|
const deltaContent = chunk.choices[0].delta.content;
|
||||||
|
fullContent += deltaContent;
|
||||||
|
|
||||||
|
flushSync(() => {
|
||||||
|
setMessages(prev => {
|
||||||
|
const newMessages = [...prev];
|
||||||
|
const lastMessage = newMessages[newMessages.length - 1];
|
||||||
|
if (lastMessage.role === "assistant") {
|
||||||
|
lastMessage.content = fullContent;
|
||||||
|
}
|
||||||
|
return newMessages;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error sending message:", err);
|
||||||
|
setError("Failed to send message. Please try again.");
|
||||||
|
setMessages(prev => prev.slice(0, -1));
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const suggestions = [
|
||||||
|
"What is the weather in San Francisco?",
|
||||||
|
"Explain step-by-step how to solve this math problem: If x² + 6x + 9 = 25, what is x?",
|
||||||
|
"Design a simple algorithm to find the longest palindrome in a string.",
|
||||||
|
];
|
||||||
|
|
||||||
|
const append = (message: { role: "user"; content: string }) => {
|
||||||
|
const newMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
setMessages(prev => [...prev, newMessage])
|
||||||
|
handleSubmitWithContent(newMessage.content);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearChat = () => {
|
const clearChat = () => {
|
||||||
|
@ -141,102 +176,48 @@ export default function ChatPlaygroundPage() {
|
||||||
<div className="flex flex-col h-full max-w-4xl mx-auto">
|
<div className="flex flex-col h-full max-w-4xl mx-auto">
|
||||||
<div className="mb-4 flex justify-between items-center">
|
<div className="mb-4 flex justify-between items-center">
|
||||||
<h1 className="text-2xl font-bold">Chat Playground</h1>
|
<h1 className="text-2xl font-bold">Chat Playground</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<DropdownMenu>
|
<Select value={selectedModel} onValueChange={setSelectedModel} disabled={isModelsLoading || isGenerating}>
|
||||||
<DropdownMenuTrigger asChild>
|
<SelectTrigger className="w-[180px]">
|
||||||
<Button variant="outline" disabled={isModelsLoading || isLoading}>
|
<SelectValue placeholder={isModelsLoading ? "Loading models..." : "Select Model"} />
|
||||||
{isModelsLoading ? (
|
</SelectTrigger>
|
||||||
<>
|
<SelectContent>
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
Loading models...
|
|
||||||
</>
|
|
||||||
) : selectedModel ? (
|
|
||||||
<>
|
|
||||||
{selectedModel}
|
|
||||||
<ChevronDown className="h-4 w-4 ml-2" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"No models available"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{models.map((model) => (
|
{models.map((model) => (
|
||||||
<DropdownMenuItem
|
<SelectItem key={model.identifier} value={model.identifier}>
|
||||||
key={model.identifier}
|
|
||||||
onClick={() => setSelectedModel(model.identifier)}
|
|
||||||
>
|
|
||||||
{model.identifier}
|
{model.identifier}
|
||||||
</DropdownMenuItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</SelectContent>
|
||||||
</DropdownMenu>
|
</Select>
|
||||||
<Button variant="outline" onClick={clearChat} disabled={isLoading}>
|
<Button variant="outline" onClick={clearChat} disabled={isGenerating}>
|
||||||
Clear Chat
|
Clear Chat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="flex-1 flex flex-col">
|
{modelsError && (
|
||||||
<CardHeader>
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
<CardTitle>Chat Messages</CardTitle>
|
<p className="text-destructive text-sm">{modelsError}</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="flex-1 flex flex-col">
|
)}
|
||||||
<div className="flex-1 overflow-y-auto mb-4 space-y-4 min-h-0">
|
|
||||||
{messages.length === 0 ? (
|
|
||||||
<div className="text-center text-muted-foreground py-8">
|
|
||||||
<p>Start a conversation by typing a message below.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
messages.map((message, index) => (
|
|
||||||
<ChatMessageItem key={index} message={message} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center py-4">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
<span className="text-muted-foreground">Thinking...</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{modelsError && (
|
{error && (
|
||||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||||
<p className="text-destructive text-sm">{modelsError}</p>
|
<p className="text-destructive text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
<Chat
|
||||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
className="flex-1"
|
||||||
<p className="text-destructive text-sm">{error}</p>
|
messages={messages}
|
||||||
</div>
|
handleSubmit={handleSubmit}
|
||||||
)}
|
input={input}
|
||||||
|
handleInputChange={handleInputChange}
|
||||||
<div className="flex gap-2">
|
isGenerating={isGenerating}
|
||||||
<Input
|
append={append}
|
||||||
value={inputMessage}
|
suggestions={suggestions}
|
||||||
onChange={(e) => setInputMessage(e.target.value)}
|
setMessages={setMessages}
|
||||||
onKeyPress={handleKeyPress}
|
/>
|
||||||
placeholder="Type your message here..."
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSendMessage}
|
|
||||||
disabled={!inputMessage.trim() || isLoading}
|
|
||||||
disabled={!inputMessage.trim() || isLoading || !selectedModel}
|
|
||||||
size="icon"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
198
llama_stack/ui/components/ui/audio-visualizer.tsx
Normal file
198
llama_stack/ui/components/ui/audio-visualizer.tsx
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
// Configuration constants for the audio analyzer
|
||||||
|
const AUDIO_CONFIG = {
|
||||||
|
FFT_SIZE: 512,
|
||||||
|
SMOOTHING: 0.8,
|
||||||
|
MIN_BAR_HEIGHT: 2,
|
||||||
|
MIN_BAR_WIDTH: 2,
|
||||||
|
BAR_SPACING: 1,
|
||||||
|
COLOR: {
|
||||||
|
MIN_INTENSITY: 100, // Minimum gray value (darker)
|
||||||
|
MAX_INTENSITY: 255, // Maximum gray value (brighter)
|
||||||
|
INTENSITY_RANGE: 155, // MAX_INTENSITY - MIN_INTENSITY
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
interface AudioVisualizerProps {
|
||||||
|
stream: MediaStream | null
|
||||||
|
isRecording: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioVisualizer({
|
||||||
|
stream,
|
||||||
|
isRecording,
|
||||||
|
onClick,
|
||||||
|
}: AudioVisualizerProps) {
|
||||||
|
// Refs for managing audio context and animation
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null)
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||||
|
const animationFrameRef = useRef<number>()
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Cleanup function to stop visualization and close audio context
|
||||||
|
const cleanup = () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current)
|
||||||
|
}
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
audioContextRef.current.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return cleanup
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Start or stop visualization based on recording state
|
||||||
|
useEffect(() => {
|
||||||
|
if (stream && isRecording) {
|
||||||
|
startVisualization()
|
||||||
|
} else {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [stream, isRecording])
|
||||||
|
|
||||||
|
// Handle window resize
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (canvasRef.current && containerRef.current) {
|
||||||
|
const container = containerRef.current
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
|
||||||
|
// Set canvas size based on container and device pixel ratio
|
||||||
|
const rect = container.getBoundingClientRect()
|
||||||
|
// Account for the 2px total margin (1px on each side)
|
||||||
|
canvas.width = (rect.width - 2) * dpr
|
||||||
|
canvas.height = (rect.height - 2) * dpr
|
||||||
|
|
||||||
|
// Scale canvas CSS size to match container minus margins
|
||||||
|
canvas.style.width = `${rect.width - 2}px`
|
||||||
|
canvas.style.height = `${rect.height - 2}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize)
|
||||||
|
// Initial setup
|
||||||
|
handleResize()
|
||||||
|
|
||||||
|
return () => window.removeEventListener("resize", handleResize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initialize audio context and start visualization
|
||||||
|
const startVisualization = async () => {
|
||||||
|
try {
|
||||||
|
const audioContext = new AudioContext()
|
||||||
|
audioContextRef.current = audioContext
|
||||||
|
|
||||||
|
const analyser = audioContext.createAnalyser()
|
||||||
|
analyser.fftSize = AUDIO_CONFIG.FFT_SIZE
|
||||||
|
analyser.smoothingTimeConstant = AUDIO_CONFIG.SMOOTHING
|
||||||
|
analyserRef.current = analyser
|
||||||
|
|
||||||
|
const source = audioContext.createMediaStreamSource(stream!)
|
||||||
|
source.connect(analyser)
|
||||||
|
|
||||||
|
draw()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting visualization:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the color intensity based on bar height
|
||||||
|
const getBarColor = (normalizedHeight: number) => {
|
||||||
|
const intensity =
|
||||||
|
Math.floor(normalizedHeight * AUDIO_CONFIG.COLOR.INTENSITY_RANGE) +
|
||||||
|
AUDIO_CONFIG.COLOR.MIN_INTENSITY
|
||||||
|
return `rgb(${intensity}, ${intensity}, ${intensity})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a single bar of the visualizer
|
||||||
|
const drawBar = (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
centerY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
color: string
|
||||||
|
) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
// Draw upper bar (above center)
|
||||||
|
ctx.fillRect(x, centerY - height, width, height)
|
||||||
|
// Draw lower bar (below center)
|
||||||
|
ctx.fillRect(x, centerY, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main drawing function
|
||||||
|
const draw = () => {
|
||||||
|
if (!isRecording) return
|
||||||
|
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
const ctx = canvas?.getContext("2d")
|
||||||
|
if (!canvas || !ctx || !analyserRef.current) return
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
|
||||||
|
const analyser = analyserRef.current
|
||||||
|
const bufferLength = analyser.frequencyBinCount
|
||||||
|
const frequencyData = new Uint8Array(bufferLength)
|
||||||
|
|
||||||
|
const drawFrame = () => {
|
||||||
|
animationFrameRef.current = requestAnimationFrame(drawFrame)
|
||||||
|
|
||||||
|
// Get current frequency data
|
||||||
|
analyser.getByteFrequencyData(frequencyData)
|
||||||
|
|
||||||
|
// Clear canvas - use CSS pixels for clearing
|
||||||
|
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr)
|
||||||
|
|
||||||
|
// Calculate dimensions in CSS pixels
|
||||||
|
const barWidth = Math.max(
|
||||||
|
AUDIO_CONFIG.MIN_BAR_WIDTH,
|
||||||
|
canvas.width / dpr / bufferLength - AUDIO_CONFIG.BAR_SPACING
|
||||||
|
)
|
||||||
|
const centerY = canvas.height / dpr / 2
|
||||||
|
let x = 0
|
||||||
|
|
||||||
|
// Draw each frequency bar
|
||||||
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
|
const normalizedHeight = frequencyData[i] / 255 // Convert to 0-1 range
|
||||||
|
const barHeight = Math.max(
|
||||||
|
AUDIO_CONFIG.MIN_BAR_HEIGHT,
|
||||||
|
normalizedHeight * centerY
|
||||||
|
)
|
||||||
|
|
||||||
|
drawBar(
|
||||||
|
ctx,
|
||||||
|
x,
|
||||||
|
centerY,
|
||||||
|
barWidth,
|
||||||
|
barHeight,
|
||||||
|
getBarColor(normalizedHeight)
|
||||||
|
)
|
||||||
|
|
||||||
|
x += barWidth + AUDIO_CONFIG.BAR_SPACING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full w-full cursor-pointer rounded-lg bg-background/80 backdrop-blur"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
@ -32,8 +32,8 @@ const buttonVariants = cva(
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
|
@ -43,9 +43,9 @@ function Button({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean;
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
@ -53,7 +53,7 @@ function Button({
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants }
|
||||||
|
|
405
llama_stack/ui/components/ui/chat-message.tsx
Normal file
405
llama_stack/ui/components/ui/chat-message.tsx
Normal file
|
@ -0,0 +1,405 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { Ban, ChevronRight, Code2, Loader2, Terminal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible"
|
||||||
|
import { FilePreview } from "@/components/ui/file-preview"
|
||||||
|
import { MarkdownRenderer } from "@/components/ui/markdown-renderer"
|
||||||
|
|
||||||
|
const chatBubbleVariants = cva(
|
||||||
|
"group/message relative break-words rounded-lg p-3 text-sm sm:max-w-[70%]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
isUser: {
|
||||||
|
true: "bg-primary text-primary-foreground",
|
||||||
|
false: "bg-muted text-foreground",
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
none: "",
|
||||||
|
slide: "duration-300 animate-in fade-in-0",
|
||||||
|
scale: "duration-300 animate-in fade-in-0 zoom-in-75",
|
||||||
|
fade: "duration-500 animate-in fade-in-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compoundVariants: [
|
||||||
|
{
|
||||||
|
isUser: true,
|
||||||
|
animation: "slide",
|
||||||
|
class: "slide-in-from-right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isUser: false,
|
||||||
|
animation: "slide",
|
||||||
|
class: "slide-in-from-left",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isUser: true,
|
||||||
|
animation: "scale",
|
||||||
|
class: "origin-bottom-right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isUser: false,
|
||||||
|
animation: "scale",
|
||||||
|
class: "origin-bottom-left",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type Animation = VariantProps<typeof chatBubbleVariants>["animation"]
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
name?: string
|
||||||
|
contentType?: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartialToolCall {
|
||||||
|
state: "partial-call"
|
||||||
|
toolName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolCall {
|
||||||
|
state: "call"
|
||||||
|
toolName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolResult {
|
||||||
|
state: "result"
|
||||||
|
toolName: string
|
||||||
|
result: {
|
||||||
|
__cancelled?: boolean
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolInvocation = PartialToolCall | ToolCall | ToolResult
|
||||||
|
|
||||||
|
interface ReasoningPart {
|
||||||
|
type: "reasoning"
|
||||||
|
reasoning: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolInvocationPart {
|
||||||
|
type: "tool-invocation"
|
||||||
|
toolInvocation: ToolInvocation
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextPart {
|
||||||
|
type: "text"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// For compatibility with AI SDK types, not used
|
||||||
|
interface SourcePart {
|
||||||
|
type: "source"
|
||||||
|
source?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePart {
|
||||||
|
type: "file"
|
||||||
|
mimeType: string
|
||||||
|
data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepStartPart {
|
||||||
|
type: "step-start"
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessagePart =
|
||||||
|
| TextPart
|
||||||
|
| ReasoningPart
|
||||||
|
| ToolInvocationPart
|
||||||
|
| SourcePart
|
||||||
|
| FilePart
|
||||||
|
| StepStartPart
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string
|
||||||
|
role: "user" | "assistant" | (string & {})
|
||||||
|
content: string
|
||||||
|
createdAt?: Date
|
||||||
|
experimental_attachments?: Attachment[]
|
||||||
|
toolInvocations?: ToolInvocation[]
|
||||||
|
parts?: MessagePart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessageProps extends Message {
|
||||||
|
showTimeStamp?: boolean
|
||||||
|
animation?: Animation
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
createdAt,
|
||||||
|
showTimeStamp = false,
|
||||||
|
animation = "scale",
|
||||||
|
actions,
|
||||||
|
experimental_attachments,
|
||||||
|
toolInvocations,
|
||||||
|
parts,
|
||||||
|
}) => {
|
||||||
|
const files = useMemo(() => {
|
||||||
|
return experimental_attachments?.map((attachment) => {
|
||||||
|
const dataArray = dataUrlToUint8Array(attachment.url)
|
||||||
|
const file = new File([dataArray], attachment.name ?? "Unknown", {
|
||||||
|
type: attachment.contentType,
|
||||||
|
})
|
||||||
|
return file
|
||||||
|
})
|
||||||
|
}, [experimental_attachments])
|
||||||
|
|
||||||
|
const isUser = role === "user"
|
||||||
|
|
||||||
|
const formattedTime = createdAt?.toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isUser) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col", isUser ? "items-end" : "items-start")}
|
||||||
|
>
|
||||||
|
{files ? (
|
||||||
|
<div className="mb-1 flex flex-wrap gap-2">
|
||||||
|
{files.map((file, index) => {
|
||||||
|
return <FilePreview file={file} key={index} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={cn(chatBubbleVariants({ isUser, animation }))}>
|
||||||
|
<MarkdownRenderer>{content}</MarkdownRenderer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTimeStamp && createdAt ? (
|
||||||
|
<time
|
||||||
|
dateTime={createdAt.toISOString()}
|
||||||
|
className={cn(
|
||||||
|
"mt-1 block px-1 text-xs opacity-50",
|
||||||
|
animation !== "none" && "duration-500 animate-in fade-in-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formattedTime}
|
||||||
|
</time>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts && parts.length > 0) {
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
if (part.type === "text") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col",
|
||||||
|
isUser ? "items-end" : "items-start"
|
||||||
|
)}
|
||||||
|
key={`text-${index}`}
|
||||||
|
>
|
||||||
|
<div className={cn(chatBubbleVariants({ isUser, animation }))}>
|
||||||
|
<MarkdownRenderer>{part.text}</MarkdownRenderer>
|
||||||
|
{actions ? (
|
||||||
|
<div className="absolute -bottom-4 right-2 flex space-x-1 rounded-lg border bg-background p-1 text-foreground opacity-0 transition-opacity group-hover/message:opacity-100">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTimeStamp && createdAt ? (
|
||||||
|
<time
|
||||||
|
dateTime={createdAt.toISOString()}
|
||||||
|
className={cn(
|
||||||
|
"mt-1 block px-1 text-xs opacity-50",
|
||||||
|
animation !== "none" && "duration-500 animate-in fade-in-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formattedTime}
|
||||||
|
</time>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else if (part.type === "reasoning") {
|
||||||
|
return <ReasoningBlock key={`reasoning-${index}`} part={part} />
|
||||||
|
} else if (part.type === "tool-invocation") {
|
||||||
|
return (
|
||||||
|
<ToolCall
|
||||||
|
key={`tool-${index}`}
|
||||||
|
toolInvocations={[part.toolInvocation]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolInvocations && toolInvocations.length > 0) {
|
||||||
|
return <ToolCall toolInvocations={toolInvocations} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col", isUser ? "items-end" : "items-start")}>
|
||||||
|
<div className={cn(chatBubbleVariants({ isUser, animation }))}>
|
||||||
|
<MarkdownRenderer>{content}</MarkdownRenderer>
|
||||||
|
{actions ? (
|
||||||
|
<div className="absolute -bottom-4 right-2 flex space-x-1 rounded-lg border bg-background p-1 text-foreground opacity-0 transition-opacity group-hover/message:opacity-100">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTimeStamp && createdAt ? (
|
||||||
|
<time
|
||||||
|
dateTime={createdAt.toISOString()}
|
||||||
|
className={cn(
|
||||||
|
"mt-1 block px-1 text-xs opacity-50",
|
||||||
|
animation !== "none" && "duration-500 animate-in fade-in-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formattedTime}
|
||||||
|
</time>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataUrlToUint8Array(data: string) {
|
||||||
|
const base64 = data.split(",")[1]
|
||||||
|
const buf = Buffer.from(base64, "base64")
|
||||||
|
return new Uint8Array(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReasoningBlock = ({ part }: { part: ReasoningPart }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex flex-col items-start sm:max-w-[70%]">
|
||||||
|
<Collapsible
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
className="group w-full overflow-hidden rounded-lg border bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center p-2">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<ChevronRight className="h-4 w-4 transition-transform group-data-[state=open]:rotate-90" />
|
||||||
|
<span>Thinking</span>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent forceMount>
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={isOpen ? "open" : "closed"}
|
||||||
|
variants={{
|
||||||
|
open: { height: "auto", opacity: 1 },
|
||||||
|
closed: { height: 0, opacity: 0 },
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.3, ease: [0.04, 0.62, 0.23, 0.98] }}
|
||||||
|
className="border-t"
|
||||||
|
>
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="whitespace-pre-wrap text-xs">
|
||||||
|
{part.reasoning}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCall({
|
||||||
|
toolInvocations,
|
||||||
|
}: Pick<ChatMessageProps, "toolInvocations">) {
|
||||||
|
if (!toolInvocations?.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
{toolInvocations.map((invocation, index) => {
|
||||||
|
const isCancelled =
|
||||||
|
invocation.state === "result" &&
|
||||||
|
invocation.result.__cancelled === true
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Ban className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Cancelled{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{"`"}
|
||||||
|
{invocation.toolName}
|
||||||
|
{"`"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (invocation.state) {
|
||||||
|
case "partial-call":
|
||||||
|
case "call":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Calling{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{"`"}
|
||||||
|
{invocation.toolName}
|
||||||
|
{"`"}
|
||||||
|
</span>
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case "result":
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col gap-1.5 rounded-lg border bg-muted/50 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Code2 className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Result from{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{"`"}
|
||||||
|
{invocation.toolName}
|
||||||
|
{"`"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap text-foreground">
|
||||||
|
{JSON.stringify(invocation.result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
349
llama_stack/ui/components/ui/chat.tsx
Normal file
349
llama_stack/ui/components/ui/chat.tsx
Normal file
|
@ -0,0 +1,349 @@
|
||||||
|
"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/ui/chat-message"
|
||||||
|
import { CopyButton } from "@/components/ui/copy-button"
|
||||||
|
import { MessageInput } from "@/components/ui/message-input"
|
||||||
|
import { MessageList } from "@/components/ui/message-list"
|
||||||
|
import { PromptSuggestions } from "@/components/ui/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: any[]) => void
|
||||||
|
transcribeAudio?: (blob: Blob) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}: 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: any) => {
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
{({ files, setFiles }) => (
|
||||||
|
<MessageInput
|
||||||
|
value={input}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
allowAttachments
|
||||||
|
files={files}
|
||||||
|
setFiles={setFiles}
|
||||||
|
stop={handleStop}
|
||||||
|
isGenerating={isGenerating}
|
||||||
|
transcribeAudio={transcribeAudio}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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
|
||||||
|
}
|
33
llama_stack/ui/components/ui/collapsible.tsx
Normal file
33
llama_stack/ui/components/ui/collapsible.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
44
llama_stack/ui/components/ui/copy-button.tsx
Normal file
44
llama_stack/ui/components/ui/copy-button.tsx
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Check, Copy } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type CopyButtonProps = {
|
||||||
|
content: string
|
||||||
|
copyMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyButton({ content, copyMessage }: CopyButtonProps) {
|
||||||
|
const { isCopied, handleCopy } = useCopyToClipboard({
|
||||||
|
text: content,
|
||||||
|
copyMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="relative h-6 w-6"
|
||||||
|
aria-label="Copy to clipboard"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 transition-transform ease-in-out",
|
||||||
|
isCopied ? "scale-100" : "scale-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Copy
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 transition-transform ease-in-out",
|
||||||
|
isCopied ? "scale-0" : "scale-100"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
153
llama_stack/ui/components/ui/file-preview.tsx
Normal file
153
llama_stack/ui/components/ui/file-preview.tsx
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useEffect } from "react"
|
||||||
|
import { motion } from "framer-motion"
|
||||||
|
import { FileIcon, X } from "lucide-react"
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
file: File
|
||||||
|
onRemove?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
if (props.file.type.startsWith("image/")) {
|
||||||
|
return <ImageFilePreview {...props} ref={ref} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
props.file.type.startsWith("text/") ||
|
||||||
|
props.file.name.endsWith(".txt") ||
|
||||||
|
props.file.name.endsWith(".md")
|
||||||
|
) {
|
||||||
|
return <TextFilePreview {...props} ref={ref} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <GenericFilePreview {...props} ref={ref} />
|
||||||
|
}
|
||||||
|
)
|
||||||
|
FilePreview.displayName = "FilePreview"
|
||||||
|
|
||||||
|
const ImageFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
||||||
|
({ file, onRemove }, ref) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
className="relative flex max-w-[200px] rounded-md border p-1.5 pr-2 text-xs"
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: "100%" }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: "100%" }}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center space-x-2">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
alt={`Attachment ${file.name}`}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-sm border bg-muted object-cover"
|
||||||
|
src={URL.createObjectURL(file)}
|
||||||
|
/>
|
||||||
|
<span className="w-full truncate text-muted-foreground">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
className="absolute -right-2 -top-2 flex h-4 w-4 items-center justify-center rounded-full border bg-background"
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label="Remove attachment"
|
||||||
|
>
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ImageFilePreview.displayName = "ImageFilePreview"
|
||||||
|
|
||||||
|
const TextFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
||||||
|
({ file, onRemove }, ref) => {
|
||||||
|
const [preview, setPreview] = React.useState<string>("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result as string
|
||||||
|
setPreview(text.slice(0, 50) + (text.length > 50 ? "..." : ""))
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}, [file])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
className="relative flex max-w-[200px] rounded-md border p-1.5 pr-2 text-xs"
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: "100%" }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: "100%" }}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center space-x-2">
|
||||||
|
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-sm border bg-muted p-0.5">
|
||||||
|
<div className="h-full w-full overflow-hidden text-[6px] leading-none text-muted-foreground">
|
||||||
|
{preview || "Loading..."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="w-full truncate text-muted-foreground">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
className="absolute -right-2 -top-2 flex h-4 w-4 items-center justify-center rounded-full border bg-background"
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label="Remove attachment"
|
||||||
|
>
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
TextFilePreview.displayName = "TextFilePreview"
|
||||||
|
|
||||||
|
const GenericFilePreview = React.forwardRef<HTMLDivElement, FilePreviewProps>(
|
||||||
|
({ file, onRemove }, ref) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
className="relative flex max-w-[200px] rounded-md border p-1.5 pr-2 text-xs"
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: "100%" }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: "100%" }}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center space-x-2">
|
||||||
|
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-sm border bg-muted">
|
||||||
|
<FileIcon className="h-6 w-6 text-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="w-full truncate text-muted-foreground">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onRemove ? (
|
||||||
|
<button
|
||||||
|
className="absolute -right-2 -top-2 flex h-4 w-4 items-center justify-center rounded-full border bg-background"
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label="Remove attachment"
|
||||||
|
>
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
GenericFilePreview.displayName = "GenericFilePreview"
|
41
llama_stack/ui/components/ui/interrupt-prompt.tsx
Normal file
41
llama_stack/ui/components/ui/interrupt-prompt.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
interface InterruptPromptProps {
|
||||||
|
isOpen: boolean
|
||||||
|
close: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InterruptPrompt({ isOpen, close }: InterruptPromptProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ top: 0, filter: "blur(5px)" }}
|
||||||
|
animate={{
|
||||||
|
top: -40,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
transition: {
|
||||||
|
type: "spring",
|
||||||
|
filter: { type: "tween" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
exit={{ top: 0, filter: "blur(5px)" }}
|
||||||
|
className="absolute left-1/2 flex -translate-x-1/2 overflow-hidden whitespace-nowrap rounded-full border bg-background py-1 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span className="ml-2.5">Press Enter again to interrupt</span>
|
||||||
|
<button
|
||||||
|
className="ml-1 mr-2.5 flex items-center"
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
195
llama_stack/ui/components/ui/markdown-renderer.tsx
Normal file
195
llama_stack/ui/components/ui/markdown-renderer.tsx
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
import React, { Suspense } 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(
|
||||||
|
async ({ children, language, ...props }: HighlightedPre) => {
|
||||||
|
const { codeToTokens, bundledLanguages } = await import("shiki")
|
||||||
|
|
||||||
|
if (!(language in bundledLanguages)) {
|
||||||
|
return <pre {...props}>{children}</pre>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tokens } = await codeToTokens(children, {
|
||||||
|
lang: language as keyof typeof bundledLanguages,
|
||||||
|
defaultColor: false,
|
||||||
|
themes: {
|
||||||
|
light: "github-light",
|
||||||
|
dark: "github-dark",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre {...props}>
|
||||||
|
<code>
|
||||||
|
{tokens.map((line, lineIndex) => (
|
||||||
|
<>
|
||||||
|
<span key={lineIndex}>
|
||||||
|
{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"}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</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: any): string {
|
||||||
|
if (typeof element === "string") {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element?.props?.children) {
|
||||||
|
let 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, node, ...rest }: any) => {
|
||||||
|
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 }: any) => 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 = ({ node, ...props }: any) => (
|
||||||
|
<Tag className={classes} {...props} />
|
||||||
|
)
|
||||||
|
Component.displayName = Tag
|
||||||
|
return Component
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MarkdownRenderer
|
465
llama_stack/ui/components/ui/message-input.tsx
Normal file
465
llama_stack/ui/components/ui/message-input.tsx
Normal file
|
@ -0,0 +1,465 @@
|
||||||
|
"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/ui/interrupt-prompt"
|
||||||
|
|
||||||
|
interface MessageInputBaseProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
value: string
|
||||||
|
submitOnEnter?: boolean
|
||||||
|
stop?: () => void
|
||||||
|
isGenerating: boolean
|
||||||
|
enableInterrupt?: boolean
|
||||||
|
transcribeAudio?: (blob: Blob) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 any)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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"])
|
||||||
|
: omit(props, ["allowAttachments"]))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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="Attach a file"
|
||||||
|
onClick={async () => {
|
||||||
|
const files = await showFileUploadDialog()
|
||||||
|
addFiles(files)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 showFileUploadDialog() {
|
||||||
|
const input = document.createElement("input")
|
||||||
|
|
||||||
|
input.type = "file"
|
||||||
|
input.multiple = true
|
||||||
|
input.accept = "*/*"
|
||||||
|
input.click()
|
||||||
|
|
||||||
|
return new Promise<File[] | null>((resolve) => {
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const files = (e.currentTarget as HTMLInputElement).files
|
||||||
|
|
||||||
|
if (files) {
|
||||||
|
resolve(Array.from(files))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
45
llama_stack/ui/components/ui/message-list.tsx
Normal file
45
llama_stack/ui/components/ui/message-list.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import {
|
||||||
|
ChatMessage,
|
||||||
|
type ChatMessageProps,
|
||||||
|
type Message,
|
||||||
|
} from "@/components/ui/chat-message"
|
||||||
|
import { TypingIndicator } from "@/components/ui/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>
|
||||||
|
)
|
||||||
|
}
|
28
llama_stack/ui/components/ui/prompt-suggestions.tsx
Normal file
28
llama_stack/ui/components/ui/prompt-suggestions.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
interface PromptSuggestionsProps {
|
||||||
|
label: string
|
||||||
|
append: (message: { role: "user"; content: string }) => void
|
||||||
|
suggestions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptSuggestions({
|
||||||
|
label,
|
||||||
|
append,
|
||||||
|
suggestions,
|
||||||
|
}: PromptSuggestionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-center text-2xl font-bold">{label}</h2>
|
||||||
|
<div className="flex gap-6 text-sm">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
onClick={() => append({ role: "user", content: suggestion })}
|
||||||
|
className="h-max flex-1 rounded-xl border bg-background p-4 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<p>{suggestion}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
185
llama_stack/ui/components/ui/select.tsx
Normal file
185
llama_stack/ui/components/ui/select.tsx
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
25
llama_stack/ui/components/ui/sonner.tsx
Normal file
25
llama_stack/ui/components/ui/sonner.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
15
llama_stack/ui/components/ui/typing-indicator.tsx
Normal file
15
llama_stack/ui/components/ui/typing-indicator.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { Dot } from "lucide-react"
|
||||||
|
|
||||||
|
export function TypingIndicator() {
|
||||||
|
return (
|
||||||
|
<div className="justify-left flex space-x-1">
|
||||||
|
<div className="rounded-lg bg-muted p-3">
|
||||||
|
<div className="flex -space-x-2.5">
|
||||||
|
<Dot className="h-5 w-5 animate-typing-dot-bounce" />
|
||||||
|
<Dot className="h-5 w-5 animate-typing-dot-bounce [animation-delay:90ms]" />
|
||||||
|
<Dot className="h-5 w-5 animate-typing-dot-bounce [animation-delay:180ms]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
93
llama_stack/ui/hooks/use-audio-recording.ts
Normal file
93
llama_stack/ui/hooks/use-audio-recording.ts
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
import { recordAudio } from "@/lib/audio-utils"
|
||||||
|
|
||||||
|
interface UseAudioRecordingOptions {
|
||||||
|
transcribeAudio?: (blob: Blob) => Promise<string>
|
||||||
|
onTranscriptionComplete?: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudioRecording({
|
||||||
|
transcribeAudio,
|
||||||
|
onTranscriptionComplete,
|
||||||
|
}: UseAudioRecordingOptions) {
|
||||||
|
const [isListening, setIsListening] = useState(false)
|
||||||
|
const [isSpeechSupported, setIsSpeechSupported] = useState(!!transcribeAudio)
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||||
|
const [audioStream, setAudioStream] = useState<MediaStream | null>(null)
|
||||||
|
const activeRecordingRef = useRef<any>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkSpeechSupport = async () => {
|
||||||
|
const hasMediaDevices = !!(
|
||||||
|
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||||||
|
)
|
||||||
|
setIsSpeechSupported(hasMediaDevices && !!transcribeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSpeechSupport()
|
||||||
|
}, [transcribeAudio])
|
||||||
|
|
||||||
|
const stopRecording = async () => {
|
||||||
|
setIsRecording(false)
|
||||||
|
setIsTranscribing(true)
|
||||||
|
try {
|
||||||
|
// First stop the recording to get the final blob
|
||||||
|
recordAudio.stop()
|
||||||
|
// Wait for the recording promise to resolve with the final blob
|
||||||
|
const recording = await activeRecordingRef.current
|
||||||
|
if (transcribeAudio) {
|
||||||
|
const text = await transcribeAudio(recording)
|
||||||
|
onTranscriptionComplete?.(text)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error transcribing audio:", error)
|
||||||
|
} finally {
|
||||||
|
setIsTranscribing(false)
|
||||||
|
setIsListening(false)
|
||||||
|
if (audioStream) {
|
||||||
|
audioStream.getTracks().forEach((track) => track.stop())
|
||||||
|
setAudioStream(null)
|
||||||
|
}
|
||||||
|
activeRecordingRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleListening = async () => {
|
||||||
|
if (!isListening) {
|
||||||
|
try {
|
||||||
|
setIsListening(true)
|
||||||
|
setIsRecording(true)
|
||||||
|
// Get audio stream first
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: true,
|
||||||
|
})
|
||||||
|
setAudioStream(stream)
|
||||||
|
|
||||||
|
// Start recording with the stream
|
||||||
|
activeRecordingRef.current = recordAudio(stream)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error recording audio:", error)
|
||||||
|
setIsListening(false)
|
||||||
|
setIsRecording(false)
|
||||||
|
if (audioStream) {
|
||||||
|
audioStream.getTracks().forEach((track) => track.stop())
|
||||||
|
setAudioStream(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await stopRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isListening,
|
||||||
|
isSpeechSupported,
|
||||||
|
isRecording,
|
||||||
|
isTranscribing,
|
||||||
|
audioStream,
|
||||||
|
toggleListening,
|
||||||
|
stopRecording,
|
||||||
|
}
|
||||||
|
}
|
73
llama_stack/ui/hooks/use-auto-scroll.ts
Normal file
73
llama_stack/ui/hooks/use-auto-scroll.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
// How many pixels from the bottom of the container to enable auto-scroll
|
||||||
|
const ACTIVATION_THRESHOLD = 50
|
||||||
|
// Minimum pixels of scroll-up movement required to disable auto-scroll
|
||||||
|
const MIN_SCROLL_UP_THRESHOLD = 10
|
||||||
|
|
||||||
|
export function useAutoScroll(dependencies: React.DependencyList) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const previousScrollTop = useRef<number | null>(null)
|
||||||
|
const [shouldAutoScroll, setShouldAutoScroll] = useState(true)
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
|
||||||
|
|
||||||
|
const distanceFromBottom = Math.abs(
|
||||||
|
scrollHeight - scrollTop - clientHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
const isScrollingUp = previousScrollTop.current
|
||||||
|
? scrollTop < previousScrollTop.current
|
||||||
|
: false
|
||||||
|
|
||||||
|
const scrollUpDistance = previousScrollTop.current
|
||||||
|
? previousScrollTop.current - scrollTop
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const isDeliberateScrollUp =
|
||||||
|
isScrollingUp && scrollUpDistance > MIN_SCROLL_UP_THRESHOLD
|
||||||
|
|
||||||
|
if (isDeliberateScrollUp) {
|
||||||
|
setShouldAutoScroll(false)
|
||||||
|
} else {
|
||||||
|
const isScrolledToBottom = distanceFromBottom < ACTIVATION_THRESHOLD
|
||||||
|
setShouldAutoScroll(isScrolledToBottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousScrollTop.current = scrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchStart = () => {
|
||||||
|
setShouldAutoScroll(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
previousScrollTop.current = containerRef.current.scrollTop
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldAutoScroll) {
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, dependencies)
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
scrollToBottom,
|
||||||
|
handleScroll,
|
||||||
|
shouldAutoScroll,
|
||||||
|
handleTouchStart,
|
||||||
|
}
|
||||||
|
}
|
39
llama_stack/ui/hooks/use-autosize-textarea.ts
Normal file
39
llama_stack/ui/hooks/use-autosize-textarea.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { useLayoutEffect, useRef } from "react"
|
||||||
|
|
||||||
|
interface UseAutosizeTextAreaProps {
|
||||||
|
ref: React.RefObject<HTMLTextAreaElement | null>
|
||||||
|
maxHeight?: number
|
||||||
|
borderWidth?: number
|
||||||
|
dependencies: React.DependencyList
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutosizeTextArea({
|
||||||
|
ref,
|
||||||
|
maxHeight = Number.MAX_SAFE_INTEGER,
|
||||||
|
borderWidth = 0,
|
||||||
|
dependencies,
|
||||||
|
}: UseAutosizeTextAreaProps) {
|
||||||
|
const originalHeight = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!ref.current) return
|
||||||
|
|
||||||
|
const currentRef = ref.current
|
||||||
|
const borderAdjustment = borderWidth * 2
|
||||||
|
|
||||||
|
if (originalHeight.current === null) {
|
||||||
|
originalHeight.current = currentRef.scrollHeight - borderAdjustment
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRef.style.removeProperty("height")
|
||||||
|
const scrollHeight = currentRef.scrollHeight
|
||||||
|
|
||||||
|
// Make sure we don't go over maxHeight
|
||||||
|
const clampedToMax = Math.min(scrollHeight, maxHeight)
|
||||||
|
// Make sure we don't go less than the original height
|
||||||
|
const clampedToMin = Math.max(clampedToMax, originalHeight.current)
|
||||||
|
|
||||||
|
currentRef.style.height = `${clampedToMin + borderAdjustment}px`
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [maxHeight, ref, ...dependencies])
|
||||||
|
}
|
36
llama_stack/ui/hooks/use-copy-to-clipboard.ts
Normal file
36
llama_stack/ui/hooks/use-copy-to-clipboard.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { useCallback, useRef, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
type UseCopyToClipboardProps = {
|
||||||
|
text: string
|
||||||
|
copyMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCopyToClipboard({
|
||||||
|
text,
|
||||||
|
copyMessage = "Copied to clipboard!",
|
||||||
|
}: UseCopyToClipboardProps) {
|
||||||
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(text)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(copyMessage)
|
||||||
|
setIsCopied(true)
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
timeoutRef.current = null
|
||||||
|
}
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setIsCopied(false)
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to copy to clipboard.")
|
||||||
|
})
|
||||||
|
}, [text, copyMessage])
|
||||||
|
|
||||||
|
return { isCopied, handleCopy }
|
||||||
|
}
|
50
llama_stack/ui/lib/audio-utils.ts
Normal file
50
llama_stack/ui/lib/audio-utils.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
type RecordAudioType = {
|
||||||
|
(stream: MediaStream): Promise<Blob>
|
||||||
|
stop: () => void
|
||||||
|
currentRecorder?: MediaRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
export const recordAudio = (function (): RecordAudioType {
|
||||||
|
const func = async function recordAudio(stream: MediaStream): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const mediaRecorder = new MediaRecorder(stream, {
|
||||||
|
mimeType: "audio/webm;codecs=opus",
|
||||||
|
})
|
||||||
|
const audioChunks: Blob[] = []
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
mediaRecorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
audioChunks.push(event.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const audioBlob = new Blob(audioChunks, { type: "audio/webm" })
|
||||||
|
resolve(audioBlob)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.onerror = () => {
|
||||||
|
reject(new Error("MediaRecorder error occurred"))
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaRecorder.start(1000)
|
||||||
|
;(func as RecordAudioType).currentRecorder = mediaRecorder
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error occurred"
|
||||||
|
throw new Error("Failed to start recording: " + errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
;(func as RecordAudioType).stop = () => {
|
||||||
|
const recorder = (func as RecordAudioType).currentRecorder
|
||||||
|
if (recorder && recorder.state !== "inactive") {
|
||||||
|
recorder.stop()
|
||||||
|
}
|
||||||
|
delete (func as RecordAudioType).currentRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
return func as RecordAudioType
|
||||||
|
})()
|
2122
llama_stack/ui/package-lock.json
generated
2122
llama_stack/ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -13,13 +13,16 @@
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.13",
|
"@radix-ui/react-dialog": "^1.1.13",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
"@radix-ui/react-dropdown-menu": "^2.1.14",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.6",
|
"@radix-ui/react-separator": "^1.1.6",
|
||||||
"@radix-ui/react-slot": "^1.2.2",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.6",
|
"@radix-ui/react-tooltip": "^1.2.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^11.18.2",
|
||||||
"llama-stack-client": "^0.2.15",
|
"llama-stack-client": "^0.2.15",
|
||||||
"lucide-react": "^0.510.0",
|
"lucide-react": "^0.510.0",
|
||||||
"next": "15.3.3",
|
"next": "15.3.3",
|
||||||
|
@ -27,6 +30,11 @@
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remeda": "^2.26.1",
|
||||||
|
"shiki": "^1.29.2",
|
||||||
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.0"
|
"tailwind-merge": "^3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue