This commit is contained in:
marko-kraemer 2025-04-15 19:54:26 +01:00
parent 09628fe6f3
commit 606daa9f05
7 changed files with 688 additions and 408 deletions

View File

@ -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>

View File

@ -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() {

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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);