diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 7daffb6d..934a3d55 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -81,6 +81,7 @@ interface BaseChatProps { selectedElement?: ElementInfo | null; setSelectedElement?: (element: ElementInfo | null) => void; addToolResult?: ({ toolCallId, result }: { toolCallId: string; result: any }) => void; + onWebSearchResult?: (result: string) => void; } export const BaseChat = React.forwardRef( @@ -130,6 +131,7 @@ export const BaseChat = React.forwardRef( addToolResult = () => { throw new Error('addToolResult not implemented'); }, + onWebSearchResult, }, ref, ) => { @@ -465,6 +467,7 @@ export const BaseChat = React.forwardRef( setDesignScheme={setDesignScheme} selectedElement={selectedElement} setSelectedElement={setSelectedElement} + onWebSearchResult={onWebSearchResult} /> diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index c4706e17..ccddaf51 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -594,6 +594,20 @@ export const ChatImpl = memo( Cookies.set('selectedProvider', newProvider.name, { expires: 30 }); }; + const handleWebSearchResult = useCallback( + (result: string) => { + const currentInput = input || ''; + const newInput = currentInput.length > 0 ? `${result}\n\n${currentInput}` : result; + + // Update the input via the same mechanism as handleInputChange + const syntheticEvent = { + target: { value: newInput }, + } as React.ChangeEvent; + handleInputChange(syntheticEvent); + }, + [input, handleInputChange], + ); + return ( ); }, diff --git a/app/components/chat/ChatBox.tsx b/app/components/chat/ChatBox.tsx index 4cd9a149..209a24c9 100644 --- a/app/components/chat/ChatBox.tsx +++ b/app/components/chat/ChatBox.tsx @@ -19,6 +19,7 @@ import { ColorSchemeDialog } from '~/components/ui/ColorSchemeDialog'; import type { DesignScheme } from '~/types/design-scheme'; import type { ElementInfo } from '~/components/workbench/Inspector'; import { McpTools } from './MCPTools'; +import { WebSearch } from './WebSearch.client'; interface ChatBoxProps { isModelSettingsCollapsed: boolean; @@ -55,6 +56,7 @@ interface ChatBoxProps { handleStop?: (() => void) | undefined; enhancingPrompt?: boolean | undefined; enhancePrompt?: (() => void) | undefined; + onWebSearchResult?: (result: string) => void; chatMode?: 'discuss' | 'build'; setChatMode?: (mode: 'discuss' | 'build') => void; designScheme?: DesignScheme; @@ -265,6 +267,7 @@ export const ChatBox: React.FC = (props) => { props.handleFileUpload()}>
+ props.onWebSearchResult?.(result)} disabled={props.isStreaming} /> void; + disabled?: boolean; +} + +interface WebSearchData { + title: string; + description: string; + content: string; + sourceUrl: string; +} + +interface WebSearchResponse { + success: boolean; + data?: WebSearchData; + error?: string; +} + +function formatSearchResult(data: WebSearchData): string { + const parts: string[] = [`[Web content from ${data.sourceUrl}]`]; + + if (data.title) { + parts.push(`Title: ${data.title}`); + } + + if (data.description) { + parts.push(`Description: ${data.description}`); + } + + parts.push('', data.content); + + return parts.join('\n'); +} + +export function WebSearch({ onSearchResult, disabled = false }: WebSearchProps) { + const [isOpen, setIsOpen] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [url, setUrl] = useState(''); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + } + }, [isOpen]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + const handleFetch = async () => { + const trimmedUrl = url.trim(); + + if (!trimmedUrl) { + return; + } + + setIsSearching(true); + + try { + const response = await fetch('/api/web-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: trimmedUrl }), + }); + + const result = (await response.json()) as WebSearchResponse; + + if (!response.ok || !result.success || !result.data) { + throw new Error(result.error || 'Failed to fetch URL content'); + } + + onSearchResult(formatSearchResult(result.data)); + toast.success('URL content fetched'); + setUrl(''); + setIsOpen(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to fetch URL'); + } finally { + setIsSearching(false); + } + }; + + return ( +
+ setIsOpen(!isOpen)} + className="transition-all" + > + {isSearching ? ( +
+ ) : ( +
+ )} + + {isOpen && ( +
+ setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !isSearching) { + handleFetch(); + } + + if (e.key === 'Escape') { + setIsOpen(false); + } + }} + placeholder="https://example.com" + disabled={isSearching} + className={classNames( + 'w-[300px] px-3 py-1.5 text-sm rounded-md', + 'border border-bolt-elements-borderColor', + 'bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary', + 'placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus', + )} + /> + +
+ )} +
+ ); +} diff --git a/app/routes/api.web-search.ts b/app/routes/api.web-search.ts new file mode 100644 index 00000000..96354f75 --- /dev/null +++ b/app/routes/api.web-search.ts @@ -0,0 +1,104 @@ +import { json } from '@remix-run/cloudflare'; +import type { ActionFunctionArgs } from '@remix-run/cloudflare'; +import { isAllowedUrl } from '~/utils/url'; + +const MAX_CONTENT_LENGTH = 8000; + +const FETCH_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', +}; + +function extractTitle(html: string): string { + const match = html.match(/]*>([^<]+)<\/title>/i); + return match ? match[1].trim() : ''; +} + +function extractMetaDescription(html: string): string { + const match = html.match(/]*name=["']description["'][^>]*content=["']([^"']*)["'][^>]*>/i); + + if (match) { + return match[1].trim(); + } + + // Try reverse attribute order + const altMatch = html.match(/]*content=["']([^"']*)["'][^>]*name=["']description["'][^>]*>/i); + + return altMatch ? altMatch[1].trim() : ''; +} + +function extractTextContent(html: string): string { + return html + .replace(/)<[^<]*)*<\/script>/gi, ' ') + .replace(/)<[^<]*)*<\/style>/gi, ' ') + .replace(/)<[^<]*)*<\/nav>/gi, ' ') + .replace(/)<[^<]*)*<\/header>/gi, ' ') + .replace(/)<[^<]*)*<\/footer>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== 'POST') { + return json({ error: 'Method not allowed' }, { status: 405 }); + } + + try { + const { url } = (await request.json()) as { url?: string }; + + if (!url || typeof url !== 'string') { + return json({ error: 'URL is required' }, { status: 400 }); + } + + if (!isAllowedUrl(url)) { + return json({ error: 'URL is not allowed. Only public HTTP/HTTPS URLs are accepted.' }, { status: 400 }); + } + + const response = await fetch(url, { + headers: FETCH_HEADERS, + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + return json({ error: `Failed to fetch URL: ${response.status} ${response.statusText}` }, { status: 502 }); + } + + const contentType = response.headers.get('content-type') || ''; + + if (!contentType.includes('text/html') && !contentType.includes('text/plain')) { + return json({ error: 'URL must point to an HTML or text page' }, { status: 400 }); + } + + const html = await response.text(); + const title = extractTitle(html); + const description = extractMetaDescription(html); + const content = extractTextContent(html); + + return json({ + success: true, + data: { + title, + description, + content: content.length > MAX_CONTENT_LENGTH ? content.slice(0, MAX_CONTENT_LENGTH) + '...' : content, + sourceUrl: url, + }, + }); + } catch (error) { + if (error instanceof DOMException && error.name === 'TimeoutError') { + return json({ error: 'Request timed out after 10 seconds' }, { status: 504 }); + } + + console.error('Web search error:', error); + + return json({ error: error instanceof Error ? error.message : 'Failed to fetch URL' }, { status: 500 }); + } +} diff --git a/app/utils/url.ts b/app/utils/url.ts new file mode 100644 index 00000000..ad6cbd05 --- /dev/null +++ b/app/utils/url.ts @@ -0,0 +1,42 @@ +/** + * URL validation utilities with SSRF protection. + */ + +const PRIVATE_IP_PATTERNS = [ + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // Loopback + /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // Class A private + /^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/, // Class B private + /^192\.168\.\d{1,3}\.\d{1,3}$/, // Class C private + /^169\.254\.\d{1,3}\.\d{1,3}$/, // Link-local + /^0\.0\.0\.0$/, // Unspecified +]; + +const BLOCKED_HOSTNAMES = new Set(['localhost', '[::1]', '0.0.0.0']); + +export function isValidUrl(input: string): boolean { + try { + const url = new URL(input); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +export function isAllowedUrl(input: string): boolean { + if (!isValidUrl(input)) { + return false; + } + + const url = new URL(input); + const hostname = url.hostname.toLowerCase(); + + if (BLOCKED_HOSTNAMES.has(hostname)) { + return false; + } + + if (PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(hostname))) { + return false; + } + + return true; +}