diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa7e3ad8..c1c1f8765 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added pagination and UTC time range filtering to the audit GET endpoint. [#949](https://github.com/sourcebot-dev/sourcebot/pull/949) +- Added AI Search Assist — describe what you're looking for in natural language and AI will generate a code search query for you. [#951](https://github.com/sourcebot-dev/sourcebot/pull/951) ### Fixed - Fixed search query parser rejecting parenthesized regex alternation in filter values (e.g. `file:(test|spec)`, `-file:(test|spec)`). [#946](https://github.com/sourcebot-dev/sourcebot/pull/946) diff --git a/docs/docs.json b/docs/docs.json index 2767e357c..dd8f3f620 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -39,6 +39,7 @@ "pages": [ "docs/features/search/overview", "docs/features/search/syntax-reference", + "docs/features/search/ai-search-assist", "docs/features/search/multi-branch-indexing", "docs/features/search/search-contexts" ] diff --git a/docs/docs/features/search/ai-search-assist.mdx b/docs/docs/features/search/ai-search-assist.mdx new file mode 100644 index 000000000..63ed88169 --- /dev/null +++ b/docs/docs/features/search/ai-search-assist.mdx @@ -0,0 +1,26 @@ +--- +title: AI Search Assist +--- + +AI Search Assist lets you describe what you're looking for in natural language and automatically generates a search query for you. + + + + +## How it works + +1. Click the wand icon () in the search bar to open the AI Search Assist panel. +2. Describe what you're looking for in natural language. +3. Click **Generate** or press to generate the query. +4. The generated query is inserted into the search bar. + +## Requirements + +AI Search Assist requires a language model to be configured. See [Configure Language Models](/docs/configuration/language-model-providers) for setup instructions. diff --git a/docs/images/search-assist.mp4 b/docs/images/search-assist.mp4 new file mode 100644 index 000000000..ea371460e Binary files /dev/null and b/docs/images/search-assist.mp4 differ diff --git a/packages/queryLanguage/src/index.ts b/packages/queryLanguage/src/index.ts index 00cfbaade..23a622c36 100644 --- a/packages/queryLanguage/src/index.ts +++ b/packages/queryLanguage/src/index.ts @@ -4,4 +4,5 @@ type Tree = ReturnType; type SyntaxNode = Tree['topNode']; export type { Tree, SyntaxNode }; export * from "./parser"; -export * from "./parser.terms"; \ No newline at end of file +export * from "./parser.terms"; +export { SEARCH_SYNTAX_DESCRIPTION } from "./syntaxDescription"; \ No newline at end of file diff --git a/packages/queryLanguage/src/syntaxDescription.ts b/packages/queryLanguage/src/syntaxDescription.ts new file mode 100644 index 000000000..1f8de0997 --- /dev/null +++ b/packages/queryLanguage/src/syntaxDescription.ts @@ -0,0 +1,99 @@ +/** + * LLM-readable description of the Sourcebot search query syntax. + * Keep this in sync with query.grammar and tokens.ts when the syntax changes. + */ +export const SEARCH_SYNTAX_DESCRIPTION = String.raw` +# Sourcebot Search Query Syntax + +## Search terms + +Bare words search across file content and are interpreted as case-sensitive regular expressions: + useState — matches files containing "useState" + useS?tate — matches files containing "useState" or "usetate" + ^import — matches lines beginning with "import" + error.*handler — matches "error" followed by "handler" on the same line + +Wrap terms in double quotes to match a phrase with spaces: + "password reset" — matches files containing the phrase "password reset" + +## Filters + +Narrow searches with prefix:value syntax: + + file: — filter by file path + lang: — filter by language. Uses linguist language definitions (e.g. TypeScript, Python, Go, Rust, Java) + repo: — filter by repository name + sym: — filter by symbol name + rev: — filter by git branch or tag + +All filter values are interpreted as case-sensitive regular expressions. +A plain word matches as a substring. No forward slashes around values. + +## Boolean logic + +Space = AND. All space-separated terms must match. + useState lang:TypeScript — TypeScript files containing useState + +or = OR (must be lowercase, not at start/end of query). + auth or login — files containing "auth" or "login" + +- = negation. Only valid before a filter or a parenthesized group. + -file:test — exclude paths matching /test/ + -(file:test or file:spec) — exclude test and spec files + +## Grouping + +Parentheses group expressions: + (auth or login) lang:TypeScript + -(file:test or file:spec) + +## Quoting + +Wrap a value in double quotes when it contains spaces: + "password reset" + "error handler" + +When the quoted value itself contains double-quote characters, escape each one as \": + "\"key\": \"value\"" — matches the literal text: "key": "value" + +For unquoted values, escape regex metacharacters with a single backslash: + file:package\.json — matches literal "package.json" + +## Examples + +Input: find all TODO comments +Output: //\s*TODO + +Input: find TypeScript files that use useState +Output: lang:TypeScript useState + +Input: find files that import from react +Output: lang:TypeScript "from \"react\"" + +Input: find all test files +Output: file:(test|spec) + +Input: find all API route handlers +Output: file:route\.(ts|js)$ + +Input: find package.json files that depend on react +Output: file:package\.json "\"react\": \"" + +Input: find package.json files with beta or alpha dependencies +Output: file:package\.json "\"[^\"]+\": \"[^\"]*-(beta|alpha)" + +Input: find package.json files where next is pinned to version 15 +Output: file:package\.json "\"next\": \"\\^?15\\." + +Input: find next versions less than 15 +Output: file:package\.json "\"next\": \"\^?(1[0-4]|[1-9])\." + +Input: find log4j versions 2.3.x or lower +Output: file:package\.json "\"log4j\": \"\^?2\.([0-2]|3)\." + +Input: find TypeScript files that import from react or react-dom +Output: lang:TypeScript "from \"(react|react-dom)\"" + +Input: find files with password reset logic, excluding tests +Output: "password reset" -file:test +`.trim(); diff --git a/packages/web/src/app/[domain]/browse/layout.tsx b/packages/web/src/app/[domain]/browse/layout.tsx index 47b07118d..b5b7d1374 100644 --- a/packages/web/src/app/[domain]/browse/layout.tsx +++ b/packages/web/src/app/[domain]/browse/layout.tsx @@ -1,5 +1,6 @@ import { auth } from "@/auth"; import { LayoutClient } from "./layoutClient"; +import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions"; interface LayoutProps { children: React.ReactNode; @@ -9,8 +10,9 @@ export default async function Layout({ children, }: LayoutProps) { const session = await auth(); + const languageModels = await getConfiguredLanguageModelsInfo(); return ( - + 0}> {children} ) diff --git a/packages/web/src/app/[domain]/browse/layoutClient.tsx b/packages/web/src/app/[domain]/browse/layoutClient.tsx index 87f7b4048..8128b5717 100644 --- a/packages/web/src/app/[domain]/browse/layoutClient.tsx +++ b/packages/web/src/app/[domain]/browse/layoutClient.tsx @@ -16,11 +16,13 @@ import { Session } from "next-auth"; interface LayoutProps { children: React.ReactNode; session: Session | null; + isSearchAssistSupported: boolean; } export function LayoutClient({ children, session, + isSearchAssistSupported, }: LayoutProps) { const { repoName, revisionName } = useBrowseParams(); const domain = useDomain(); @@ -38,6 +40,7 @@ export function LayoutClient({ query: `repo:^${escapeStringRegexp(repoName)}$${revisionName ? ` rev:${revisionName}` : ''} `, }} className="w-full" + isSearchAssistSupported={isSearchAssistSupported} /> void; + onQueryGenerated: (query: string) => void; + className?: string; +} + +export const SearchAssistBox = ({ + isEnabled, + onBlur, + onQueryGenerated, + className, +}: SearchAssistBoxProps) => { + const [query, setQuery] = useState(""); + const boxRef = useRef(null); + const inputRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + const captureEvent = useCaptureEvent(); + + const translateQuery = useCallback(async () => { + if (!query.trim() || isLoading) { + return; + } + + setIsLoading(true); + try { + const result = await translateSearchQuery({ prompt: query }); + if (isServiceError(result)) { + toast({ title: "Failed to generate query", description: result.message ?? "An unexpected error occurred.", variant: "destructive" }); + captureEvent('wa_search_assist_generate_failed', {}); + return; + } + onQueryGenerated(result.query); + captureEvent('wa_search_assist_query_generated', {}); + setQuery(""); + } catch { + toast({ title: "Failed to generate query", description: "An unexpected error occurred.", variant: "destructive" }); + captureEvent('wa_search_assist_generate_failed', {}); + } finally { + setIsLoading(false); + } + }, [query, isLoading, toast, onQueryGenerated, captureEvent]); + + const onExampleClicked = useCallback((example: string) => { + setQuery(example); + inputRef.current?.focus(); + captureEvent('wa_search_assist_example_clicked', { example }); + }, [captureEvent]); + + if (!isEnabled) { + return null; + } + + return ( +
{ + // Don't close if focus is moving to another element within this box + if (boxRef.current?.contains(e.relatedTarget as Node)) { + return; + } + + onBlur(); + }} + > +
+

Generate a query

+ + + + + +

Describe what you're looking for in natural language and AI will generate a search query for you.

+ + Learn more + +
+
+
+
+
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + translateQuery(); + } + }} + disabled={isLoading} + autoFocus + className="focus-visible:ring-0 focus-visible:ring-offset-0 h-9 pr-12" + /> + {!isLoading && query.trim() && ( +
+ +
+ )} +
+ +
+
+ {EXAMPLES.map((example) => ( + + ))} +
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx index dda7ab2ab..1093dba86 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx @@ -44,7 +44,13 @@ import { Toggle } from "@/components/ui/toggle"; import { useDomain } from "@/hooks/useDomain"; import { createAuditAction } from "@/ee/features/audit/actions"; import tailwind from "@/tailwind"; -import { CaseSensitiveIcon, RegexIcon } from "lucide-react"; +import React from "react"; +import Link from "next/link"; +import { CaseSensitiveIcon, RegexIcon, Wand2Icon } from "lucide-react"; +import { SearchAssistBox } from "./searchAssistBox"; +import useCaptureEvent from "@/hooks/useCaptureEvent"; + +const LANGUAGE_MODEL_DOCS_URL = "https://docs.sourcebot.dev/docs/configuration/language-model-providers"; interface SearchBarProps { className?: string; @@ -55,6 +61,7 @@ interface SearchBarProps { query?: string; } autoFocus?: boolean; + isSearchAssistSupported: boolean; } const searchBarKeymap: readonly KeyBinding[] = ([ @@ -100,14 +107,18 @@ export const SearchBar = ({ isRegexEnabled: defaultIsRegexEnabled = false, isCaseSensitivityEnabled: defaultIsCaseSensitivityEnabled = false, query: defaultQuery = "", - } = {} + } = {}, + isSearchAssistSupported, }: SearchBarProps) => { const router = useRouter(); const domain = useDomain(); + const captureEvent = useCaptureEvent(); const suggestionBoxRef = useRef(null); const editorRef = useRef(null); const [cursorPosition, setCursorPosition] = useState(0); - const [isSuggestionsEnabled, setIsSuggestionsEnabled] = useState(false); + const [activePanel, setActivePanel] = useState<'suggestions' | 'searchAssist'>(); + const isSuggestionsEnabled = activePanel === 'suggestions'; + const isSearchAssistEnabled = activePanel === 'searchAssist'; const [isSuggestionsBoxFocused, setIsSuggestionsBoxFocused] = useState(false); const [isHistorySearchEnabled, setIsHistorySearchEnabled] = useState(false); const [isRegexEnabled, setIsRegexEnabled] = useState(defaultIsRegexEnabled); @@ -132,6 +143,7 @@ export const SearchBar = ({ } }, [defaultQuery]) + const { suggestionMode, suggestionQuery } = useSuggestionModeAndQuery({ isSuggestionsEnabled, isHistorySearchEnabled, @@ -194,7 +206,7 @@ export const SearchBar = ({ useHotkeys('/', (event) => { event.preventDefault(); focusEditor(); - setIsSuggestionsEnabled(true); + setActivePanel('suggestions'); if (editorRef.current?.view) { cursorDocEnd({ state: editorRef.current.view.state, @@ -206,14 +218,14 @@ export const SearchBar = ({ // Collapse the suggestions box if the user clicks outside of the search bar container. useClickListener('.search-bar-container', (isElementClicked) => { if (!isElementClicked) { - setIsSuggestionsEnabled(false); + setActivePanel(undefined); } else { - setIsSuggestionsEnabled(true); + setActivePanel(prev => prev ?? 'suggestions'); } }); const onSubmit = useCallback((query: string) => { - setIsSuggestionsEnabled(false); + setActivePanel(undefined); setIsHistorySearchEnabled(false); createAuditAction({ @@ -237,18 +249,20 @@ export const SearchBar = ({ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); - setIsSuggestionsEnabled(false); - onSubmit(query); + if (activePanel !== 'searchAssist') { + setActivePanel(undefined); + onSubmit(query); + } } if (e.key === 'Escape') { e.preventDefault(); - setIsSuggestionsEnabled(false); + setActivePanel(undefined); } - if (e.key === 'ArrowDown') { + if (e.key === 'ArrowDown' && !isSearchAssistEnabled) { e.preventDefault(); - setIsSuggestionsEnabled(true); + setActivePanel('suggestions'); focusSuggestionsBox(); } @@ -257,15 +271,46 @@ export const SearchBar = ({ } }} > - { - setQuery(""); - setIsHistorySearchEnabled(!isHistorySearchEnabled); - setIsSuggestionsEnabled(true); - focusEditor(); - }} - /> +
+ { + setQuery(""); + setIsHistorySearchEnabled(!isHistorySearchEnabled); + setActivePanel('suggestions'); + focusEditor(); + }} + tooltip="Search history" + icon={CounterClockwiseClockIcon} + /> + { + setQuery(""); + setIsHistorySearchEnabled(false); + setActivePanel(prev => { + const next = prev === 'searchAssist' ? undefined : 'searchAssist'; + if (next === 'searchAssist') { + captureEvent('wa_search_assist_opened', {}); + } + return next; + }); + focusEditor(); + }} + tooltip="AI search assist" + icon={Wand2Icon} + preventBlurOnClick + disabled={!isSearchAssistSupported} + disabledTooltip={ + + AI search assist requires a language model to be configured.{" "} + + Learn more + . + + } + /> +
- - - - - - - - - - {isCaseSensitivityEnabled ? "Disable" : "Enable"} case sensitivity - - - - - - - - - - - - {isRegexEnabled ? "Disable" : "Enable"} regular expressions - - + setIsCaseSensitivityEnabled(!isCaseSensitivityEnabled)} + tooltip={`${isCaseSensitivityEnabled ? "Disable" : "Enable"} case sensitivity`} + icon={CaseSensitiveIcon} + + /> + setIsRegexEnabled(!isRegexEnabled)} + tooltip={`${isRegexEnabled ? "Disable" : "Enable"} regular expressions`} + icon={RegexIcon} + + />
+ { + setActivePanel(undefined); + }} + onQueryGenerated={(translatedQuery: string) => { + setQuery(translatedQuery); + editorRef.current?.view?.dispatch({ + changes: { from: 0, to: editorRef.current.view.state.doc.length, insert: translatedQuery }, + selection: { anchor: translatedQuery.length }, + }); + setActivePanel(undefined); + focusEditor(); + // Always enable regex and case sensitivity when using search assist. + setIsRegexEnabled(true); + setIsCaseSensitivityEnabled(true); + }} + /> void + onClick: () => void, + tooltip: React.ReactNode, + icon: React.ElementType, + preventBlurOnClick?: boolean, + disabled?: boolean, + disabledTooltip?: React.ReactNode, }) => { return ( - + {/* @see : https://github.com/shadcn-ui/ui/issues/1988#issuecomment-1980597269 */}
e.preventDefault() : undefined} + disabled={disabled} > - +
- - Search history + + {disabled && disabledTooltip ? disabledTooltip : tooltip}
) -} +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx index 92b6b7168..c5bebb640 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchSuggestionsBox.tsx @@ -17,6 +17,7 @@ import { Separator } from "@/components/ui/separator"; import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { useSyntaxGuide } from "@/app/[domain]/components/syntaxGuideProvider"; import { useRefineModeSuggestions } from "./useRefineModeSuggestions"; +import { cn } from "@/lib/utils"; export type Suggestion = { value: string; @@ -41,6 +42,7 @@ export type SuggestionMode = "context"; interface SearchSuggestionsBoxProps { + className?: string; query: string; suggestionQuery: string; suggestionMode: SuggestionMode; @@ -62,6 +64,7 @@ interface SearchSuggestionsBoxProps { } const SearchSuggestionsBox = forwardRef(({ + className, query, suggestionQuery, suggestionMode, @@ -315,7 +318,7 @@ const SearchSuggestionsBox = forwardRef(({ return (
{ if (e.key === 'Enter') { diff --git a/packages/web/src/app/[domain]/search/components/searchLandingPage.tsx b/packages/web/src/app/[domain]/search/components/searchLandingPage.tsx index 5e96917b8..a0f33757d 100644 --- a/packages/web/src/app/[domain]/search/components/searchLandingPage.tsx +++ b/packages/web/src/app/[domain]/search/components/searchLandingPage.tsx @@ -12,10 +12,12 @@ import { isServiceError } from "@/lib/utils" export interface SearchLandingPageProps { domain: string; + isSearchAssistSupported: boolean; } export const SearchLandingPage = async ({ domain, + isSearchAssistSupported, }: SearchLandingPageProps) => { const carouselRepos = await getRepos({ where: { @@ -47,6 +49,7 @@ export const SearchLandingPage = async ({
diff --git a/packages/web/src/app/[domain]/search/components/searchResultsPage.tsx b/packages/web/src/app/[domain]/search/components/searchResultsPage.tsx index bd2694500..e93609462 100644 --- a/packages/web/src/app/[domain]/search/components/searchResultsPage.tsx +++ b/packages/web/src/app/[domain]/search/components/searchResultsPage.tsx @@ -42,6 +42,7 @@ interface SearchResultsPageProps { isRegexEnabled: boolean; isCaseSensitivityEnabled: boolean; session: Session | null; + isSearchAssistSupported: boolean; } export const SearchResultsPage = ({ @@ -50,6 +51,7 @@ export const SearchResultsPage = ({ isRegexEnabled, isCaseSensitivityEnabled, session, + isSearchAssistSupported, }: SearchResultsPageProps) => { const router = useRouter(); const { setSearchHistory } = useSearchHistory(); @@ -183,6 +185,7 @@ export const SearchResultsPage = ({ query: searchQuery, }} className="w-full" + isSearchAssistSupported={isSearchAssistSupported} /> diff --git a/packages/web/src/app/[domain]/search/page.tsx b/packages/web/src/app/[domain]/search/page.tsx index f85fc60ef..a0667f430 100644 --- a/packages/web/src/app/[domain]/search/page.tsx +++ b/packages/web/src/app/[domain]/search/page.tsx @@ -2,6 +2,7 @@ import { env } from "@sourcebot/shared"; import { SearchLandingPage } from "./components/searchLandingPage"; import { SearchResultsPage } from "./components/searchResultsPage"; import { auth } from "@/auth"; +import { getConfiguredLanguageModelsInfo } from "@/features/chat/actions"; interface SearchPageProps { params: Promise<{ domain: string }>; @@ -20,9 +21,11 @@ export default async function SearchPage(props: SearchPageProps) { const isCaseSensitivityEnabled = searchParams?.isCaseSensitivityEnabled === "true"; const session = await auth(); + const languageModels = await getConfiguredLanguageModelsInfo(); + const isSearchAssistSupported = languageModels.length > 0; if (query === undefined || query.length === 0) { - return + return } return ( @@ -32,6 +35,7 @@ export default async function SearchPage(props: SearchPageProps) { isRegexEnabled={isRegexEnabled} isCaseSensitivityEnabled={isCaseSensitivityEnabled} session={session} + isSearchAssistSupported={isSearchAssistSupported} /> ) } diff --git a/packages/web/src/features/searchAssist/actions.ts b/packages/web/src/features/searchAssist/actions.ts new file mode 100644 index 000000000..d389306db --- /dev/null +++ b/packages/web/src/features/searchAssist/actions.ts @@ -0,0 +1,52 @@ +'use server'; + +import { sew } from "@/actions"; +import { _getAISDKLanguageModelAndOptions, _getConfiguredLanguageModelsFull } from "@/features/chat/actions"; +import { ErrorCode } from "@/lib/errorCodes"; +import { ServiceError } from "@/lib/serviceError"; +import { withOptionalAuthV2 } from "@/withAuthV2"; +import { SEARCH_SYNTAX_DESCRIPTION } from "@sourcebot/query-language"; +import { generateObject } from "ai"; +import { z } from "zod"; +import { StatusCodes } from "http-status-codes"; + +const SYSTEM_PROMPT = `You are a search query translator for Sourcebot, a code search engine. + +Your job is to convert a natural language description into a valid Sourcebot search query. + +${SEARCH_SYNTAX_DESCRIPTION} + +## Instructions + +- Output ONLY the search query string. Do not include any explanation, markdown formatting, code fences, or surrounding text. +- Use the most specific filters that match the user's intent. +- Use regex values (bare word with regex syntax) when the user implies patterns, ranges, or variations. +- Keep the query as simple as possible while accurately capturing the intent. +`; + +export const translateSearchQuery = async ({ prompt }: { prompt: string }) => sew(() => + withOptionalAuthV2(async () => { + const models = await _getConfiguredLanguageModelsFull(); + + if (models.length === 0) { + return { + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.INVALID_REQUEST_BODY, + message: 'No language models are configured.', + } satisfies ServiceError; + } + + const { model } = await _getAISDKLanguageModelAndOptions(models[0]); + + const { object } = await generateObject({ + model, + system: SYSTEM_PROMPT, + prompt, + schema: z.object({ + query: z.string().describe("The Sourcebot search query."), + }), + }); + + return { query: object.query }; + }) +); diff --git a/packages/web/src/lib/newsData.ts b/packages/web/src/lib/newsData.ts index 9d23f6820..102ad760f 100644 --- a/packages/web/src/lib/newsData.ts +++ b/packages/web/src/lib/newsData.ts @@ -1,6 +1,12 @@ import { NewsItem } from "./types"; export const newsData: NewsItem[] = [ + { + unique_id: "ai-search-assist", + header: "AI Search Assist", + sub_header: "Describe what you're looking for in natural language and AI will generate a code search query for you.", + url: "https://docs.sourcebot.dev/docs/features/search/ai-search-assist" + }, { unique_id: "chat-sharing", header: "Chat Sharing", diff --git a/packages/web/src/lib/posthogEvents.ts b/packages/web/src/lib/posthogEvents.ts index 3c7f51cc9..01f86522f 100644 --- a/packages/web/src/lib/posthogEvents.ts +++ b/packages/web/src/lib/posthogEvents.ts @@ -235,6 +235,13 @@ export type PosthogEventMap = { wa_askgh_login_wall_prompted: {}, ////////////////////////////////////////////////////////////////// wa_demo_docs_link_pressed: {}, + wa_search_assist_opened: {}, + wa_search_assist_query_generated: {}, + wa_search_assist_generate_failed: {}, + wa_search_assist_example_clicked: { + example: string, + }, + ////////////////////////////////////////////////////////////////// wa_demo_search_example_card_pressed: { exampleTitle: string, exampleUrl: string,