diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 1319f96a..5610f006 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,6 +1,7 @@ import { streamText, smoothStream, + consumeStream, convertToModelMessages, pruneMessages, safeValidateUIMessages, @@ -38,7 +39,11 @@ import { } from "@/lib/ai/gateway-provider-options"; import { db } from "@/lib/db/client"; import { chatThreads, chatMessages } from "@/lib/db/schema"; -import { CHAT_MESSAGE_FORMAT, type ChatMessage } from "@/lib/chat/types"; +import { + CHAT_MESSAGE_FORMAT, + hasMeaningfulContent, + type ChatMessage, +} from "@/lib/chat/types"; import { CHAT_DEBUG_TAG, summarizeMessage, @@ -113,7 +118,8 @@ async function loadThreadHistory(threadId: string): Promise { ), ) .orderBy(asc(chatMessages.createdAt)); - return rows.map((r) => r.content) as ChatMessage[]; + const messages = rows.map((r) => r.content) as ChatMessage[]; + return messages.filter((m) => hasMeaningfulContent(m)); } async function getThreadById(threadId: string) { @@ -344,6 +350,7 @@ async function handlePOST(req: Request) { try { convertedMessages = await convertToModelMessages(validatedMessages, { tools, + ignoreIncompleteToolCalls: true, }); } catch (convertError) { logger.error("❌ [CHAT-API] convertToModelMessages FAILED:", { @@ -473,6 +480,7 @@ async function handlePOST(req: Request) { tools, providerOptions, headers: getGatewayAttributionHeaders(), + abortSignal: req.signal, experimental_telemetry: { isEnabled: true, metadata: { @@ -546,10 +554,20 @@ async function handlePOST(req: Request) { } }, onFinish: async ({ responseMessage, isAborted }) => { - if (isAborted || !userId) { + const meaningful = hasMeaningfulContent(responseMessage); + if (isAborted) { + logger.debug(`${CHAT_DEBUG_TAG} onFinish skipped (aborted)`, { + hasUserId: !!userId, + hasMeaningfulContent: meaningful, + partCount: responseMessage.parts?.length ?? 0, + }); + return; + } + if (!userId || !meaningful) { logger.warn(`${CHAT_DEBUG_TAG} onFinish skipped`, { - isAborted, hasUserId: !!userId, + hasMeaningfulContent: meaningful, + partCount: responseMessage.parts?.length ?? 0, }); return; } @@ -599,7 +617,10 @@ async function handlePOST(req: Request) { }, }); - return createUIMessageStreamResponse({ stream }); + return createUIMessageStreamResponse({ + stream, + consumeSseStream: consumeStream, + }); } catch (error) { if (error instanceof Response) { return error; diff --git a/src/app/api/threads/[id]/messages/route.ts b/src/app/api/threads/[id]/messages/route.ts index 02df082e..2f3585c6 100644 --- a/src/app/api/threads/[id]/messages/route.ts +++ b/src/app/api/threads/[id]/messages/route.ts @@ -14,7 +14,7 @@ import { summarizeMessage, summarizeRoster, } from "@/lib/chat/debug"; -import { CHAT_MESSAGE_FORMAT } from "@/lib/chat/types"; +import { CHAT_MESSAGE_FORMAT, hasMeaningfulContent } from "@/lib/chat/types"; async function getThreadAndVerify(id: string, userId: string) { const [thread] = await db @@ -70,7 +70,12 @@ export const GET = withServerObservability( .orderBy(asc(chatMessages.createdAt)); // content is a stored UIMessage object; no reshaping required. - const messages = rows.map((r) => r.content); + // Drop any pre-existing rows whose stored content has no meaningful + // parts (empty or `step-start`-only) so corrupted history from before + // the persistence guard never poisons hydrated `useChat` state. + const messages = rows + .map((r) => r.content) + .filter((content) => hasMeaningfulContent(content)); // [chat-debug] Snapshot what's actually in the DB for this thread so // we can correlate against what the client receives. If the assistant diff --git a/src/components/chat/ChatProvider.tsx b/src/components/chat/ChatProvider.tsx index 6a455fee..a433bd51 100644 --- a/src/components/chat/ChatProvider.tsx +++ b/src/components/chat/ChatProvider.tsx @@ -27,6 +27,7 @@ import { } from "@/lib/chat/queries"; import { createChatTransport } from "@/lib/chat/transport"; import type { ChatMessage } from "@/lib/chat/types"; +import { hasMeaningfulContent } from "@/lib/chat/types"; import { useUIStore } from "@/lib/stores/ui-store"; import { selectCurrentThreadId, @@ -302,7 +303,11 @@ export function ChatProvider({ workspaceId, children }: ChatProviderProps) { const chat = useChat({ id: threadId, transport, - messages: shouldHydrateHistory ? persistedMessages : undefined, + messages: shouldHydrateHistory + ? (persistedMessages as ChatMessage[]).filter((m) => + hasMeaningfulContent(m), + ) + : undefined, onError: handleError, // Live title updates: the chat route writes a `data-chat-title` part to // the SSE stream once `generateThreadTitle` resolves. We patch the @@ -366,20 +371,25 @@ export function ChatProvider({ workspaceId, children }: ChatProviderProps) { useEffect(() => { if (seededRef.current) return; if (persistedMessages.length === 0) return; + const cleanPersisted = (persistedMessages as ChatMessage[]).filter((m) => + hasMeaningfulContent(m), + ); if (messages.length > 0) { chatDebug("seed: skipped, useChat already has messages", { threadId, useChatCount: messages.length, persistedCount: persistedMessages.length, + cleanCount: cleanPersisted.length, }); seededRef.current = true; return; } chatDebug("seed: pushing persisted messages into useChat", { threadId, - ...summarizeRoster(persistedMessages as unknown[]), + droppedEmpty: persistedMessages.length - cleanPersisted.length, + ...summarizeRoster(cleanPersisted as unknown[]), }); - setMessages(persistedMessages); + setMessages(cleanPersisted); seededRef.current = true; }, [persistedMessages, messages.length, setMessages, threadId]); diff --git a/src/components/chat/tools/WebMapToolUI.tsx b/src/components/chat/tools/WebMapToolUI.tsx new file mode 100644 index 00000000..e672ebdc --- /dev/null +++ b/src/components/chat/tools/WebMapToolUI.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { + MapIcon, + ChevronDownIcon, + ExternalLinkIcon, + AlertCircle, +} from "lucide-react"; +import { useState, type FC, type PropsWithChildren } from "react"; + +import type { ChatToolUIProps } from "@/lib/chat/tool-ui-types"; + +import { ToolUIErrorBoundary } from "@/components/tool-ui/shared"; +import { parseWebMapResult } from "@/lib/ai/tool-result-schemas"; +import { + normalizeWebMapArgs, + type WebMapLink, + type WebMapOutput, +} from "@/lib/ai/web-map-shared"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import ShinyText from "@/components/ShinyText"; +import { Badge } from "@/components/ui/badge"; + +const ANIMATION_DURATION = 200; +const SHIMMER_DURATION = 1000; + +/** + * Root collapsible container that manages open/closed state. + */ +const ToolRoot: FC< + PropsWithChildren<{ + className?: string; + }> +> = ({ className, children }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + {children} + + ); +}; + +ToolRoot.displayName = "ToolRoot"; + +/** + * Gradient overlay that softens the bottom edge during expand/collapse animations. + */ +const GradientFade: FC<{ className?: string }> = ({ className }) => ( +
+); + +/** + * Trigger button for the tool collapsible. + */ +const ToolTrigger: FC<{ + active: boolean; + label: string; + icon: React.ReactNode; + badges?: React.ReactNode; + className?: string; +}> = ({ active, label, icon, badges, className }) => ( + + {icon} + + {active ? ( + + ) : ( + {label} + )} + + {badges} + + +); + +/** + * Collapsible content wrapper that handles height expand/collapse animation. + */ +const ToolContent: FC< + PropsWithChildren<{ + className?: string; + "aria-busy"?: boolean; + }> +> = ({ className, children, "aria-busy": ariaBusy }) => ( + + {children} + + +); + +ToolContent.displayName = "ToolContent"; + +/** + * Text content wrapper that animates the tool text visibility. + */ +const ToolText: FC< + PropsWithChildren<{ + className?: string; + }> +> = ({ className, children }) => ( +
+ {children} +
+); + +ToolText.displayName = "ToolText"; + +function getDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** + * Tool UI component for web_map tool. + * Displays the discovered URLs with optional title/description in a collapsible card. + */ +type WebMapArgs = { + url?: string; + search?: string; + limit?: number; + includeSubdomains?: boolean; + sitemap?: "include" | "skip" | "only"; + jsonInput?: string; +}; + +export const renderWebMapToolUI: ChatToolUIProps< + WebMapArgs, + string | WebMapOutput +>["render"] = ({ args, status, result }) => { + const isRunning = status.type === "running"; + const isComplete = status.type === "complete"; + + const normalizedArgs = normalizeWebMapArgs(args); + const sourceUrl = normalizedArgs?.url ?? ""; + + const parsedResult = result != null ? parseWebMapResult(result) : null; + const structured = + parsedResult && typeof parsedResult === "object" ? parsedResult : null; + const links: WebMapLink[] = structured?.links ?? []; + const metadata = structured?.metadata; + const errorMessage = metadata?.error ?? null; + const truncated = metadata?.truncated ?? false; + const total = metadata?.total ?? links.length; + const fallbackText = + typeof parsedResult === "string" ? parsedResult : (structured?.text ?? ""); + + const domain = sourceUrl ? getDomain(sourceUrl) : ""; + const triggerLabel = isRunning + ? sourceUrl + ? `Mapping ${domain || sourceUrl}` + : "Mapping site" + : isComplete + ? errorMessage + ? "Map failed" + : total > 0 + ? `Mapped ${total} URL${total !== 1 ? "s" : ""}` + : "No URLs found" + : "Map cancelled"; + + const triggerBadges = + isComplete && !errorMessage && total > 0 ? ( + + {domain || sourceUrl} + + ) : null; + + return ( + + + } + badges={triggerBadges} + /> + + + +
+ {sourceUrl && ( +
+ + Source: + {" "} + + {sourceUrl} + +
+ )} + + {isRunning && ( +
+
+ + Discovering URLs... + +
+ )} + + {isComplete && errorMessage && ( +
+ + + {errorMessage} + +
+ )} + + {isComplete && !errorMessage && links.length > 0 && ( +
+
+ + URLs: + + + {total} total{truncated ? " (truncated)" : ""} + +
+
+ {links.map((link) => ( +
+ + {link.title && ( +
+ {link.title} +
+ )} + {link.description && ( +
+ {link.description} +
+ )} +
+ ))} +
+
+ )} + + {isComplete && + !errorMessage && + links.length === 0 && + fallbackText && ( +
+ {fallbackText} +
+ )} + + {!isRunning && !isComplete && ( +
+ Map cancelled. +
+ )} +
+ + + + + ); +}; diff --git a/src/components/chat/tools/registry.ts b/src/components/chat/tools/registry.ts index 9e5b10c4..00ae9ad7 100644 --- a/src/components/chat/tools/registry.ts +++ b/src/components/chat/tools/registry.ts @@ -12,6 +12,7 @@ import { renderExecuteCodeToolUI } from "./ExecuteCodeToolUI"; import { renderReadWorkspaceToolUI } from "./ReadWorkspaceToolUI"; import { renderSearchWorkspaceToolUI } from "./SearchWorkspaceToolUI"; import { renderURLContextToolUI } from "./URLContextToolUI"; +import { renderWebMapToolUI } from "./WebMapToolUI"; import { renderWebSearchToolUI } from "./WebSearchToolUI"; import { renderYouTubeSearchToolUI } from "./YouTubeSearchToolUI"; @@ -23,6 +24,7 @@ type ToolRender = (args: ChatToolUIRenderArgs) => React.ReactNode; */ export const TOOL_RENDERERS: Partial> = { [CHAT_TOOL.WEB_FETCH]: renderURLContextToolUI as ToolRender, + [CHAT_TOOL.WEB_MAP]: renderWebMapToolUI as ToolRender, [CHAT_TOOL.WEB_SEARCH]: renderWebSearchToolUI as ToolRender, [CHAT_TOOL.CODE_EXECUTE]: renderExecuteCodeToolUI as ToolRender, [CHAT_TOOL.WORKSPACE_SEARCH]: renderSearchWorkspaceToolUI as ToolRender, diff --git a/src/lib/ai/__tests__/web-map-shared.test.ts b/src/lib/ai/__tests__/web-map-shared.test.ts new file mode 100644 index 00000000..29f39ac7 --- /dev/null +++ b/src/lib/ai/__tests__/web-map-shared.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_WEB_MAP_LIMIT_HARD_CAP, + WebMapInputSchema, + normalizeWebMapArgs, +} from "../web-map-shared"; + +describe("normalizeWebMapArgs", () => { + it("accepts the structured tool input shape", () => { + expect(normalizeWebMapArgs({ url: "https://example.com" })).toEqual({ + url: "https://example.com", + }); + }); + + it("parses optional fields", () => { + expect( + normalizeWebMapArgs({ + url: "https://example.com", + limit: 5, + search: "foo", + }), + ).toEqual({ + url: "https://example.com", + limit: 5, + search: "foo", + }); + }); + + it("normalizes legacy jsonInput payloads", () => { + expect( + normalizeWebMapArgs({ + jsonInput: JSON.stringify({ url: "https://example.com" }), + }), + ).toEqual({ url: "https://example.com" }); + }); + + it("returns null for malformed jsonInput", () => { + expect(normalizeWebMapArgs({ jsonInput: "not-json" })).toBeNull(); + }); + + it("returns null for empty payload", () => { + expect(normalizeWebMapArgs({})).toBeNull(); + }); + + it("returns null when url is empty", () => { + expect(normalizeWebMapArgs({ url: "" })).toBeNull(); + }); +}); + +describe("WebMapInputSchema", () => { + it("rejects limit greater than MAX_WEB_MAP_LIMIT_HARD_CAP", () => { + const result = WebMapInputSchema.safeParse({ + url: "https://example.com", + limit: MAX_WEB_MAP_LIMIT_HARD_CAP + 1, + }); + expect(result.success).toBe(false); + }); + + it("accepts limit at MAX_WEB_MAP_LIMIT_HARD_CAP", () => { + const result = WebMapInputSchema.safeParse({ + url: "https://example.com", + limit: MAX_WEB_MAP_LIMIT_HARD_CAP, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/lib/ai/chat-tool-names.ts b/src/lib/ai/chat-tool-names.ts index ecf753b6..beefa4c7 100644 --- a/src/lib/ai/chat-tool-names.ts +++ b/src/lib/ai/chat-tool-names.ts @@ -6,6 +6,7 @@ export const CHAT_TOOL = { WEB_FETCH: "web_fetch", + WEB_MAP: "web_map", WEB_SEARCH: "web_search", WORKSPACE_SEARCH: "workspace_search", WORKSPACE_READ: "workspace_read", diff --git a/src/lib/ai/tool-result-schemas.ts b/src/lib/ai/tool-result-schemas.ts index 82771b8d..ffe8f03d 100644 --- a/src/lib/ai/tool-result-schemas.ts +++ b/src/lib/ai/tool-result-schemas.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { parseWithSchema } from "@/components/tool-ui/shared"; import { ProcessUrlsOutputSchema } from "@/lib/ai/process-urls-shared"; +import { WebMapOutputSchema } from "@/lib/ai/web-map-shared"; import { WebSearchResultSchema, normalizeWebSearchResult, @@ -153,3 +154,42 @@ export function parseWebSearchResult(input: unknown): z.infer; + +export function parseWebMapResult(input: unknown): WebMapResult { + if (typeof input === "string") { + return input; + } + + if (input == null) { + return ""; + } + + if (typeof input !== "object") { + return String(input); + } + + if (Array.isArray(input)) { + return JSON.stringify(input); + } + + const res = WebMapOutputSchema.safeParse(input); + if (res.success) { + return res.data; + } + + const obj = input as Record; + if (typeof obj.text === "string") { + return obj.text; + } + + if (typeof obj.message === "string") { + return obj.message; + } + + return JSON.stringify(input); +} diff --git a/src/lib/ai/tools/index.ts b/src/lib/ai/tools/index.ts index 0ceebd2b..cd855dc3 100644 --- a/src/lib/ai/tools/index.ts +++ b/src/lib/ai/tools/index.ts @@ -4,6 +4,7 @@ */ import { createProcessUrlsTool } from "./process-urls"; +import { createWebMapTool } from "./web-map"; import { createDocumentTool, createDeleteItemTool, @@ -45,6 +46,7 @@ export function createChatTools(config: ChatToolsConfig): Record { return { // URL processing [CHAT_TOOL.WEB_FETCH]: createProcessUrlsTool(), + [CHAT_TOOL.WEB_MAP]: createWebMapTool(), // Search [CHAT_TOOL.WEB_SEARCH]: createWebSearchTool(), diff --git a/src/lib/ai/tools/web-map.ts b/src/lib/ai/tools/web-map.ts new file mode 100644 index 00000000..0be402e2 --- /dev/null +++ b/src/lib/ai/tools/web-map.ts @@ -0,0 +1,107 @@ +import { tool, zodSchema } from "ai"; +import { logger } from "@/lib/utils/logger"; +import { + WebMapInputSchema, + WebMapOutputSchema, + MAX_WEB_MAP_LIMIT, + type WebMapOutput, +} from "@/lib/ai/web-map-shared"; +import { FirecrawlClient } from "@/lib/ai/utils/firecrawl"; + +/** + * Create the web_map tool for discovering URLs on a website. + */ +export function createWebMapTool() { + return tool({ + description: + "Discover URLs on a website (sibling pages, sub-pages, related docs) without fetching their content. Use to map out the structure of a site before deciding which specific pages to fetch with web_fetch. Useful when the user pastes a single docs URL and you want to know what other pages live on the same site, or when finding a specific page on a known domain. Returns up to ~50 URLs with title and description. Cheap and fast; safe to call when in doubt about site structure.", + inputSchema: zodSchema(WebMapInputSchema), + outputSchema: zodSchema(WebMapOutputSchema), + strict: true, + execute: async ({ + url, + search, + limit, + includeSubdomains, + sitemap, + }): Promise => { + const effectiveLimit = limit ?? MAX_WEB_MAP_LIMIT; + logger.debug("πŸ—ΊοΈ [WEB_MAP] Mapping site", { + url, + search, + limit: effectiveLimit, + includeSubdomains, + sitemap, + }); + + try { + const client = new FirecrawlClient(); + const result = await client.mapUrl(url, { + search, + limit: effectiveLimit, + includeSubdomains, + sitemap, + }); + + if (!result.success) { + logger.warn("πŸ—ΊοΈ [WEB_MAP] Map failed", { + url, + error: result.error, + }); + return { + text: `Failed to map ${url}: ${result.error ?? "Unknown error"}`, + links: [], + metadata: { + sourceUrl: url, + total: 0, + truncated: false, + error: result.error ?? "Unknown error", + }, + }; + } + + const truncated = result.links.length >= effectiveLimit; + const summary = + result.links.length === 0 + ? `No URLs found at ${url}.` + : `Found ${result.links.length} URL(s) on ${url}${truncated ? " (limit reached β€” increase `limit` or narrow with `search` for more)" : ""}.`; + + const text = [ + summary, + "", + ...result.links.map((link) => { + const titlePart = link.title ? ` β€” ${link.title}` : ""; + const descPart = link.description ? `\n ${link.description}` : ""; + return `- ${link.url}${titlePart}${descPart}`; + }), + ].join("\n"); + + return { + text, + links: result.links, + metadata: { + sourceUrl: url, + total: result.links.length, + truncated, + error: null, + }, + }; + } catch (error) { + logger.error("πŸ—ΊοΈ [WEB_MAP] Error mapping site", { + url, + error: error instanceof Error ? error.message : String(error), + }); + return { + text: `Error mapping ${url}: ${error instanceof Error ? error.message : String(error)}`, + links: [], + metadata: { + sourceUrl: url, + total: 0, + truncated: false, + error: error instanceof Error ? error.message : String(error), + }, + }; + } + }, + }); +} diff --git a/src/lib/ai/utils/firecrawl.ts b/src/lib/ai/utils/firecrawl.ts index 5290b81e..c1dee367 100644 --- a/src/lib/ai/utils/firecrawl.ts +++ b/src/lib/ai/utils/firecrawl.ts @@ -26,6 +26,19 @@ export interface FirecrawlPageResult { metadata?: FirecrawlMetadata; } +export interface FirecrawlMapResult { + success: boolean; + links: Array<{ url: string; title?: string; description?: string }>; + error?: string; +} + +export interface FirecrawlMapOptions { + search?: string; + limit?: number; + includeSubdomains?: boolean; + sitemap?: "include" | "skip" | "only"; +} + export class FirecrawlClient { private apiKey: string; private baseUrl = "https://api.firecrawl.dev/v2"; @@ -212,4 +225,97 @@ export class FirecrawlClient { logger.debug(`πŸ”₯ [Firecrawl] Scraping ${urls.length} URLs in parallel`); return await Promise.all(urls.map((url) => this.scrapeUrl(url))); } + + async mapUrl( + url: string, + options: FirecrawlMapOptions = {}, + ): Promise { + if (!this.apiKey) { + return { + success: false, + links: [], + error: "Firecrawl API key not configured", + }; + } + + try { + logger.debug(`πŸ”₯ [Firecrawl] Mapping URL: ${url}`); + + const body: Record = { url }; + if (options.search !== undefined) body.search = options.search; + if (options.limit !== undefined) body.limit = options.limit; + if (options.includeSubdomains !== undefined) { + body.includeSubdomains = options.includeSubdomains; + } + if (options.sitemap !== undefined) body.sitemap = options.sitemap; + + const response = await fetch(`${this.baseUrl}/map`, { + method: "POST", + headers: this.getHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + return { + success: false, + links: [], + error: await this.parseErrorResponse(response), + }; + } + + const result = await response.json(); + if (result?.success === false) { + return { + success: false, + links: [], + error: + typeof result?.error === "string" + ? result.error + : "Unknown Firecrawl map error", + }; + } + + const rawLinks = Array.isArray(result?.links) ? result.links : []; + const links = rawLinks + .map((entry: unknown) => { + if (typeof entry === "string") { + return entry.length > 0 ? { url: entry } : null; + } + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const rec = entry as { + url?: unknown; + title?: unknown; + description?: unknown; + }; + if (typeof rec.url !== "string" || rec.url.length === 0) { + return null; + } + return { + url: rec.url, + title: typeof rec.title === "string" ? rec.title : undefined, + description: + typeof rec.description === "string" + ? rec.description + : undefined, + }; + } + return null; + }) + .filter( + ( + link: { url: string; title?: string; description?: string } | null, + ): link is { url: string; title?: string; description?: string } => + link !== null, + ); + + return { success: true, links }; + } catch (error) { + logger.error(`❌ [Firecrawl] Error mapping ${url}:`, error); + return { + success: false, + links: [], + error: error instanceof Error ? error.message : String(error), + }; + } + } } diff --git a/src/lib/ai/web-map-shared.ts b/src/lib/ai/web-map-shared.ts new file mode 100644 index 00000000..c1d562af --- /dev/null +++ b/src/lib/ai/web-map-shared.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +export const MAX_WEB_MAP_LIMIT = 50; +export const MAX_WEB_MAP_LIMIT_HARD_CAP = 200; + +export const WebMapInputSchema = z.object({ + url: z + .string() + .min(1) + .describe( + "The base URL of the site to map. Discovers URLs reachable from this page (sitemap + link discovery). Provide the canonical entry point (e.g. https://docs.python.org/3/ rather than a deep page) when you want a broad map.", + ), + search: z + .string() + .optional() + .describe( + "Optional keyword filter applied server-side. Only URLs whose path/title/description match are returned. Use this to narrow large sites.", + ), + limit: z + .number() + .int() + .min(1) + .max(MAX_WEB_MAP_LIMIT_HARD_CAP) + .optional() + .describe( + `Maximum URLs to return. Defaults to ${MAX_WEB_MAP_LIMIT}. Hard cap ${MAX_WEB_MAP_LIMIT_HARD_CAP} to keep tool responses small.`, + ), + includeSubdomains: z + .boolean() + .optional() + .describe( + "Whether to include URLs on subdomains of the given host. Defaults to false.", + ), + sitemap: z + .enum(["include", "skip", "only"]) + .optional() + .describe( + "How to use the site's sitemap.xml. 'include' (default) merges sitemap + link discovery, 'skip' ignores sitemap, 'only' uses sitemap only.", + ), +}); + +export type WebMapInput = z.infer; + +export const WebMapLinkSchema = z.object({ + url: z.string(), + title: z.string().optional(), + description: z.string().optional(), +}); + +export type WebMapLink = z.infer; + +export const WebMapOutputSchema = z.object({ + text: z + .string() + .describe( + "Human-readable summary of the mapped URLs, used by the model to reason over results without re-shaping the array.", + ), + links: z.array(WebMapLinkSchema), + metadata: z.object({ + sourceUrl: z.string(), + total: z.number(), + truncated: z.boolean(), + error: z.string().nullable().optional(), + }), +}); + +export type WebMapOutput = z.infer; + +export function normalizeWebMapArgs(input: unknown): WebMapInput | null { + const direct = WebMapInputSchema.safeParse(input); + if (direct.success) return direct.data; + + if (!input || typeof input !== "object" || Array.isArray(input)) return null; + + const legacy = input as { jsonInput?: string }; + if (typeof legacy?.jsonInput === "string") { + try { + const parsed = JSON.parse(legacy.jsonInput); + const normalized = WebMapInputSchema.safeParse(parsed); + return normalized.success ? normalized.data : null; + } catch { + return null; + } + } + return null; +} diff --git a/src/lib/chat/types.ts b/src/lib/chat/types.ts index da77854c..d6cbe367 100644 --- a/src/lib/chat/types.ts +++ b/src/lib/chat/types.ts @@ -22,3 +22,28 @@ export type ChatMessage = UIMessage; /** Marker value written to `chat_messages.format` for every new row created after the migration. */ export const CHAT_MESSAGE_FORMAT = "ai-sdk-ui/v1" as const; export type ChatMessageFormat = typeof CHAT_MESSAGE_FORMAT; + +/** + * Returns true if the UI message has at least one user-visible part. Pure structural + * parts (`step-start`) and empty/whitespace text parts don't count. + * + * Used as a guard for persistence and hydration so empty/aborted assistant rows + * (which would otherwise fail `safeValidateUIMessages.parts.nonempty(...)`) never + * make it into the validated message list. + */ +export function hasMeaningfulContent(message: unknown): boolean { + if (!message || typeof message !== "object") return false; + const parts = (message as { parts?: unknown }).parts; + if (!Array.isArray(parts) || parts.length === 0) return false; + return parts.some((part) => { + if (!part || typeof part !== "object") return false; + const type = (part as { type?: unknown }).type; + if (typeof type !== "string") return false; + if (type === "step-start") return false; + if (type === "text" || type === "reasoning") { + const text = (part as { text?: unknown }).text; + return typeof text === "string" && text.trim().length > 0; + } + return true; + }); +} diff --git a/src/lib/utils/format-workspace-context.ts b/src/lib/utils/format-workspace-context.ts index 13fdd5b6..9de65bd5 100644 --- a/src/lib/utils/format-workspace-context.ts +++ b/src/lib/utils/format-workspace-context.ts @@ -144,6 +144,7 @@ WEB SEARCH GUIDELINES: Use web_search when: temporal cues ("today", "latest", "current"), real-time data (scores, stocks, weather), fact verification, niche/recent info. Use internal knowledge for: creative writing, coding, general concepts, summarizing provided content. If the information is time-sensitive, niche, or uncertain, prefer web_search. +Use web_map when the user references a single URL but the answer might require knowing what other pages exist on that site (e.g. "what's on docs.foo.com?", "find the auth section of this docs site", or before calling web_fetch on multiple URLs from the same domain). Pair with web_fetch: web_map first to discover, then web_fetch on the chosen URL(s). Don't use web_map for general web search β€” use web_search. ${getCodeExecutionSystemInstructions()}