mirror of https://github.com/kortix-ai/suna.git
wip
This commit is contained in:
parent
09628fe6f3
commit
606daa9f05
|
@ -3,16 +3,16 @@
|
|||
import React, { useState, useEffect, useCallback, useRef, useContext } from "react";
|
||||
import { getProject, getMessages, getThread, addUserMessage, startAgent, stopAgent, getAgentRuns, streamAgent, type Message, type Project, type Thread, type AgentRun } from "@/lib/api";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { AlertCircle, Square, Send, User, Plus } from "lucide-react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SUPPORTED_XML_TAGS, ParsedTag } from "@/lib/types/tool-calls";
|
||||
import { SUPPORTED_XML_TAGS } from "@/lib/types/tool-calls";
|
||||
import { ToolCallsContext } from "@/app/providers";
|
||||
import { getComponentForTag } from "@/components/chat/tool-components";
|
||||
import { BillingErrorAlert } from "@/components/billing/BillingErrorAlert";
|
||||
import { useBillingError } from "@/hooks/useBillingError";
|
||||
import { MessageList } from "@/components/thread/message-list";
|
||||
import { ChatInput } from "@/components/thread/chat-input";
|
||||
|
||||
interface AgentPageProps {
|
||||
params: {
|
||||
|
@ -21,9 +21,9 @@ interface AgentPageProps {
|
|||
}
|
||||
|
||||
// Parse XML tags in content
|
||||
function parseXMLTags(content: string): { parts: (string | ParsedTag)[], openTags: Record<string, ParsedTag> } {
|
||||
const parts: (string | ParsedTag)[] = [];
|
||||
const openTags: Record<string, ParsedTag> = {};
|
||||
function parseXMLTags(content: string): { parts: any[], openTags: Record<string, any> } {
|
||||
const parts: any[] = [];
|
||||
const openTags: Record<string, any> = {};
|
||||
const tagStack: Array<{tagName: string, position: number}> = [];
|
||||
|
||||
// Find all opening and closing tags
|
||||
|
@ -85,7 +85,7 @@ function parseXMLTags(content: string): { parts: (string | ParsedTag)[], openTag
|
|||
}
|
||||
|
||||
// Create tag object with unique ID
|
||||
const parsedTag: ParsedTag = {
|
||||
const parsedTag: any = {
|
||||
tagName,
|
||||
attributes,
|
||||
content: '',
|
||||
|
@ -112,7 +112,7 @@ function parseXMLTags(content: string): { parts: (string | ParsedTag)[], openTag
|
|||
for (let i = tagStack.length - 1; i >= 0; i--) {
|
||||
if (tagStack[i].tagName === tagName) {
|
||||
const openTagIndex = tagStack[i].position;
|
||||
const openTag = parts[openTagIndex] as ParsedTag;
|
||||
const openTag = parts[openTagIndex] as any;
|
||||
|
||||
// Get content between this opening and closing tag pair
|
||||
const contentStart = position;
|
||||
|
@ -193,41 +193,12 @@ function parseXMLTags(content: string): { parts: (string | ParsedTag)[], openTag
|
|||
return { parts, openTags };
|
||||
}
|
||||
|
||||
// Simple component to handle message formatting with XML tag support
|
||||
function MessageContent({ content }: { content: string }) {
|
||||
const { parts, openTags } = parseXMLTags(content);
|
||||
|
||||
return (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{parts.map((part, index) => {
|
||||
if (typeof part === 'string') {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{part.split('\n').map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{line}
|
||||
{i < part.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
// Render specialized tool component based on tag type
|
||||
const ToolComponent = getComponentForTag(part);
|
||||
return <ToolComponent key={index} tag={part} mode="compact" />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentPage({ params }: AgentPageProps) {
|
||||
const resolvedParams = React.use(params as any) as { threadId: string };
|
||||
const { threadId } = resolvedParams;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialMessage = searchParams.get('message');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const streamCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const [agent, setAgent] = useState<Project | null>(null);
|
||||
|
@ -254,7 +225,7 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
console.log(`[TOOLS] Processing ${allContent.length} content items for tool calls`);
|
||||
|
||||
// Create a new array of tags with a better deduplication strategy
|
||||
const extractedTags: ParsedTag[] = [];
|
||||
const extractedTags: any[] = [];
|
||||
const seenTagIds = new Set<string>();
|
||||
|
||||
allContent.forEach((content, idx) => {
|
||||
|
@ -342,8 +313,8 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
});
|
||||
|
||||
// Try to pair tool calls with their results
|
||||
const pairedTags: ParsedTag[] = [];
|
||||
const callsByTagName: Record<string, ParsedTag[]> = {};
|
||||
const pairedTags: any[] = [];
|
||||
const callsByTagName: Record<string, any[]> = {};
|
||||
|
||||
// Group by tag name first
|
||||
extractedTags.forEach(tag => {
|
||||
|
@ -396,11 +367,6 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
setToolCalls(pairedTags);
|
||||
}, [messages, streamContent, setToolCalls, agent]);
|
||||
|
||||
// Scroll to bottom of messages
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
|
@ -470,11 +436,6 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
};
|
||||
}, [threadId, initialMessage, router]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamContent, scrollToBottom]);
|
||||
|
||||
// Handle streaming agent responses
|
||||
const handleStreamAgent = useCallback((agentRunId: string) => {
|
||||
// Clean up any existing stream first
|
||||
|
@ -645,8 +606,8 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
}, [threadId, conversation, handleBillingError]);
|
||||
|
||||
// Handle sending a message
|
||||
const handleSendMessage = async () => {
|
||||
if (!userMessage.trim() || isSending) return;
|
||||
const handleSendMessage = async (message: string) => {
|
||||
if (!message.trim() || isSending) return;
|
||||
if (!conversation) return;
|
||||
|
||||
setIsSending(true);
|
||||
|
@ -658,7 +619,7 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
const userMsg: Message = {
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
content: message,
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
|
||||
|
@ -666,7 +627,7 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
setUserMessage("");
|
||||
|
||||
// Add user message to API and start agent
|
||||
await addUserMessage(conversation.thread_id, userMessage);
|
||||
await addUserMessage(conversation.thread_id, message);
|
||||
const agentResponse = await startAgent(conversation.thread_id);
|
||||
|
||||
// Set current agent run ID and start streaming
|
||||
|
@ -751,130 +712,25 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
isOpen={true}
|
||||
/>
|
||||
<div className="flex flex-col h-[calc(100vh-10rem)] max-h-[calc(100vh-10rem)] overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-[120px] space-y-4" id="messages-container">
|
||||
{messages.length === 0 && !streamContent ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">Start a conversation</h3>
|
||||
<p className="text-sm text-muted-foreground mt-2 mb-4">
|
||||
Send a message to start talking with {agent?.name || "the AI agent"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message, index) => {
|
||||
// Skip messages containing "ToolResult("
|
||||
if (!message || !message?.content || !message?.role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (message.content.includes("ToolResult(")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${
|
||||
message.role === "user" ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] p-4 rounded-lg ${
|
||||
message.role === "user"
|
||||
? "bg-[#f0efe7] text-foreground flex items-start"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{message.role === "user" && (
|
||||
<User className="h-5 w-5 mr-2 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<MessageContent content={message.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show streaming content if available */}
|
||||
{streamContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] p-4">
|
||||
<div className="whitespace-pre-wrap">
|
||||
<MessageContent content={streamContent} />
|
||||
{isStreaming && (
|
||||
<span
|
||||
className="inline-block h-4 w-0.5 bg-foreground/50 mx-px"
|
||||
style={{
|
||||
opacity: 0.7,
|
||||
animation: 'cursorBlink 1s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<style jsx global>{`
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show a loading indicator if the agent is running but no stream yet */}
|
||||
{isAgentRunning && !streamContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-2 bg-foreground rounded-full animate-pulse"></div>
|
||||
<div className="h-2 w-2 bg-foreground rounded-full animate-pulse delay-150"></div>
|
||||
<div className="h-2 w-2 bg-foreground rounded-full animate-pulse delay-300"></div>
|
||||
<span className="text-sm text-muted-foreground ml-2">AI is thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} id="messages-end"></div>
|
||||
</div>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamContent={streamContent}
|
||||
isStreaming={isStreaming}
|
||||
isAgentRunning={isAgentRunning}
|
||||
agentName={agent?.name}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 bg-background pb-6">
|
||||
<div className="relative bg-white border border-gray-200 rounded-2xl p-2 mx-4">
|
||||
<Textarea
|
||||
<div className="fixed bottom-0 left-0 right-0 z-10 bg-background pb-6">
|
||||
<div className="max-w-screen-md mx-auto px-4">
|
||||
<ChatInput
|
||||
onSubmit={handleSendMessage}
|
||||
loading={isSending}
|
||||
disabled={true}
|
||||
isAgentRunning={isAgentRunning}
|
||||
onStopAgent={handleStopAgent}
|
||||
value={userMessage}
|
||||
onChange={(e) => setUserMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
className="resize-none min-h-[100px] pr-12 border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
disabled={isAgentRunning || isSending || !conversation}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.shiftKey === false) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
onChange={setUserMessage}
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 w-10 p-0 rounded-2xl border border-gray-200"
|
||||
disabled={isAgentRunning || isSending || !conversation}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={isAgentRunning ? handleStopAgent : handleSendMessage}
|
||||
className="h-10 w-10 p-0 rounded-2xl"
|
||||
disabled={(!userMessage.trim() && !isAgentRunning) || isSending || !conversation}
|
||||
>
|
||||
{isAgentRunning ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -930,129 +786,25 @@ export default function AgentPage({ params }: AgentPageProps) {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-10rem)] max-h-[calc(100vh-10rem)] overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-[120px] space-y-4" id="messages-container">
|
||||
{messages.length === 0 && !streamContent ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">Start a conversation</h3>
|
||||
<p className="text-sm text-muted-foreground mt-2 mb-4">
|
||||
Send a message to start talking with {agent?.name || "the AI agent"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message, index) => {
|
||||
// Skip messages containing "ToolResult("
|
||||
if (!message || !message?.content || !message?.role) {
|
||||
return null;
|
||||
}
|
||||
if (message.content.includes("ToolResult(")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${
|
||||
message.role === "user" ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] p-4 rounded-lg ${
|
||||
message.role === "user"
|
||||
? "bg-[#f0efe7] text-foreground flex items-start"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{message.role === "user" && (
|
||||
<User className="h-5 w-5 mr-2 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<MessageContent content={message.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show streaming content if available */}
|
||||
{streamContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] p-4">
|
||||
<div className="whitespace-pre-wrap">
|
||||
<MessageContent content={streamContent} />
|
||||
{isStreaming && (
|
||||
<span
|
||||
className="inline-block h-4 w-0.5 bg-foreground/50 mx-px"
|
||||
style={{
|
||||
opacity: 0.7,
|
||||
animation: 'cursorBlink 1s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<style jsx global>{`
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show a loading indicator if the agent is running but no stream yet */}
|
||||
{isAgentRunning && !streamContent && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-2 w-2 bg-foreground rounded-full animate-pulse"></div>
|
||||
<div className="h-2 w-2 bg-foreground rounded-full animate-pulse delay-150"></div>
|
||||
<div className="h-2 w-2 bg-foreground rounded-full animate-pulse delay-300"></div>
|
||||
<span className="text-sm text-muted-foreground ml-2">AI is thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} id="messages-end"></div>
|
||||
</div>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamContent={streamContent}
|
||||
isStreaming={isStreaming}
|
||||
isAgentRunning={isAgentRunning}
|
||||
agentName={agent?.name}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 bg-background pb-6">
|
||||
<div className="relative bg-white border border-gray-200 rounded-2xl p-2 mx-4">
|
||||
<Textarea
|
||||
<div className="fixed bottom-0 left-0 right-0 z-10 bg-background pb-6">
|
||||
<div className="max-w-screen-md mx-auto px-4">
|
||||
<ChatInput
|
||||
onSubmit={handleSendMessage}
|
||||
loading={isSending}
|
||||
disabled={!conversation}
|
||||
isAgentRunning={isAgentRunning}
|
||||
onStopAgent={handleStopAgent}
|
||||
value={userMessage}
|
||||
onChange={(e) => setUserMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
className="resize-none min-h-[100px] pr-12 border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
disabled={isAgentRunning || isSending || !conversation}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.shiftKey === false) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
onChange={setUserMessage}
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 w-10 p-0 rounded-2xl border border-gray-200"
|
||||
disabled={isAgentRunning || isSending || !conversation}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={isAgentRunning ? handleStopAgent : handleSendMessage}
|
||||
className="h-10 w-10 p-0 rounded-2xl"
|
||||
disabled={(!userMessage.trim() && !isAgentRunning) || isSending || !conversation}
|
||||
>
|
||||
{isAgentRunning ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useState, Suspense } from 'react';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ChatInput } from '@/components/chat/chat-input';
|
||||
import { ChatInput } from '@/components/thread/chat-input';
|
||||
import { createProject, addUserMessage, startAgent, createThread } from "@/lib/api";
|
||||
|
||||
function DashboardContent() {
|
||||
|
|
|
@ -3,9 +3,12 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Send, Square, Loader2, File, Upload, X } from "lucide-react";
|
||||
|
||||
import { Send, Square, Loader2, File, Upload, X, Plus, Paperclip, Image } from "lucide-react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { toast } from "sonner";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Define API_URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
@ -32,7 +35,7 @@ interface UploadedFile {
|
|||
|
||||
export function ChatInput({
|
||||
onSubmit,
|
||||
placeholder = "Type your message... (Enter to send, Shift+Enter for new line)",
|
||||
placeholder = "Type your message...",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
isAgentRunning = false,
|
||||
|
@ -48,6 +51,7 @@ export function ChatInput({
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Allow controlled or uncontrolled usage
|
||||
const isControlled = value !== undefined && onChange !== undefined;
|
||||
|
@ -216,121 +220,139 @@ export function ChatInput({
|
|||
return (
|
||||
<div className="w-full">
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{uploadedFiles.map((file, index) => (
|
||||
<div key={index} className="p-2 bg-secondary/20 rounded-md flex items-center justify-between">
|
||||
<div className="flex items-center text-sm">
|
||||
<File className="h-4 w-4 mr-2 text-primary" />
|
||||
<span className="font-medium">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => removeUploadedFile(index)}
|
||||
<AnimatePresence>
|
||||
{uploadedFiles.length > 0 && (
|
||||
<motion.div
|
||||
className="mb-2 space-y-1.5"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
{uploadedFiles.map((file, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="p-2 rounded-lg bg-background border border-border/50 flex items-center justify-between shadow-sm"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center text-sm">
|
||||
<File className="h-4 w-4 mr-2 text-primary" />
|
||||
<span className="font-medium text-xs">{file.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 rounded-full hover:bg-background"
|
||||
onClick={() => removeUploadedFile(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isAgentRunning
|
||||
? "Agent is thinking..."
|
||||
: placeholder
|
||||
}
|
||||
className="min-h-[50px] max-h-[200px] pr-20 resize-none"
|
||||
disabled={loading || (disabled && !isAgentRunning)}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 bottom-2 flex items-center space-x-1">
|
||||
{/* Upload file button */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFileUpload}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={loading || (disabled && !isAgentRunning) || isUploading}
|
||||
aria-label="Upload files"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={processFileUpload}
|
||||
multiple
|
||||
<div className={cn(
|
||||
"relative bg-background border rounded-2xl transition-all duration-150",
|
||||
isFocused
|
||||
? "border-secondary/50 shadow-[0_0_15px_rgba(var(--secondary),0.2)]"
|
||||
: "border-border"
|
||||
)}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={
|
||||
isAgentRunning
|
||||
? "Agent is running... type 'stop' to stop"
|
||||
: placeholder
|
||||
}
|
||||
className="resize-none pr-24 min-h-[56px] max-h-[200px] rounded-2xl border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none"
|
||||
disabled={disabled || loading}
|
||||
aria-disabled={disabled || loading}
|
||||
/>
|
||||
|
||||
{/* File browser button */}
|
||||
{onFileBrowse && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onFileBrowse}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={loading || (disabled && !isAgentRunning)}
|
||||
aria-label="Browse files"
|
||||
>
|
||||
<File className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type={isAgentRunning ? 'button' : 'submit'}
|
||||
onClick={isAgentRunning ? onStopAgent : undefined}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={((!inputValue.trim() && uploadedFiles.length === 0) && !isAgentRunning) || loading || (disabled && !isAgentRunning)}
|
||||
aria-label={isAgentRunning ? 'Stop agent' : 'Send message'}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isAgentRunning ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
<div className="absolute bottom-2 right-2 flex items-center space-x-1">
|
||||
{/* File upload button */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={processFileUpload}
|
||||
multiple
|
||||
disabled={disabled || loading || isUploading || !sandboxId}
|
||||
/>
|
||||
|
||||
{sandboxId && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-full bg-background/80 hover:bg-muted"
|
||||
onClick={handleFileUpload}
|
||||
disabled={disabled || loading || isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Additional action button (+ button) */}
|
||||
{onFileBrowse && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-full bg-background/80 hover:bg-muted"
|
||||
onClick={onFileBrowse}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Submit or Stop button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full",
|
||||
isAgentRunning
|
||||
? "bg-destructive/90 hover:bg-destructive text-white"
|
||||
: inputValue.trim() || uploadedFiles.length > 0
|
||||
? "bg-secondary hover:bg-secondary/90 text-white"
|
||||
: "bg-muted/70 text-muted-foreground"
|
||||
)}
|
||||
disabled={((!inputValue.trim() && uploadedFiles.length === 0) && !isAgentRunning) || loading || (disabled && !isAgentRunning)}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isAgentRunning ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Subtle glow effect under the input */}
|
||||
<div className="absolute -bottom-4 inset-x-0 h-6 bg-secondary/10 blur-xl rounded-full -z-10 opacity-50"></div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{isAgentRunning && (
|
||||
<div className="mt-2 flex items-center justify-center gap-2">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="inline-flex items-center">
|
||||
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||
Agent is thinking...
|
||||
</span>
|
||||
<span className="text-muted-foreground/60 border-l pl-1.5">
|
||||
Press <kbd className="inline-flex items-center justify-center p-0.5 bg-muted border rounded text-xs"><Square className="h-2.5 w-2.5" /></kbd> to stop
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,416 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { User, Bot, ChevronDown, ChevronUp, Code, Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ParsedTag, SUPPORTED_XML_TAGS } from "@/lib/types/tool-calls";
|
||||
import { getComponentForTag } from "@/components/thread/tool-components";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface MessageDisplayProps {
|
||||
content: string;
|
||||
role: 'user' | 'assistant';
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
// Full implementation of XML tag parsing
|
||||
function parseXMLTags(content: string): { parts: (string | ParsedTag)[], openTags: Record<string, ParsedTag> } {
|
||||
const parts: (string | ParsedTag)[] = [];
|
||||
const openTags: Record<string, ParsedTag> = {};
|
||||
const tagStack: Array<{tagName: string, position: number}> = [];
|
||||
|
||||
// Find all opening and closing tags
|
||||
let currentPosition = 0;
|
||||
|
||||
// Match opening tags with attributes like <tag-name attr="value">
|
||||
const openingTagRegex = new RegExp(`<(${SUPPORTED_XML_TAGS.join('|')})\\s*([^>]*)>`, 'g');
|
||||
// Match closing tags like </tag-name>
|
||||
const closingTagRegex = new RegExp(`</(${SUPPORTED_XML_TAGS.join('|')})>`, 'g');
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
let matches: { regex: RegExp, match: RegExpExecArray, isOpening: boolean, position: number }[] = [];
|
||||
|
||||
// Find all opening tags
|
||||
while ((match = openingTagRegex.exec(content)) !== null) {
|
||||
matches.push({
|
||||
regex: openingTagRegex,
|
||||
match,
|
||||
isOpening: true,
|
||||
position: match.index
|
||||
});
|
||||
}
|
||||
|
||||
// Find all closing tags
|
||||
while ((match = closingTagRegex.exec(content)) !== null) {
|
||||
matches.push({
|
||||
regex: closingTagRegex,
|
||||
match,
|
||||
isOpening: false,
|
||||
position: match.index
|
||||
});
|
||||
}
|
||||
|
||||
// Sort matches by their position in the content
|
||||
matches.sort((a, b) => a.position - b.position);
|
||||
|
||||
// Process matches in order
|
||||
for (const { match, isOpening, position } of matches) {
|
||||
const tagName = match[1];
|
||||
const matchEnd = position + match[0].length;
|
||||
|
||||
// Add text before this tag if needed
|
||||
if (position > currentPosition) {
|
||||
parts.push(content.substring(currentPosition, position));
|
||||
}
|
||||
|
||||
if (isOpening) {
|
||||
// Parse attributes for opening tags
|
||||
const attributesStr = match[2]?.trim();
|
||||
const attributes: Record<string, string> = {};
|
||||
|
||||
if (attributesStr) {
|
||||
// Match attributes in format: name="value" or name='value'
|
||||
const attrRegex = /(\w+)=["']([^"']*)["']/g;
|
||||
let attrMatch;
|
||||
while ((attrMatch = attrRegex.exec(attributesStr)) !== null) {
|
||||
attributes[attrMatch[1]] = attrMatch[2];
|
||||
}
|
||||
}
|
||||
|
||||
// Create tag object with unique ID
|
||||
const parsedTag: ParsedTag = {
|
||||
tagName,
|
||||
attributes,
|
||||
content: '',
|
||||
isClosing: false,
|
||||
id: `${tagName}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
rawMatch: match[0]
|
||||
};
|
||||
|
||||
// Add timestamp if not present
|
||||
if (!parsedTag.timestamp) {
|
||||
parsedTag.timestamp = Date.now();
|
||||
}
|
||||
|
||||
// Push to parts and track in stack
|
||||
parts.push(parsedTag);
|
||||
tagStack.push({ tagName, position: parts.length - 1 });
|
||||
openTags[tagName] = parsedTag;
|
||||
|
||||
} else {
|
||||
// Handle closing tag
|
||||
// Find the corresponding opening tag in the stack (last in, first out)
|
||||
let foundOpeningTag = false;
|
||||
|
||||
for (let i = tagStack.length - 1; i >= 0; i--) {
|
||||
if (tagStack[i].tagName === tagName) {
|
||||
const openTagIndex = tagStack[i].position;
|
||||
const openTag = parts[openTagIndex] as ParsedTag;
|
||||
|
||||
// Get content between this opening and closing tag pair
|
||||
let tagContentStart = openTagIndex + 1;
|
||||
let tagContentEnd = parts.length;
|
||||
|
||||
// Mark that we need to capture content between these positions
|
||||
let contentToCapture = '';
|
||||
|
||||
// Collect all content parts between the opening and closing tags
|
||||
for (let j = tagContentStart; j < tagContentEnd; j++) {
|
||||
if (typeof parts[j] === 'string') {
|
||||
contentToCapture += parts[j];
|
||||
}
|
||||
}
|
||||
|
||||
// Try getting content directly from original text (most reliable approach)
|
||||
const openTagMatch = openTag.rawMatch || '';
|
||||
const openTagPosition = content.indexOf(openTagMatch, Math.max(0, openTagIndex > 0 ? currentPosition - 200 : 0));
|
||||
if (openTagPosition >= 0) {
|
||||
const openTagEndPosition = openTagPosition + openTagMatch.length;
|
||||
// Only use if the positions make sense
|
||||
if (openTagEndPosition > 0 && position > openTagEndPosition) {
|
||||
// Get content and clean up excessive whitespace but preserve formatting
|
||||
let extractedContent = content.substring(openTagEndPosition, position);
|
||||
|
||||
// Trim leading newline if present
|
||||
if (extractedContent.startsWith('\n')) {
|
||||
extractedContent = extractedContent.substring(1);
|
||||
}
|
||||
|
||||
// Trim trailing newline if present
|
||||
if (extractedContent.endsWith('\n')) {
|
||||
extractedContent = extractedContent.substring(0, extractedContent.length - 1);
|
||||
}
|
||||
|
||||
contentToCapture = extractedContent;
|
||||
}
|
||||
}
|
||||
|
||||
// Update opening tag with collected content
|
||||
openTag.content = contentToCapture;
|
||||
openTag.isClosing = true;
|
||||
|
||||
// Remove all parts between the opening tag and this position
|
||||
// because they're now captured in the tag's content
|
||||
if (tagContentStart < tagContentEnd) {
|
||||
parts.splice(tagContentStart, tagContentEnd - tagContentStart);
|
||||
}
|
||||
|
||||
// Remove this tag from the stack
|
||||
tagStack.splice(i, 1);
|
||||
// Remove from openTags
|
||||
delete openTags[tagName];
|
||||
|
||||
foundOpeningTag = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no corresponding opening tag found, add closing tag as text
|
||||
if (!foundOpeningTag) {
|
||||
parts.push(match[0]);
|
||||
}
|
||||
}
|
||||
|
||||
currentPosition = matchEnd;
|
||||
}
|
||||
|
||||
// Add any remaining text
|
||||
if (currentPosition < content.length) {
|
||||
parts.push(content.substring(currentPosition));
|
||||
}
|
||||
|
||||
return { parts, openTags };
|
||||
}
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export function MessageContent({ content, maxHeight = 300 }: MessageContentProps) {
|
||||
const { parts, openTags } = parseXMLTags(content);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showRawXml, setShowRawXml] = useState(false);
|
||||
|
||||
// Check if content has XML tags
|
||||
const hasXmlTags = parts.some(part => typeof part !== 'string');
|
||||
|
||||
// Check if content is very long
|
||||
const isLongContent = content.length > 1000 || (parts.length > 0 && parts.some(p => typeof p !== 'string'));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-pre-wrap overflow-hidden transition-all duration-200",
|
||||
!expanded && isLongContent && `max-h-[${maxHeight}px]`
|
||||
)}
|
||||
style={{ maxHeight: !expanded && isLongContent ? maxHeight : 'none' }}
|
||||
>
|
||||
{showRawXml ? (
|
||||
<pre className="p-2 bg-muted/40 rounded text-xs overflow-x-auto">
|
||||
{content}
|
||||
</pre>
|
||||
) : (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
if (typeof part === 'string') {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{part.split('\n').map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{line}
|
||||
{i < part.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
// Render specialized tool component based on tag type
|
||||
const ToolComponent = getComponentForTag(part);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="my-2 rounded border border-border/50 overflow-hidden"
|
||||
>
|
||||
<div className="bg-muted/30 px-2 py-1 text-xs font-medium border-b border-border/30 flex items-center justify-between">
|
||||
<span>{part.tagName}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">Tool</Badge>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<ToolComponent key={index} tag={part} mode="compact" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fade-out effect for collapsed long content */}
|
||||
{isLongContent && !expanded && (
|
||||
<div className="h-12 bg-gradient-to-t from-background to-transparent -mt-12 relative"></div>
|
||||
)}
|
||||
|
||||
{/* Controls for expanding/showing raw XML */}
|
||||
{(isLongContent || hasXmlTags) && (
|
||||
<div className="flex items-center space-x-2 mt-2 text-xs text-muted-foreground">
|
||||
{isLongContent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs flex items-center gap-1 rounded-md"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasXmlTags && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs flex items-center gap-1 rounded-md"
|
||||
onClick={() => setShowRawXml(!showRawXml)}
|
||||
>
|
||||
{showRawXml ? (
|
||||
<>
|
||||
<EyeOff className="h-3 w-3" />
|
||||
Hide XML
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Code className="h-3 w-3" />
|
||||
Show XML
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageDisplay({ content, role, isStreaming = false }: MessageDisplayProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full max-w-screen-md mx-auto",
|
||||
role === "user" ? "justify-end" : "justify-start",
|
||||
"mb-4"
|
||||
)}
|
||||
>
|
||||
{role === "assistant" && (
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-secondary/20 border border-secondary/30">
|
||||
<Bot className="w-4 h-4 text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-card rounded-2xl rounded-tl-sm p-4 shadow-sm border border-border/50">
|
||||
<MessageContent content={content} />
|
||||
{isStreaming && (
|
||||
<span
|
||||
className="inline-block h-4 w-0.5 bg-foreground/50 mx-px"
|
||||
style={{
|
||||
opacity: 0.7,
|
||||
animation: 'cursorBlink 1s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<style jsx global>{`
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role === "user" && (
|
||||
<div className="flex items-start justify-end space-x-4 max-w-3xl">
|
||||
<div className="flex-1">
|
||||
<div className="bg-accent rounded-2xl rounded-tr-sm p-4 ml-auto shadow-sm border border-border/50">
|
||||
<MessageContent content={content} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/20 border border-primary/30">
|
||||
<User className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for displaying a loading/thinking indicator
|
||||
export function ThinkingIndicator() {
|
||||
return (
|
||||
<div className="flex justify-start max-w-screen-md mx-auto mb-4">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-secondary/20 border border-secondary/30">
|
||||
<Bot className="w-4 h-4 text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-card rounded-2xl rounded-tl-sm p-4 shadow-sm border border-border/50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<motion.div
|
||||
animate={{ scale: [0.8, 1, 0.8] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
|
||||
className="w-2 h-2 bg-muted-foreground/60 rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ scale: [0.8, 1, 0.8] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut", delay: 0.2 }}
|
||||
className="w-2 h-2 bg-muted-foreground/60 rounded-full"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ scale: [0.8, 1, 0.8] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut", delay: 0.4 }}
|
||||
className="w-2 h-2 bg-muted-foreground/60 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for empty/start conversation state
|
||||
export function EmptyChat({ agentName = "AI assistant" }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full max-w-screen-md mx-auto">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-secondary/20 border border-secondary/30 mb-4">
|
||||
<Bot className="w-6 h-6 text-secondary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">Start a conversation</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-[300px]">
|
||||
Send a message to start talking with {agentName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
'use client';
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { Message } from "@/lib/api";
|
||||
import { MessageDisplay, ThinkingIndicator, EmptyChat } from "@/components/thread/message-display";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
streamContent?: string;
|
||||
isStreaming?: boolean;
|
||||
isAgentRunning?: boolean;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
streamContent = "",
|
||||
isStreaming = false,
|
||||
isAgentRunning = false,
|
||||
agentName = "AI assistant"
|
||||
}: MessageListProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamContent]);
|
||||
|
||||
// Filter out invalid messages and tool results
|
||||
const filteredMessages = messages.filter(message =>
|
||||
message &&
|
||||
message.content &&
|
||||
message.role &&
|
||||
!message.content.includes("ToolResult(")
|
||||
);
|
||||
|
||||
// If no messages and not streaming, show empty state
|
||||
if (filteredMessages.length === 0 && !streamContent && !isAgentRunning) {
|
||||
return <EmptyChat agentName={agentName} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-y-auto overflow-x-hidden p-4 pb-32">
|
||||
{/* Regular messages */}
|
||||
<AnimatePresence initial={false}>
|
||||
{filteredMessages.map((message, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
delay: 0.1
|
||||
}}
|
||||
>
|
||||
<MessageDisplay
|
||||
content={message.content}
|
||||
role={message.role as 'user' | 'assistant'}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Streaming message */}
|
||||
{streamContent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<MessageDisplay
|
||||
content={streamContent}
|
||||
role="assistant"
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator when agent is running but no stream yet */}
|
||||
{isAgentRunning && !streamContent && (
|
||||
<ThinkingIndicator />
|
||||
)}
|
||||
|
||||
{/* Element to scroll to */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -4,7 +4,7 @@ import { useEffect, useState, useContext } from 'react';
|
|||
import { ToolCallsContext } from '@/app/providers';
|
||||
import { ParsedTag, ToolDisplayMode } from '@/lib/types/tool-calls';
|
||||
import { Grid3X3 } from 'lucide-react';
|
||||
import { getComponentForTag } from '@/components/chat/tool-components';
|
||||
import { getComponentForTag } from '@/components/thread/tool-components';
|
||||
|
||||
export function useToolsPanel() {
|
||||
const { toolCalls, setToolCalls } = useContext(ToolCallsContext);
|
||||
|
|
Loading…
Reference in New Issue