suna/frontend/src/components/thread/tool-views/utils.ts

1449 lines
48 KiB
TypeScript

// Import at the top
import { formatDistanceToNow } from 'date-fns';
import {
FileText,
FileCode,
FileImage,
FileJson,
File,
FolderOpen,
FileType,
FileVideo,
FileAudio,
FileArchive,
Table,
} from 'lucide-react';
// Helper function to format timestamp
export function formatTimestamp(isoString?: string): string {
if (!isoString) return '';
try {
const date = new Date(isoString);
return isNaN(date.getTime()) ? 'Invalid date' : date.toLocaleString();
} catch (e) {
return 'Invalid date';
}
}
// Get standardized tool title
export function getToolTitle(toolName: string): string {
// Normalize tool name
const normalizedName = toolName.toLowerCase();
// Map of tool names to their display titles
const toolTitles: Record<string, string> = {
'execute-command': 'Execute Command',
'check-command-output': 'Check Command Output',
'str-replace': 'String Replace',
'create-file': 'Create File',
'full-file-rewrite': 'Rewrite File',
'delete-file': 'Delete File',
'web-search': 'Web Search',
'crawl-webpage': 'Web Crawl',
'scrape-webpage': 'Web Scrape',
'browser-navigate': 'Browser Navigate',
'browser-click': 'Browser Click',
'browser-extract': 'Browser Extract',
'browser-fill': 'Browser Fill',
'browser-wait': 'Browser Wait',
'see-image': 'View Image',
'ask': 'Ask',
'complete': 'Task Complete',
'execute-data-provider-call': 'Data Provider Call',
'get-data-provider-endpoints': 'Data Endpoints',
'generic-tool': 'Tool',
'default': 'Tool',
};
// Return the mapped title or a formatted version of the name
if (toolTitles[normalizedName]) {
return toolTitles[normalizedName];
}
// For browser tools not explicitly mapped
if (normalizedName.startsWith('browser-')) {
const operation = normalizedName.replace('browser-', '').replace(/-/g, ' ');
return 'Browser ' + operation.charAt(0).toUpperCase() + operation.slice(1);
}
// Format any other tool name
return toolName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
// Helper to extract command from execute-command content
export function extractCommand(content: string | object | undefined | null): string | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
// First try to extract from XML tags (with or without attributes)
const commandMatch = contentStr.match(
/<execute-command[^>]*>([\s\S]*?)<\/execute-command>/,
);
if (commandMatch) {
return commandMatch[1].trim();
}
// Try to find command in JSON structure (for native tool calls)
try {
const parsed = JSON.parse(contentStr);
if (parsed.tool_calls && Array.isArray(parsed.tool_calls)) {
const execCommand = parsed.tool_calls.find(tc =>
tc.function?.name === 'execute-command' ||
tc.function?.name === 'execute_command'
);
if (execCommand && execCommand.function?.arguments) {
try {
const args = typeof execCommand.function.arguments === 'string'
? JSON.parse(execCommand.function.arguments)
: execCommand.function.arguments;
if (args.command) return args.command;
} catch (e) {
// If arguments parsing fails, continue
}
}
}
} catch (e) {
// Not JSON, continue with other checks
}
// If no XML tags found, check if the content itself is the command
// This handles cases where the command is passed directly
if (!contentStr.includes('<execute-command') && !contentStr.includes('</execute-command>')) {
// Check if it looks like a command (not JSON, not XML)
if (!contentStr.startsWith('{') && !contentStr.startsWith('<')) {
// Don't return content that looks like a tool result or error message
if (!contentStr.includes('ToolResult') && !contentStr.includes('No command')) {
return contentStr.trim();
}
}
}
console.log('extractCommand: Could not extract command from content:', contentStr.substring(0, 200));
return null;
}
// Helper to extract session name from check-command-output content
export function extractSessionName(content: string | object | undefined | null): string | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
// First try to extract from XML tags (with or without attributes)
const sessionMatch = contentStr.match(
/<check-command-output[^>]*session_name=["']([^"']+)["']/,
);
if (sessionMatch) {
return sessionMatch[1].trim();
}
// Try to find session_name in JSON structure (for native tool calls)
try {
const parsed = JSON.parse(contentStr);
if (parsed.tool_calls && Array.isArray(parsed.tool_calls)) {
const checkCommand = parsed.tool_calls.find(tc =>
tc.function?.name === 'check-command-output' ||
tc.function?.name === 'check_command_output'
);
if (checkCommand && checkCommand.function?.arguments) {
try {
const args = typeof checkCommand.function.arguments === 'string'
? JSON.parse(checkCommand.function.arguments)
: checkCommand.function.arguments;
if (args.session_name) return args.session_name;
} catch (e) {
// If arguments parsing fails, continue
}
}
}
} catch (e) {
// Not JSON, continue with other checks
}
// Look for session_name attribute in the content
const sessionNameMatch = contentStr.match(/session_name=["']([^"']+)["']/);
if (sessionNameMatch) {
return sessionNameMatch[1].trim();
}
return null;
}
// Helper to extract command output from tool result content
export function extractCommandOutput(
content: string | object | undefined | null,
): string | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
try {
// First try to parse the JSON content
const parsedContent = JSON.parse(contentStr);
// Handle check-command-output specific format
if (parsedContent.output && typeof parsedContent.output === 'string') {
return parsedContent.output;
}
if (parsedContent.content && typeof parsedContent.content === 'string') {
// Look for a tool_result tag
const toolResultMatch = parsedContent.content.match(
/<tool_result>\s*<(?:execute-command|check-command-output)>([\s\S]*?)<\/(?:execute-command|check-command-output)>\s*<\/tool_result>/,
);
if (toolResultMatch) {
return toolResultMatch[1].trim();
}
// Look for output field in a ToolResult pattern
const outputMatch = parsedContent.content.match(
/ToolResult\(.*?output='([\s\S]*?)'.*?\)/,
);
if (outputMatch) {
return outputMatch[1];
}
// Return the content itself as a fallback
return parsedContent.content;
}
// If parsedContent is the actual output (new format)
if (typeof parsedContent === 'string') {
return parsedContent;
}
} catch (e) {
// If JSON parsing fails, try regex directly
const toolResultMatch = contentStr.match(
/<tool_result>\s*<(?:execute-command|check-command-output)>([\s\S]*?)<\/(?:execute-command|check-command-output)>\s*<\/tool_result>/,
);
if (toolResultMatch) {
return toolResultMatch[1].trim();
}
const outputMatch = contentStr.match(
/ToolResult\(.*?output='([\s\S]*?)'.*?\)/,
);
if (outputMatch) {
return outputMatch[1];
}
// If no special format is found, return the content as-is
// This handles cases where the output is stored directly
if (!contentStr.startsWith('<') && !contentStr.includes('ToolResult')) {
return contentStr;
}
}
return contentStr;
}
// Helper to extract the exit code from tool result
export function extractExitCode(content: string | object | undefined | null): number | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
try {
const exitCodeMatch = contentStr.match(/exit_code=(\d+)/);
if (exitCodeMatch && exitCodeMatch[1]) {
return parseInt(exitCodeMatch[1], 10);
}
return 0; // Assume success if no exit code found but command completed
} catch (e) {
return null;
}
}
// Helper to extract file path from commands
export function extractFilePath(content: string | object | undefined | null): string | null {
// Convert content to string using the helper
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
// Handle double-escaped JSON (old format)
if (typeof content === 'string' && content.startsWith('"{') && content.endsWith('}"')) {
try {
// First parse to get the inner JSON string
const innerString = JSON.parse(content);
// Then parse the inner string to get the actual object
const parsed = JSON.parse(innerString);
if (parsed && typeof parsed === 'object') {
if (parsed.file_path) {
return cleanFilePath(parsed.file_path);
}
if (parsed.arguments && parsed.arguments.file_path) {
return cleanFilePath(parsed.arguments.file_path);
}
}
} catch (e) {
// Continue with normal extraction
}
}
// First, check if content is already a parsed object (new format after double-escape fix)
if (typeof content === 'object' && content !== null) {
try {
// Check if it's a direct object with content field
if ('content' in content && typeof content.content === 'string') {
// Look for XML tags in the content string
const xmlFilePathMatch =
content.content.match(/<(?:create-file|delete-file|full-file-rewrite|str-replace)[^>]*\s+file_path=["']([\s\S]*?)["']/i) ||
content.content.match(/<delete[^>]*\s+file_path=["']([\s\S]*?)["']/i) ||
content.content.match(/<delete-file[^>]*>([^<]+)<\/delete-file>/i) ||
content.content.match(/<(?:create-file|delete-file|full-file-rewrite)\s+file_path=["']([^"']+)/i);
if (xmlFilePathMatch) {
return cleanFilePath(xmlFilePathMatch[1]);
}
}
// Check for direct file_path property
if ('file_path' in content) {
return cleanFilePath(content.file_path as string);
}
// Check for arguments.file_path
if ('arguments' in content && content.arguments && typeof content.arguments === 'object') {
const args = content.arguments as any;
if (args.file_path) {
return cleanFilePath(args.file_path);
}
}
} catch (e) {
// Continue with string parsing if object parsing fails
}
}
// Try parsing as JSON string (old format)
try {
const parsedContent = JSON.parse(contentStr);
if (parsedContent.file_path) {
return cleanFilePath(parsedContent.file_path);
}
if (parsedContent.arguments && parsedContent.arguments.file_path) {
return cleanFilePath(parsedContent.arguments.file_path);
}
} catch (e) {
// Continue with original content if parsing fails
}
// Look for file_path in different formats
const filePathMatch =
contentStr.match(/file_path=["']([\s\S]*?)["']/i) ||
contentStr.match(/target_file=["']([\s\S]*?)["']/i) ||
contentStr.match(/path=["']([\s\S]*?)["']/i);
if (filePathMatch) {
const path = filePathMatch[1].trim();
// Handle newlines and return first line if multiple lines
return cleanFilePath(path);
}
// Look for file_path in XML-like tags (including incomplete ones for streaming)
const xmlFilePathMatch =
contentStr.match(/<(?:create-file|delete-file|full-file-rewrite|str-replace)[^>]*\s+file_path=["']([\s\S]*?)["']/i) ||
contentStr.match(/<delete[^>]*\s+file_path=["']([\s\S]*?)["']/i) ||
contentStr.match(/<delete-file[^>]*>([^<]+)<\/delete-file>/i) ||
// Handle incomplete tags during streaming
contentStr.match(/<(?:create-file|delete-file|full-file-rewrite)\s+file_path=["']([^"']+)/i);
if (xmlFilePathMatch) {
return cleanFilePath(xmlFilePathMatch[1]);
}
// Look for file paths in delete operations in particular
if (
contentStr.toLowerCase().includes('delete') ||
contentStr.includes('delete-file')
) {
// Look for patterns like "Deleting file: path/to/file.txt"
const deletePathMatch = contentStr.match(
/(?:delete|remove|deleting)\s+(?:file|the file)?:?\s+["']?([\w\-./\\]+\.\w+)["']?/i,
);
if (deletePathMatch) return cleanFilePath(deletePathMatch[1]);
// Look for isolated file paths with extensions
const fileMatch = contentStr.match(/["']?([\w\-./\\]+\.\w+)["']?/);
if (fileMatch) return cleanFilePath(fileMatch[1]);
}
return null;
}
// Helper to clean and process a file path string, handling escaped chars
function cleanFilePath(path: string): string {
if (!path) return path;
// Handle escaped newlines and other escaped characters
return path
.replace(/\\n/g, '\n') // Replace \n with actual newlines
.replace(/\\t/g, '\t') // Replace \t with actual tabs
.replace(/\\r/g, '') // Remove \r
.replace(/\\\\/g, '\\') // Replace \\ with \
.replace(/\\"/g, '"') // Replace \" with "
.replace(/\\'/g, "'") // Replace \' with '
.split('\n')[0] // Take only the first line if multiline
.trim(); // Trim whitespace
}
// Helper to extract str-replace old and new strings
export function extractStrReplaceContent(content: string | object | undefined | null): {
oldStr: string | null;
newStr: string | null;
} {
const contentStr = normalizeContentToString(content);
if (!contentStr) return { oldStr: null, newStr: null };
// First try to extract from a str-replace tag with attributes
const strReplaceMatch = contentStr.match(/<str-replace[^>]*>([\s\S]*?)<\/str-replace>/);
if (strReplaceMatch) {
const innerContent = strReplaceMatch[1];
const oldMatch = innerContent.match(/<old_str>([\s\S]*?)<\/old_str>/);
const newMatch = innerContent.match(/<new_str>([\s\S]*?)<\/new_str>/);
return {
oldStr: oldMatch ? oldMatch[1] : null,
newStr: newMatch ? newMatch[1] : null,
};
}
// Fall back to direct search for old_str and new_str tags
const oldMatch = contentStr.match(/<old_str>([\s\S]*?)<\/old_str>/);
const newMatch = contentStr.match(/<new_str>([\s\S]*?)<\/new_str>/);
return {
oldStr: oldMatch ? oldMatch[1] : null,
newStr: newMatch ? newMatch[1] : null,
};
}
// Helper to extract file content from create-file or file-rewrite
export function extractFileContent(
content: string | object | undefined | null,
toolType: 'create-file' | 'full-file-rewrite',
): string | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
// First, check if content is already a parsed object (new format after double-escape fix)
if (typeof content === 'object' && content !== null) {
try {
// Check if it's a direct object with content field
if ('content' in content && typeof content.content === 'string') {
const tagName = toolType === 'create-file' ? 'create-file' : 'full-file-rewrite';
const contentMatch = content.content.match(
new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'),
);
if (contentMatch && contentMatch[1]) {
return processFileContent(contentMatch[1]);
}
}
} catch (e) {
// Continue with string parsing if object parsing fails
}
}
// Fallback to string-based extraction (old format)
const tagName = toolType === 'create-file' ? 'create-file' : 'full-file-rewrite';
const contentMatch = contentStr.match(
new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'),
);
if (contentMatch && contentMatch[1]) {
return processFileContent(contentMatch[1]);
}
return null;
}
// Helper to process and clean file content
function processFileContent(content: string): string {
if (!content) return content;
// Handle escaped characters
return content
.replace(/\\n/g, '\n') // Replace \n with actual newlines
.replace(/\\t/g, '\t') // Replace \t with actual tabs
.replace(/\\r/g, '') // Remove \r
.replace(/\\\\/g, '\\') // Replace \\ with \
.replace(/\\"/g, '"') // Replace \" with "
.replace(/\\'/g, "'"); // Replace \' with '
}
// Helper to determine file type (for syntax highlighting)
export function getFileType(filePath: string): string {
const extension = filePath.split('.').pop()?.toLowerCase() || '';
switch (extension) {
case 'js':
return 'JavaScript';
case 'ts':
return 'TypeScript';
case 'jsx':
case 'tsx':
return 'React';
case 'py':
return 'Python';
case 'html':
return 'HTML';
case 'css':
return 'CSS';
case 'json':
return 'JSON';
case 'md':
return 'Markdown';
default:
return extension.toUpperCase() || 'Text';
}
}
// Helper to extract URL from browser navigate operations
export function extractBrowserUrl(content: string | object | undefined | null): string | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
const urlMatch = contentStr.match(/url=["'](https?:\/\/[^"']+)["']/);
return urlMatch ? urlMatch[1] : null;
}
// Helper to extract browser operation type
export function extractBrowserOperation(toolName: string | undefined): string {
if (!toolName) return 'Browser Operation';
const operation = toolName.replace('browser-', '').replace(/-/g, ' ');
return operation.charAt(0).toUpperCase() + operation.slice(1);
}
// Helper to extract search query
export function extractSearchQuery(content: string | object | undefined | null): string | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
// First, look for ToolResult pattern in the content string
const toolResultMatch = contentStr.match(
/ToolResult\(.*?output='([\s\S]*?)'.*?\)/,
);
if (toolResultMatch) {
try {
// Parse the output JSON from ToolResult
const outputJson = JSON.parse(toolResultMatch[1]);
// Check if this is the new Tavily response format with query field
if (outputJson.query && typeof outputJson.query === 'string') {
return outputJson.query;
}
} catch (e) {
// Continue with other extraction methods
}
}
let contentToSearch = contentStr; // Start with the normalized content
// Try parsing as JSON first
try {
const parsedContent = JSON.parse(contentStr);
// Check if it's the new Tavily response format
if (parsedContent.query && typeof parsedContent.query === 'string') {
return parsedContent.query;
}
// Continue with existing logic for backward compatibility
if (typeof parsedContent.content === 'string') {
// If the outer content is JSON and has a 'content' string field,
// use that inner content for searching the query.
contentToSearch = parsedContent.content;
// Also check common JSON structures within the outer parsed object itself
if (typeof parsedContent.query === 'string') {
return parsedContent.query;
}
if (
typeof parsedContent.arguments === 'object' &&
parsedContent.arguments !== null &&
typeof parsedContent.arguments.query === 'string'
) {
return parsedContent.arguments.query;
}
if (
Array.isArray(parsedContent.tool_calls) &&
parsedContent.tool_calls.length > 0
) {
const toolCall = parsedContent.tool_calls[0];
if (
typeof toolCall.arguments === 'object' &&
toolCall.arguments !== null &&
typeof toolCall.arguments.query === 'string'
) {
return toolCall.arguments.query;
}
if (typeof toolCall.arguments === 'string') {
try {
const argsParsed = JSON.parse(toolCall.arguments);
if (typeof argsParsed.query === 'string') {
return argsParsed.query;
}
} catch {}
}
}
}
} catch (e) {
// If parsing fails, continue with the original content string
}
// Now search within contentToSearch (either original or nested content)
// 1. Try regex for attribute within <web-search ...> tag (with or without attributes)
const xmlQueryMatch = contentToSearch.match(
/<web-search[^>]*\s+query=[\"']([^\"']*)["'][^>]*>/i,
);
if (xmlQueryMatch && xmlQueryMatch[1]) {
return xmlQueryMatch[1].trim();
}
// 2. Try simple attribute regex (fallback, less specific)
const simpleAttrMatch = contentToSearch.match(/query=[\"']([\s\S]*?)["']/i);
if (simpleAttrMatch && simpleAttrMatch[1]) {
return simpleAttrMatch[1].split(/[\"']/)[0].trim();
}
// 4. If nothing found after checking original/nested content and JSON structure, return 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 }> = [];
// Try to parse as JSON first to extract proper results
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed)) {
return parsed.map(result => ({
title: result.title || '',
url: result.url || '',
snippet: result.content || result.snippet || '',
}));
}
if (parsed.results && Array.isArray(parsed.results)) {
return parsed.results.map(result => ({
title: result.title || '',
url: result.url || '',
snippet: result.content || '',
}));
}
} catch (e) {
// Not valid JSON, continue with regex extraction
}
// Look for properly formatted JSON objects with title and url
const jsonObjectPattern = /\{\s*"title"\s*:\s*"([^"]+)"\s*,\s*"url"\s*:\s*"(https?:\/\/[^"]+)"\s*(?:,\s*"content"\s*:\s*"([^"]*)")?\s*\}/g;
let objectMatch;
while ((objectMatch = jsonObjectPattern.exec(content)) !== null) {
const title = objectMatch[1];
const url = objectMatch[2];
const snippet = objectMatch[3] || '';
if (url && title && !results.some((r) => r.url === url)) {
results.push({ title, url, snippet });
}
}
// 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/scraping
export function extractCrawlUrl(content: string | object | undefined | null): string | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
try {
// Try to parse content as JSON first (for the new format)
const parsedContent = JSON.parse(contentStr);
if (parsedContent.content) {
// Look for URL in the content string (with or without attributes)
const urlMatch = parsedContent.content.match(
/<(?:crawl|scrape)-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 (updated to handle attributes)
const urlMatch =
contentStr.match(
/<(?:crawl|scrape)-webpage[^>]*\s+url=["'](https?:\/\/[^"']+)["']/i,
) || contentStr.match(/url=["'](https?:\/\/[^"']+)["']/i);
return urlMatch ? urlMatch[1] : null;
}
// Helper to extract webpage content from crawl/scrape result
export function extractWebpageContent(
content: string | object | undefined | null,
): { title: string; text: string } | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
try {
// Try to parse the JSON content
const parsedContent = JSON.parse(contentStr);
// Handle case where content is in parsedContent.content field
if (parsedContent.content && typeof parsedContent.content === 'string') {
// Look for tool_result tag (with attributes)
const toolResultMatch = parsedContent.content.match(
/<tool_result[^>]*>\s*<(?:crawl|scrape)-webpage[^>]*>([\s\S]*?)<\/(?:crawl|scrape)-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> or <scrape-webpage> format outside of content field (with attributes)
const webpageMatch = contentStr.match(
/<(?:crawl|scrape)-webpage[^>]*>([\s\S]*?)<\/(?:crawl|scrape)-webpage>/,
);
if (webpageMatch) {
const rawData = webpageMatch[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' && parsedContent !== null) {
// Check if it's already the webpage data (new format after double-escape fix)
if ('Title' in parsedContent || 'title' in parsedContent || 'Text' in parsedContent || 'text' in parsedContent) {
return {
title: parsedContent.Title || parsedContent.title || 'Webpage Content',
text:
parsedContent.Text ||
parsedContent.text ||
parsedContent.content ||
'',
};
}
// Otherwise, try to stringify it
return {
title: 'Webpage Content',
text: JSON.stringify(parsedContent),
};
}
} catch (e) {
// Last resort, try to match the ToolResult pattern directly in the raw content
const toolResultMatch = contentStr.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 (contentStr) {
return {
title: 'Webpage Content',
text: contentStr,
};
}
}
return null;
}
// Helper to extract search results from tool response
export function extractSearchResults(
content: string | object | undefined | null,
): Array<{ title: string; url: string; snippet?: string }> {
const contentStr = normalizeContentToString(content);
if (!contentStr) return [];
try {
// Instead of trying to parse the complex ToolResult JSON,
// let's look for the results array pattern directly in the content
// Look for the results array pattern within the content
const resultsPattern = /"results":\s*\[([^\]]*(?:\[[^\]]*\][^\]]*)*)\]/;
const resultsMatch = contentStr.match(resultsPattern);
if (resultsMatch) {
try {
// Extract just the results array and parse it
const resultsArrayStr = '[' + resultsMatch[1] + ']';
const results = JSON.parse(resultsArrayStr);
if (Array.isArray(results)) {
return results.map(result => ({
title: result.title || '',
url: result.url || '',
snippet: result.content || '',
}));
}
} catch (e) {
console.warn('Failed to parse results array:', e);
}
}
// Fallback: Look for individual result objects
const resultObjectPattern = /\{\s*"url":\s*"([^"]+)"\s*,\s*"title":\s*"([^"]+)"\s*,\s*"content":\s*"([^"]*)"[^}]*\}/g;
const results = [];
let match;
while ((match = resultObjectPattern.exec(contentStr)) !== null) {
results.push({
url: match[1],
title: match[2],
snippet: match[3],
});
}
if (results.length > 0) {
return results;
}
// Try parsing the entire content as JSON (for direct Tavily responses)
const parsedContent = JSON.parse(contentStr);
// Check if this is the new Tavily response format
if (parsedContent.results && Array.isArray(parsedContent.results)) {
return parsedContent.results.map(result => ({
title: result.title || '',
url: result.url || '',
snippet: result.content || '',
}));
}
// Continue with existing logic for backward compatibility
if (parsedContent.content && typeof parsedContent.content === 'string') {
// Look for a tool_result tag (with attributes)
const toolResultTagMatch = parsedContent.content.match(
/<tool_result[^>]*>\s*<web-search[^>]*>([\s\S]*?)<\/web-search>\s*<\/tool_result>/,
);
if (toolResultTagMatch) {
// Try to parse the results array
try {
return JSON.parse(toolResultTagMatch[1]);
} catch (e) {
// Fallback to regex extraction of URLs and titles
return extractUrlsAndTitles(toolResultTagMatch[1]);
}
}
// Try to find JSON array in the content
const jsonArrayMatch = parsedContent.content.match(/\[\s*{[\s\S]*}\s*\]/);
if (jsonArrayMatch) {
try {
return JSON.parse(jsonArrayMatch[0]);
} catch (e) {
return extractUrlsAndTitles(parsedContent.content);
}
}
// If none of the above worked, try the whole content
return extractUrlsAndTitles(parsedContent.content);
}
} catch (e) {
// If JSON parsing fails, extract directly from the content
return extractUrlsAndTitles(contentStr);
}
// Last resort fallback
return extractUrlsAndTitles(contentStr);
}
// Function to determine which tool component to render based on the tool name
export function getToolComponent(toolName: string): string {
if (!toolName) return 'GenericToolView';
const normalizedName = toolName.toLowerCase();
// 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';
// Command execution
case 'execute-command':
return 'CommandToolView';
// File operations
case 'create-file':
case 'delete-file':
case 'full-file-rewrite':
case 'read-file':
return 'FileOperationToolView';
// String operations
case 'str-replace':
return 'StrReplaceToolView';
// Web operations
case 'web-search':
return 'WebSearchToolView';
case 'crawl-webpage':
return 'WebCrawlToolView';
case 'scrape-webpage':
return 'WebScrapeToolView';
// Data provider operations
case 'execute-data-provider-call':
case 'get-data-provider-endpoints':
return 'DataProviderToolView';
// Default
default:
return 'GenericToolView';
}
}
// Helper function to normalize content to string
export function normalizeContentToString(content: string | object | undefined | null): string | null {
if (!content) return null;
if (typeof content === 'string') {
// Check if it's a double-escaped JSON string (old format)
if (content.startsWith('"{') && content.endsWith('}"')) {
try {
// First parse to get the inner JSON string
const innerString = JSON.parse(content);
// Then parse the inner string to get the actual object
const parsed = JSON.parse(innerString);
// Return the content field if it exists
if (parsed && typeof parsed === 'object' && 'content' in parsed) {
return parsed.content;
}
// Otherwise return the stringified object
return JSON.stringify(parsed);
} catch (e) {
// If parsing fails, return as is
}
}
return content;
}
if (typeof content === 'object' && content !== null) {
try {
// Handle case where content is a parsed object with content field (new format)
if ('content' in content && typeof content.content === 'string') {
return content.content;
}
// Handle case where content is a parsed object with content field that's also an object
else if ('content' in content && typeof content.content === 'object' && content.content !== null) {
// Check if the nested content has a content field
if ('content' in content.content && typeof content.content.content === 'string') {
return content.content.content;
}
// Try to stringify nested content object
return JSON.stringify(content.content);
}
// Handle message format {role: 'tool', content: '...'}
else if ('role' in content && 'content' in content && typeof content.content === 'string') {
return content.content;
}
// Handle nested message format {role: 'assistant', content: {role: 'assistant', content: '...'}}
else if ('role' in content && 'content' in content && typeof content.content === 'object' && content.content !== null) {
if ('content' in content.content && typeof content.content.content === 'string') {
return content.content.content;
}
// Try to stringify nested content object
return JSON.stringify(content.content);
}
// Handle direct object that might be the content itself (new format)
else {
// If it looks like it might contain XML or structured content, stringify it
const stringified = JSON.stringify(content);
// Check if the stringified version contains XML tags or other structured content
if (stringified.includes('<') || stringified.includes('file_path') || stringified.includes('command')) {
return stringified;
}
// Otherwise, try to extract meaningful content
return stringified;
}
} catch (e) {
console.error('Error in normalizeContentToString:', e, 'Content:', content);
return null;
}
}
return null;
}
// Helper function to extract file content for streaming (handles incomplete XML)
export function extractStreamingFileContent(
content: string | object | undefined | null,
toolType: 'create-file' | 'full-file-rewrite',
): string | null {
const contentStr = normalizeContentToString(content);
if (!contentStr) return null;
const tagName = toolType === 'create-file' ? 'create-file' : 'full-file-rewrite';
// First check if content is already a parsed object (new format)
if (typeof content === 'object' && content !== null) {
try {
if ('content' in content && typeof content.content === 'string') {
// Look for the opening tag
const openTagMatch = content.content.match(new RegExp(`<${tagName}[^>]*>`, 'i'));
if (openTagMatch) {
// Find where the tag ends
const tagEndIndex = content.content.indexOf(openTagMatch[0]) + openTagMatch[0].length;
// Extract everything after the opening tag
const afterTag = content.content.substring(tagEndIndex);
// Check if there's a closing tag
const closeTagMatch = afterTag.match(new RegExp(`<\\/${tagName}>`, 'i'));
if (closeTagMatch) {
// Return content between tags
return processFileContent(afterTag.substring(0, closeTagMatch.index));
} else {
// No closing tag yet (streaming), return what we have
return processFileContent(afterTag);
}
}
}
} catch (e) {
// Continue with string parsing
}
}
// Fallback to string-based extraction
// Look for the opening tag
const openTagMatch = contentStr.match(new RegExp(`<${tagName}[^>]*>`, 'i'));
if (openTagMatch) {
// Find where the tag ends
const tagEndIndex = contentStr.indexOf(openTagMatch[0]) + openTagMatch[0].length;
// Extract everything after the opening tag
const afterTag = contentStr.substring(tagEndIndex);
// Check if there's a closing tag
const closeTagMatch = afterTag.match(new RegExp(`<\\/${tagName}>`, 'i'));
if (closeTagMatch) {
// Return content between tags
return processFileContent(afterTag.substring(0, closeTagMatch.index));
} else {
// No closing tag yet (streaming), return what we have
return processFileContent(afterTag);
}
}
return null;
}
export const getFileIconAndColor = (filename: string) => {
const ext = filename.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js':
case 'jsx':
case 'ts':
case 'tsx':
return {
icon: FileCode,
color: 'text-yellow-500 dark:text-yellow-400',
bgColor: 'bg-gradient-to-br from-yellow-500/20 to-yellow-600/10 border border-yellow-500/20'
};
case 'py':
return {
icon: FileCode,
color: 'text-blue-500 dark:text-blue-400',
bgColor: 'bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20'
};
case 'html':
case 'css':
case 'scss':
return {
icon: FileCode,
color: 'text-orange-500 dark:text-orange-400',
bgColor: 'bg-gradient-to-br from-orange-500/20 to-orange-600/10 border border-orange-500/20'
};
// Data files
case 'json':
return {
icon: FileJson,
color: 'text-green-500 dark:text-green-400',
bgColor: 'bg-gradient-to-br from-green-500/20 to-green-600/10 border border-green-500/20'
};
case 'csv':
return {
icon: Table,
color: 'text-emerald-500 dark:text-emerald-400',
bgColor: 'bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 border border-emerald-500/20'
};
case 'xml':
case 'yaml':
case 'yml':
return {
icon: FileCode,
color: 'text-purple-500 dark:text-purple-400',
bgColor: 'bg-gradient-to-br from-purple-500/20 to-purple-600/10 border border-purple-500/20'
};
// Image files
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'svg':
case 'webp':
return {
icon: FileImage,
color: 'text-pink-500 dark:text-pink-400',
bgColor: 'bg-gradient-to-br from-pink-500/20 to-pink-600/10 border border-pink-500/20'
};
// Document files
case 'md':
case 'mdx':
return {
icon: FileText,
color: 'text-slate-500 dark:text-slate-400',
bgColor: 'bg-gradient-to-br from-slate-500/20 to-slate-600/10 border border-slate-500/20'
};
case 'txt':
return {
icon: FileText,
color: 'text-zinc-500 dark:text-zinc-400',
bgColor: 'bg-gradient-to-br from-zinc-500/20 to-zinc-600/10 border border-zinc-500/20'
};
case 'pdf':
return {
icon: FileType,
color: 'text-red-500 dark:text-red-400',
bgColor: 'bg-gradient-to-br from-red-500/20 to-red-600/10 border border-red-500/20'
};
// Media files
case 'mp4':
case 'avi':
case 'mov':
return {
icon: FileVideo,
color: 'text-indigo-500 dark:text-indigo-400',
bgColor: 'bg-gradient-to-br from-indigo-500/20 to-indigo-600/10 border border-indigo-500/20'
};
case 'mp3':
case 'wav':
case 'ogg':
return {
icon: FileAudio,
color: 'text-teal-500 dark:text-teal-400',
bgColor: 'bg-gradient-to-br from-teal-500/20 to-teal-600/10 border border-teal-500/20'
};
// Archive files
case 'zip':
case 'tar':
case 'gz':
case 'rar':
return {
icon: FileArchive,
color: 'text-amber-500 dark:text-amber-400',
bgColor: 'bg-gradient-to-br from-amber-500/20 to-amber-600/10 border border-amber-500/20'
};
// Default
default:
if (!ext || filename.includes('/')) {
return {
icon: FolderOpen,
color: 'text-blue-500 dark:text-blue-400',
bgColor: 'bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20'
};
}
return {
icon: File,
color: 'text-zinc-500 dark:text-zinc-400',
bgColor: 'bg-gradient-to-br from-zinc-500/20 to-zinc-600/10 border border-zinc-500/20'
};
}
};