mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 16:14:28 +00:00
feat: add search provider settings page (#429)
* feat: add search provider settings page with 5 engine options Allow users to select their preferred search engine (Google, DuckDuckGo, Bing, Brave Search, Yahoo) from a new settings page. The selected provider drives search suggestions, search URL navigation, placeholder text, and analytics tracking. Replaces all hardcoded Google references with the stored preference. Adds Brave Search support, replacing Yandex. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add error handling for search provider storage writes Write to storage before updating React state so UI never diverges from persisted value on failure. Add try/catch in the settings page to show an error toast if the write fails. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
MessageSquare,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Server,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
@@ -33,6 +34,7 @@ const settingsNavItems: NavItem[] = [
|
||||
icon: Palette,
|
||||
feature: Feature.CUSTOMIZATION_SUPPORT,
|
||||
},
|
||||
{ name: 'Search Provider', to: '/settings/search', icon: Search },
|
||||
{
|
||||
name: 'Agent Soul',
|
||||
to: '/settings/soul',
|
||||
|
||||
@@ -23,6 +23,7 @@ import { MagicLinkCallback } from './login/MagicLinkCallback'
|
||||
import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage'
|
||||
import { ProfilePage } from './profile/ProfilePage'
|
||||
import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage'
|
||||
import { SearchProviderPage } from './search-provider/SearchProviderPage'
|
||||
import { SoulPage } from './soul/SoulPage'
|
||||
import { WorkflowsPageWrapper } from './workflows/WorkflowsPageWrapper'
|
||||
|
||||
@@ -44,6 +45,7 @@ const OptionsRedirect: FC = () => {
|
||||
'connect-mcp': '/connect-apps',
|
||||
mcp: '/settings/mcp',
|
||||
customization: '/settings/customization',
|
||||
search: '/settings/search',
|
||||
soul: '/settings/soul',
|
||||
'jtbd-agent': '/settings/survey',
|
||||
workflows: '/workflows',
|
||||
@@ -91,6 +93,7 @@ export const App: FC = () => {
|
||||
<Route path="chat" element={<LlmHubPage />} />
|
||||
<Route path="mcp" element={<MCPSettingsPage />} />
|
||||
<Route path="customization" element={<CustomizationPage />} />
|
||||
<Route path="search" element={<SearchProviderPage />} />
|
||||
<Route path="soul" element={<SoulPage />} />
|
||||
<Route path="survey" element={<SurveyPage {...surveyParams} />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { SearchProviderConfig } from '@/lib/search-provider/providers'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SearchProviderCardProps {
|
||||
provider: SearchProviderConfig
|
||||
isSelected: boolean
|
||||
onSelect: (provider: SearchProviderConfig) => void
|
||||
}
|
||||
|
||||
export const SearchProviderCard: FC<SearchProviderCardProps> = ({
|
||||
provider,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(provider)}
|
||||
className={cn(
|
||||
'group relative flex w-full flex-col items-start gap-3 rounded-xl border p-5 text-left transition-all',
|
||||
'hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isSelected
|
||||
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]/5 shadow-sm'
|
||||
: 'border-border bg-card shadow-sm hover:border-[var(--accent-orange)]/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'font-semibold text-base transition-colors',
|
||||
isSelected && 'text-[var(--accent-orange)]',
|
||||
)}
|
||||
>
|
||||
{provider.name}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 transition-all',
|
||||
isSelected
|
||||
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]'
|
||||
: 'border-muted-foreground/30 group-hover:border-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-white" />}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{provider.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SearchProviders } from '@/entrypoints/newtab/index/lib/searchSuggestions/SearchProviders'
|
||||
import type { SearchProviderConfig } from '@/lib/search-provider/providers'
|
||||
import { SEARCH_PROVIDERS } from '@/lib/search-provider/providers'
|
||||
import { SearchProviderCard } from './SearchProviderCard'
|
||||
|
||||
interface SearchProviderGridProps {
|
||||
selectedProvider: SearchProviders
|
||||
onSelectProvider: (provider: SearchProviderConfig) => void
|
||||
}
|
||||
|
||||
export const SearchProviderGrid: FC<SearchProviderGridProps> = ({
|
||||
selectedProvider,
|
||||
onSelectProvider,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
|
||||
<h3 className="mb-4 font-semibold text-lg">Available Search Engines</h3>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{SEARCH_PROVIDERS.map((provider) => (
|
||||
<SearchProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
isSelected={provider.id === selectedProvider}
|
||||
onSelect={onSelectProvider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import type { SearchProviderConfig } from '@/lib/search-provider/providers'
|
||||
|
||||
interface SearchProviderHeaderProps {
|
||||
activeProvider: SearchProviderConfig
|
||||
}
|
||||
|
||||
export const SearchProviderHeader: FC<SearchProviderHeaderProps> = ({
|
||||
activeProvider,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10">
|
||||
<Search className="h-6 w-6 text-[var(--accent-orange)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="mb-1 font-semibold text-xl">Search Provider</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Choose the default search engine for your browser's address bar and
|
||||
new tab page
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Currently using:
|
||||
</span>
|
||||
<span className="font-medium text-sm">{activeProvider.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FC } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { SEARCH_PROVIDER_CHANGED_EVENT } from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import type { SearchProviderConfig } from '@/lib/search-provider/providers'
|
||||
import { getProviderConfig } from '@/lib/search-provider/providers'
|
||||
import { useSearchProvider } from '@/lib/search-provider/search-provider-storage'
|
||||
import { SearchProviderGrid } from './SearchProviderGrid'
|
||||
import { SearchProviderHeader } from './SearchProviderHeader'
|
||||
|
||||
export const SearchProviderPage: FC = () => {
|
||||
const { provider, setProvider } = useSearchProvider()
|
||||
const activeConfig = getProviderConfig(provider)
|
||||
|
||||
const handleSelectProvider = async (selected: SearchProviderConfig) => {
|
||||
if (selected.id === provider) return
|
||||
|
||||
try {
|
||||
await setProvider(selected.id)
|
||||
track(SEARCH_PROVIDER_CHANGED_EVENT, {
|
||||
provider: selected.id,
|
||||
previous_provider: provider,
|
||||
})
|
||||
toast.success(`Search provider changed to ${selected.name}`)
|
||||
} catch {
|
||||
toast.error('Failed to save search provider')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
||||
<SearchProviderHeader activeProvider={activeConfig} />
|
||||
<SearchProviderGrid
|
||||
selectedProvider={provider}
|
||||
onSelectProvider={handleSelectProvider}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export const NewTab = () => {
|
||||
setSelectedTabs((prev) => prev.filter((t) => t.id !== tabId))
|
||||
}
|
||||
|
||||
const { sections, flatItems } = useSuggestions({
|
||||
const { sections, flatItems, providerConfig } = useSuggestions({
|
||||
query: inputValue,
|
||||
selectedTabs,
|
||||
})
|
||||
@@ -290,9 +290,11 @@ export const NewTab = () => {
|
||||
|
||||
switch (item.type) {
|
||||
case 'search':
|
||||
track(NEWTAB_SEARCH_EXECUTED_EVENT, { search_engine: 'google' })
|
||||
track(NEWTAB_SEARCH_EXECUTED_EVENT, {
|
||||
search_engine: providerConfig.id,
|
||||
})
|
||||
window.open(
|
||||
`https://www.google.com/search?q=${encodeURIComponent(item.query)}`,
|
||||
`${providerConfig.searchUrl}${encodeURIComponent(item.query)}`,
|
||||
'_self',
|
||||
)
|
||||
break
|
||||
@@ -422,7 +424,7 @@ export const NewTab = () => {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ask AI or search Google..."
|
||||
placeholder={`Ask AI or search ${providerConfig.name}...`}
|
||||
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||
{...getInputProps({
|
||||
ref: inputRef,
|
||||
|
||||
@@ -6,4 +6,4 @@ export type SearchProviders =
|
||||
| 'bing'
|
||||
| 'yahoo'
|
||||
| 'duckduckgo'
|
||||
| 'yandex'
|
||||
| 'brave'
|
||||
|
||||
@@ -36,9 +36,9 @@ const getDuckDuckGoSuggestions = async (query: string): Promise<string[]> => {
|
||||
return data[1] || []
|
||||
}
|
||||
|
||||
const getYandexSuggestions = async (query: string): Promise<string[]> => {
|
||||
const getBraveSuggestions = async (query: string): Promise<string[]> => {
|
||||
const response = await fetch(
|
||||
`https://suggest.yandex.com/suggest-ff.cgi?part=${encodeURIComponent(query)}&uil=en&v=3`,
|
||||
`https://search.brave.com/api/suggest?q=${encodeURIComponent(query)}`,
|
||||
)
|
||||
const data = await response.json()
|
||||
return data[1] || []
|
||||
@@ -60,8 +60,8 @@ export const getSearchSuggestions = async ([searchEngine, query]: [
|
||||
return getYahooIndiaSuggestions(query)
|
||||
case 'duckduckgo':
|
||||
return getDuckDuckGoSuggestions(query)
|
||||
case 'yandex':
|
||||
return getYandexSuggestions(query)
|
||||
case 'brave':
|
||||
return getBraveSuggestions(query)
|
||||
default:
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMemo } from 'react'
|
||||
import { getProviderConfig } from '@/lib/search-provider/providers'
|
||||
import { useSearchProvider } from '@/lib/search-provider/search-provider-storage'
|
||||
import { useAITabSuggestions } from '../aiTabSuggestions/useAITabSuggestions'
|
||||
import { useBrowserOSSuggestions } from '../browserOSSuggestions/useBrowserOSSuggestions'
|
||||
import { useSearchSuggestions } from '../searchSuggestions/useSearchSuggestions'
|
||||
@@ -19,9 +21,12 @@ interface UseSuggestionsArgs {
|
||||
* @public
|
||||
*/
|
||||
export const useSuggestions = ({ query, selectedTabs }: UseSuggestionsArgs) => {
|
||||
const { provider } = useSearchProvider()
|
||||
const providerConfig = getProviderConfig(provider)
|
||||
|
||||
const { data: searchResultsFromAPI } = useSearchSuggestions({
|
||||
query,
|
||||
searchEngine: 'google',
|
||||
searchEngine: provider,
|
||||
})
|
||||
|
||||
const searchResults: string[] = useMemo(() => {
|
||||
@@ -81,8 +86,8 @@ export const useSuggestions = ({ query, selectedTabs }: UseSuggestionsArgs) => {
|
||||
}),
|
||||
)
|
||||
result.push({
|
||||
id: 'google-search',
|
||||
title: 'Google Search',
|
||||
id: 'search',
|
||||
title: `${providerConfig.name} Search`,
|
||||
items: searchItems,
|
||||
})
|
||||
}
|
||||
@@ -94,6 +99,7 @@ export const useSuggestions = ({ query, selectedTabs }: UseSuggestionsArgs) => {
|
||||
selectedTabs.length,
|
||||
aiTabResults,
|
||||
searchResults,
|
||||
providerConfig.name,
|
||||
])
|
||||
|
||||
const flatItems = useMemo(
|
||||
@@ -101,7 +107,7 @@ export const useSuggestions = ({ query, selectedTabs }: UseSuggestionsArgs) => {
|
||||
[sections],
|
||||
)
|
||||
|
||||
return { sections, flatItems }
|
||||
return { sections, flatItems, providerConfig }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -177,6 +177,9 @@ export const SCHEDULED_TASK_RETRIED_EVENT = 'settings.scheduled_task.retried'
|
||||
/** @public */
|
||||
export const JTBD_POPUP_DISMISSED_EVENT = 'ui.jtbd_popup.dismissed'
|
||||
|
||||
/** @public */
|
||||
export const SEARCH_PROVIDER_CHANGED_EVENT = 'settings.search_provider.changed'
|
||||
|
||||
/** @public */
|
||||
export const ONBOARDING_STARTED_EVENT = 'onboarding.started'
|
||||
|
||||
|
||||
45
apps/agent/lib/search-provider/providers.ts
Normal file
45
apps/agent/lib/search-provider/providers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SearchProviders } from '@/entrypoints/newtab/index/lib/searchSuggestions/SearchProviders'
|
||||
|
||||
export interface SearchProviderConfig {
|
||||
id: SearchProviders
|
||||
name: string
|
||||
searchUrl: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const SEARCH_PROVIDERS: SearchProviderConfig[] = [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
searchUrl: 'https://www.google.com/search?q=',
|
||||
description: 'The most popular search engine worldwide',
|
||||
},
|
||||
{
|
||||
id: 'duckduckgo',
|
||||
name: 'DuckDuckGo',
|
||||
searchUrl: 'https://duckduckgo.com/?q=',
|
||||
description: 'Privacy-focused search with no tracking',
|
||||
},
|
||||
{
|
||||
id: 'bing',
|
||||
name: 'Bing',
|
||||
searchUrl: 'https://www.bing.com/search?q=',
|
||||
description: 'Microsoft search with AI-powered answers',
|
||||
},
|
||||
{
|
||||
id: 'brave',
|
||||
name: 'Brave Search',
|
||||
searchUrl: 'https://search.brave.com/search?q=',
|
||||
description: 'Independent search with its own index',
|
||||
},
|
||||
{
|
||||
id: 'yahoo',
|
||||
name: 'Yahoo',
|
||||
searchUrl: 'https://search.yahoo.com/search?p=',
|
||||
description: 'Classic search with news and web results',
|
||||
},
|
||||
]
|
||||
|
||||
export function getProviderConfig(id: SearchProviders): SearchProviderConfig {
|
||||
return SEARCH_PROVIDERS.find((p) => p.id === id) ?? SEARCH_PROVIDERS[0]
|
||||
}
|
||||
32
apps/agent/lib/search-provider/search-provider-storage.ts
Normal file
32
apps/agent/lib/search-provider/search-provider-storage.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { storage } from '@wxt-dev/storage'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { SearchProviders } from '@/entrypoints/newtab/index/lib/searchSuggestions/SearchProviders'
|
||||
|
||||
const DEFAULT_PROVIDER: SearchProviders = 'google'
|
||||
|
||||
export const searchProviderStorage = storage.defineItem<SearchProviders>(
|
||||
'local:search-provider',
|
||||
{ fallback: DEFAULT_PROVIDER },
|
||||
)
|
||||
|
||||
export function useSearchProvider() {
|
||||
const [provider, setProviderState] =
|
||||
useState<SearchProviders>(DEFAULT_PROVIDER)
|
||||
|
||||
useEffect(() => {
|
||||
searchProviderStorage.getValue().then((value) => {
|
||||
setProviderState(value ?? DEFAULT_PROVIDER)
|
||||
})
|
||||
const unwatch = searchProviderStorage.watch((newValue) => {
|
||||
setProviderState(newValue ?? DEFAULT_PROVIDER)
|
||||
})
|
||||
return unwatch
|
||||
}, [])
|
||||
|
||||
const setProvider = useCallback(async (value: SearchProviders) => {
|
||||
await searchProviderStorage.setValue(value)
|
||||
setProviderState(value)
|
||||
}, [])
|
||||
|
||||
return { provider, setProvider }
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export default defineConfig({
|
||||
'https://api.bing.com/*',
|
||||
'https://in.search.yahoo.com/*',
|
||||
'https://duckduckgo.com/*',
|
||||
'https://suggest.yandex.com/*',
|
||||
'https://search.brave.com/*',
|
||||
],
|
||||
},
|
||||
vite: () => ({
|
||||
|
||||
Reference in New Issue
Block a user