diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 52d41fb8aa..b489f4dcc2 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -21,7 +21,7 @@ import type { VisibilityType } from "@/components/visibility-selector"; import { entitlementsByUserType } from "@/lib/ai/entitlements"; import type { ChatModel } from "@/lib/ai/models"; import { type RequestHints, systemPrompt } from "@/lib/ai/prompts"; -import { myProvider } from "@/lib/ai/providers"; +import { buildProviderOptions, myProvider } from "@/lib/ai/providers"; import { createDocument } from "@/lib/ai/tools/create-document"; import { getWeather } from "@/lib/ai/tools/get-weather"; import { requestSuggestions } from "@/lib/ai/tools/request-suggestions"; @@ -39,7 +39,7 @@ import { } from "@/lib/db/queries"; import type { DBMessage } from "@/lib/db/schema"; import { ChatSDKError } from "@/lib/errors"; -import type { ChatMessage } from "@/lib/types"; +import type { ChatMessage, SearchSource } from "@/lib/types"; import type { AppUsage } from "@/lib/usage"; import { convertToUIMessages, generateUUID } from "@/lib/utils"; import { generateTitleFromUserMessage } from "../../actions"; @@ -168,6 +168,7 @@ export async function POST(request: Request) { parts: message.parts, attachments: [], createdAt: new Date(), + searchResults: null, }, ], }); @@ -176,6 +177,7 @@ export async function POST(request: Request) { await createStreamId({ streamId, chatId: id }); let finalMergedUsage: AppUsage | undefined; + let searchResultsData: SearchSource[] | null = null; const stream = createUIMessageStream({ execute: ({ writer: dataStream }) => { @@ -203,6 +205,7 @@ export async function POST(request: Request) { dataStream, }), }, + providerOptions: buildProviderOptions(selectedChatModel), experimental_telemetry: { isEnabled: isProductionEnvironment, functionId: "stream-text", @@ -241,6 +244,46 @@ export async function POST(request: Request) { }, }); + (async () => { + try { + const sources = await result.sources; + + if (sources && sources.length > 0) { + + const validSources = sources.filter( + (source): source is typeof source & { url: string } => + "url" in source && typeof source.url === "string" + ); + + // Deduplicate by URL - workaround for AI SDK duplicate sources issue + // TODO: Remove once https://github.com/vercel/ai/issues/9771 is fixed + const seenUrls = new Set(); + const uniqueSources = validSources.filter((source) => { + if (seenUrls.has(source.url)) { + return false; + } + seenUrls.add(source.url); + return true; + }); + + if (uniqueSources.length > 0) { + searchResultsData = uniqueSources.map((source) => ({ + title: source.title || source.url, + url: source.url, + favicon: `https://www.google.com/s2/favicons?domain=${new URL(source.url).hostname}&sz=32`, + })); + + dataStream.write({ + type: "data-searchResults", + data: searchResultsData, + }); + } + } + } catch (error) { + console.error("Error processing search results:", error); + } + })(); + result.consumeStream(); dataStream.merge( @@ -259,6 +302,9 @@ export async function POST(request: Request) { createdAt: new Date(), attachments: [], chatId: id, + // Add search results to assistant messages + searchResults: + currentMessage.role === "assistant" ? searchResultsData : null, })), }); diff --git a/components/message.tsx b/components/message.tsx index 41fd613b98..45cc6e1e7e 100644 --- a/components/message.tsx +++ b/components/message.tsx @@ -24,6 +24,7 @@ import { MessageEditor } from "./message-editor"; import { MessageReasoning } from "./message-reasoning"; import { PreviewAttachment } from "./preview-attachment"; import { Weather } from "./weather"; +import { SearchResults, SearchingIndicator } from "./search-results"; const PurePreviewMessage = ({ chatId, @@ -52,6 +53,18 @@ const PurePreviewMessage = ({ useDataStream(); + // Check if search results are in the message parts + const searchResultsPart = message.parts?.find( + (part) => part.type === "data-searchResults" + ); + + const searchResults = + searchResultsPart && "data" in searchResultsPart && searchResultsPart.data + ? searchResultsPart.data + : undefined; + + // Check if we should show searching indicator (message is loading and assistant role) + const isSearching = isLoading && message.role === "assistant" && (!searchResults || searchResults.length === 0); return ( )} + {message.role === "assistant" && (isSearching || searchResults) && ( +
+ {searchResults ? ( + + ) : ( + + )} +
+ )} + {message.parts?.map((part, index) => { const { type } = part; const key = `message-${message.id}-part-${index}`; diff --git a/components/search-results.tsx b/components/search-results.tsx new file mode 100644 index 0000000000..3e868b6adf --- /dev/null +++ b/components/search-results.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { GlobeIcon } from "lucide-react"; +import type { SearchSource } from "@/lib/types"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { ChevronDownIcon } from "lucide-react"; +import { useState } from "react"; +import Link from "next/link"; + + +export function SearchResults({ searchResult }: { searchResult: SearchSource[] }) { + const [isOpen, setIsOpen] = useState(false); + + if (!searchResult || !Array.isArray(searchResult) || searchResult.length === 0) { + return null; + } + + return ( + + + + + Searched web + +
+ + {searchResult.length} {searchResult.length === 1 ? "result" : "results"} + + +
+
+ + +
+ {searchResult.map((source, index) => ( + +
+ {source.favicon ? ( + { + e.currentTarget.style.display = "none"; + }} + /> + ) : ( + + )} +
+ + {source.title} + + + {new URL(source.url).hostname.replace('www.', '')} + + + ))} +
+
+
+ ); +} + +export function SearchingIndicator() { + return ( +
+ + Searching the web +
+ ); +} diff --git a/lib/ai/models.ts b/lib/ai/models.ts index 5696bb57e7..f0639be2ff 100644 --- a/lib/ai/models.ts +++ b/lib/ai/models.ts @@ -4,6 +4,9 @@ export type ChatModel = { id: string; name: string; description: string; + capabilities?: { + webSearch?: boolean; + }; }; export const chatModels: ChatModel[] = [ @@ -11,11 +14,26 @@ export const chatModels: ChatModel[] = [ id: "chat-model", name: "Grok Vision", description: "Advanced multimodal model with vision and text capabilities", + capabilities: { + webSearch: true, + }, }, { id: "chat-model-reasoning", name: "Grok Reasoning", description: "Uses advanced chain-of-thought reasoning for complex problems", + capabilities: { + webSearch: true, + }, }, ]; + +export function getModelCapabilities(modelId: string) { + const model = chatModels.find((m) => m.id === modelId); + return model?.capabilities || {}; +} + +export function supportsWebSearch(modelId: string): boolean { + return getModelCapabilities(modelId).webSearch === true; +} diff --git a/lib/ai/providers.ts b/lib/ai/providers.ts index 660e412bed..6daf53b756 100644 --- a/lib/ai/providers.ts +++ b/lib/ai/providers.ts @@ -5,6 +5,7 @@ import { wrapLanguageModel, } from "ai"; import { isTestEnvironment } from "../constants"; +import { supportsWebSearch } from "./models"; export const myProvider = isTestEnvironment ? (() => { @@ -34,3 +35,24 @@ export const myProvider = isTestEnvironment "artifact-model": gateway.languageModel("xai/grok-2-1212"), }, }); + +/** + * Builds provider-specific options based on model capabilities + */ +export function buildProviderOptions(modelId: string) { + if (!supportsWebSearch(modelId)) { + return; + } + + const options = { + xai: { + searchParameters: { + mode: "auto" as const, + returnCitations: true, + maxSearchResults: 10, + }, + }, + }; + + return options; +} diff --git a/lib/db/migrations/0008_left_giant_girl.sql b/lib/db/migrations/0008_left_giant_girl.sql new file mode 100644 index 0000000000..563c3e0de8 --- /dev/null +++ b/lib/db/migrations/0008_left_giant_girl.sql @@ -0,0 +1 @@ +ALTER TABLE "Message_v2" ADD COLUMN "searchResults" jsonb; \ No newline at end of file diff --git a/lib/db/migrations/meta/0008_snapshot.json b/lib/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000000..7a24abafc1 --- /dev/null +++ b/lib/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,577 @@ +{ + "id": "e13f85ed-4f6c-4b13-8b2e-a946025162fa", + "prevId": "097660a7-976a-4b3e-8ebb-79312e3ece6f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Chat": { + "name": "Chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "lastContext": { + "name": "lastContext", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "Chat_userId_User_id_fk": { + "name": "Chat_userId_User_id_fk", + "tableFrom": "Chat", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Document": { + "name": "Document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Document_userId_User_id_fk": { + "name": "Document_userId_User_id_fk", + "tableFrom": "Document", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Document_id_createdAt_pk": { + "name": "Document_id_createdAt_pk", + "columns": [ + "id", + "createdAt" + ] + } + }, + "uniqueConstraints": {} + }, + "public.Message_v2": { + "name": "Message_v2", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "searchResults": { + "name": "searchResults", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Message_v2_chatId_Chat_id_fk": { + "name": "Message_v2_chatId_Chat_id_fk", + "tableFrom": "Message_v2", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Message": { + "name": "Message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Message_chatId_Chat_id_fk": { + "name": "Message_chatId_Chat_id_fk", + "tableFrom": "Message", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Stream": { + "name": "Stream", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Stream_chatId_Chat_id_fk": { + "name": "Stream_chatId_Chat_id_fk", + "tableFrom": "Stream", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Stream_id_pk": { + "name": "Stream_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.Suggestion": { + "name": "Suggestion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "documentId": { + "name": "documentId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "documentCreatedAt": { + "name": "documentCreatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "originalText": { + "name": "originalText", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggestedText": { + "name": "suggestedText", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isResolved": { + "name": "isResolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Suggestion_userId_User_id_fk": { + "name": "Suggestion_userId_User_id_fk", + "tableFrom": "Suggestion", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk": { + "name": "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk", + "tableFrom": "Suggestion", + "tableTo": "Document", + "columnsFrom": [ + "documentId", + "documentCreatedAt" + ], + "columnsTo": [ + "id", + "createdAt" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Suggestion_id_pk": { + "name": "Suggestion_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Vote_v2": { + "name": "Vote_v2", + "schema": "", + "columns": { + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "isUpvoted": { + "name": "isUpvoted", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Vote_v2_chatId_Chat_id_fk": { + "name": "Vote_v2_chatId_Chat_id_fk", + "tableFrom": "Vote_v2", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Vote_v2_messageId_Message_v2_id_fk": { + "name": "Vote_v2_messageId_Message_v2_id_fk", + "tableFrom": "Vote_v2", + "tableTo": "Message_v2", + "columnsFrom": [ + "messageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Vote_v2_chatId_messageId_pk": { + "name": "Vote_v2_chatId_messageId_pk", + "columns": [ + "chatId", + "messageId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.Vote": { + "name": "Vote", + "schema": "", + "columns": { + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "isUpvoted": { + "name": "isUpvoted", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Vote_chatId_Chat_id_fk": { + "name": "Vote_chatId_Chat_id_fk", + "tableFrom": "Vote", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Vote_messageId_Message_id_fk": { + "name": "Vote_messageId_Message_id_fk", + "tableFrom": "Vote", + "tableTo": "Message", + "columnsFrom": [ + "messageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Vote_chatId_messageId_pk": { + "name": "Vote_chatId_messageId_pk", + "columns": [ + "chatId", + "messageId" + ] + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 6440daa1ce..4d3c4e4cde 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1757362773211, "tag": "0007_flowery_ben_parker", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1762604749298, + "tag": "0008_left_giant_girl", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index e85da0efd1..50463461ea 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -58,6 +58,7 @@ export const message = pgTable("Message_v2", { role: varchar("role").notNull(), parts: json("parts").notNull(), attachments: json("attachments").notNull(), + searchResults: jsonb("searchResults"), createdAt: timestamp("createdAt").notNull(), }); diff --git a/lib/types.ts b/lib/types.ts index 4a9ad92bdc..d660b04250 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -30,6 +30,13 @@ export type ChatTools = { requestSuggestions: requestSuggestionsTool; }; +export type SearchSource = { + title: string; + url: string; + favicon?: string; +}; + + export type CustomUIDataTypes = { textDelta: string; imageDelta: string; @@ -43,6 +50,7 @@ export type CustomUIDataTypes = { clear: null; finish: null; usage: AppUsage; + searchResults: SearchSource[]; }; export type ChatMessage = UIMessage< diff --git a/lib/utils.ts b/lib/utils.ts index d94b492a9c..fa8dafb7ec 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -98,14 +98,30 @@ export function sanitizeText(text: string) { } export function convertToUIMessages(messages: DBMessage[]): ChatMessage[] { - return messages.map((message) => ({ - id: message.id, - role: message.role as 'user' | 'assistant' | 'system', - parts: message.parts as UIMessagePart[], - metadata: { - createdAt: formatISO(message.createdAt), - }, - })); + return messages.map((message) => { + const parts = [...(message.parts as UIMessagePart[])]; + + if (message.searchResults) { + // Handle both old format {query, sources} and new format [sources] + const searchResultsData = Array.isArray(message.searchResults) + ? message.searchResults + : (message.searchResults as any).sources || []; + + parts.push({ + type: 'data-searchResults', + data: searchResultsData, + } as UIMessagePart); + } + + return { + id: message.id, + role: message.role as 'user' | 'assistant' | 'system', + parts, + metadata: { + createdAt: formatISO(message.createdAt), + }, + }; + }); } export function getTextFromMessage(message: ChatMessage | UIMessage): string {