mirror of https://github.com/kortix-ai/suna.git
revert back to old fe
This commit is contained in:
parent
f9e1f5b1ab
commit
1b9553b865
|
@ -153,7 +153,7 @@ async def run_agent(thread_id: str, project_id: str, stream: bool = True, thread
|
|||
stream=stream,
|
||||
llm_model=model_name,
|
||||
llm_temperature=0,
|
||||
llm_max_tokens=128000,
|
||||
llm_max_tokens=1280000,
|
||||
tool_choice="auto",
|
||||
max_xml_tool_calls=1,
|
||||
temporary_message=temporary_message,
|
||||
|
|
|
@ -187,7 +187,7 @@ class SandboxFilesTool(SandboxToolsBase):
|
|||
end_line = replacement_line + self.SNIPPET_LINES + new_str.count('\n')
|
||||
snippet = '\n'.join(new_content.split('\n')[start_line:end_line + 1])
|
||||
|
||||
return self.success_response(f"Replacement successful. Snippet of changes:\n{snippet}")
|
||||
return self.success_response(f"Replacement successful.")
|
||||
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error replacing string: {str(e)}")
|
||||
|
|
|
@ -1224,19 +1224,19 @@ class ResponseProcessor:
|
|||
logger.error(f"Failed even with fallback message: {str(e2)}", exc_info=True)
|
||||
|
||||
def _format_xml_tool_result(self, tool_call: Dict[str, Any], result: ToolResult) -> str:
|
||||
"""Format a tool result as an XML tag or plain text.
|
||||
|
||||
"""Format a tool result wrapped in a <tool_result> tag.
|
||||
|
||||
Args:
|
||||
tool_call: The tool call that was executed
|
||||
result: The result of the tool execution
|
||||
|
||||
|
||||
Returns:
|
||||
String containing the formatted result
|
||||
String containing the formatted result wrapped in <tool_result> tag
|
||||
"""
|
||||
# Always use xml_tag_name if it exists
|
||||
if "xml_tag_name" in tool_call:
|
||||
xml_tag_name = tool_call["xml_tag_name"]
|
||||
return f"<{xml_tag_name}> {str(result)} </{xml_tag_name}>"
|
||||
return f"<tool_result><{xml_tag_name}> {str(result)} </{xml_tag_name}></tool_result>"
|
||||
|
||||
# Non-XML tool, just return the function result
|
||||
function_name = tool_call["function_name"]
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -231,8 +231,8 @@
|
|||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(100% 0 0);
|
||||
--accent-foreground: oklch(96.7% 0.003 264.54);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.145 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
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";
|
||||
|
@ -17,6 +17,7 @@ interface ChatInputProps {
|
|||
disabled?: boolean;
|
||||
isAgentRunning?: boolean;
|
||||
onStopAgent?: () => void;
|
||||
autoFocus?: boolean;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onFileBrowse?: () => void;
|
||||
|
@ -31,72 +32,102 @@ interface UploadedFile {
|
|||
|
||||
export function ChatInput({
|
||||
onSubmit,
|
||||
placeholder = "Type your message...",
|
||||
placeholder = "Type your message... (Enter to send, Shift+Enter for new line)",
|
||||
loading = false,
|
||||
disabled = false,
|
||||
isAgentRunning = false,
|
||||
onStopAgent,
|
||||
value = '',
|
||||
autoFocus = true,
|
||||
value,
|
||||
onChange,
|
||||
onFileBrowse,
|
||||
sandboxId
|
||||
}: ChatInputProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [inputValue, setInputValue] = useState(value || "");
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadedFiles, setUploadedFiles] = React.useState<UploadedFile[]>([]);
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// Allow controlled or uncontrolled usage
|
||||
const isControlled = value !== undefined && onChange !== undefined;
|
||||
|
||||
// Update local state if controlled and value changes
|
||||
useEffect(() => {
|
||||
if (isControlled && value !== inputValue) {
|
||||
setInputValue(value);
|
||||
}
|
||||
}, [value, isControlled, inputValue]);
|
||||
|
||||
// Auto-focus on textarea when component loads
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
if (autoFocus && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
}, [autoFocus]);
|
||||
|
||||
// Handle textarea height adjustment
|
||||
// Adjust textarea height based on content
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const adjustHeight = () => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
const newHeight = Math.min(textarea.scrollHeight, 200); // Max height of 200px
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(adjustHeight);
|
||||
observer.observe(textarea);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
adjustHeight();
|
||||
|
||||
// Adjust on window resize too
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [inputValue]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading || (disabled && !isAgentRunning)) return;
|
||||
|
||||
if ((!inputValue.trim() && uploadedFiles.length === 0) || loading || (disabled && !isAgentRunning)) return;
|
||||
|
||||
if (isAgentRunning && onStopAgent) {
|
||||
onStopAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
const message = value.trim();
|
||||
if (!message && uploadedFiles.length === 0) return;
|
||||
|
||||
let finalMessage = message;
|
||||
|
||||
let message = inputValue;
|
||||
|
||||
// Add file information to the message if files were uploaded
|
||||
if (uploadedFiles.length > 0) {
|
||||
const fileInfo = uploadedFiles.map(file =>
|
||||
`[Uploaded file: ${file.name} (${formatFileSize(file.size)}) at ${file.path}]`
|
||||
).join('\n');
|
||||
finalMessage = message ? `${message}\n\n${fileInfo}` : fileInfo;
|
||||
message = message ? `${message}\n\n${fileInfo}` : fileInfo;
|
||||
}
|
||||
|
||||
onSubmit(finalMessage);
|
||||
|
||||
onSubmit(message);
|
||||
|
||||
if (!isControlled) {
|
||||
setInputValue("");
|
||||
}
|
||||
|
||||
// Reset the uploaded files after sending
|
||||
setUploadedFiles([]);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
if (isControlled) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setInputValue(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e as React.FormEvent);
|
||||
if ((inputValue.trim() || uploadedFiles.length > 0) && !loading && (!disabled || isAgentRunning)) {
|
||||
handleSubmit(e as React.FormEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -107,22 +138,27 @@ export function ChatInput({
|
|||
};
|
||||
|
||||
const processFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!sandboxId || !event.target.files?.length) return;
|
||||
if (!sandboxId || !event.target.files || event.target.files.length === 0) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
|
||||
const files = Array.from(event.target.files);
|
||||
const newUploadedFiles: UploadedFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
if (file.size > 50 * 1024 * 1024) { // 50MB limit
|
||||
toast.error(`File size exceeds 50MB limit: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a FormData object
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', `/workspace/${file.name}`);
|
||||
|
||||
// Upload to workspace root by default
|
||||
const uploadPath = `/workspace/${file.name}`;
|
||||
formData.append('path', uploadPath);
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
@ -131,9 +167,12 @@ export function ChatInput({
|
|||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
// Upload using FormData
|
||||
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${session.access_token}` },
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
|
@ -141,15 +180,17 @@ export function ChatInput({
|
|||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Add to uploaded files
|
||||
newUploadedFiles.push({
|
||||
name: file.name,
|
||||
path: `/workspace/${file.name}`,
|
||||
path: uploadPath,
|
||||
size: file.size
|
||||
});
|
||||
|
||||
toast.success(`File uploaded: ${file.name}`);
|
||||
}
|
||||
|
||||
// Update the uploaded files state
|
||||
setUploadedFiles(prev => [...prev, ...newUploadedFiles]);
|
||||
|
||||
} catch (error) {
|
||||
|
@ -157,6 +198,7 @@ export function ChatInput({
|
|||
toast.error(typeof error === 'string' ? error : (error instanceof Error ? error.message : "Failed to upload file"));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset the input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
@ -201,16 +243,21 @@ export function ChatInput({
|
|||
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isAgentRunning ? "Agent is thinking..." : placeholder}
|
||||
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}
|
||||
|
@ -227,6 +274,7 @@ export function ChatInput({
|
|||
)}
|
||||
</Button>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
|
@ -235,6 +283,7 @@ export function ChatInput({
|
|||
multiple
|
||||
/>
|
||||
|
||||
{/* File browser button */}
|
||||
{onFileBrowse && (
|
||||
<Button
|
||||
type="button"
|
||||
|
@ -255,7 +304,7 @@ export function ChatInput({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
disabled={loading || (!isAgentRunning && (!value.trim() && uploadedFiles.length === 0))}
|
||||
disabled={((!inputValue.trim() && uploadedFiles.length === 0) && !isAgentRunning) || loading || (disabled && !isAgentRunning)}
|
||||
aria-label={isAgentRunning ? 'Stop agent' : 'Send message'}
|
||||
>
|
||||
{loading ? (
|
||||
|
|
|
@ -0,0 +1,224 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { File, Folder, ChevronRight, ChevronUp, FileText, Coffee } from "lucide-react";
|
||||
import { listSandboxFiles, getSandboxFileContent, type FileInfo } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface FileBrowserProps {
|
||||
sandboxId: string;
|
||||
onSelectFile?: (path: string, content: string) => void;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FileBrowser({ sandboxId, onSelectFile, trigger }: FileBrowserProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState("");
|
||||
const [files, setFiles] = useState<FileInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadFiles("");
|
||||
} else {
|
||||
setFileContent(null);
|
||||
setSelectedFile(null);
|
||||
}
|
||||
}, [isOpen, sandboxId]);
|
||||
|
||||
// Load files from the current path
|
||||
const loadFiles = async (path: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const files = await listSandboxFiles(sandboxId, path);
|
||||
setFiles(files);
|
||||
setCurrentPath(path);
|
||||
|
||||
// Update breadcrumbs
|
||||
if (path === "") {
|
||||
setBreadcrumbs([]);
|
||||
} else {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
setBreadcrumbs(parts);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to load files");
|
||||
console.error("Failed to load files:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load file content
|
||||
const loadFileContent = async (path: string) => {
|
||||
setIsLoading(true);
|
||||
setSelectedFile(path);
|
||||
try {
|
||||
const content = await getSandboxFileContent(sandboxId, path);
|
||||
if (typeof content === 'string') {
|
||||
setFileContent(content);
|
||||
} else {
|
||||
// For binary files, show a message
|
||||
setFileContent("[Binary file]");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to load file content");
|
||||
console.error("Failed to load file content:", error);
|
||||
setFileContent(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file or folder click
|
||||
const handleItemClick = (file: FileInfo) => {
|
||||
if (file.is_dir) {
|
||||
loadFiles(file.path);
|
||||
} else {
|
||||
loadFileContent(file.path);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to a specific breadcrumb
|
||||
const navigateToBreadcrumb = (index: number) => {
|
||||
if (index === -1) {
|
||||
// Root directory
|
||||
loadFiles("");
|
||||
} else {
|
||||
const path = breadcrumbs.slice(0, index + 1).join('/');
|
||||
loadFiles(path);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle select button click
|
||||
const handleSelectFile = () => {
|
||||
if (selectedFile && fileContent && onSelectFile) {
|
||||
onSelectFile(selectedFile, fileContent);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || <Button variant="outline">Browse Files</Button>}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sandbox Files</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<div className="flex items-center space-x-1 text-sm py-2 border-b">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => navigateToBreadcrumb(-1)}
|
||||
>
|
||||
<Folder className="h-4 w-4 mr-1" />
|
||||
root
|
||||
</Button>
|
||||
{breadcrumbs.map((part, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => navigateToBreadcrumb(index)}
|
||||
>
|
||||
{part}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 flex-1 overflow-hidden">
|
||||
{/* File list */}
|
||||
<div className="border rounded-md overflow-y-auto h-[400px]">
|
||||
{isLoading && !files.length ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-6 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Coffee className="h-8 w-8 mb-2" />
|
||||
<p>No files found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{currentPath !== "" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm mb-1"
|
||||
onClick={() => {
|
||||
const parentPath = currentPath.split('/').slice(0, -1).join('/');
|
||||
loadFiles(parentPath);
|
||||
}}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 mr-2" />
|
||||
..
|
||||
</Button>
|
||||
)}
|
||||
{files.map((file) => (
|
||||
<Button
|
||||
key={file.path}
|
||||
variant={selectedFile === file.path ? "secondary" : "ghost"}
|
||||
className="w-full justify-start text-sm mb-1"
|
||||
onClick={() => handleItemClick(file)}
|
||||
>
|
||||
{file.is_dir ? (
|
||||
<Folder className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{file.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File preview */}
|
||||
<div className="border rounded-md overflow-hidden flex flex-col">
|
||||
<div className="p-2 bg-muted text-sm font-medium border-b">
|
||||
{selectedFile ? selectedFile.split('/').pop() : "File Preview"}
|
||||
</div>
|
||||
<div className="p-2 overflow-y-auto flex-1 h-[360px]">
|
||||
{isLoading && selectedFile ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<pre className="text-xs whitespace-pre-wrap">{fileContent}</pre>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<File className="h-8 w-8 mb-2" />
|
||||
<p>Select a file to preview</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedFile && fileContent && onSelectFile && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleSelectFile}>Select File</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,524 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Upload,
|
||||
Download,
|
||||
ChevronRight,
|
||||
Home,
|
||||
ArrowLeft
|
||||
} from "lucide-react";
|
||||
import { listSandboxFiles, getSandboxFileContent, type FileInfo } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
// Define API_URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || '';
|
||||
|
||||
interface FileViewerModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sandboxId: string;
|
||||
}
|
||||
|
||||
export function FileViewerModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
sandboxId
|
||||
}: FileViewerModalProps) {
|
||||
const [workspaceFiles, setWorkspaceFiles] = useState<FileInfo[]>([]);
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [binaryFileUrl, setBinaryFileUrl] = useState<string | null>(null);
|
||||
const [fileType, setFileType] = useState<'text' | 'image' | 'pdf' | 'binary'>('text');
|
||||
const [isLoadingContent, setIsLoadingContent] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Navigation state
|
||||
const [currentPath, setCurrentPath] = useState<string>("/workspace");
|
||||
const [pathHistory, setPathHistory] = useState<string[]>(["/workspace"]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number>(0);
|
||||
|
||||
// Load files when the modal opens or sandbox ID changes
|
||||
useEffect(() => {
|
||||
if (open && sandboxId) {
|
||||
loadFilesAtPath(currentPath);
|
||||
}
|
||||
}, [open, sandboxId, currentPath]);
|
||||
|
||||
// Function to load files from a specific path
|
||||
const loadFilesAtPath = async (path: string) => {
|
||||
if (!sandboxId) return;
|
||||
|
||||
setIsLoadingFiles(true);
|
||||
try {
|
||||
const files = await listSandboxFiles(sandboxId, path);
|
||||
setWorkspaceFiles(files);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load files at ${path}:`, error);
|
||||
toast.error("Failed to load files");
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to a folder
|
||||
const navigateToFolder = (folderPath: string) => {
|
||||
// Update current path
|
||||
setCurrentPath(folderPath);
|
||||
|
||||
// Add to navigation history, discarding any forward history if we're not at the end
|
||||
if (historyIndex < pathHistory.length - 1) {
|
||||
setPathHistory(prevHistory => [...prevHistory.slice(0, historyIndex + 1), folderPath]);
|
||||
setHistoryIndex(historyIndex + 1);
|
||||
} else {
|
||||
setPathHistory(prevHistory => [...prevHistory, folderPath]);
|
||||
setHistoryIndex(pathHistory.length);
|
||||
}
|
||||
|
||||
// Reset file selection and content
|
||||
setSelectedFile(null);
|
||||
setFileContent(null);
|
||||
setBinaryFileUrl(null);
|
||||
};
|
||||
|
||||
// Go back in history
|
||||
const goBack = () => {
|
||||
if (historyIndex > 0) {
|
||||
setHistoryIndex(historyIndex - 1);
|
||||
setCurrentPath(pathHistory[historyIndex - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
// Go to home directory
|
||||
const goHome = () => {
|
||||
setCurrentPath("/workspace");
|
||||
// Reset file selection and content
|
||||
setSelectedFile(null);
|
||||
setFileContent(null);
|
||||
setBinaryFileUrl(null);
|
||||
};
|
||||
|
||||
// Determine file type based on extension
|
||||
const getFileType = (filename: string): 'text' | 'image' | 'pdf' | 'binary' => {
|
||||
const extension = filename.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
|
||||
if (imageExtensions.includes(extension)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (extension === 'pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
const textExtensions = [
|
||||
'txt', 'md', 'js', 'jsx', 'ts', 'tsx', 'html', 'css', 'json', 'py',
|
||||
'java', 'c', 'cpp', 'h', 'cs', 'php', 'rb', 'go', 'rs', 'sh', 'yml',
|
||||
'yaml', 'toml', 'xml', 'csv', 'sql'
|
||||
];
|
||||
if (textExtensions.includes(extension)) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return 'binary';
|
||||
};
|
||||
|
||||
// Handle file or folder click
|
||||
const handleFileClick = async (file: FileInfo) => {
|
||||
if (file.is_dir) {
|
||||
// If it's a directory, navigate to it
|
||||
navigateToFolder(file.path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise handle as regular file
|
||||
setSelectedFile(file.path);
|
||||
setIsLoadingContent(true);
|
||||
setFileContent(null);
|
||||
setBinaryFileUrl(null);
|
||||
|
||||
try {
|
||||
// Determine file type based on extension
|
||||
const fileType = getFileType(file.path);
|
||||
setFileType(fileType);
|
||||
|
||||
const content = await getSandboxFileContent(sandboxId, file.path);
|
||||
|
||||
// Force certain file types to be treated as text
|
||||
if (fileType === 'text') {
|
||||
// For text files (including markdown), always try to render as text
|
||||
if (typeof content === 'string') {
|
||||
setFileContent(content);
|
||||
} else if (content instanceof Blob) {
|
||||
// If we got a Blob for a text file, convert it to text
|
||||
const text = await content.text();
|
||||
setFileContent(text);
|
||||
}
|
||||
} else if (fileType === 'image' || fileType === 'pdf') {
|
||||
// For images and PDFs, create a blob URL to render them
|
||||
if (content instanceof Blob) {
|
||||
const url = URL.createObjectURL(content);
|
||||
setBinaryFileUrl(url);
|
||||
} else if (typeof content === 'string') {
|
||||
try {
|
||||
// For base64 content or binary text, create a blob
|
||||
const blob = new Blob([content]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
setBinaryFileUrl(url);
|
||||
} catch (e) {
|
||||
console.error("Failed to create blob URL:", e);
|
||||
setFileType('text');
|
||||
setFileContent(content);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For other binary files
|
||||
if (content instanceof Blob) {
|
||||
const url = URL.createObjectURL(content);
|
||||
setBinaryFileUrl(url);
|
||||
} else if (typeof content === 'string') {
|
||||
setFileContent("[Binary file]");
|
||||
|
||||
try {
|
||||
const blob = new Blob([content]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
setBinaryFileUrl(url);
|
||||
} catch (e) {
|
||||
console.error("Failed to create blob URL:", e);
|
||||
setFileContent(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load file content:", error);
|
||||
toast.error("Failed to load file content");
|
||||
setFileContent(null);
|
||||
setBinaryFileUrl(null);
|
||||
} finally {
|
||||
setIsLoadingContent(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up blob URLs on unmount or when they're no longer needed
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (binaryFileUrl) {
|
||||
URL.revokeObjectURL(binaryFileUrl);
|
||||
}
|
||||
};
|
||||
}, [binaryFileUrl]);
|
||||
|
||||
// Handle file upload
|
||||
const handleFileUpload = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Process the file upload - upload to current directory
|
||||
const processFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!sandboxId || !event.target.files || event.target.files.length === 0) return;
|
||||
|
||||
try {
|
||||
setIsLoadingFiles(true);
|
||||
|
||||
const file = event.target.files[0];
|
||||
|
||||
if (file.size > 50 * 1024 * 1024) { // 50MB limit
|
||||
toast.error("File size exceeds 50MB limit");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a FormData object
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', `${currentPath}/${file.name}`);
|
||||
|
||||
const supabase = createClient();
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
|
||||
if (!session?.access_token) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
// Upload using FormData - no need for any encoding/decoding
|
||||
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
// Important: Do NOT set Content-Type header here, let the browser set it with the boundary
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
toast.success(`File uploaded: ${file.name}`);
|
||||
|
||||
// Refresh file list for current path
|
||||
loadFilesAtPath(currentPath);
|
||||
} catch (error) {
|
||||
console.error("File upload failed:", error);
|
||||
toast.error(typeof error === 'string' ? error : (error instanceof Error ? error.message : "Failed to upload file"));
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
// Reset the input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Render breadcrumb navigation
|
||||
const renderBreadcrumbs = () => {
|
||||
if (currentPath === "/workspace") {
|
||||
return (
|
||||
<div className="text-sm font-medium">/workspace</div>
|
||||
);
|
||||
}
|
||||
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const isInWorkspace = parts[0] === 'workspace';
|
||||
const pathParts = isInWorkspace ? parts.slice(1) : parts;
|
||||
|
||||
return (
|
||||
<div className="flex items-center overflow-x-auto whitespace-nowrap py-1 text-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={goHome}
|
||||
>
|
||||
<Home className="h-3 w-3 mr-1" />
|
||||
workspace
|
||||
</Button>
|
||||
|
||||
{pathParts.map((part, index) => {
|
||||
// Build the path up to this part
|
||||
const pathUpToHere = isInWorkspace
|
||||
? `/workspace/${pathParts.slice(0, index + 1).join('/')}`
|
||||
: `/${pathParts.slice(0, index + 1).join('/')}`;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center">
|
||||
<ChevronRight className="h-3 w-3 mx-1 text-muted-foreground" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => navigateToFolder(pathUpToHere)}
|
||||
>
|
||||
{part}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render file content based on type
|
||||
const renderFileContent = () => {
|
||||
if (isLoadingContent) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<File className="h-10 w-10 mb-2 opacity-30" />
|
||||
<p className="text-sm">Select a file to view its contents</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'text' && fileContent) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words overflow-auto flex-1 max-h-full">
|
||||
{fileContent}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'image' && binaryFileUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<img
|
||||
src={binaryFileUrl}
|
||||
alt={selectedFile}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'pdf' && binaryFileUrl) {
|
||||
return (
|
||||
<iframe
|
||||
src={binaryFileUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={selectedFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (binaryFileUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<File className="h-16 w-16 opacity-50" />
|
||||
<p className="text-sm text-center">This is a binary file and cannot be previewed</p>
|
||||
<a
|
||||
href={binaryFileUrl}
|
||||
download={selectedFile.split('/').pop()}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download File
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<File className="h-10 w-10 mb-2 opacity-30" />
|
||||
<p className="text-sm">No preview available</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[900px] h-[80vh] max-h-[700px] flex flex-col p-0">
|
||||
<DialogHeader className="p-4 border-b">
|
||||
<DialogTitle>Workspace Files</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col sm:flex-row h-full overflow-hidden">
|
||||
{/* File browser sidebar */}
|
||||
<div className="w-full sm:w-64 border-r flex flex-col h-full">
|
||||
<div className="p-2 flex items-center justify-between border-b">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={goBack}
|
||||
disabled={historyIndex === 0}
|
||||
title="Go back"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={goHome}
|
||||
title="Home directory"
|
||||
>
|
||||
<Home className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleFileUpload}
|
||||
title="Upload file"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
onChange={processFileUpload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1 border-b">
|
||||
{renderBreadcrumbs()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoadingFiles ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-6 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : workspaceFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-4">
|
||||
<Folder className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-xs text-center">This folder is empty</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{workspaceFiles.map((file) => (
|
||||
<Button
|
||||
key={file.path}
|
||||
variant={selectedFile === file.path ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start h-8 mb-1 text-xs"
|
||||
onClick={() => handleFileClick(file)}
|
||||
>
|
||||
{file.is_dir ? (
|
||||
<Folder className="h-3.5 w-3.5 mr-2 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="h-3.5 w-3.5 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{file.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File content pane */}
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b p-2">
|
||||
<h3 className="text-sm font-medium truncate">
|
||||
{selectedFile ? selectedFile.split('/').pop() : 'Select a file to view'}
|
||||
</h3>
|
||||
{selectedFile && binaryFileUrl && (
|
||||
<div className="flex gap-1">
|
||||
<a
|
||||
href={binaryFileUrl}
|
||||
download={selectedFile.split('/').pop()}
|
||||
className="inline-flex items-center justify-center h-8 w-8 rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
title="Download file"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 bg-muted/30">
|
||||
{renderFileContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -1,416 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Maximize2, Terminal, FileText } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ParsedTag, SUPPORTED_XML_TAGS } from "@/lib/types/tool-calls";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
interface MessageDisplayProps {
|
||||
content: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
isStreaming?: boolean;
|
||||
showIdentifier?: boolean;
|
||||
isPartOfChain?: 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;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
isPartOfChain?: boolean;
|
||||
}
|
||||
|
||||
function ToolHeader({ icon: Icon, label }: { icon: any, label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ content, filename }: { content: string, filename?: string }) {
|
||||
return (
|
||||
<div className="font-mono text-sm">
|
||||
{filename && (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border/30 bg-background/50 rounded-t-xl">
|
||||
<span className="text-xs text-muted-foreground font-medium">{filename}</span>
|
||||
<div className="flex gap-1.5">
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted/30 font-medium">Diff</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted/30 font-medium">Original</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium">Modified</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 bg-muted/[0.02] rounded-b-xl">
|
||||
<pre className="whitespace-pre-wrap text-[13px] leading-relaxed">{content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageContent({ content, maxHeight = 300, role, isPartOfChain }: MessageContentProps) {
|
||||
const { parts } = parseXMLTags(content);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const isTool = role === 'tool';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(
|
||||
"w-full",
|
||||
isPartOfChain && "pl-6 border-l border-border/20"
|
||||
)}>
|
||||
<div className={cn(
|
||||
"w-full overflow-hidden rounded-xl border border-border/40 bg-background/50 backdrop-blur-sm shadow-[0_0_15px_rgba(0,0,0,0.03)]",
|
||||
isTool && "font-mono text-[13px]"
|
||||
)}>
|
||||
{parts.map((part, index) => {
|
||||
if (typeof part === 'string') {
|
||||
return (
|
||||
<div key={index} className="p-4">
|
||||
{part.split('\n').map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<span className="leading-relaxed">{line}</span>
|
||||
{i < part.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const isCreateFile = part.tagName === 'create-file';
|
||||
const isExecuteCommand = part.tagName === 'execute-command';
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="px-4 py-2 border-b border-border/30">
|
||||
{isCreateFile && (
|
||||
<ToolHeader
|
||||
icon={FileText}
|
||||
label={`Creating file ${part.attributes.file_path || ''}`}
|
||||
/>
|
||||
)}
|
||||
{isExecuteCommand && (
|
||||
<ToolHeader
|
||||
icon={Terminal}
|
||||
label="Executing command"
|
||||
/>
|
||||
)}
|
||||
{!isCreateFile && !isExecuteCommand && (
|
||||
<ToolHeader
|
||||
icon={Terminal}
|
||||
label={part.tagName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CodeBlock
|
||||
content={part.content}
|
||||
filename={isCreateFile ? part.attributes.file_path : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent side="right" className="w-[600px] sm:w-[800px] bg-background">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">
|
||||
{isTool ? 'Command Output' : 'Message Content'}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-4 overflow-y-auto max-h-[calc(100vh-8rem)] px-2">
|
||||
<CodeBlock content={content} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageDisplay({ content, role, isStreaming = false, showIdentifier = false, isPartOfChain = false }: MessageDisplayProps) {
|
||||
const isUser = role === 'user';
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"w-full px-4 py-3 flex flex-col",
|
||||
isUser ? "bg-transparent" : "bg-muted/[0.03]",
|
||||
isStreaming && "animate-pulse",
|
||||
isPartOfChain && "pt-0"
|
||||
)}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
<div className="w-full max-w-5xl mx-auto relative">
|
||||
{!isUser && showIdentifier && (
|
||||
<motion.div
|
||||
className="flex items-center gap-2 mb-2"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center bg-background rounded-full size-8 flex-shrink-0 justify-center shadow-[0_0_10px_rgba(0,0,0,0.05)] border border-border">
|
||||
<Image
|
||||
src="/kortix-symbol.svg"
|
||||
alt="Suna"
|
||||
width={16}
|
||||
height={16}
|
||||
className={theme === 'dark' ? 'invert' : ''}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Suna</span>
|
||||
</motion.div>
|
||||
)}
|
||||
<MessageContent
|
||||
content={content}
|
||||
role={role}
|
||||
isPartOfChain={isPartOfChain}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThinkingIndicator() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="w-full px-4 py-3 flex flex-col bg-muted/[0.03]"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<motion.div
|
||||
className="flex items-center gap-2 mb-2"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center bg-background rounded-full size-8 flex-shrink-0 justify-center shadow-[0_0_10px_rgba(0,0,0,0.05)] border border-border">
|
||||
<Image
|
||||
src="/kortix-symbol.svg"
|
||||
alt="Suna"
|
||||
width={16}
|
||||
height={16}
|
||||
className={theme === 'dark' ? 'invert' : ''}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Suna</span>
|
||||
</motion.div>
|
||||
|
||||
<div className="w-full overflow-hidden rounded-xl border border-border/40 bg-background/50 backdrop-blur-sm shadow-[0_0_15px_rgba(0,0,0,0.03)]">
|
||||
<div className="p-4">
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="w-2 h-2 bg-primary/40 rounded-full"
|
||||
animate={{ y: [0, -5, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: index * 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyChat({ agentName = "AI assistant" }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<div className="text-center px-4">
|
||||
<h3 className="text-base font-medium mb-1">What can I help you ship?</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-[300px]">
|
||||
Send a message to start talking with Suna
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,109 +0,0 @@
|
|||
'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";
|
||||
|
||||
type MessageRole = 'user' | 'assistant' | 'tool';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
streamContent?: string;
|
||||
isStreaming?: boolean;
|
||||
isAgentRunning?: boolean;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
streamContent = "",
|
||||
isStreaming = false,
|
||||
isAgentRunning = false,
|
||||
agentName = "Suna"
|
||||
}: MessageListProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamContent]);
|
||||
|
||||
// Filter out invalid messages
|
||||
const filteredMessages = messages.filter(message =>
|
||||
message &&
|
||||
message.content &&
|
||||
message.role &&
|
||||
(message.role === 'user' || message.role === 'assistant' || message.role === 'tool')
|
||||
);
|
||||
|
||||
// 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 bg-background">
|
||||
<div className="w-full">
|
||||
<AnimatePresence initial={false}>
|
||||
{filteredMessages.map((message, index) => {
|
||||
const prevMessage = index > 0 ? filteredMessages[index - 1] : null;
|
||||
const nextMessage = index < filteredMessages.length - 1 ? filteredMessages[index + 1] : null;
|
||||
|
||||
// Show identifier if this is an AI/tool message that follows a user message
|
||||
const showIdentifier = message.role !== 'user' &&
|
||||
(!prevMessage || prevMessage.role === 'user');
|
||||
|
||||
// Part of a chain if:
|
||||
// 1. Current message is tool/assistant AND
|
||||
// 2. Previous message was also tool/assistant
|
||||
const isPartOfChain = message.role !== 'user' &&
|
||||
prevMessage?.role !== 'user';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
delay: 0.05
|
||||
}}
|
||||
>
|
||||
<MessageDisplay
|
||||
content={message.content}
|
||||
role={message.role as MessageRole}
|
||||
showIdentifier={showIdentifier}
|
||||
isPartOfChain={isPartOfChain}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
|
||||
{streamContent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<MessageDisplay
|
||||
content={streamContent}
|
||||
role="assistant"
|
||||
isStreaming={isStreaming}
|
||||
showIdentifier={filteredMessages.length === 0 || filteredMessages[filteredMessages.length - 1].role === 'user'}
|
||||
isPartOfChain={filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1].role !== 'user'}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{isAgentRunning && !streamContent && (
|
||||
<ThinkingIndicator />
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} className="h-24" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,306 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useToolsPanel } from '@/hooks/use-tools-panel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ChevronLeft, ChevronRight, Grid3X3, Maximize2, Minimize2, PanelRightClose, Terminal, FileText, Search, Globe, ArrowRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getComponentForTag } from '@/components/thread/tool-components';
|
||||
import { ParsedTag } from '@/lib/types/tool-calls';
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface ToolCallsSidebarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CombinedToolCall {
|
||||
toolCall: ParsedTag; // From assistant message
|
||||
toolResult: ParsedTag | null; // From tool message
|
||||
id: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const ToolIcon = ({ type }: { type: string }) => {
|
||||
if (type.includes('file')) return <FileText className="h-3.5 w-3.5" />;
|
||||
if (type.includes('command')) return <Terminal className="h-3.5 w-3.5" />;
|
||||
if (type.includes('search')) return <Search className="h-3.5 w-3.5" />;
|
||||
if (type.includes('browser')) return <Globe className="h-3.5 w-3.5" />;
|
||||
return <Terminal className="h-3.5 w-3.5" />;
|
||||
};
|
||||
|
||||
const ToolStatus = ({ status }: { status: string }) => {
|
||||
return (
|
||||
<Badge variant={status === 'completed' ? 'default' : status === 'running' ? 'secondary' : 'destructive'} className="h-5">
|
||||
{status === 'running' && (
|
||||
<div className="mr-1 h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
||||
)}
|
||||
{status === 'completed' ? 'Done' : status === 'running' ? 'Running' : 'Failed'}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export function ToolCallsSidebar({ className }: ToolCallsSidebarProps) {
|
||||
const {
|
||||
toolCalls,
|
||||
showPanel,
|
||||
setShowPanel,
|
||||
currentToolIndex,
|
||||
setCurrentToolIndex,
|
||||
nextTool,
|
||||
prevTool,
|
||||
} = useToolsPanel();
|
||||
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const [viewMode, setViewMode] = React.useState<'single' | 'grid'>('single');
|
||||
|
||||
// Combine tool calls with their results
|
||||
const combinedToolCalls = React.useMemo(() => {
|
||||
const combined: CombinedToolCall[] = [];
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
toolCalls.forEach((tag, index) => {
|
||||
// Skip if we've already processed this tag
|
||||
if (processedIds.has(tag.id)) return;
|
||||
|
||||
// Look for matching result in subsequent tags
|
||||
// A result is a tag with the same name in a tool message
|
||||
const nextTags = toolCalls.slice(index + 1);
|
||||
const matchingResult = nextTags.find(nextTag =>
|
||||
nextTag.tagName === tag.tagName &&
|
||||
!processedIds.has(nextTag.id) &&
|
||||
// Match attributes if they exist
|
||||
(!tag.attributes || Object.entries(tag.attributes).every(([key, value]) =>
|
||||
nextTag.attributes?.[key] === value
|
||||
))
|
||||
);
|
||||
|
||||
if (matchingResult) {
|
||||
processedIds.add(matchingResult.id);
|
||||
}
|
||||
|
||||
combined.push({
|
||||
toolCall: tag,
|
||||
toolResult: matchingResult || null,
|
||||
id: tag.id,
|
||||
timestamp: tag.timestamp || Date.now()
|
||||
});
|
||||
|
||||
processedIds.add(tag.id);
|
||||
});
|
||||
|
||||
return combined;
|
||||
}, [toolCalls]);
|
||||
|
||||
// No need to render if there are no tool calls
|
||||
if (combinedToolCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setViewMode(viewMode === 'single' ? 'grid' : 'single');
|
||||
};
|
||||
|
||||
const renderToolCall = (combined: CombinedToolCall, mode: 'compact' | 'detailed') => {
|
||||
const { toolCall, toolResult } = combined;
|
||||
const isCompleted = !!toolResult;
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolIcon type={toolCall.tagName} />
|
||||
<span className="text-sm font-medium">{toolCall.tagName}</span>
|
||||
<div className="flex-1" />
|
||||
<ToolStatus status={isCompleted ? 'completed' : 'running'} />
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{toolCall.content}
|
||||
</div>
|
||||
{toolResult && (
|
||||
<>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span>Result</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{toolResult.content}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card text-card-foreground">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToolIcon type={toolCall.tagName} />
|
||||
<span className="text-sm font-medium">{toolCall.tagName}</span>
|
||||
</div>
|
||||
<ToolStatus status={isCompleted ? 'completed' : 'running'} />
|
||||
</div>
|
||||
<div className="p-4 text-sm font-mono">
|
||||
{toolCall.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toolResult && (
|
||||
<div className="rounded-lg border bg-card text-card-foreground">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
<span className="text-sm font-medium">Result</span>
|
||||
</div>
|
||||
<div className="p-4 text-sm font-mono">
|
||||
{toolResult.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{showPanel && (
|
||||
<motion.aside
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className={cn(
|
||||
"fixed right-0 top-0 z-30 flex h-screen flex-col border-l bg-background/80 backdrop-blur-md",
|
||||
isExpanded ? "w-[600px]" : "w-[400px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Toggle button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute -left-10 top-4 h-8 w-8 rounded-l-md border-y border-l bg-background/80 backdrop-blur-md shadow-sm hover:bg-background/90 transition-colors"
|
||||
onClick={() => setShowPanel(!showPanel)}
|
||||
>
|
||||
<PanelRightClose className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
!showPanel && "rotate-180"
|
||||
)} />
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b bg-background/50 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-medium">Tool Calls</h3>
|
||||
<Badge variant="outline" className="h-5">
|
||||
{currentToolIndex + 1}/{combinedToolCalls.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={toggleViewMode}
|
||||
title={viewMode === 'single' ? 'Switch to grid view' : 'Switch to single view'}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
{viewMode === 'single' ? (
|
||||
<div className="space-y-4">
|
||||
{renderToolCall(combinedToolCalls[currentToolIndex], 'detailed')}
|
||||
|
||||
{/* Navigation */}
|
||||
{combinedToolCalls.length > 1 && (
|
||||
<div className="flex items-center gap-2 mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={prevTool}
|
||||
disabled={currentToolIndex === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={nextTool}
|
||||
disabled={currentToolIndex === combinedToolCalls.length - 1}
|
||||
className="flex-1"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{combinedToolCalls.map((toolCall, index) => (
|
||||
<motion.div
|
||||
key={toolCall.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className={cn(
|
||||
"cursor-pointer transition-all border rounded-lg p-3 hover:shadow-sm",
|
||||
index === currentToolIndex
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/40 hover:bg-muted/40"
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentToolIndex(index);
|
||||
setViewMode('single');
|
||||
}}
|
||||
>
|
||||
{renderToolCall(toolCall, 'compact')}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Progress bar */}
|
||||
{viewMode === 'single' && combinedToolCalls.length > 1 && (
|
||||
<div className="px-4 py-3 border-t bg-background/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={combinedToolCalls.length - 1}
|
||||
value={currentToolIndex}
|
||||
onChange={(e) => setCurrentToolIndex(parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
|
@ -1,656 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { ParsedTag, ToolComponentProps } from '@/lib/types/tool-calls';
|
||||
import {
|
||||
File, FileText, Terminal, FolderPlus, Folder, Code, Search as SearchIcon,
|
||||
Bell, Replace, Plus, Minus, Globe, Search
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { diffLines } from 'diff';
|
||||
|
||||
// Shared compact mode component
|
||||
const CompactToolDisplay: React.FC<{
|
||||
icon: React.ReactNode,
|
||||
name: string,
|
||||
input: string,
|
||||
isRunning?: boolean
|
||||
}> = ({ icon, name, input, isRunning }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs bg-muted/30 rounded">
|
||||
{icon}
|
||||
<span className="font-medium">{name}: {input}</span>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create File Tool Component
|
||||
*/
|
||||
export const CreateFileTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const filePath = tag.attributes.file_path || '';
|
||||
const fileContent = tag.content || '';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<FileText className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? "Creating file" : "Created file"}
|
||||
input={filePath}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">{isRunning ? "Creating file" : "Created file"}: {filePath}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span className="font-mono">{filePath}</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
|
||||
{fileContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Full File Rewrite Tool Component
|
||||
*/
|
||||
export const FullFileRewriteTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const filePath = tag.attributes.file_path || '';
|
||||
const fileContent = tag.content || '';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<FileText className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? "Rewriting file" : "Rewrote file"}
|
||||
input={filePath}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">{isRunning ? "Rewriting file" : "Rewrote file"}: {filePath}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span className="font-mono">{filePath}</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
|
||||
{fileContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Read File Tool Component
|
||||
*/
|
||||
export const ReadFileTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const filePath = tag.attributes.file_path || '';
|
||||
const fileContent = tag.content || '';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<File className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? "Reading file" : "Read file"}
|
||||
input={filePath}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<File className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">{isRunning ? "Reading file" : "Read file"}: {filePath}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||
<File className="h-3 w-3" />
|
||||
<span className="font-mono">{filePath}</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
|
||||
{fileContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute Command Tool Component
|
||||
*/
|
||||
export const ExecuteCommandTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const output = tag.content || '';
|
||||
const command = tag.resultTag?.content || '';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<Terminal className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? "Executing" : "Executed"}
|
||||
input={tag.content} // in compact mode, the command is in the content
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">{isRunning ? `Executing: ${command}` : `Executed: ${command}`}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<div className="font-mono text-xs bg-black text-green-400 p-3 rounded-md">
|
||||
<div className="flex items-center gap-2 mb-2 text-white/80">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<div className="flex-1 text-center text-xs">Terminal</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-blue-400">user@localhost</span>
|
||||
<span className="text-white/60">:</span>
|
||||
<span className="text-purple-400">~</span>
|
||||
<span className="text-white/60">$</span>
|
||||
<span>{command}</span>
|
||||
</div>
|
||||
|
||||
{output && (
|
||||
<pre className="whitespace-pre-wrap pl-4 text-white/90">{output}</pre>
|
||||
)}
|
||||
|
||||
{!isRunning && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-blue-400">user@localhost</span>
|
||||
<span className="text-white/60">:</span>
|
||||
<span className="text-purple-400">~</span>
|
||||
<span className="text-white/60">$</span>
|
||||
<span className="animate-pulse">█</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* String Replace Tool Component
|
||||
*/
|
||||
export const StringReplaceTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const content = tag.content || '';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
// Parse the old and new strings from the content
|
||||
const oldStrMatch = content.match(/<old_str>([\s\S]*?)<\/old_str>/);
|
||||
const newStrMatch = content.match(/<new_str>([\s\S]*?)<\/new_str>/);
|
||||
|
||||
const oldStr = oldStrMatch ? oldStrMatch[1] : '';
|
||||
const newStr = newStrMatch ? newStrMatch[1] : '';
|
||||
|
||||
// Calculate the diff between old and new strings
|
||||
const diff = diffLines(oldStr, newStr);
|
||||
|
||||
interface DiffPart {
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<Replace className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? "Updating file" : "Updated file"}
|
||||
input=""
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<Replace className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">{isRunning ? "Updating file" : "Updated file"}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<div className="space-y-2">
|
||||
<div className="border border-subtle dark:border-white/10 rounded-md overflow-hidden font-mono text-xs">
|
||||
{diff.map((part: DiffPart, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"px-2 py-0.5 flex items-start",
|
||||
part.added ? "bg-green-500/10 text-green-700 dark:text-green-400" :
|
||||
part.removed ? "bg-red-500/10 text-red-700 dark:text-red-400" :
|
||||
"text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="mr-2">
|
||||
{part.added ? <Plus className="h-3 w-3" /> :
|
||||
part.removed ? <Minus className="h-3 w-3" /> :
|
||||
<span className="w-3" />}
|
||||
</span>
|
||||
<pre className="whitespace-pre-wrap flex-1">{part.value}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Notification Tool Component
|
||||
*/
|
||||
export const NotifyTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const message = tag.attributes.message || '';
|
||||
const type = tag.attributes.type || 'info';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<Bell className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? "Sending notification" : "Sent notification"}
|
||||
input={message.substring(0, 30) + (message.length > 30 ? '...' : '')}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">{isRunning ? "Sending notification" : "Sent notification"}: {message.substring(0, 30)}{message.length > 30 ? '...' : ''}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<div className={cn(
|
||||
"p-2 rounded-md",
|
||||
type === 'error' ? 'bg-red-500/10 text-red-600 dark:text-red-400' :
|
||||
type === 'warning' ? 'bg-amber-500/10 text-amber-600 dark:text-amber-400' :
|
||||
type === 'success' ? 'bg-green-500/10 text-green-600 dark:text-green-400' :
|
||||
'bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||
)}>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Directory Tool Component (for create-directory and list-directory)
|
||||
*/
|
||||
export const DirectoryTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const path = tag.attributes.path || '';
|
||||
const content = tag.content || '';
|
||||
const isListDirectory = tag.tagName === 'list-directory';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={isListDirectory ? <Folder className="h-4 w-4 mr-2" /> : <FolderPlus className="h-4 w-4 mr-2" />}
|
||||
name={isListDirectory ?
|
||||
(isRunning ? "Listing directory" : "Listed directory") :
|
||||
(isRunning ? "Creating directory" : "Created directory")}
|
||||
input={path}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
{isListDirectory ? <Folder className="h-4 w-4 mr-2" /> : <FolderPlus className="h-4 w-4 mr-2" />}
|
||||
<div className="flex-1">
|
||||
{isListDirectory ?
|
||||
(isRunning ? `Listing directory: ${path}` : `Listed directory: ${path}`) :
|
||||
(isRunning ? `Creating directory: ${path}` : `Created directory: ${path}`)}
|
||||
</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||
<Folder className="h-3 w-3" />
|
||||
<span className="font-mono">{path}</span>
|
||||
</div>
|
||||
{isListDirectory && content && (
|
||||
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Search Code Tool Component
|
||||
*/
|
||||
export const SearchCodeTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const query = tag.attributes.query || '';
|
||||
const content = tag.content || '';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<SearchIcon className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? "Searching code" : "Searched code"}
|
||||
input={query}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<SearchIcon className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">{isRunning ? "Searching code" : "Searched code"}: {query}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs bg-primary/10 p-1 rounded">
|
||||
<SearchIcon className="h-3 w-3" />
|
||||
<span className="font-mono">{query}</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs overflow-x-auto max-h-60">
|
||||
{content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Browser Navigate Tool Component
|
||||
*/
|
||||
export const BrowserNavigateTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const url = tag.content || '';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<Globe className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? "Navigating to" : "Navigated to"}
|
||||
input={url}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">{isRunning ? `Navigating to` : `Navigated to`}: {url}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||
<Globe className="h-3 w-3" />
|
||||
<span className="font-mono">{url}</span>
|
||||
</div>
|
||||
|
||||
{/* Display VNC preview if available */}
|
||||
{tag.vncPreview && (
|
||||
<div className="mt-2 border border-subtle dark:border-white/10 rounded-md overflow-hidden">
|
||||
<div className="text-xs bg-black text-white p-1">VNC Preview</div>
|
||||
<div className="relative w-full h-[300px] overflow-hidden">
|
||||
<iframe
|
||||
src={tag.vncPreview}
|
||||
title="Browser preview"
|
||||
className="absolute top-0 left-0 border-0"
|
||||
style={{
|
||||
width: '200%',
|
||||
height: '200%',
|
||||
transform: 'scale(0.5)',
|
||||
transformOrigin: '0 0'
|
||||
}}
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Web Search Tool Component
|
||||
*/
|
||||
export const WebSearchTool: React.FC<ToolComponentProps> = ({ tag, mode }) => {
|
||||
const query = tag.attributes.query || '';
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<Search className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? "Web search in progress..." : "Web search complete"}
|
||||
input={query}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const results = tag.result?.output ? JSON.parse(tag.result.output) : [];
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">Web Search: {query}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Searching</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
{results.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{results.map((result: any, index: number) => (
|
||||
<div key={index} className="text-sm">
|
||||
<a href={result.URL} target="_blank" rel="noopener noreferrer" className="font-medium text-blue-600 hover:underline">
|
||||
{result.Title}
|
||||
</a>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{result.URL}
|
||||
{result['Published Date'] && (
|
||||
<span className="ml-2">
|
||||
({new Date(result['Published Date']).toLocaleDateString()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No results found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Tool component registry
|
||||
export const ToolComponentRegistry: Record<string, React.FC<ToolComponentProps>> = {
|
||||
'create-file': CreateFileTool,
|
||||
'read-file': ReadFileTool,
|
||||
'execute-command': ExecuteCommandTool,
|
||||
'str-replace': StringReplaceTool,
|
||||
'create-directory': DirectoryTool,
|
||||
'list-directory': DirectoryTool,
|
||||
'search-code': SearchCodeTool,
|
||||
'notify': NotifyTool,
|
||||
'ask': NotifyTool, // Handle ask similar to notify for now
|
||||
'complete': NotifyTool, // Handle complete similar to notify for now
|
||||
'full-file-rewrite': FullFileRewriteTool,
|
||||
'browser-navigate-to': BrowserNavigateTool,
|
||||
'browser-click-element': BrowserNavigateTool,
|
||||
'browser-input-text': BrowserNavigateTool,
|
||||
'browser-go-back': BrowserNavigateTool,
|
||||
'browser-wait': BrowserNavigateTool,
|
||||
'browser-scroll-down': BrowserNavigateTool,
|
||||
'browser-scroll-up': BrowserNavigateTool,
|
||||
'browser-scroll-to-text': BrowserNavigateTool,
|
||||
'browser-switch-tab': BrowserNavigateTool,
|
||||
'browser-close-tab': BrowserNavigateTool,
|
||||
'browser-get-dropdown-options': BrowserNavigateTool,
|
||||
'browser-select-dropdown-option': BrowserNavigateTool,
|
||||
'browser-drag-drop': BrowserNavigateTool,
|
||||
'web-search': WebSearchTool,
|
||||
};
|
||||
|
||||
// Helper function to get the appropriate component for a tag
|
||||
export function getComponentForTag(tag: ParsedTag): React.FC<ToolComponentProps> {
|
||||
console.log("getComponentForTag", tag);
|
||||
if (!tag || !tag?.tagName) {
|
||||
console.warn(`No tag name for tag: ${tag}`);
|
||||
}
|
||||
if (!ToolComponentRegistry[tag.tagName]) {
|
||||
console.warn(`No component registered for tag type: ${tag.tagName}`);
|
||||
}
|
||||
return ToolComponentRegistry[tag.tagName] ||
|
||||
// Fallback component for unknown tag types
|
||||
(({tag, mode}) => {
|
||||
console.log(`Rendering fallback component for tag: ${tag.tagName}`, tag);
|
||||
const isRunning = tag.status === 'running';
|
||||
|
||||
if (mode === 'compact') {
|
||||
return (
|
||||
<CompactToolDisplay
|
||||
icon={<Code className="h-4 w-4 mr-2" />}
|
||||
name={isRunning ? `${tag.tagName} operation running` : `${tag.tagName} operation complete`}
|
||||
input=""
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden border-subtle dark:border-white/10">
|
||||
<div className="flex items-center px-2 py-1 text-xs font-medium border-b border-subtle dark:border-white/10 bg-background-secondary dark:bg-background-secondary text-foreground">
|
||||
<Code className="h-4 w-4 mr-2" />
|
||||
<div className="flex-1">{isRunning ? `${tag.tagName} operation running` : `${tag.tagName} operation complete`}</div>
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-500">Running</span>
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-card-bg dark:bg-background-secondary text-foreground">
|
||||
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md text-xs">{tag.content || ''}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
|
@ -1,177 +0,0 @@
|
|||
'use client';
|
||||
|
||||
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/thread/tool-components';
|
||||
|
||||
export function useToolsPanel() {
|
||||
const { toolCalls, setToolCalls } = useContext(ToolCallsContext);
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [currentToolIndex, setCurrentToolIndex] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<'single' | 'grid'>('single');
|
||||
|
||||
// Update panel visibility when tool calls change
|
||||
useEffect(() => {
|
||||
if (toolCalls.length > 0 && !showPanel) {
|
||||
setShowPanel(true);
|
||||
}
|
||||
}, [toolCalls, showPanel]);
|
||||
|
||||
// Reset current tool index when tools change
|
||||
useEffect(() => {
|
||||
if (toolCalls.length > 0) {
|
||||
console.log(`[TOOLS PANEL] Has ${toolCalls.length} tools to display:`,
|
||||
toolCalls.map(t => `${t.tagName}${t.isClosing ? '(completed)' : '(running)'}`).join(', '));
|
||||
setCurrentToolIndex(state => Math.min(state, toolCalls.length - 1));
|
||||
} else {
|
||||
setCurrentToolIndex(0);
|
||||
}
|
||||
}, [toolCalls]);
|
||||
|
||||
// Clear all tool calls
|
||||
const clearToolCalls = () => {
|
||||
setToolCalls([]);
|
||||
setShowPanel(false);
|
||||
};
|
||||
|
||||
// Navigate to next tool
|
||||
const nextTool = () => {
|
||||
if (toolCalls.length > 0) {
|
||||
setCurrentToolIndex((currentToolIndex + 1) % toolCalls.length);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to previous tool
|
||||
const prevTool = () => {
|
||||
if (toolCalls.length > 0) {
|
||||
setCurrentToolIndex((currentToolIndex - 1 + toolCalls.length) % toolCalls.length);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle view mode between single and grid
|
||||
const toggleViewMode = () => {
|
||||
setViewMode(viewMode === 'single' ? 'grid' : 'single');
|
||||
};
|
||||
|
||||
// Navigate to next/previous tool with keyboard support
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (viewMode === 'single' && toolCalls.length > 1) {
|
||||
if (e.key === 'ArrowRight') {
|
||||
nextTool();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
prevTool();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [viewMode, toolCalls.length, nextTool, prevTool]);
|
||||
|
||||
// Render the tools panel content
|
||||
const renderToolsPanel = () => {
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-foreground/40">
|
||||
<p className="text-sm">No tool calls yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Panel header */}
|
||||
<div className="flex justify-between items-center border-b border-subtle p-3">
|
||||
<h3 className="text-sm font-medium">Tools ({toolCalls.length})</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleViewMode}
|
||||
className="p-1 rounded hover:bg-background-hover"
|
||||
title={viewMode === 'single' ? "Grid view" : "Single view"}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4 text-foreground/60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Panel content */}
|
||||
<div className="flex-grow overflow-y-auto p-3">
|
||||
{viewMode === 'single' ? (
|
||||
<>
|
||||
{/* Single tool view */}
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="h-full flex flex-col">
|
||||
{renderToolComponent(toolCalls[currentToolIndex], 'detailed')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Grid view */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{toolCalls.map((tool, index) => (
|
||||
<div
|
||||
key={tool.id || index}
|
||||
className={`cursor-pointer transition-all border rounded-md p-2 ${
|
||||
index === currentToolIndex
|
||||
? 'border-primary shadow-sm'
|
||||
: 'border-subtle hover:border-primary/40'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCurrentToolIndex(index);
|
||||
setViewMode('single');
|
||||
}}
|
||||
>
|
||||
{renderToolComponent(tool, 'compact')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gallery navigation controls (only in single view with multiple tools) */}
|
||||
{viewMode === 'single' && toolCalls.length > 1 && (
|
||||
<div className="border-t border-subtle p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Tool {currentToolIndex + 1} of {toolCalls.length}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={toolCalls.length - 1}
|
||||
value={currentToolIndex}
|
||||
onChange={(e) => setCurrentToolIndex(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render a tool component based on the tool type
|
||||
const renderToolComponent = (tag: ParsedTag, mode: ToolDisplayMode) => {
|
||||
// Get the specialized component from the registry
|
||||
const ToolComponent = getComponentForTag(tag);
|
||||
return <ToolComponent tag={tag} mode={mode} />;
|
||||
};
|
||||
|
||||
return {
|
||||
toolCalls,
|
||||
setToolCalls,
|
||||
showPanel,
|
||||
setShowPanel,
|
||||
renderToolsPanel,
|
||||
clearToolCalls,
|
||||
currentToolIndex,
|
||||
setCurrentToolIndex,
|
||||
nextTool,
|
||||
prevTool,
|
||||
};
|
||||
}
|
|
@ -575,7 +575,7 @@ export const streamAgent = (agentRunId: string, callbacks: {
|
|||
|
||||
if (!session?.access_token) {
|
||||
console.error('[STREAM] No auth token available');
|
||||
callbacks.onError(new Error('Authentication required'));
|
||||
callbacks.onError('Authentication required');
|
||||
callbacks.onClose();
|
||||
return;
|
||||
}
|
||||
|
@ -595,50 +595,77 @@ export const streamAgent = (agentRunId: string, callbacks: {
|
|||
const rawData = event.data;
|
||||
if (rawData.includes('"type":"ping"')) return;
|
||||
|
||||
// Skip empty messages
|
||||
if (!rawData || rawData.trim() === '') return;
|
||||
|
||||
// Log raw data for debugging
|
||||
console.log(`[STREAM] Received data: ${rawData.substring(0, 100)}${rawData.length > 100 ? '...' : ''}`);
|
||||
|
||||
// Skip empty messages
|
||||
if (!rawData || rawData.trim() === '') {
|
||||
console.debug('[STREAM] Received empty message, skipping');
|
||||
|
||||
let jsonData;
|
||||
try {
|
||||
jsonData = JSON.parse(rawData);
|
||||
} catch (parseError) {
|
||||
console.error('[STREAM] Failed to parse message:', parseError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a status completion message
|
||||
if (rawData.includes('"type":"status"') && rawData.includes('"status":"completed"')) {
|
||||
console.log(`[STREAM] ⚠️ Detected completion status message: ${rawData}`);
|
||||
|
||||
// Handle stream errors and failures first
|
||||
if (jsonData.status === 'error' || (jsonData.type === 'status' && jsonData.status === 'failed')) {
|
||||
// Get a clean string version of any error message
|
||||
const errorMessage = typeof jsonData.message === 'object'
|
||||
? JSON.stringify(jsonData.message)
|
||||
: String(jsonData.message || 'Stream failed');
|
||||
|
||||
try {
|
||||
// Explicitly call onMessage before closing the stream to ensure the message is processed
|
||||
callbacks.onMessage(rawData);
|
||||
|
||||
// Explicitly close the EventSource connection when we receive a completion message
|
||||
if (eventSourceInstance && !isClosing) {
|
||||
console.log(`[STREAM] ⚠️ Closing EventSource due to completion message for ${agentRunId}`);
|
||||
isClosing = true;
|
||||
// Only log to console if it's an unexpected error (not a known API error response)
|
||||
if (jsonData.status !== 'error') {
|
||||
console.error(`[STREAM] Stream error for ${agentRunId}:`, errorMessage);
|
||||
}
|
||||
|
||||
// Ensure we close the stream and prevent reconnection
|
||||
if (!isClosing) {
|
||||
isClosing = true;
|
||||
if (eventSourceInstance) {
|
||||
eventSourceInstance.close();
|
||||
eventSourceInstance = null;
|
||||
|
||||
// Explicitly call onClose here to ensure the client knows the stream is closed
|
||||
setTimeout(() => {
|
||||
console.log(`[STREAM] 🚨 Explicitly calling onClose after completion for ${agentRunId}`);
|
||||
callbacks.onClose();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Exit early to prevent duplicate message processing
|
||||
return;
|
||||
} catch (closeError) {
|
||||
console.error(`[STREAM] ❌ Error while closing stream on completion: ${closeError}`);
|
||||
// Continue with normal processing if there's an error during closure
|
||||
callbacks.onError(errorMessage);
|
||||
callbacks.onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle completion status
|
||||
if (jsonData.type === 'status' && jsonData.status === 'completed') {
|
||||
console.log(`[STREAM] Completion message received for ${agentRunId}`);
|
||||
|
||||
if (!isClosing) {
|
||||
isClosing = true;
|
||||
callbacks.onMessage(rawData);
|
||||
if (eventSourceInstance) {
|
||||
eventSourceInstance.close();
|
||||
eventSourceInstance = null;
|
||||
}
|
||||
callbacks.onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass other messages normally
|
||||
if (!isClosing) {
|
||||
callbacks.onMessage(rawData);
|
||||
}
|
||||
|
||||
// Pass the raw data directly to onMessage for handling in the component
|
||||
callbacks.onMessage(rawData);
|
||||
} catch (error) {
|
||||
console.error(`[STREAM] Error handling message:`, error);
|
||||
callbacks.onError(error instanceof Error ? error : String(error));
|
||||
console.error(`[STREAM] Error in message handler:`, error);
|
||||
|
||||
if (!isClosing) {
|
||||
isClosing = true;
|
||||
if (eventSourceInstance) {
|
||||
eventSourceInstance.close();
|
||||
eventSourceInstance = null;
|
||||
}
|
||||
callbacks.onError(error instanceof Error ? error.message : 'Stream processing error');
|
||||
callbacks.onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -646,7 +673,6 @@ export const streamAgent = (agentRunId: string, callbacks: {
|
|||
// Add detailed event logging
|
||||
console.log(`[STREAM] 🔍 EventSource onerror triggered for ${agentRunId}`, event);
|
||||
|
||||
// EventSource errors are often just connection closures
|
||||
// For clean closures (manual or completed), we don't need to log an error
|
||||
if (isClosing) {
|
||||
console.log(`[STREAM] EventSource closed as expected for ${agentRunId}`);
|
||||
|
@ -654,10 +680,10 @@ export const streamAgent = (agentRunId: string, callbacks: {
|
|||
}
|
||||
|
||||
// Only log as error for unexpected closures
|
||||
console.log(`[STREAM] EventSource connection closed for ${agentRunId}`);
|
||||
console.error(`[STREAM] EventSource connection error/closed unexpectedly for ${agentRunId}`);
|
||||
|
||||
if (!isClosing) {
|
||||
console.log(`[STREAM] Handling connection close for ${agentRunId}`);
|
||||
console.log(`[STREAM] Handling unexpected connection close for ${agentRunId}`);
|
||||
|
||||
// Close the connection
|
||||
if (eventSourceInstance) {
|
||||
|
@ -665,8 +691,9 @@ export const streamAgent = (agentRunId: string, callbacks: {
|
|||
eventSourceInstance = null;
|
||||
}
|
||||
|
||||
// Then notify error (once)
|
||||
// Then notify error and close (once)
|
||||
isClosing = true;
|
||||
callbacks.onError(new Error('Stream connection closed unexpectedly.')); // Add error callback
|
||||
callbacks.onClose();
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue