mirror of https://github.com/kortix-ai/suna.git
tool views
This commit is contained in:
parent
2e4072e70e
commit
08110738d6
File diff suppressed because it is too large
Load Diff
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@calcom/embed-react": "^1.5.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@next/third-parties": "^15.3.1",
|
||||
"@number-flow/react": "^0.5.7",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-avatar": "^1.1.4",
|
||||
|
@ -30,17 +31,22 @@
|
|||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-tabs": "^1.1.8",
|
||||
"@radix-ui/react-tooltip": "^1.2.3",
|
||||
"@react-pdf-viewer/core": "^3.12.0",
|
||||
"@react-pdf-viewer/default-layout": "^3.12.0",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@silevis/reactgrid": "^4.1.17",
|
||||
"@supabase/ssr": "latest",
|
||||
"@supabase/supabase-js": "latest",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.10",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.10",
|
||||
"@uiw/codemirror-theme-xcode": "^4.23.10",
|
||||
"@uiw/react-codemirror": "^4.23.10",
|
||||
"@usebasejump/shared": "^0.0.3",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
"autoprefixer": "10.4.17",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
@ -58,7 +64,7 @@
|
|||
"next": "15.2.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"papaparse": "^5.5.2",
|
||||
"pdfjs-dist": "^4.8.69",
|
||||
"pdfjs-dist": "^3.4.120",
|
||||
"postcss": "8.4.33",
|
||||
"react": "^18",
|
||||
"react-day-picker": "^8.10.1",
|
||||
|
@ -66,8 +72,8 @@
|
|||
"react-hook-form": "^7.55.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-papaparse": "^4.4.0",
|
||||
"react-pdf": "^9.2.1",
|
||||
"react-scan": "^0.3.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
|
|
@ -195,6 +195,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
const [toolCalls, setToolCalls] = useState<ToolCallInput[]>([]);
|
||||
const [currentToolIndex, setCurrentToolIndex] = useState<number>(0);
|
||||
const [autoOpenedPanel, setAutoOpenedPanel] = useState(false);
|
||||
const [initialPanelOpenAttempted, setInitialPanelOpenAttempted] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
@ -221,18 +222,33 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
const initialLayoutAppliedRef = useRef(false);
|
||||
|
||||
const userClosedPanelRef = useRef(false);
|
||||
|
||||
// Initialize as if user already closed panel to prevent auto-opening
|
||||
|
||||
// Replace both useEffect hooks with a single one that respects user closing
|
||||
useEffect(() => {
|
||||
userClosedPanelRef.current = true;
|
||||
}, []);
|
||||
if (initialLoadCompleted.current && !initialPanelOpenAttempted) {
|
||||
// Only attempt to open panel once on initial load
|
||||
setInitialPanelOpenAttempted(true);
|
||||
|
||||
// Open the panel with tool calls if available
|
||||
if (toolCalls.length > 0) {
|
||||
setIsSidePanelOpen(true);
|
||||
setCurrentToolIndex(toolCalls.length - 1);
|
||||
} else {
|
||||
// Only if there are messages but no tool calls yet
|
||||
if (messages.length > 0) {
|
||||
setIsSidePanelOpen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [initialPanelOpenAttempted, messages, toolCalls]);
|
||||
|
||||
const toggleSidePanel = useCallback(() => {
|
||||
setIsSidePanelOpen(prevIsOpen => {
|
||||
const newState = !prevIsOpen;
|
||||
if (!newState) {
|
||||
userClosedPanelRef.current = true;
|
||||
} else {
|
||||
}
|
||||
if (newState) {
|
||||
// Close left sidebar when opening side panel
|
||||
setLeftSidebarOpen(false);
|
||||
}
|
||||
|
@ -1370,6 +1386,7 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
|
|||
onClose={() => {
|
||||
setIsSidePanelOpen(false);
|
||||
userClosedPanelRef.current = true;
|
||||
setAutoOpenedPanel(true);
|
||||
}}
|
||||
toolCalls={toolCalls}
|
||||
messages={messages as ApiMessageType[]}
|
||||
|
|
|
@ -5,6 +5,10 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { GoogleAnalytics } from "@next/third-parties/google";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import Script from "next/script";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
@ -93,13 +97,32 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
{/* <head>
|
||||
<Script src="https://unpkg.com/react-scan/dist/auto.global.js" />
|
||||
</head> */}
|
||||
<head>
|
||||
{/* Google Tag Manager */}
|
||||
<Script id="google-tag-manager" strategy="afterInteractive">
|
||||
{`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-PCHSN4M2');`}
|
||||
</Script>
|
||||
{/* End Google Tag Manager */}
|
||||
</head>
|
||||
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased font-sans bg-background`}
|
||||
>
|
||||
{/* Google Tag Manager (noscript) */}
|
||||
<noscript>
|
||||
<iframe
|
||||
src="https://www.googletagmanager.com/ns.html?id=GTM-PCHSN4M2"
|
||||
height="0"
|
||||
width="0"
|
||||
style={{ display: 'none', visibility: 'hidden' }}
|
||||
/>
|
||||
</noscript>
|
||||
{/* End Google Tag Manager (noscript) */}
|
||||
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
@ -110,6 +133,9 @@ export default function RootLayout({
|
|||
{children}
|
||||
<Toaster />
|
||||
</Providers>
|
||||
<Analytics />
|
||||
<GoogleAnalytics gaId="G-6ETJFB3PT3" />
|
||||
<SpeedInsights />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,190 +1,190 @@
|
|||
// "use client";
|
||||
"use client";
|
||||
|
||||
// import { useState, useEffect, useMemo } from "react";
|
||||
// import { readString } from "react-papaparse";
|
||||
// import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
// import {
|
||||
// Table,
|
||||
// TableBody,
|
||||
// TableCell,
|
||||
// TableHead,
|
||||
// TableHeader,
|
||||
// TableRow,
|
||||
// } from "@/components/ui/table";
|
||||
// import { Input } from "@/components/ui/input";
|
||||
// import { Button } from "@/components/ui/button";
|
||||
// import { Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
// import { cn } from "@/lib/utils";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { readString } from "react-papaparse";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// interface CsvRendererProps {
|
||||
// content: string;
|
||||
// className?: string;
|
||||
// }
|
||||
interface CsvRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// interface ParsedCsvData {
|
||||
// data: Record<string, string>[];
|
||||
// headers: string[];
|
||||
// errors?: any[];
|
||||
// }
|
||||
interface ParsedCsvData {
|
||||
data: Record<string, string>[];
|
||||
headers: string[];
|
||||
errors?: any[];
|
||||
}
|
||||
|
||||
// // Define the PapaParse result interface
|
||||
// interface PapaParseResult {
|
||||
// data: Record<string, string>[];
|
||||
// errors: { message: string; row: number }[];
|
||||
// meta: {
|
||||
// delimiter: string;
|
||||
// linebreak: string;
|
||||
// aborted: boolean;
|
||||
// truncated: boolean;
|
||||
// cursor: number;
|
||||
// fields: string[];
|
||||
// };
|
||||
// }
|
||||
// Define the PapaParse result interface
|
||||
interface PapaParseResult {
|
||||
data: Record<string, string>[];
|
||||
errors: { message: string; row: number }[];
|
||||
meta: {
|
||||
delimiter: string;
|
||||
linebreak: string;
|
||||
aborted: boolean;
|
||||
truncated: boolean;
|
||||
cursor: number;
|
||||
fields: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// export function CsvRenderer({ content, className }: CsvRendererProps) {
|
||||
// const [searchTerm, setSearchTerm] = useState("");
|
||||
// const [currentPage, setCurrentPage] = useState(1);
|
||||
// const rowsPerPage = 15;
|
||||
export function CsvRenderer({ content, className }: CsvRendererProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const rowsPerPage = 15;
|
||||
|
||||
// // Parse CSV data
|
||||
// const parsedData = useMemo<ParsedCsvData>(() => {
|
||||
// if (!content) return { data: [], headers: [] };
|
||||
// Parse CSV data
|
||||
const parsedData = useMemo<ParsedCsvData>(() => {
|
||||
if (!content) return { data: [], headers: [] };
|
||||
|
||||
// try {
|
||||
// let headers: string[] = [];
|
||||
// let data: Record<string, string>[] = [];
|
||||
try {
|
||||
let headers: string[] = [];
|
||||
let data: Record<string, string>[] = [];
|
||||
|
||||
// readString(content, {
|
||||
// header: true,
|
||||
// skipEmptyLines: true,
|
||||
// complete: (results: any) => {
|
||||
// if (results.errors && results.errors.length > 0) {
|
||||
// console.error("CSV parsing errors:", results.errors);
|
||||
// }
|
||||
readString(content, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results: any) => {
|
||||
if (results.errors && results.errors.length > 0) {
|
||||
console.error("CSV parsing errors:", results.errors);
|
||||
}
|
||||
|
||||
// headers = results.meta.fields || [];
|
||||
// data = results.data || [];
|
||||
// },
|
||||
// error: (error: Error) => {
|
||||
// console.error("CSV parsing error:", error);
|
||||
// }
|
||||
// });
|
||||
headers = results.meta.fields || [];
|
||||
data = results.data || [];
|
||||
},
|
||||
error: (error: Error) => {
|
||||
console.error("CSV parsing error:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// return {
|
||||
// data,
|
||||
// headers,
|
||||
// };
|
||||
// } catch (error) {
|
||||
// console.error("Failed to parse CSV:", error);
|
||||
// return { data: [], headers: [] };
|
||||
// }
|
||||
// }, [content]);
|
||||
return {
|
||||
data,
|
||||
headers,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to parse CSV:", error);
|
||||
return { data: [], headers: [] };
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// // Filter data based on search term
|
||||
// const filteredData = useMemo(() => {
|
||||
// if (!searchTerm.trim()) return parsedData.data;
|
||||
// Filter data based on search term
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchTerm.trim()) return parsedData.data;
|
||||
|
||||
// const lowerCaseSearchTerm = searchTerm.toLowerCase();
|
||||
// return parsedData.data.filter((row: Record<string, string>) => {
|
||||
// return Object.values(row).some((value: any) => {
|
||||
// if (value === null || value === undefined) return false;
|
||||
// return String(value).toLowerCase().includes(lowerCaseSearchTerm);
|
||||
// });
|
||||
// });
|
||||
// }, [parsedData.data, searchTerm]);
|
||||
const lowerCaseSearchTerm = searchTerm.toLowerCase();
|
||||
return parsedData.data.filter((row: Record<string, string>) => {
|
||||
return Object.values(row).some((value: any) => {
|
||||
if (value === null || value === undefined) return false;
|
||||
return String(value).toLowerCase().includes(lowerCaseSearchTerm);
|
||||
});
|
||||
});
|
||||
}, [parsedData.data, searchTerm]);
|
||||
|
||||
// // Paginate data
|
||||
// const paginatedData = useMemo(() => {
|
||||
// const startIndex = (currentPage - 1) * rowsPerPage;
|
||||
// return filteredData.slice(startIndex, startIndex + rowsPerPage);
|
||||
// }, [filteredData, currentPage, rowsPerPage]);
|
||||
// Paginate data
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * rowsPerPage;
|
||||
return filteredData.slice(startIndex, startIndex + rowsPerPage);
|
||||
}, [filteredData, currentPage, rowsPerPage]);
|
||||
|
||||
// // Calculate total pages
|
||||
// const totalPages = Math.max(1, Math.ceil(filteredData.length / rowsPerPage));
|
||||
// Calculate total pages
|
||||
const totalPages = Math.max(1, Math.ceil(filteredData.length / rowsPerPage));
|
||||
|
||||
// // Reset to first page when search changes
|
||||
// useEffect(() => {
|
||||
// setCurrentPage(1);
|
||||
// }, [searchTerm]);
|
||||
// Reset to first page when search changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm]);
|
||||
|
||||
// return (
|
||||
// <div className={cn("flex flex-col h-full w-full", className)}>
|
||||
// {/* Search and pagination controls */}
|
||||
// <div className="flex items-center justify-between p-4 border-b">
|
||||
// <div className="relative w-full max-w-sm">
|
||||
// <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
// <Input
|
||||
// type="text"
|
||||
// placeholder="Search..."
|
||||
// value={searchTerm}
|
||||
// onChange={(e) => setSearchTerm(e.target.value)}
|
||||
// className="pl-9"
|
||||
// />
|
||||
// </div>
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full w-full", className)}>
|
||||
{/* Search and pagination controls */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
// <div className="flex items-center gap-2 ml-4">
|
||||
// <Button
|
||||
// variant="outline"
|
||||
// size="icon"
|
||||
// onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
// disabled={currentPage === 1}
|
||||
// >
|
||||
// <ChevronLeft className="h-4 w-4" />
|
||||
// </Button>
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
// <span className="text-sm text-muted-foreground min-w-[100px] text-center">
|
||||
// Page {currentPage} of {totalPages}
|
||||
// </span>
|
||||
<span className="text-sm text-muted-foreground min-w-[100px] text-center">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
|
||||
// <Button
|
||||
// variant="outline"
|
||||
// size="icon"
|
||||
// onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
// disabled={currentPage === totalPages}
|
||||
// >
|
||||
// <ChevronRight className="h-4 w-4" />
|
||||
// </Button>
|
||||
// </div>
|
||||
// </div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// {/* Table */}
|
||||
// <ScrollArea className="flex-1 w-full relative">
|
||||
// <div className="min-w-full">
|
||||
// <Table>
|
||||
// <TableHeader className="sticky top-0 bg-background z-10">
|
||||
// <TableRow>
|
||||
// {parsedData.headers.map((header, index) => (
|
||||
// <TableHead key={index} className="whitespace-nowrap">
|
||||
// {header}
|
||||
// </TableHead>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// </TableHeader>
|
||||
// <TableBody>
|
||||
// {paginatedData.length > 0 ? (
|
||||
// paginatedData.map((row, rowIndex) => (
|
||||
// <TableRow key={rowIndex}>
|
||||
// {parsedData.headers.map((header, cellIndex) => (
|
||||
// <TableCell key={cellIndex} className={cellIndex === 0 ? "font-medium" : ""}>
|
||||
// {row[header] || ""}
|
||||
// </TableCell>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// ))
|
||||
// ) : (
|
||||
// <TableRow>
|
||||
// <TableCell
|
||||
// colSpan={parsedData.headers.length || 1}
|
||||
// className="h-24 text-center"
|
||||
// >
|
||||
// {searchTerm ? "No results found." : "No data available."}
|
||||
// </TableCell>
|
||||
// </TableRow>
|
||||
// )}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// </div>
|
||||
// </ScrollArea>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
{/* Table */}
|
||||
<ScrollArea className="flex-1 w-full relative">
|
||||
<div className="min-w-full">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
{parsedData.headers.map((header, index) => (
|
||||
<TableHead key={index} className="whitespace-nowrap">
|
||||
{header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedData.length > 0 ? (
|
||||
paginatedData.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{parsedData.headers.map((header, cellIndex) => (
|
||||
<TableCell key={cellIndex} className={cellIndex === 0 ? "font-medium" : ""}>
|
||||
{row[header] || ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={parsedData.headers.length || 1}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{searchTerm ? "No results found." : "No data available."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,24 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Fullscreen,
|
||||
Loader
|
||||
} from "lucide-react";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||
import { Worker, Viewer } from '@react-pdf-viewer/core';
|
||||
import { defaultLayoutPlugin } from '@react-pdf-viewer/default-layout';
|
||||
|
||||
// Initialize pdfjs worker
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
|
||||
// Import styles
|
||||
import '@react-pdf-viewer/core/lib/styles/index.css';
|
||||
import '@react-pdf-viewer/default-layout/lib/styles/index.css';
|
||||
|
||||
interface PdfRendererProps {
|
||||
url: string;
|
||||
|
@ -26,178 +15,19 @@ interface PdfRendererProps {
|
|||
}
|
||||
|
||||
export function PdfRenderer({ url, className }: PdfRendererProps) {
|
||||
// State for zoom and rotation controls
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [numPages, setNumPages] = useState<number | null>(null);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Handle zoom in/out
|
||||
const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.2, 2.0));
|
||||
const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.2, 0.5));
|
||||
|
||||
// Handle rotation
|
||||
const handleRotate = () => setRotation(prev => (prev + 90) % 360);
|
||||
|
||||
// Handle download
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = url.split('/').pop() || 'document.pdf';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Handle page navigation
|
||||
const goToPrevPage = () => setPageNumber(prev => Math.max(prev - 1, 1));
|
||||
const goToNextPage = () => {
|
||||
if (numPages !== null) {
|
||||
setPageNumber(prev => Math.min(prev + 1, numPages));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle document loading
|
||||
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
|
||||
setNumPages(numPages);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const onDocumentLoadError = (error: Error) => {
|
||||
console.error("Error loading PDF:", error);
|
||||
setError(error);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Handle fullscreen
|
||||
const handleFullscreen = () => {
|
||||
const pdfContainer = document.getElementById('pdf-container');
|
||||
if (pdfContainer) {
|
||||
if (pdfContainer.requestFullscreen) {
|
||||
pdfContainer.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
// Create the default layout plugin instance
|
||||
const defaultLayoutPluginInstance = defaultLayoutPlugin();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col w-full h-full", className)}>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between py-2 px-4 bg-muted/30 border-b mb-2 rounded-t-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleZoomOut}
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium">{Math.round(zoom * 100)}%</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleZoomIn}
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleRotate}
|
||||
title="Rotate"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{numPages && (
|
||||
<div className="flex items-center space-x-2 mr-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={goToPrevPage}
|
||||
disabled={pageNumber <= 1}
|
||||
title="Previous page"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium">
|
||||
{pageNumber} / {numPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={goToNextPage}
|
||||
disabled={pageNumber >= (numPages || 1)}
|
||||
title="Next page"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleFullscreen}
|
||||
title="Fullscreen"
|
||||
>
|
||||
<Fullscreen className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleDownload}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PDF Viewer */}
|
||||
<div id="pdf-container" className="flex-1 overflow-auto rounded-b-md bg-white flex justify-center">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Loader className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full text-destructive p-4 text-center">
|
||||
<p className="font-semibold">Failed to load PDF</p>
|
||||
<p className="text-sm mt-2">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Document
|
||||
file={url}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={null}
|
||||
className="mx-auto"
|
||||
>
|
||||
{!isLoading && !error && (
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={zoom}
|
||||
rotate={rotation}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
className="shadow-md"
|
||||
/>
|
||||
)}
|
||||
</Document>
|
||||
<div className="flex-1 overflow-hidden rounded-md">
|
||||
<Worker workerUrl={`https://unpkg.com/pdfjs-dist@3.4.120/build/pdf.worker.min.js`}>
|
||||
<Viewer
|
||||
fileUrl={url}
|
||||
plugins={[defaultLayoutPluginInstance]}
|
||||
defaultScale={1}
|
||||
/>
|
||||
</Worker>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,6 +16,7 @@ import { FileOperationToolView } from "./tool-views/FileOperationToolView";
|
|||
import { BrowserToolView } from "./tool-views/BrowserToolView";
|
||||
import { WebSearchToolView } from "./tool-views/WebSearchToolView";
|
||||
import { WebCrawlToolView } from "./tool-views/WebCrawlToolView";
|
||||
import { DataProviderToolView } from "./tool-views/DataProviderToolView";
|
||||
|
||||
// Simple input interface
|
||||
export interface ToolCallInput {
|
||||
|
@ -116,7 +117,7 @@ function getToolView(
|
|||
isSuccess={isSuccess}
|
||||
/>
|
||||
);
|
||||
case 'web-crawl':
|
||||
case 'crawl-webpage':
|
||||
return (
|
||||
<WebCrawlToolView
|
||||
assistantContent={assistantContent}
|
||||
|
@ -126,6 +127,19 @@ function getToolView(
|
|||
isSuccess={isSuccess}
|
||||
/>
|
||||
);
|
||||
case 'execute-data-provider-call':
|
||||
case 'get-data-provider-endpoints':
|
||||
return (
|
||||
<DataProviderToolView
|
||||
name={normalizedToolName}
|
||||
assistantContent={assistantContent}
|
||||
toolContent={toolContent}
|
||||
assistantTimestamp={assistantTimestamp}
|
||||
toolTimestamp={toolTimestamp}
|
||||
isSuccess={isSuccess}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
// Check if it's a browser operation
|
||||
if (normalizedToolName.startsWith('browser-')) {
|
||||
|
@ -155,6 +169,7 @@ function getToolView(
|
|||
assistantTimestamp={assistantTimestamp}
|
||||
toolTimestamp={toolTimestamp}
|
||||
isSuccess={isSuccess}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
import React from "react";
|
||||
import { ToolViewProps } from "./types";
|
||||
import { formatTimestamp, getToolTitle } from "./utils";
|
||||
import { getToolIcon } from "../utils";
|
||||
import { CircleDashed, CheckCircle, AlertTriangle, Network, Database } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DataProviderToolView({
|
||||
name = 'unknown',
|
||||
assistantContent,
|
||||
toolContent,
|
||||
isSuccess = true,
|
||||
isStreaming = false,
|
||||
assistantTimestamp,
|
||||
toolTimestamp
|
||||
}: ToolViewProps) {
|
||||
const toolTitle = getToolTitle(name);
|
||||
const Icon = getToolIcon(name) || Network;
|
||||
|
||||
// Extract data from the assistant content (request)
|
||||
const extractRequest = React.useMemo(() => {
|
||||
if (!assistantContent) return null;
|
||||
|
||||
try {
|
||||
// Parse assistant content as JSON
|
||||
const parsed = JSON.parse(assistantContent);
|
||||
|
||||
if (parsed.content) {
|
||||
// Try to extract content from service name and route
|
||||
const serviceMatch = parsed.content.match(/service_name=\\?"([^"\\]+)\\?"/);
|
||||
const routeMatch = parsed.content.match(/route=\\?"([^"\\]+)\\?"/);
|
||||
|
||||
// For execute-data-provider-call, also extract the payload
|
||||
let payload = null;
|
||||
if (name === 'execute-data-provider-call') {
|
||||
const payloadMatch = parsed.content.match(/{([^}]+)}/);
|
||||
if (payloadMatch) {
|
||||
try {
|
||||
// Try to parse the payload JSON
|
||||
payload = JSON.parse(`{${payloadMatch[1]}}`);
|
||||
} catch (e) {
|
||||
payload = payloadMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
service: serviceMatch ? serviceMatch[1] : undefined,
|
||||
route: routeMatch ? routeMatch[1] : undefined,
|
||||
payload
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing assistant content:", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [assistantContent, name]);
|
||||
|
||||
// Parse the tool response
|
||||
const parsedResponse = React.useMemo(() => {
|
||||
if (!toolContent || isStreaming) return null;
|
||||
|
||||
try {
|
||||
// Extract content from tool_result tags if present
|
||||
const toolResultMatch = toolContent.match(/<tool_result>\s*<[^>]+>([\s\S]*?)<\/[^>]+>\s*<\/tool_result>/);
|
||||
let contentToFormat = toolResultMatch ? toolResultMatch[1] : toolContent;
|
||||
|
||||
// Look for a ToolResult pattern
|
||||
const toolResultOutputMatch = contentToFormat.match(/ToolResult\(success=.+?, output='([\s\S]*?)'\)/);
|
||||
if (toolResultOutputMatch) {
|
||||
contentToFormat = toolResultOutputMatch[1];
|
||||
}
|
||||
|
||||
// Try to parse as JSON for pretty formatting
|
||||
try {
|
||||
// Replace escaped quotes and newlines
|
||||
contentToFormat = contentToFormat.replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
||||
const parsedJson = JSON.parse(contentToFormat);
|
||||
return JSON.stringify(parsedJson, null, 2);
|
||||
} catch (e) {
|
||||
// If not valid JSON, return as is
|
||||
return contentToFormat;
|
||||
}
|
||||
} catch (e) {
|
||||
return toolContent;
|
||||
}
|
||||
}, [toolContent, isStreaming]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 p-4 overflow-auto">
|
||||
<div className="border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden h-full flex flex-col">
|
||||
{/* Header - exactly like other tool views */}
|
||||
<div className="flex items-center p-2 bg-zinc-100 dark:bg-zinc-900 justify-between border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex items-center">
|
||||
<Database className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" />
|
||||
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">{toolTitle}</span>
|
||||
</div>
|
||||
|
||||
{!isStreaming && (
|
||||
<span className={cn(
|
||||
"text-xs flex items-center",
|
||||
isSuccess ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
<span className="h-1.5 w-1.5 rounded-full mr-1.5 bg-current"></span>
|
||||
{isSuccess ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Request Info Bar - match style with file paths in other tools */}
|
||||
{extractRequest && (
|
||||
<div className="px-3 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900">
|
||||
<code className="text-xs font-mono text-zinc-700 dark:text-zinc-300">
|
||||
{extractRequest.service}{extractRequest.route && `/${extractRequest.route}`}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Container */}
|
||||
{!isStreaming ? (
|
||||
<div className="flex-1 bg-white dark:bg-zinc-950 font-mono text-sm">
|
||||
<div className="p-3">
|
||||
{/* Request section - show payload if available */}
|
||||
{extractRequest?.payload && (
|
||||
<div className="mb-4">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">Request Payload</div>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md">
|
||||
<pre className="p-3 text-xs overflow-auto whitespace-pre-wrap text-zinc-800 dark:text-zinc-300 font-mono">
|
||||
{typeof extractRequest.payload === 'object'
|
||||
? JSON.stringify(extractRequest.payload, null, 2)
|
||||
: extractRequest.payload}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response section */}
|
||||
{parsedResponse && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">Response Data</div>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md">
|
||||
<pre className="p-3 text-xs overflow-auto whitespace-pre-wrap text-zinc-800 dark:text-zinc-300 font-mono">
|
||||
{parsedResponse}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show raw data if parsed content isn't available */}
|
||||
{!extractRequest?.payload && !parsedResponse && assistantContent && (
|
||||
<div className="mb-4">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">Raw Request</div>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md">
|
||||
<pre className="p-3 text-xs overflow-auto whitespace-pre-wrap text-zinc-800 dark:text-zinc-300 font-mono">
|
||||
{assistantContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!parsedResponse && toolContent && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">Raw Response</div>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md">
|
||||
<pre className="p-3 text-xs overflow-auto whitespace-pre-wrap text-zinc-800 dark:text-zinc-300 font-mono">
|
||||
{toolContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 bg-white dark:bg-zinc-950 flex items-center justify-center">
|
||||
<div className="text-center p-6">
|
||||
<CircleDashed className="h-8 w-8 mx-auto mb-3 text-blue-500 animate-spin" />
|
||||
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
Processing {name.toLowerCase()} operation...
|
||||
</p>
|
||||
{extractRequest?.service && extractRequest?.route && (
|
||||
<p className="text-xs mt-1 text-zinc-500 dark:text-zinc-400 font-mono">
|
||||
{extractRequest.service}/{extractRequest.route}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer - exactly like other tool views */}
|
||||
<div className="p-4 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{!isStreaming && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
|
||||
)}
|
||||
<span>
|
||||
{isSuccess
|
||||
? `${toolTitle} completed successfully`
|
||||
: `${toolTitle} operation failed`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleDashed className="h-3.5 w-3.5 text-blue-500 animate-spin" />
|
||||
<span>Executing {toolTitle.toLowerCase()}...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs">
|
||||
{toolTimestamp && !isStreaming
|
||||
? formatTimestamp(toolTimestamp)
|
||||
: assistantTimestamp
|
||||
? formatTimestamp(assistantTimestamp)
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,14 +1,83 @@
|
|||
import React, { useState } from "react";
|
||||
import { FileCode, FileSymlink, FolderPlus, FileX, Replace, CheckCircle, AlertTriangle, ExternalLink, CircleDashed, Code, Eye } from "lucide-react";
|
||||
import { FileCode, FileSymlink, FolderPlus, FileX, Replace, CheckCircle, AlertTriangle, ExternalLink, CircleDashed, Code, Eye, FileSpreadsheet } from "lucide-react";
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { ToolViewProps } from "./types";
|
||||
import { extractFilePath, extractFileContent, getFileType, formatTimestamp, getToolTitle } from "./utils";
|
||||
import { GenericToolView } from "./GenericToolView";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { CsvRenderer } from "@/components/file-renderers/csv-renderer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
// Type for operation type
|
||||
type FileOperation = "create" | "rewrite" | "delete";
|
||||
|
||||
// Map file extensions to language names for syntax highlighting
|
||||
const getLanguageFromFileName = (fileName: string): string => {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
// Map of file extensions to language names for syntax highlighting
|
||||
const extensionMap: Record<string, string> = {
|
||||
// Web languages
|
||||
'html': 'html',
|
||||
'htm': 'html',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'scss',
|
||||
'less': 'less',
|
||||
'js': 'javascript',
|
||||
'jsx': 'jsx',
|
||||
'ts': 'typescript',
|
||||
'tsx': 'tsx',
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
|
||||
// Build and config files
|
||||
'xml': 'xml',
|
||||
'yml': 'yaml',
|
||||
'yaml': 'yaml',
|
||||
'toml': 'toml',
|
||||
'ini': 'ini',
|
||||
'env': 'bash',
|
||||
'gitignore': 'bash',
|
||||
'dockerignore': 'bash',
|
||||
|
||||
// Scripting languages
|
||||
'py': 'python',
|
||||
'rb': 'ruby',
|
||||
'php': 'php',
|
||||
'go': 'go',
|
||||
'java': 'java',
|
||||
'kt': 'kotlin',
|
||||
'c': 'c',
|
||||
'cpp': 'cpp',
|
||||
'h': 'c',
|
||||
'hpp': 'cpp',
|
||||
'cs': 'csharp',
|
||||
'swift': 'swift',
|
||||
'rs': 'rust',
|
||||
|
||||
// Shell scripts
|
||||
'sh': 'bash',
|
||||
'bash': 'bash',
|
||||
'zsh': 'bash',
|
||||
'ps1': 'powershell',
|
||||
'bat': 'batch',
|
||||
'cmd': 'batch',
|
||||
|
||||
// Markup languages (excluding markdown which has its own renderer)
|
||||
'svg': 'svg',
|
||||
'tex': 'latex',
|
||||
|
||||
// Data formats
|
||||
'graphql': 'graphql',
|
||||
'gql': 'graphql',
|
||||
};
|
||||
|
||||
return extensionMap[extension] || 'text';
|
||||
};
|
||||
|
||||
export function FileOperationToolView({
|
||||
assistantContent,
|
||||
toolContent,
|
||||
|
@ -19,6 +88,9 @@ export function FileOperationToolView({
|
|||
name,
|
||||
project
|
||||
}: ToolViewProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDarkTheme = resolvedTheme === 'dark';
|
||||
|
||||
// Determine operation type from content or name
|
||||
const getOperationType = (): FileOperation => {
|
||||
// First check tool name if available
|
||||
|
@ -64,14 +136,17 @@ export function FileOperationToolView({
|
|||
const fileType = processedFilePath ? getFileType(processedFilePath) : '';
|
||||
const isMarkdown = fileName.endsWith('.md');
|
||||
const isHtml = fileName.endsWith('.html');
|
||||
const isCsv = fileName.endsWith('.csv');
|
||||
const language = getLanguageFromFileName(fileName);
|
||||
const hasHighlighting = language !== 'text';
|
||||
|
||||
// Construct HTML file preview URL if we have a sandbox and the file is HTML
|
||||
const htmlPreviewUrl = (isHtml && project?.sandbox?.sandbox_url && processedFilePath)
|
||||
? `${project.sandbox.sandbox_url}/${processedFilePath}`
|
||||
: undefined;
|
||||
|
||||
// Add state for view mode toggle (code or preview) - moved before any conditional returns
|
||||
const [viewMode, setViewMode] = useState<'code' | 'preview'>(isHtml || isMarkdown ? 'preview' : 'code');
|
||||
// Add state for view mode toggle (code or preview)
|
||||
const [viewMode, setViewMode] = useState<'code' | 'preview'>(isHtml || isMarkdown || isCsv ? 'preview' : 'code');
|
||||
|
||||
// Fall back to generic view if file path is missing or if content is missing for non-delete operations
|
||||
if ((!filePath && !showDebugInfo) || (operation !== "delete" && !fileContent)) {
|
||||
|
@ -118,6 +193,8 @@ export function FileOperationToolView({
|
|||
<div className="flex items-center">
|
||||
{isMarkdown ?
|
||||
<FileCode className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" /> :
|
||||
isCsv ?
|
||||
<FileSpreadsheet className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" /> :
|
||||
<FileSymlink className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" />
|
||||
}
|
||||
<span className="text-xs font-medium">{fileName}</span>
|
||||
|
@ -182,28 +259,109 @@ export function FileOperationToolView({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* View switcher for CSV files */}
|
||||
{isCsv && isSuccess && (
|
||||
<div className="flex rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<button
|
||||
onClick={() => setViewMode('code')}
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs px-2 py-1 transition-colors",
|
||||
viewMode === 'code'
|
||||
? "bg-zinc-800 text-zinc-100 dark:bg-zinc-700 dark:text-zinc-100"
|
||||
: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700"
|
||||
)}
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs px-2 py-1 transition-colors",
|
||||
viewMode === 'preview'
|
||||
? "bg-zinc-800 text-zinc-100 dark:bg-zinc-700 dark:text-zinc-100"
|
||||
: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 hover:bg-zinc-300 dark:hover:bg-zinc-700"
|
||||
)}
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400 bg-zinc-200 dark:bg-zinc-800 px-2 py-0.5 rounded">
|
||||
{fileType}
|
||||
{hasHighlighting ? language.toUpperCase() : fileType}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Content (Code View) */}
|
||||
{viewMode === 'code' || (!isHtml && !isMarkdown) || !isSuccess ? (
|
||||
{/* File Content (Code View with Syntax Highlighting) */}
|
||||
{viewMode === 'code' || (!isHtml && !isMarkdown && !isCsv) || !isSuccess ? (
|
||||
<div className="flex-1 overflow-auto bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100">
|
||||
<div className="min-w-full table">
|
||||
{contentLines.map((line, idx) => (
|
||||
<div key={idx} className="table-row hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors">
|
||||
<div className="table-cell text-right pr-3 py-0.5 text-xs font-mono text-zinc-500 dark:text-zinc-500 select-none w-12 border-r border-zinc-200 dark:border-zinc-800">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="table-cell pl-3 py-0.5 text-xs font-mono whitespace-pre text-zinc-800 dark:text-zinc-300">
|
||||
{line || ' '}
|
||||
</div>
|
||||
{hasHighlighting ? (
|
||||
<div className="relative">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-12 border-r border-zinc-200 dark:border-zinc-800 z-10 flex flex-col">
|
||||
{contentLines.map((_, idx) => (
|
||||
<div key={idx}
|
||||
className="h-6 text-right pr-3 text-xs font-mono text-zinc-500 dark:text-zinc-500 select-none">
|
||||
{idx + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="table-row h-4"></div>
|
||||
</div>
|
||||
<div className="pl-12">
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDarkTheme ? oneDark : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.5rem 1rem',
|
||||
background: 'transparent',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: '1.5rem',
|
||||
minHeight: '100%',
|
||||
backgroundColor: 'transparent'
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
backgroundColor: 'transparent'
|
||||
}
|
||||
}}
|
||||
useInlineStyles={true}
|
||||
wrapLines={true}
|
||||
lineProps={(lineNumber) => ({
|
||||
style: {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
lineHeight: '1.5rem',
|
||||
minHeight: '1.5rem',
|
||||
padding: '0 0.25rem',
|
||||
':hover': {
|
||||
backgroundColor: isDarkTheme ? 'rgba(39, 39, 42, 0.5)' : 'rgba(244, 244, 245, 0.5)',
|
||||
}
|
||||
},
|
||||
className: 'hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors'
|
||||
})}
|
||||
showLineNumbers={false}
|
||||
>
|
||||
{fileContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-full table">
|
||||
{contentLines.map((line, idx) => (
|
||||
<div key={idx} className="table-row hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors">
|
||||
<div className="table-cell text-right pr-3 py-0.5 text-xs font-mono text-zinc-500 dark:text-zinc-500 select-none w-12 border-r border-zinc-200 dark:border-zinc-800">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="table-cell pl-3 py-0.5 text-xs font-mono whitespace-pre text-zinc-800 dark:text-zinc-300">
|
||||
{line || ' '}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="table-row h-4"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
@ -229,6 +387,13 @@ export function FileOperationToolView({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* CSV Preview */}
|
||||
{isCsv && viewMode === 'preview' && isSuccess && (
|
||||
<div className="flex-1 overflow-hidden bg-white dark:bg-zinc-950">
|
||||
<CsvRenderer content={fileContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External link button for HTML files */}
|
||||
{isHtml && viewMode === 'preview' && htmlPreviewUrl && isSuccess && (
|
||||
<div className="bg-zinc-100 dark:bg-zinc-900 p-2 border-t border-zinc-200 dark:border-zinc-800 flex justify-end">
|
||||
|
|
|
@ -15,43 +15,42 @@ export function GenericToolView({
|
|||
assistantTimestamp,
|
||||
toolTimestamp
|
||||
}: ToolViewProps) {
|
||||
console.log('GenericToolView:', {
|
||||
name,
|
||||
assistantContent,
|
||||
toolContent,
|
||||
isSuccess,
|
||||
isStreaming,
|
||||
assistantTimestamp,
|
||||
toolTimestamp
|
||||
});
|
||||
|
||||
const toolTitle = getToolTitle(name);
|
||||
const Icon = getToolIcon(name);
|
||||
|
||||
// Parse the assistant content to extract tool parameters
|
||||
const parsedContent = React.useMemo(() => {
|
||||
if (!assistantContent) return null;
|
||||
// Format content for display
|
||||
const formatContent = (content: string | null) => {
|
||||
if (!content) return null;
|
||||
|
||||
// Try to extract content from XML tags
|
||||
const xmlMatch = assistantContent.match(/<([a-zA-Z\-_]+)(?:\s+[^>]*)?>([^<]*)<\/\1>/);
|
||||
if (xmlMatch) {
|
||||
return {
|
||||
tag: xmlMatch[1],
|
||||
content: xmlMatch[2].trim()
|
||||
};
|
||||
try {
|
||||
// Try to parse as JSON for pretty formatting
|
||||
const parsedJson = JSON.parse(content);
|
||||
return JSON.stringify(parsedJson, null, 2);
|
||||
} catch (e) {
|
||||
// If not valid JSON, return as is
|
||||
return content;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [assistantContent]);
|
||||
|
||||
// Parse the tool content to extract result from inside <tool_result> tags
|
||||
const parsedToolContent = React.useMemo(() => {
|
||||
if (!toolContent || isStreaming) return null;
|
||||
|
||||
// Try to extract content from <tool_result> tags
|
||||
const toolResultMatch = toolContent.match(/<tool_result>([\s\S]*?)<\/tool_result>/);
|
||||
if (toolResultMatch) {
|
||||
return toolResultMatch[1].trim();
|
||||
}
|
||||
|
||||
return toolContent;
|
||||
}, [toolContent, isStreaming]);
|
||||
};
|
||||
|
||||
// Format the contents
|
||||
const formattedAssistantContent = React.useMemo(() => formatContent(assistantContent), [assistantContent]);
|
||||
const formattedToolContent = React.useMemo(() => formatContent(toolContent), [toolContent]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 p-4 overflow-auto">
|
||||
{/* Tool Parameters */}
|
||||
{parsedContent && (
|
||||
{/* Assistant Content */}
|
||||
{assistantContent && !isStreaming && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400">Input</div>
|
||||
|
@ -60,30 +59,7 @@ export function GenericToolView({
|
|||
)}
|
||||
</div>
|
||||
<div className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 p-3">
|
||||
{parsedContent.content.startsWith('{') ? (
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words text-zinc-800 dark:text-zinc-300 font-mono">
|
||||
{JSON.stringify(JSON.parse(parsedContent.content), null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<Markdown className="text-xs prose prose-zinc dark:prose-invert max-w-none">
|
||||
{parsedContent.content}
|
||||
</Markdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show original assistant content if couldn't parse properly */}
|
||||
{assistantContent && !parsedContent && !isStreaming && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400">Input</div>
|
||||
{assistantTimestamp && (
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">{formatTimestamp(assistantTimestamp)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 p-3">
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words text-zinc-800 dark:text-zinc-300 font-mono">{assistantContent}</pre>
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words text-zinc-800 dark:text-zinc-300 font-mono">{formattedAssistantContent}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -113,7 +89,7 @@ export function GenericToolView({
|
|||
<span>Executing {toolTitle.toLowerCase()}...</span>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words text-zinc-800 dark:text-zinc-300 font-mono">{parsedToolContent || toolContent}</pre>
|
||||
<pre className="text-xs overflow-auto whitespace-pre-wrap break-words text-zinc-800 dark:text-zinc-300 font-mono">{formattedToolContent}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import React from "react";
|
||||
import { Globe, ArrowUpRight, Copy, CheckCircle, AlertTriangle, CircleDashed } from "lucide-react";
|
||||
import { Globe, CheckCircle, AlertTriangle, CircleDashed, ExternalLink } from "lucide-react";
|
||||
import { ToolViewProps } from "./types";
|
||||
import { extractCrawlUrl, extractWebpageContent, formatTimestamp, getToolTitle } from "./utils";
|
||||
import { GenericToolView } from "./GenericToolView";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function WebCrawlToolView({
|
||||
name = "web-crawl",
|
||||
name = "crawl-webpage",
|
||||
assistantContent,
|
||||
toolContent,
|
||||
assistantTimestamp,
|
||||
|
@ -44,55 +44,48 @@ export function WebCrawlToolView({
|
|||
|
||||
const domain = url ? formatDomain(url) : 'Unknown';
|
||||
|
||||
// Format the extracted text into paragraphs
|
||||
const formatTextContent = (text: string): React.ReactNode[] => {
|
||||
if (!text) return [<p key="empty" className="text-zinc-500 dark:text-zinc-400 italic">No content extracted</p>];
|
||||
|
||||
return text.split('\n\n').map((paragraph, idx) => {
|
||||
if (!paragraph.trim()) return null;
|
||||
return (
|
||||
<p key={idx} className="mb-3 text-zinc-700 dark:text-zinc-300">
|
||||
{paragraph.trim()}
|
||||
</p>
|
||||
);
|
||||
}).filter(Boolean);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 p-4 overflow-auto">
|
||||
<div className="border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden h-full flex flex-col">
|
||||
{/* Webpage Header */}
|
||||
<div className="flex items-center p-2 bg-zinc-100 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 justify-between border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex items-center p-2 bg-zinc-100 dark:bg-zinc-900 justify-between border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex items-center">
|
||||
<Globe className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" />
|
||||
<span className="text-xs font-medium line-clamp-1 pr-2">
|
||||
{webpageContent?.title || domain}
|
||||
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{toolTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 flex items-center gap-1"
|
||||
>
|
||||
Visit <ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{!isStreaming && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-xs flex items-center",
|
||||
isSuccess ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
<span className="h-1.5 w-1.5 rounded-full mr-1.5 bg-current"></span>
|
||||
{isSuccess ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 py-1 px-2 text-xs text-zinc-700 dark:text-zinc-300 bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 rounded transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-zinc-500 flex-shrink-0" />
|
||||
<span>Open URL</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* URL Bar */}
|
||||
<div className="px-3 py-1.5 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 flex items-center justify-between">
|
||||
<div className="flex-1 bg-zinc-100 dark:bg-zinc-800 rounded px-2 py-1 text-zinc-800 dark:text-zinc-300 flex items-center">
|
||||
<code className="text-xs font-mono truncate">{url}</code>
|
||||
</div>
|
||||
<button className="ml-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300" title="Copy URL">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="px-3 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900">
|
||||
<code className="text-xs font-mono text-zinc-700 dark:text-zinc-300">{url}</code>
|
||||
</div>
|
||||
|
||||
{/* Webpage Content */}
|
||||
{/* Content */}
|
||||
{isStreaming ? (
|
||||
<div className="flex-1 bg-white dark:bg-zinc-950 flex items-center justify-center">
|
||||
<div className="text-center p-6">
|
||||
|
@ -102,13 +95,15 @@ export function WebCrawlToolView({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto bg-white dark:bg-zinc-950 p-4">
|
||||
<div className="flex-1 overflow-auto bg-white dark:bg-zinc-950 font-mono text-sm">
|
||||
{webpageContent ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<h1 className="text-lg font-bold mb-4 text-zinc-900 dark:text-zinc-100">{webpageContent.title}</h1>
|
||||
<div className="text-sm">
|
||||
{formatTextContent(webpageContent.text)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">Page Content</div>
|
||||
<div className="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md">
|
||||
<pre className="p-3 text-xs overflow-auto whitespace-pre-wrap text-zinc-800 dark:text-zinc-300 font-mono">
|
||||
{webpageContent.text || "No content extracted"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 h-full flex items-center justify-center">
|
||||
|
@ -135,7 +130,7 @@ export function WebCrawlToolView({
|
|||
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
|
||||
)}
|
||||
<span>
|
||||
{isSuccess ? 'Webpage crawled successfully' : 'Failed to crawl webpage'}
|
||||
{isSuccess ? `${toolTitle} completed successfully` : `${toolTitle} operation failed`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
@ -143,7 +138,7 @@ export function WebCrawlToolView({
|
|||
{isStreaming && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleDashed className="h-3.5 w-3.5 text-blue-500 animate-spin" />
|
||||
<span>Crawling webpage...</span>
|
||||
<span>Executing {toolTitle.toLowerCase()}...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
@ -13,15 +13,6 @@ export function WebSearchToolView({
|
|||
isSuccess = true,
|
||||
isStreaming = false
|
||||
}: ToolViewProps) {
|
||||
console.log({
|
||||
name,
|
||||
assistantContent,
|
||||
toolContent,
|
||||
assistantTimestamp,
|
||||
toolTimestamp,
|
||||
isSuccess,
|
||||
isStreaming
|
||||
});
|
||||
const query = extractSearchQuery(assistantContent);
|
||||
const searchResults = extractSearchResults(toolContent);
|
||||
const toolTitle = getToolTitle(name);
|
||||
|
@ -29,61 +20,72 @@ export function WebSearchToolView({
|
|||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 p-4 overflow-auto">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center p-2 justify-between mb-3">
|
||||
<div className="border border-zinc-200 dark:border-zinc-800 rounded-md overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center p-2 bg-zinc-100 dark:bg-zinc-900 justify-between border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex items-center">
|
||||
<Search className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
|
||||
<span className="text-xs font-medium text-zinc-600 dark:text-zinc-300">Search Results</span>
|
||||
<Search className="h-4 w-4 mr-2 text-zinc-600 dark:text-zinc-400" />
|
||||
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">{toolTitle}</span>
|
||||
</div>
|
||||
|
||||
{!isStreaming && (
|
||||
<span className={cn(
|
||||
"text-xs flex items-center",
|
||||
isSuccess ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
<span className="h-1.5 w-1.5 rounded-full mr-1.5 bg-current"></span>
|
||||
{isSuccess ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-2 mb-4">
|
||||
<div className="flex items-center bg-zinc-100 dark:bg-zinc-800/50 rounded p-2">
|
||||
<div className="text-xs font-medium mr-2 text-zinc-600 dark:text-zinc-400 shrink-0">Query:</div>
|
||||
<div className="text-xs flex-1 text-zinc-800 dark:text-zinc-300 truncate">{query || 'Unknown query'}</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{isStreaming ? 'Searching...' : searchResults.length > 0 ? `Found ${searchResults.length} results` : 'No results found'}
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900">
|
||||
<code className="text-xs font-mono text-zinc-700 dark:text-zinc-300">{query || 'Unknown query'}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-white dark:bg-zinc-950 rounded-md border border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex-1 overflow-auto bg-white dark:bg-zinc-950 font-mono text-sm">
|
||||
{isStreaming ? (
|
||||
<div className="p-6 text-center flex-1 flex flex-col items-center justify-center h-full">
|
||||
<CircleDashed className="h-6 w-6 mx-auto mb-2 text-blue-500 animate-spin" />
|
||||
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Searching the web...</p>
|
||||
<p className="text-xs mt-1 text-zinc-500 dark:text-zinc-400">This might take a moment</p>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center p-6">
|
||||
<CircleDashed className="h-8 w-8 mx-auto mb-3 text-blue-500 animate-spin" />
|
||||
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Searching the web...</p>
|
||||
<p className="text-xs mt-1 text-zinc-500 dark:text-zinc-400">This might take a moment</p>
|
||||
</div>
|
||||
</div>
|
||||
) : searchResults.length > 0 ? (
|
||||
<div className="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
{searchResults.map((result, idx) => (
|
||||
<div key={idx} className="p-3 space-y-1">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 truncate mb-0.5">
|
||||
{cleanUrl(result.url)}
|
||||
<div className="p-3">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-3">
|
||||
Found {searchResults.length} results
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-100 dark:divide-zinc-800 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-md">
|
||||
{searchResults.map((result, idx) => (
|
||||
<div key={idx} className="p-3 space-y-1">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 truncate mb-0.5">
|
||||
{cleanUrl(result.url)}
|
||||
</div>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline font-medium flex items-center gap-1"
|
||||
>
|
||||
{result.title}
|
||||
<ExternalLink className="h-3 w-3 opacity-60" />
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline font-medium flex items-center gap-1"
|
||||
>
|
||||
{result.title}
|
||||
<ExternalLink className="h-3 w-3 opacity-60" />
|
||||
</a>
|
||||
{result.snippet && (
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-400 line-clamp-2">
|
||||
{result.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{result.snippet && (
|
||||
<p className="text-xs text-zinc-600 dark:text-zinc-400 line-clamp-2">
|
||||
{result.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center text-zinc-500 flex-1 flex flex-col items-center justify-center h-full">
|
||||
<Search className="h-6 w-6 mx-auto mb-2 opacity-40" />
|
||||
<p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">No results found</p>
|
||||
<div className="p-6 text-center flex-1 flex flex-col items-center justify-center h-full">
|
||||
<Search className="h-6 w-6 mx-auto mb-2 text-zinc-400 dark:text-zinc-500" />
|
||||
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">No results found</p>
|
||||
<p className="text-xs mt-1 text-zinc-500 dark:text-zinc-400">Try refining your search query</p>
|
||||
</div>
|
||||
)}
|
||||
|
@ -91,25 +93,25 @@ export function WebSearchToolView({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50">
|
||||
<div className="p-4 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<div className="flex items-center justify-between text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{!isStreaming && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
|
||||
)}
|
||||
<span>
|
||||
{isSuccess ? 'Search completed' : 'Search failed'}
|
||||
{isSuccess ? `${toolTitle} completed successfully` : `${toolTitle} operation failed`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleDashed className="h-3.5 w-3.5 text-blue-500 animate-spin" />
|
||||
<span>Searching...</span>
|
||||
<span>Executing {toolTitle.toLowerCase()}...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ export function getToolTitle(toolName: string): string {
|
|||
'full-file-rewrite': 'Rewrite File',
|
||||
'delete-file': 'Delete File',
|
||||
'web-search': 'Web Search',
|
||||
'web-crawl': 'Web Crawl',
|
||||
'crawl-webpage': 'Web Crawl',
|
||||
'browser-navigate': 'Browser Navigate',
|
||||
'browser-click': 'Browser Click',
|
||||
'browser-extract': 'Browser Extract',
|
||||
|
@ -301,10 +301,361 @@ export function extractSearchQuery(content: string | undefined): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Helper to extract URLs and titles with regex
|
||||
export function extractUrlsAndTitles(content: string): Array<{ title: string, url: string, snippet?: string }> {
|
||||
const results: Array<{ title: string, url: string, snippet?: string }> = [];
|
||||
|
||||
// First try to handle the case where content contains fragments of JSON objects
|
||||
// This pattern matches both complete and partial result objects
|
||||
const jsonFragmentPattern = /"?title"?\s*:\s*"([^"]+)"[^}]*"?url"?\s*:\s*"?(https?:\/\/[^",\s]+)"?|https?:\/\/[^",\s]+[",\s]*"?title"?\s*:\s*"([^"]+)"/g;
|
||||
let fragmentMatch;
|
||||
|
||||
while ((fragmentMatch = jsonFragmentPattern.exec(content)) !== null) {
|
||||
// Extract title and URL from the matched groups
|
||||
// Groups can match in different orders depending on the fragment format
|
||||
const title = fragmentMatch[1] || fragmentMatch[3] || '';
|
||||
let url = fragmentMatch[2] || '';
|
||||
|
||||
// If no URL was found in the JSON fragment pattern but we have a title,
|
||||
// try to find a URL on its own line above the title
|
||||
if (!url && title) {
|
||||
// Look backwards from the match position
|
||||
const beforeText = content.substring(0, fragmentMatch.index);
|
||||
const urlMatch = beforeText.match(/https?:\/\/[^\s",]+\s*$/);
|
||||
if (urlMatch && urlMatch[0]) {
|
||||
url = urlMatch[0].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (url && title && !results.some(r => r.url === url)) {
|
||||
results.push({ title, url });
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find any results with the JSON fragment approach, fall back to standard URL extraction
|
||||
if (results.length === 0) {
|
||||
// Regex to find URLs, attempting to exclude common trailing unwanted characters/tags
|
||||
const urlRegex = /https?:\/\/[^\s"<]+/g;
|
||||
let match;
|
||||
|
||||
while ((match = urlRegex.exec(content)) !== null) {
|
||||
let url = match[0];
|
||||
|
||||
// --- Start: New Truncation Logic ---
|
||||
// Find the first occurrence of potential garbage separators like /n or \n after the protocol.
|
||||
const protocolEndIndex = url.indexOf('://');
|
||||
const searchStartIndex = protocolEndIndex !== -1 ? protocolEndIndex + 3 : 0;
|
||||
|
||||
const newlineIndexN = url.indexOf('/n', searchStartIndex);
|
||||
const newlineIndexSlashN = url.indexOf('\\n', searchStartIndex);
|
||||
|
||||
let firstNewlineIndex = -1;
|
||||
if (newlineIndexN !== -1 && newlineIndexSlashN !== -1) {
|
||||
firstNewlineIndex = Math.min(newlineIndexN, newlineIndexSlashN);
|
||||
} else if (newlineIndexN !== -1) {
|
||||
firstNewlineIndex = newlineIndexN;
|
||||
} else if (newlineIndexSlashN !== -1) {
|
||||
firstNewlineIndex = newlineIndexSlashN;
|
||||
}
|
||||
|
||||
// If a newline indicator is found, truncate the URL there.
|
||||
if (firstNewlineIndex !== -1) {
|
||||
url = url.substring(0, firstNewlineIndex);
|
||||
}
|
||||
// --- End: New Truncation Logic ---
|
||||
|
||||
// Basic cleaning: remove common tags or artifacts if they are directly appended
|
||||
url = url.replace(/<\/?url>$/, '')
|
||||
.replace(/<\/?content>$/, '')
|
||||
.replace(/%3C$/, ''); // Remove trailing %3C (less than sign)
|
||||
|
||||
// Aggressive trailing character removal (common issues)
|
||||
// Apply this *after* potential truncation
|
||||
while (/[);.,\/]$/.test(url)) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
|
||||
// Decode URI components to handle % sequences, but catch errors
|
||||
try {
|
||||
// Decode multiple times? Sometimes needed for double encoding
|
||||
url = decodeURIComponent(decodeURIComponent(url));
|
||||
} catch (e) {
|
||||
try { // Try decoding once if double decoding failed
|
||||
url = decodeURIComponent(url);
|
||||
} catch (e2) {
|
||||
console.warn("Failed to decode URL component:", url, e2);
|
||||
}
|
||||
}
|
||||
|
||||
// Final cleaning for specific problematic sequences like ellipsis or remaining tags
|
||||
url = url.replace(/\u2026$/, ''); // Remove trailing ellipsis (…)
|
||||
url = url.replace(/<\/?url>$/, '').replace(/<\/?content>$/, ''); // Re-apply tag removal after decode
|
||||
|
||||
// Try to find a title near this URL
|
||||
const urlIndex = match.index;
|
||||
const surroundingText = content.substring(Math.max(0, urlIndex - 100), urlIndex + url.length + 200);
|
||||
|
||||
// Look for title patterns more robustly
|
||||
const titleMatch = surroundingText.match(/title"?\s*:\s*"([^"]+)"/i) ||
|
||||
surroundingText.match(/Title[:\s]+([^\n<]+)/i) ||
|
||||
surroundingText.match(/\"(.*?)\"[\s\n]*?https?:\/\//);
|
||||
|
||||
let title = cleanUrl(url); // Default to cleaned URL hostname/path
|
||||
if (titleMatch && titleMatch[1].trim()) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// Avoid adding duplicates if the cleaning resulted in the same URL
|
||||
if (url && !results.some(r => r.url === url)) {
|
||||
results.push({
|
||||
title: title,
|
||||
url: url
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Helper to clean URL for display
|
||||
export function cleanUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname.replace('www.', '') + (urlObj.pathname !== '/' ? urlObj.pathname : '');
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to extract URL for webpage crawling
|
||||
export function extractCrawlUrl(content: string | undefined): string | null {
|
||||
if (!content) return null;
|
||||
|
||||
try {
|
||||
// Try to parse content as JSON first (for the new format)
|
||||
const parsedContent = JSON.parse(content);
|
||||
if (parsedContent.content) {
|
||||
// Look for URL in the content string
|
||||
const urlMatch = parsedContent.content.match(/<crawl-webpage\s+url=["'](https?:\/\/[^"']+)["']/i);
|
||||
if (urlMatch) return urlMatch[1];
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to direct regex search if JSON parsing fails
|
||||
}
|
||||
|
||||
// Direct regex search in the content string
|
||||
const urlMatch = content.match(/<crawl-webpage\s+url=["'](https?:\/\/[^"']+)["']/i) ||
|
||||
content.match(/url=["'](https?:\/\/[^"']+)["']/i);
|
||||
|
||||
return urlMatch ? urlMatch[1] : null;
|
||||
}
|
||||
|
||||
// Helper to extract webpage content from crawl result
|
||||
export function extractWebpageContent(content: string | undefined): { title: string, text: string } | null {
|
||||
if (!content) return null;
|
||||
|
||||
try {
|
||||
// Try to parse the JSON content
|
||||
const parsedContent = JSON.parse(content);
|
||||
|
||||
// Handle case where content is in parsedContent.content field
|
||||
if (parsedContent.content && typeof parsedContent.content === 'string') {
|
||||
// Look for tool_result tag
|
||||
const toolResultMatch = parsedContent.content.match(/<tool_result>\s*<crawl-webpage>([\s\S]*?)<\/crawl-webpage>\s*<\/tool_result>/);
|
||||
if (toolResultMatch) {
|
||||
try {
|
||||
// Try to parse the content inside the tags
|
||||
const rawData = toolResultMatch[1];
|
||||
|
||||
// Look for ToolResult pattern in the raw data
|
||||
const toolResultOutputMatch = rawData.match(/ToolResult\(.*?output='([\s\S]*?)'.*?\)/);
|
||||
if (toolResultOutputMatch) {
|
||||
try {
|
||||
// If ToolResult pattern found, try to parse its output which may be a stringified JSON
|
||||
const outputJson = JSON.parse(toolResultOutputMatch[1].replace(/\\\\n/g, '\\n').replace(/\\\\u/g, '\\u'));
|
||||
|
||||
// Handle array format (first item)
|
||||
if (Array.isArray(outputJson) && outputJson.length > 0) {
|
||||
const item = outputJson[0];
|
||||
return {
|
||||
title: item.Title || item.title || '',
|
||||
text: item.Text || item.text || item.content || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Handle direct object format
|
||||
return {
|
||||
title: outputJson.Title || outputJson.title || '',
|
||||
text: outputJson.Text || outputJson.text || outputJson.content || ''
|
||||
};
|
||||
} catch (e) {
|
||||
// If parsing fails, use the raw output
|
||||
return {
|
||||
title: 'Webpage Content',
|
||||
text: toolResultOutputMatch[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as direct JSON if no ToolResult pattern
|
||||
const crawlData = JSON.parse(rawData);
|
||||
|
||||
// Handle array format
|
||||
if (Array.isArray(crawlData) && crawlData.length > 0) {
|
||||
const item = crawlData[0];
|
||||
return {
|
||||
title: item.Title || item.title || '',
|
||||
text: item.Text || item.text || item.content || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Handle direct object format
|
||||
return {
|
||||
title: crawlData.Title || crawlData.title || '',
|
||||
text: crawlData.Text || crawlData.text || crawlData.content || ''
|
||||
};
|
||||
} catch (e) {
|
||||
// Fallback to basic text extraction
|
||||
return {
|
||||
title: 'Webpage Content',
|
||||
text: toolResultMatch[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ToolResult pattern in the content directly
|
||||
const toolResultOutputMatch = parsedContent.content.match(/ToolResult\(.*?output='([\s\S]*?)'.*?\)/);
|
||||
if (toolResultOutputMatch) {
|
||||
try {
|
||||
// Parse the output which might be a stringified JSON
|
||||
const outputJson = JSON.parse(toolResultOutputMatch[1].replace(/\\\\n/g, '\\n').replace(/\\\\u/g, '\\u'));
|
||||
|
||||
// Handle array format
|
||||
if (Array.isArray(outputJson) && outputJson.length > 0) {
|
||||
const item = outputJson[0];
|
||||
return {
|
||||
title: item.Title || item.title || '',
|
||||
text: item.Text || item.text || item.content || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Handle direct object format
|
||||
return {
|
||||
title: outputJson.Title || outputJson.title || '',
|
||||
text: outputJson.Text || outputJson.text || outputJson.content || ''
|
||||
};
|
||||
} catch (e) {
|
||||
// If parsing fails, use the raw output
|
||||
return {
|
||||
title: 'Webpage Content',
|
||||
text: toolResultOutputMatch[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct handling of <crawl-webpage> format outside of content field
|
||||
const crawlWebpageMatch = content.match(/<crawl-webpage>([\s\S]*?)<\/crawl-webpage>/);
|
||||
if (crawlWebpageMatch) {
|
||||
const rawData = crawlWebpageMatch[1];
|
||||
|
||||
// Look for ToolResult pattern
|
||||
const toolResultOutputMatch = rawData.match(/ToolResult\(.*?output='([\s\S]*?)'.*?\)/);
|
||||
if (toolResultOutputMatch) {
|
||||
try {
|
||||
// Parse the output which might be a stringified JSON
|
||||
const outputString = toolResultOutputMatch[1].replace(/\\\\n/g, '\\n').replace(/\\\\u/g, '\\u');
|
||||
const outputJson = JSON.parse(outputString);
|
||||
|
||||
// Handle array format
|
||||
if (Array.isArray(outputJson) && outputJson.length > 0) {
|
||||
const item = outputJson[0];
|
||||
return {
|
||||
title: item.Title || item.title || (item.URL ? new URL(item.URL).hostname : ''),
|
||||
text: item.Text || item.text || item.content || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Handle direct object format
|
||||
return {
|
||||
title: outputJson.Title || outputJson.title || '',
|
||||
text: outputJson.Text || outputJson.text || outputJson.content || ''
|
||||
};
|
||||
} catch (e) {
|
||||
// If parsing fails, use the raw output
|
||||
return {
|
||||
title: 'Webpage Content',
|
||||
text: toolResultOutputMatch[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct content extraction from parsed JSON if it's an array
|
||||
if (Array.isArray(parsedContent) && parsedContent.length > 0) {
|
||||
const item = parsedContent[0];
|
||||
return {
|
||||
title: item.Title || item.title || '',
|
||||
text: item.Text || item.text || item.content || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Direct content extraction from parsed JSON as object
|
||||
if (typeof parsedContent === 'object') {
|
||||
return {
|
||||
title: parsedContent.Title || parsedContent.title || 'Webpage Content',
|
||||
text: parsedContent.Text || parsedContent.text || parsedContent.content || JSON.stringify(parsedContent)
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// Last resort, try to match the ToolResult pattern directly in the raw content
|
||||
const toolResultMatch = content.match(/ToolResult\(.*?output='([\s\S]*?)'.*?\)/);
|
||||
if (toolResultMatch) {
|
||||
try {
|
||||
// Try to parse the output which might be a stringified JSON
|
||||
const outputJson = JSON.parse(toolResultMatch[1].replace(/\\\\n/g, '\\n').replace(/\\\\u/g, '\\u'));
|
||||
|
||||
// Handle array format
|
||||
if (Array.isArray(outputJson) && outputJson.length > 0) {
|
||||
const item = outputJson[0];
|
||||
return {
|
||||
title: item.Title || item.title || '',
|
||||
text: item.Text || item.text || item.content || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Handle direct object format
|
||||
return {
|
||||
title: outputJson.Title || outputJson.title || '',
|
||||
text: outputJson.Text || outputJson.text || outputJson.content || ''
|
||||
};
|
||||
} catch (e) {
|
||||
// If parsing fails, use the raw output
|
||||
return {
|
||||
title: 'Webpage Content',
|
||||
text: toolResultMatch[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If all else fails, return the content as-is
|
||||
if (content) {
|
||||
return {
|
||||
title: 'Webpage Content',
|
||||
text: content
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to extract search results from tool response
|
||||
export function extractSearchResults(content: string | undefined): Array<{ title: string, url: string, snippet?: string }> {
|
||||
if (!content) return [];
|
||||
|
||||
// First try the standard JSON extraction methods
|
||||
try {
|
||||
// Try to parse JSON content first
|
||||
const parsedContent = JSON.parse(content);
|
||||
|
@ -337,174 +688,67 @@ export function extractSearchResults(content: string | undefined): Array<{ title
|
|||
try {
|
||||
return JSON.parse(jsonArrayMatch[0]);
|
||||
} catch (e) {
|
||||
return [];
|
||||
return extractUrlsAndTitles(parsedContent.content);
|
||||
}
|
||||
}
|
||||
|
||||
// If none of the above worked, try the whole content
|
||||
return extractUrlsAndTitles(parsedContent.content);
|
||||
}
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, try regex direct extraction
|
||||
const urlMatch = content.match(/https?:\/\/[^\s"]+/g);
|
||||
if (urlMatch) {
|
||||
return urlMatch.map(url => ({
|
||||
title: cleanUrl(url),
|
||||
url
|
||||
}));
|
||||
}
|
||||
// If JSON parsing fails, extract directly from the content
|
||||
return extractUrlsAndTitles(content);
|
||||
}
|
||||
|
||||
return [];
|
||||
// Last resort fallback
|
||||
return extractUrlsAndTitles(content);
|
||||
}
|
||||
|
||||
// Helper to extract URLs and titles with regex
|
||||
export function extractUrlsAndTitles(content: string): Array<{ title: string, url: string, snippet?: string }> {
|
||||
const results: Array<{ title: string, url: string, snippet?: string }> = [];
|
||||
// Function to determine which tool component to render based on the tool name
|
||||
export function getToolComponent(toolName: string): string {
|
||||
if (!toolName) return 'GenericToolView';
|
||||
|
||||
// Regex to find URLs, attempting to exclude common trailing unwanted characters/tags
|
||||
const urlRegex = /https?:\/\/[^\s"<]+/g;
|
||||
let match;
|
||||
const normalizedName = toolName.toLowerCase();
|
||||
|
||||
while ((match = urlRegex.exec(content)) !== null) {
|
||||
let url = match[0];
|
||||
// Map specific tool names to their respective components
|
||||
switch (normalizedName) {
|
||||
// Browser tools
|
||||
case 'browser-navigate':
|
||||
case 'browser-click':
|
||||
case 'browser-extract':
|
||||
case 'browser-fill':
|
||||
case 'browser-wait':
|
||||
case 'browser-screenshot':
|
||||
return 'BrowserToolView';
|
||||
|
||||
// --- Start: New Truncation Logic ---
|
||||
// Find the first occurrence of potential garbage separators like /n or \n after the protocol.
|
||||
const protocolEndIndex = url.indexOf('://');
|
||||
const searchStartIndex = protocolEndIndex !== -1 ? protocolEndIndex + 3 : 0;
|
||||
// Command execution
|
||||
case 'execute-command':
|
||||
return 'CommandToolView';
|
||||
|
||||
const newlineIndexN = url.indexOf('/n', searchStartIndex);
|
||||
const newlineIndexSlashN = url.indexOf('\\n', searchStartIndex);
|
||||
// File operations
|
||||
case 'create-file':
|
||||
case 'delete-file':
|
||||
case 'full-file-rewrite':
|
||||
case 'read-file':
|
||||
return 'FileOperationToolView';
|
||||
|
||||
let firstNewlineIndex = -1;
|
||||
if (newlineIndexN !== -1 && newlineIndexSlashN !== -1) {
|
||||
firstNewlineIndex = Math.min(newlineIndexN, newlineIndexSlashN);
|
||||
} else if (newlineIndexN !== -1) {
|
||||
firstNewlineIndex = newlineIndexN;
|
||||
} else if (newlineIndexSlashN !== -1) {
|
||||
firstNewlineIndex = newlineIndexSlashN;
|
||||
}
|
||||
// String operations
|
||||
case 'str-replace':
|
||||
return 'StrReplaceToolView';
|
||||
|
||||
// If a newline indicator is found, truncate the URL there.
|
||||
if (firstNewlineIndex !== -1) {
|
||||
url = url.substring(0, firstNewlineIndex);
|
||||
}
|
||||
// --- End: New Truncation Logic ---
|
||||
|
||||
// Basic cleaning: remove common tags or artifacts if they are directly appended
|
||||
url = url.replace(/<\/?url>$/, '')
|
||||
.replace(/<\/?content>$/, '')
|
||||
.replace(/%3C$/, ''); // Remove trailing %3C (less than sign)
|
||||
|
||||
// Aggressive trailing character removal (common issues)
|
||||
// Apply this *after* potential truncation
|
||||
while (/[);.,\/]$/.test(url)) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
// Web operations
|
||||
case 'web-search':
|
||||
return 'WebSearchToolView';
|
||||
case 'crawl-webpage':
|
||||
return 'WebCrawlToolView';
|
||||
|
||||
// Data provider operations
|
||||
case 'execute-data-provider-call':
|
||||
case 'get-data-provider-endpoints':
|
||||
return 'DataProviderToolView';
|
||||
|
||||
// Decode URI components to handle % sequences, but catch errors
|
||||
try {
|
||||
// Decode multiple times? Sometimes needed for double encoding
|
||||
url = decodeURIComponent(decodeURIComponent(url));
|
||||
} catch (e) {
|
||||
try { // Try decoding once if double decoding failed
|
||||
url = decodeURIComponent(url);
|
||||
} catch (e2) {
|
||||
console.warn("Failed to decode URL component:", url, e2);
|
||||
}
|
||||
}
|
||||
|
||||
// Final cleaning for specific problematic sequences like ellipsis or remaining tags
|
||||
url = url.replace(/\u2026$/, ''); // Remove trailing ellipsis (…)
|
||||
url = url.replace(/<\/?url>$/, '').replace(/<\/?content>$/, ''); // Re-apply tag removal after decode
|
||||
|
||||
// Try to find a title near this URL - simplified logic
|
||||
const urlIndex = match.index;
|
||||
const surroundingText = content.substring(Math.max(0, urlIndex - 100), urlIndex + url.length + 150); // Increased lookahead for content
|
||||
|
||||
// Look for title patterns more robustly
|
||||
const contentMatch = surroundingText.match(/<content>([^<]+)<\/content>/i);
|
||||
const titleMatch = surroundingText.match(/Title[:\s]+([^\n<]+)/i) ||
|
||||
surroundingText.match(/\"(.*?)\"[\s\n]*?https?:\/\//);
|
||||
|
||||
let title = cleanUrl(url); // Default to cleaned URL hostname/path
|
||||
if (contentMatch && contentMatch[1].trim()) {
|
||||
title = contentMatch[1].trim();
|
||||
} else if (titleMatch && titleMatch[1].trim()) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// Avoid adding duplicates if the cleaning resulted in the same URL
|
||||
if (url && !results.some(r => r.url === url)) { // Added check for non-empty url
|
||||
results.push({
|
||||
title: title,
|
||||
url: url
|
||||
// Snippet extraction could be added here if needed
|
||||
});
|
||||
}
|
||||
// Default
|
||||
default:
|
||||
return 'GenericToolView';
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Helper to clean URL for display
|
||||
export function cleanUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname.replace('www.', '') + (urlObj.pathname !== '/' ? urlObj.pathname : '');
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to extract URL for webpage crawling
|
||||
export function extractCrawlUrl(content: string | undefined): string | null {
|
||||
if (!content) return null;
|
||||
const urlMatch = content.match(/url=["'](https?:\/\/[^"']+)["']/);
|
||||
return urlMatch ? urlMatch[1] : null;
|
||||
}
|
||||
|
||||
// Helper to extract webpage content from crawl result
|
||||
export function extractWebpageContent(content: string | undefined): { title: string, text: string } | null {
|
||||
if (!content) return null;
|
||||
|
||||
try {
|
||||
// Try to parse the JSON content
|
||||
const parsedContent = JSON.parse(content);
|
||||
if (parsedContent.content && typeof parsedContent.content === 'string') {
|
||||
// Look for tool_result tag
|
||||
const toolResultMatch = parsedContent.content.match(/<tool_result>\s*<crawl-webpage>([\s\S]*?)<\/crawl-webpage>\s*<\/tool_result>/);
|
||||
if (toolResultMatch) {
|
||||
try {
|
||||
const crawlData = JSON.parse(toolResultMatch[1]);
|
||||
return {
|
||||
title: crawlData.title || '',
|
||||
text: crawlData.text || crawlData.content || ''
|
||||
};
|
||||
} catch (e) {
|
||||
// Fallback to basic text extraction
|
||||
return {
|
||||
title: 'Webpage Content',
|
||||
text: toolResultMatch[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct content extraction from parsed JSON
|
||||
if (parsedContent.content) {
|
||||
return {
|
||||
title: 'Webpage Content',
|
||||
text: parsedContent.content
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, return the content as-is
|
||||
if (content) {
|
||||
return {
|
||||
title: 'Webpage Content',
|
||||
text: content
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
|
@ -59,6 +59,8 @@ export const getToolIcon = (toolName: string): ElementType => {
|
|||
return ExternalLink;
|
||||
case 'get-data-provider-endpoints':
|
||||
return Network;
|
||||
case 'execute-data-provider-call':
|
||||
return Network;
|
||||
|
||||
// Code operations
|
||||
case 'delete-file':
|
||||
|
|
Loading…
Reference in New Issue