tool views

This commit is contained in:
marko-kraemer 2025-04-21 04:29:20 +01:00
parent 2e4072e70e
commit 08110738d6
14 changed files with 2291 additions and 1073 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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[]}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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