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 (
+
+
+
+ Wikipedia
+
+ {lang}
+
+
+
+
+
+ );
+}
+
+// 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,
+);