mirror of https://github.com/kortix-ai/suna.git
1449 lines
48 KiB
TypeScript
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'
|
|
};
|
|
}
|
|
}; |