diff --git a/src/components/message-parts.tsx b/src/components/message-parts.tsx index 366d989c8..9b6fabcfc 100644 --- a/src/components/message-parts.tsx +++ b/src/components/message-parts.tsx @@ -726,6 +726,17 @@ const ImageGeneratorToolInvocation = dynamic( }, ); +const HttpRequestToolInvocation = dynamic( + () => + import("./tool-invocation/http-request").then( + (mod) => mod.HttpRequestToolInvocation, + ), + { + ssr: false, + loading, + }, +); + // Local shortcuts for tool invocation approval/rejection const approveToolInvocationShortcut: Shortcut = { description: "approveToolInvocation", @@ -876,6 +887,10 @@ export const ToolMessagePart = memo( return ; } + if (toolName === DefaultToolName.Http) { + return ; + } + if (toolName === ImageToolName) { return ; } diff --git a/src/components/tool-invocation/http-renderers/wikipedia.tsx b/src/components/tool-invocation/http-renderers/wikipedia.tsx new file mode 100644 index 000000000..2ba47ea07 --- /dev/null +++ b/src/components/tool-invocation/http-renderers/wikipedia.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "lib/utils"; +import { Badge } from "ui/badge"; +import { Separator } from "ui/separator"; +import { BookOpen, ExternalLink, Calendar } from "lucide-react"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "ui/hover-card"; + +// Types for Wikipedia API responses + +// Summary API response (rest_v1/page/summary) +interface WikipediaSummaryResponse { + title: string; + displaytitle?: string; + extract: string; + extract_html?: string; + description?: string; + thumbnail?: { + source: string; + width: number; + height: number; + }; + originalimage?: { + source: string; + width: number; + height: number; + }; + content_urls?: { + desktop?: { page: string }; + mobile?: { page: string }; + }; + timestamp?: string; + lang?: string; +} + +// Search API response (w/api.php?action=query&list=search) +interface WikipediaSearchResult { + ns: number; + title: string; + pageid: number; + size: number; + wordcount: number; + snippet: string; + timestamp: string; +} + +interface WikipediaSearchResponse { + batchcomplete?: string; + query?: { + searchinfo?: { totalhits: number }; + search?: WikipediaSearchResult[]; + }; +} + +// OpenSearch API response +type WikipediaOpenSearchResponse = [ + string, // query + string[], // titles + string[], // descriptions + string[], // urls +]; + +interface WikipediaRendererProps { + input: { + url: string; + method?: string; + }; + output: { + status: number; + body: + | WikipediaSummaryResponse + | WikipediaSearchResponse + | WikipediaOpenSearchResponse; + ok: boolean; + }; +} + +// Detect response type +function detectResponseType( + body: any, +): "summary" | "search" | "opensearch" | "article" | "unknown" { + if (Array.isArray(body) && body.length === 4) { + return "opensearch"; + } + if (body?.query?.search) { + return "search"; + } + if (body?.extract || (body?.title && body?.content_urls)) { + return "summary"; + } + // HTML article page + if ( + typeof body === "string" && + (body.includes("]*>/g, "") + .replace(/"/g, '"') + .replace(/&/g, "&"); +} + +// Get Wikipedia language from URL +function getWikiLang(url: string): string { + const match = url.match(/(?:https?:\/\/)?(\w+)\.wikipedia\.org/); + return match?.[1] || "en"; +} + +// Get article title from URL path +function getArticleTitleFromUrl(url: string): string { + try { + const urlObj = new URL(url); + const path = urlObj.pathname; + // /wiki/Article_Name -> Article_Name + const match = path.match(/\/wiki\/(.+)/); + if (match) { + return decodeURIComponent(match[1].replace(/_/g, " ")); + } + return ""; + } catch { + return ""; + } +} + +// Extract first paragraph text from HTML +function extractFirstParagraph(html: string): string { + // Try to find content in mw-parser-output (main content area) + const contentMatch = html.match( + /]*class="[^"]*mw-parser-output[^"]*"[^>]*>([\s\S]*?)<\/div>/, + ); + const content = contentMatch ? contentMatch[1] : html; + + // Find first substantial paragraph (skip empty or very short ones) + const paragraphs = content.match(/]*>([\s\S]*?)<\/p>/gi) || []; + for (const p of paragraphs) { + const text = stripHtml(p).trim(); + // Skip coordinates, empty, or very short paragraphs + if (text.length > 50 && !text.startsWith("Coordinates:")) { + return text.slice(0, 300) + (text.length > 300 ? "..." : ""); + } + } + return ""; +} + +// Article HTML view component +function ArticleView({ + html, + url, + lang, +}: { html: string; url: string; lang: string }) { + const title = getArticleTitleFromUrl(url); + const excerpt = useMemo(() => extractFirstParagraph(html), [html]); + + return ( +
+
+ + Wikipedia + + {lang} + +
+ + +
+ ); +} + +// Summary view component +function SummaryView({ + data, + lang, +}: { data: WikipediaSummaryResponse; lang: string }) { + const pageUrl = + data.content_urls?.desktop?.page || + `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(data.title)}`; + + return ( + + ); +} + +// Search results view component +function SearchView({ + data, + lang, + query, +}: { + data: WikipediaSearchResponse; + lang: string; + query: string; +}) { + const results = data.query?.search || []; + const totalHits = data.query?.searchinfo?.totalhits || results.length; + + return ( +
+
+ + Wikipedia + «{query}» +
+ +
+
+ +
+
+ {/* Compact pill results */} +
+ {results.map((result) => { + const pageUrl = `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(result.title)}`; + + return ( + + + +
+ +
+ {result.title} +
+
+ + +
+
+ +
+
+

{result.title}

+
+ {result.wordcount.toLocaleString()} words + · + {(result.size / 1024).toFixed(1)} KB +
+
+
+ + {result.snippet && ( +
+

+ {stripHtml(result.snippet)}... +

+
+ )} + +
+ + + {new Date(result.timestamp).toLocaleDateString()} + + + {lang}.wikipedia.org +
+
+
+ ); + })} +
+ +

