Skip to content

Commit 0e31c22

Browse files
committed
feat(search): add web search functionality with multiple providers
- Implemented a new search page with configuration options for various web search providers (Brave, Perplexity, Grok, Gemini, Kimi). - Added a model selector component to allow users to choose specific models for the selected provider. - Created a search playground for testing queries against the configured agents and displaying results. - Introduced a status bar to show the active provider, model, cache TTL, and configured keys. - Developed hooks for managing search configuration and playground state, including saving model settings and running searches. - Added provider cards to display configuration status for each search provider. - Included error handling and loading states for improved user experience.
1 parent 7dab85a commit 0e31c22

11 files changed

Lines changed: 1067 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@
77
### Added
88

99

10+
#### Web Search `/search`
11+
- **Web Search page** — new route `/search`; nav item (Search icon) in sidebar; wrapped with `PageErrorBoundary`
12+
- **Provider Status Bar** — 4 tiles from `config.get`: Active Provider (accent only when *that* provider's key is configured), Model, Cache TTL, Keys Configured (n/5 from config-stored keys only; full env-var detection in Phase 7)
13+
- **Provider Cards** — 5 cards for all supported providers: `brave · perplexity · grok · gemini · kimi`; configured status from `config.get`; active badge; env var name shown; Perplexity card notes OpenRouter `baseUrl` override
14+
- **Model Selector** — shown when active provider has a model field; Perplexity: 3 preset buttons (sonar / sonar-pro / sonar-reasoning-pro); Grok / Gemini / Kimi: free-form text input with documented default as placeholder; saves via `config.patch` merge; re-syncs when config refreshes externally
15+
- **Search Playground** — agent dropdown (from gateway store) + session dropdown (filtered by agent); query input + result count (1 / 3 / 5 / 10); CLI equivalent preview with copy button; runs search via `chat.send` (not `chat.inject` — which does not trigger agent processing); streams response via Gateway `chat` broadcast events; result panel shows status badge · provider · agent · duration and renders response as markdown (same renderer as `/chat`)
16+
- **No-provider warning banner** — shown when zero API keys are set in config; includes setup guide link
17+
- **`PROVIDER_LIST`** constant in `search/types.ts` — single source of truth for the 5-provider ordered list; used by provider cards, warning banner, and status bar
18+
- **Load error state** — inline error panel when `config.get` fails on initial load (instead of blank page); `loadError` tracked in `useSearchConfig`
19+
- **Gateway types**`WebSearchProvider`, `WebSearchConfig`, `PlaygroundState` added to `src/app/search/types.ts`
20+
1021
#### Browser `/browser`
1122
- **Browser page** — new route `/browser`; nav item (Globe) in sidebar
1223
- **Browser Status card** — probes `browser.request GET /` on load; shows Running · CDP Ready · Profile · Browser tiles; disabled state (browser control off) with docs link
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { CheckCircle } from 'lucide-react'
2+
import { useEffect, useState } from 'react'
3+
import { Button } from '@/components/ui/button'
4+
import { Input } from '@/components/ui/input'
5+
import { Label } from '@/components/ui/label'
6+
import { cn } from '@/lib/utils'
7+
import type { WebSearchConfig, WebSearchProvider } from '../types'
8+
9+
// Perplexity has well-known preset models; others use a free-form text input.
10+
const PERPLEXITY_PRESETS = [
11+
{ id: 'perplexity/sonar', label: 'Sonar', description: 'Quick Q&A lookups' },
12+
{ id: 'perplexity/sonar-pro', label: 'Sonar Pro', description: 'Complex multi-step (default)' },
13+
{ id: 'perplexity/sonar-reasoning-pro', label: 'Sonar Reasoning Pro', description: 'Deep chain-of-thought' },
14+
] as const
15+
16+
const TEXT_INPUT_PROVIDERS: Partial<Record<WebSearchProvider, { placeholder: string }>> = {
17+
grok: { placeholder: 'grok-4-1-fast' },
18+
gemini: { placeholder: 'gemini-2.5-flash' },
19+
kimi: { placeholder: 'moonshot-v1-128k' },
20+
}
21+
22+
function currentModel(provider: WebSearchProvider, cfg: WebSearchConfig): string {
23+
if (provider === 'perplexity') return cfg.perplexity?.model ?? ''
24+
if (provider === 'grok') return cfg.grok?.model ?? ''
25+
if (provider === 'gemini') return cfg.gemini?.model ?? ''
26+
if (provider === 'kimi') return cfg.kimi?.model ?? ''
27+
return ''
28+
}
29+
30+
export function ModelSelector({
31+
cfg,
32+
saving,
33+
onSave,
34+
}: {
35+
cfg: WebSearchConfig
36+
saving: boolean
37+
onSave: (provider: string, model: string) => void
38+
}) {
39+
const provider = cfg.provider ?? 'brave'
40+
const model = currentModel(provider, cfg)
41+
const [inputValue, setInputValue] = useState(model)
42+
43+
// F3 — re-sync when config is refreshed externally
44+
useEffect(() => {
45+
setInputValue(model)
46+
}, [model])
47+
48+
// Brave has no model field
49+
if (provider === 'brave') return null
50+
51+
// Perplexity — preset buttons
52+
if (provider === 'perplexity') {
53+
const active = model || 'perplexity/sonar-pro'
54+
return (
55+
<div className="rounded-xl border border-border/60 bg-muted/20 p-4 space-y-3">
56+
<div>
57+
<h3 className="text-xs font-semibold text-foreground/80">Perplexity Model</h3>
58+
<p className="mt-0.5 text-xs text-muted-foreground">
59+
Currently using <code className="rounded bg-muted px-1 text-foreground/70">{active}</code>
60+
</p>
61+
</div>
62+
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-3">
63+
{PERPLEXITY_PRESETS.map((m) => {
64+
const isSelected = active === m.id
65+
return (
66+
<button
67+
key={m.id}
68+
type="button"
69+
disabled={saving || isSelected}
70+
onClick={() => onSave(provider, m.id)}
71+
className={cn(
72+
'rounded-lg border px-2.5 py-2 text-left transition-colors',
73+
isSelected
74+
? 'border-violet-500/30 bg-violet-500/10 cursor-default'
75+
: 'border-border/40 bg-muted/10 hover:border-violet-500/20 hover:bg-violet-500/5 cursor-pointer disabled:opacity-50',
76+
)}
77+
>
78+
<div className="flex items-center gap-1.5">
79+
<p className={cn('text-xs font-medium', isSelected ? 'text-violet-300' : 'text-foreground/70')}>
80+
{m.label}
81+
</p>
82+
{isSelected && <CheckCircle className="h-3 w-3 text-violet-400" />}
83+
</div>
84+
<p className="mt-0.5 text-xs text-muted-foreground">{m.description}</p>
85+
<code className="mt-1 block text-xs text-muted-foreground/60">{m.id}</code>
86+
</button>
87+
)
88+
})}
89+
</div>
90+
</div>
91+
)
92+
}
93+
94+
// Grok / Gemini / Kimi — free-form text input
95+
const inputMeta = TEXT_INPUT_PROVIDERS[provider]
96+
if (!inputMeta) return null
97+
98+
return (
99+
<div className="rounded-xl border border-border/60 bg-muted/20 p-4 space-y-3">
100+
<h3 className="text-xs font-semibold text-foreground/80">Model Override</h3>
101+
<div className="flex items-end gap-2">
102+
<div className="flex-1 space-y-1">
103+
<Label htmlFor="model-input" className="text-xs font-medium text-muted-foreground">
104+
Model ID
105+
</Label>
106+
<Input
107+
id="model-input"
108+
value={inputValue}
109+
onChange={(e) => setInputValue(e.target.value)}
110+
placeholder={inputMeta.placeholder}
111+
className="h-8 text-xs font-mono"
112+
/>
113+
</div>
114+
<Button
115+
size="sm"
116+
disabled={saving || !inputValue.trim() || inputValue.trim() === model}
117+
onClick={() => onSave(provider, inputValue.trim())}
118+
>
119+
{saving ? 'Saving…' : 'Save'}
120+
</Button>
121+
</div>
122+
<p className="text-xs text-muted-foreground/60">
123+
Leave empty to use the provider default ({inputMeta.placeholder}).
124+
</p>
125+
</div>
126+
)
127+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { CheckCircle, XCircle } from 'lucide-react'
2+
import { cn } from '@/lib/utils'
3+
import type { WebSearchConfig, WebSearchProvider } from '../types'
4+
import { PROVIDER_LIST } from '../types'
5+
6+
type ProviderMeta = {
7+
label: string
8+
description: string
9+
envKey: string
10+
note?: string
11+
}
12+
13+
const PROVIDER_META: Record<WebSearchProvider, ProviderMeta> = {
14+
brave: {
15+
label: 'Brave Search',
16+
description: 'Structured search results from the independent Brave index',
17+
envKey: 'BRAVE_API_KEY',
18+
},
19+
perplexity: {
20+
label: 'Perplexity',
21+
description: 'AI-synthesized answers with citations from real-time web search',
22+
envKey: 'PERPLEXITY_API_KEY',
23+
note: 'Also supports OpenRouter: set perplexity.baseUrl = https://openrouter.ai/api/v1',
24+
},
25+
grok: {
26+
label: 'Grok (xAI)',
27+
description: 'Live web search via xAI Grok with real-time internet access',
28+
envKey: 'XAI_API_KEY',
29+
},
30+
gemini: {
31+
label: 'Gemini',
32+
description: 'Google Gemini with grounded search and cited responses',
33+
envKey: 'GEMINI_API_KEY',
34+
},
35+
kimi: {
36+
label: 'Kimi (Moonshot)',
37+
description: 'Moonshot AI Kimi with long-context web understanding',
38+
envKey: 'KIMI_API_KEY / MOONSHOT_API_KEY',
39+
},
40+
}
41+
42+
function isConfigured(provider: WebSearchProvider, cfg: WebSearchConfig): boolean {
43+
if (provider === 'brave') return Boolean(cfg.apiKey)
44+
return Boolean(cfg[provider]?.apiKey)
45+
}
46+
47+
function ProviderCard({
48+
provider,
49+
cfg,
50+
isActive,
51+
}: {
52+
provider: WebSearchProvider
53+
cfg: WebSearchConfig
54+
isActive: boolean
55+
}) {
56+
const meta = PROVIDER_META[provider]
57+
const configured = isConfigured(provider, cfg)
58+
59+
return (
60+
<div
61+
className={cn(
62+
'rounded-xl border p-3.5 transition-colors',
63+
isActive
64+
? 'border-emerald-500/30 bg-emerald-500/5'
65+
: configured
66+
? 'border-border/60 bg-muted/20'
67+
: 'border-border/30 bg-muted/10 opacity-60',
68+
)}
69+
>
70+
<div className="flex items-start justify-between gap-2">
71+
<div className="min-w-0">
72+
<div className="flex items-center gap-2">
73+
<p className="text-xs font-semibold text-foreground/90">{meta.label}</p>
74+
{isActive && (
75+
<span className="shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/15 px-2 py-0.5 text-xs font-semibold uppercase tracking-wider text-emerald-400">
76+
Active
77+
</span>
78+
)}
79+
</div>
80+
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">{meta.description}</p>
81+
</div>
82+
{configured ? (
83+
<CheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
84+
) : (
85+
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground/40" />
86+
)}
87+
</div>
88+
89+
<div className="mt-2.5 rounded-lg border border-border/40 bg-muted/20 px-2.5 py-2">
90+
<div className="flex items-center justify-between gap-2">
91+
<code className="text-xs font-medium text-muted-foreground">{meta.envKey}</code>
92+
{configured ? (
93+
<span className="text-xs text-emerald-400/80">Set in config</span>
94+
) : (
95+
<span className="text-xs text-muted-foreground/60">Not set in config</span>
96+
)}
97+
</div>
98+
{!configured && (
99+
<p className="mt-1 text-xs text-muted-foreground/50">
100+
Set via <code className="text-xs">openclaw.json</code>, env block, or system environment
101+
</p>
102+
)}
103+
{meta.note && <p className="mt-1 text-xs text-muted-foreground/60">{meta.note}</p>}
104+
</div>
105+
</div>
106+
)
107+
}
108+
109+
export function ProviderCards({ cfg }: { cfg: WebSearchConfig }) {
110+
const activeProvider = cfg.provider ?? 'brave'
111+
112+
return (
113+
<div>
114+
<h2 className="mb-2 text-xs font-semibold text-foreground/80">Search Providers</h2>
115+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
116+
{PROVIDER_LIST.map((p) => (
117+
<ProviderCard key={p} provider={p} cfg={cfg} isActive={activeProvider === p} />
118+
))}
119+
</div>
120+
</div>
121+
)
122+
}

0 commit comments

Comments
 (0)