+ {totalHits.toLocaleString()} results found +

+
+
+
+ ); +} + +// OpenSearch results view component +function OpenSearchView({ + data, + lang, +}: { + data: WikipediaOpenSearchResponse; + lang: string; +}) { + const [query, titles, descriptions, urls] = data; + + return ( +
+
+ + Wikipedia + + «{query}» ({lang}.wikipedia.org) + +
+ +
+
+ +
+
+
+ {titles.map((title, i) => ( + + + +
+ +
+ {title} +
+
+ + +
+
+ +
+
+

{title}

+ {descriptions[i] && ( +

+ {descriptions[i]} +

+ )} +
+
+
+
+ ))} +
+ +

+ {titles.length} results found +

+
+
+
+ ); +} + +export function WikipediaResult({ input, output }: WikipediaRendererProps) { + const lang = useMemo(() => getWikiLang(input.url), [input.url]); + + const query = useMemo(() => { + try { + const url = new URL(input.url); + return ( + url.searchParams.get("srsearch") || + url.searchParams.get("search") || + url.searchParams.get("titles") || + "" + ); + } catch { + return ""; + } + }, [input.url]); + + const responseType = useMemo( + () => detectResponseType(output.body), + [output.body], + ); + + if (responseType === "summary") { + return ( + + ); + } + + if (responseType === "search") { + return ( + + ); + } + + if (responseType === "opensearch") { + return ( + + ); + } + + if (responseType === "article") { + return ( + + ); + } + + // Unknown response type - return null to fall back to default rendering + return null; +} diff --git a/src/components/tool-invocation/http-request.tsx b/src/components/tool-invocation/http-request.tsx new file mode 100644 index 000000000..f2f66a6af --- /dev/null +++ b/src/components/tool-invocation/http-request.tsx @@ -0,0 +1,406 @@ +"use client"; + +import type { JSX } from "react"; +import { ToolUIPart } from "ai"; +import equal from "lib/equal"; +import { toAny } from "lib/utils"; +import { AlertTriangleIcon, Globe } from "lucide-react"; +import { Fragment, memo, useLayoutEffect, useMemo, useState } from "react"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "ui/hover-card"; +import JsonView from "ui/json-view"; +import { Separator } from "ui/separator"; +import { TextShimmer } from "ui/text-shimmer"; +import { Badge } from "ui/badge"; +import { WikipediaResult } from "./http-renderers/wikipedia"; +import { codeToHast, type BundledLanguage } from "shiki/bundle/web"; +import { toJsxRuntime } from "hast-util-to-jsx-runtime"; +import { jsx, jsxs } from "react/jsx-runtime"; +import { useTheme } from "next-themes"; + +interface HttpRequestToolInvocationProps { + part: ToolUIPart; +} + +// HTTP response type from the fetch tool +interface HttpFetchResponse { + status: number; + statusText: string; + headers: Record; + body: any; + ok: boolean; + url: string; + isError?: boolean; + error?: string; +} + +// Domain-based renderer registry +// Supports wildcards: "*.example.com" matches any subdomain +// Renderers should return null if they can't handle the response +type DomainRenderer = React.FC<{ + input: any; + output: HttpFetchResponse; +}>; + +const DOMAIN_RENDERERS: Record = { + "*.wikipedia.org": WikipediaResult, +}; + +/** + * Extract domain from URL + */ +function extractDomain(url: string): string | null { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + return null; + } +} + +/** + * Convert wildcard pattern to regex + * e.g., "*.wikipedia.org" -> /^.+\.wikipedia\.org$/ + */ +function wildcardToRegex(pattern: string): RegExp { + // First replace * with a placeholder that won't be escaped + const placeholder = "\x00WILDCARD\x00"; + const withPlaceholder = pattern.replace(/\*/g, placeholder); + // Escape special regex chars + const escaped = withPlaceholder.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + // Replace placeholder with .+ + const final = escaped.replace(new RegExp(placeholder, "g"), ".+"); + return new RegExp(`^${final}$`); +} + +/** + * Get the appropriate renderer for a URL + */ +function getRendererForUrl(url: string): DomainRenderer | null { + const domain = extractDomain(url); + if (!domain) return null; + + // Check for exact match first + if (DOMAIN_RENDERERS[domain]) { + return DOMAIN_RENDERERS[domain]; + } + + // Check wildcard patterns + for (const [pattern, renderer] of Object.entries(DOMAIN_RENDERERS)) { + if (pattern.includes("*")) { + const regex = wildcardToRegex(pattern); + if (regex.test(domain)) { + return renderer; + } + } + } + + return null; +} + +/** + * Detect content type from response body + */ +function detectContentType( + body: any, + headers?: Record, +): BundledLanguage | "text" { + // Check content-type header first + const contentType = + headers?.["content-type"] || headers?.["Content-Type"] || ""; + if (contentType.includes("text/html")) return "html"; + if (contentType.includes("application/json")) return "json"; + if ( + contentType.includes("text/xml") || + contentType.includes("application/xml") + ) + return "xml"; + if (contentType.includes("text/css")) return "css"; + if ( + contentType.includes("text/javascript") || + contentType.includes("application/javascript") + ) + return "javascript"; + + // Fallback: detect from body content + if (typeof body === "string") { + const trimmed = body.trim(); + if ( + trimmed.startsWith("(null); + + useLayoutEffect(() => { + // Skip highlighting for plain text + if (lang === "text") { + setHighlighted(null); + return; + } + + let cancelled = false; + + codeToHast(code, { + lang, + theme: theme === "dark" ? "dark-plus" : "github-light", + }) + .then((hast) => { + if (cancelled) return; + const element = toJsxRuntime(hast, { + Fragment, + jsx, + jsxs, + components: { + pre: ({ children, ...props }) => ( +
+                {children}
+              
+ ), + }, + }) as JSX.Element; + setHighlighted(element); + }) + .catch(() => { + // Fallback to plain text on error + if (!cancelled) setHighlighted(null); + }); + + return () => { + cancelled = true; + }; + }, [code, lang, theme]); + + return ( +
+ {highlighted || ( +
{code}
+ )} +
+ ); +} + +/** + * Default HTTP response view + */ +function DefaultHttpView({ + input, + result, + options, +}: { + input: { url: string; method?: string }; + result: HttpFetchResponse; + options: React.ReactNode; +}) { + return ( +
+
+ + HTTP Request + + {result?.status} {result?.statusText} + + {options} +
+
+
+ +
+
+
+ {input?.method || "GET"}{" "} + {input?.url} +
+ {result?.body && + (typeof result.body === "object" ? ( +
+ +
+ ) : ( + + ))} +
+
+
+ ); +} + +/** + * Wrapper that tries custom renderer, falls back to default if it returns null + */ +function CustomRendererWithFallback({ + CustomRenderer, + input, + result, + options, +}: { + CustomRenderer: DomainRenderer; + input: { url: string; method?: string }; + result: HttpFetchResponse; + options: React.ReactNode; +}) { + const customResult = CustomRenderer({ input, output: result }); + + if (customResult === null) { + return ; + } + + return customResult; +} + +function PureHttpRequestToolInvocation({ + part, +}: HttpRequestToolInvocationProps) { + const result = useMemo(() => { + if (!part.state.startsWith("output")) return null; + return part.output as HttpFetchResponse; + }, [part.state, part.output]); + + const input = part.input as { + url: string; + method?: string; + headers?: Record; + body?: string; + timeout?: number; + }; + + const options = useMemo(() => { + return ( + + + + Request details + + + +

+ HTTP request configuration +

+
+ +
+
+
+ ); + }, [input]); + + // Check if there's a custom renderer for this URL + const CustomRenderer = useMemo(() => { + if (!input?.url || !result) return null; + return getRendererForUrl(input.url); + }, [input?.url, result]); + + // Loading state + if (!part.state.startsWith("output")) { + const loadingText = `${input?.method || "GET"} ${input?.url ? truncateUrl(input.url) : "..."}`; + return ( +
+ + {loadingText} +
+ ); + } + + // Error state + if (result?.isError) { + return ( +
+
+ + HTTP Request Failed + {options} +
+
+
+ +
+
+

+ + {result.error || "Request failed"} +

+
+
+
+ ); + } + + // If we have a custom renderer, try it first - it will render default view if it returns null + if (CustomRenderer && result) { + return ( + + ); + } + + // Default rendering + return ; +} + +function truncateUrl(url: string, maxLength = 50): string { + if (url.length <= maxLength) return url; + try { + const urlObj = new URL(url); + const path = + urlObj.pathname.length > 20 + ? urlObj.pathname.slice(0, 20) + "..." + : urlObj.pathname; + return `${urlObj.hostname}${path}`; + } catch { + return url.slice(0, maxLength) + "..."; + } +} + +function areEqual( + { part: prevPart }: HttpRequestToolInvocationProps, + { part: nextPart }: HttpRequestToolInvocationProps, +) { + if (prevPart.state !== nextPart.state) return false; + if (!equal(prevPart.input, nextPart.input)) return false; + if ( + prevPart.state.startsWith("output") && + !equal(prevPart.output, toAny(nextPart).output) + ) + return false; + return true; +} + +export const HttpRequestToolInvocation = memo( + PureHttpRequestToolInvocation, + areEqual, +);