feat(websearch): add real provider chain

This commit is contained in:
Tam Nhu Tran committed 2026-03-23 15:26:05 -04:00
1 parent 84fd2b001c
commit 6c7d215ecc
18 files changed
+2105 -1203

No files matched your search

@@ -2,17 +2,13 @@
* WebSearch Config Hook
*/
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import { useSettingsContext, useSettingsActions } from './context-hooks';
import type { WebSearchConfig } from '../types';
export function useWebSearchConfig() {
const { state } = useSettingsContext();
const actions = useSettingsActions();
const [geminiModelInput, setGeminiModelInput] = useState('');
const [opencodeModelInput, setOpencodeModelInput] = useState('');
const [geminiModelSaved, setGeminiModelSaved] = useState(false);
const [opencodeModelSaved, setOpencodeModelSaved] = useState(false);
const fetchConfig = useCallback(async () => {
try {
@@ -46,9 +42,13 @@ export function useWebSearchConfig() {
const saveConfig = useCallback(
async (updates: Partial<WebSearchConfig>) => {
const config = state.webSearchConfig;
if (!config) return;
if (!config) return false;
const optimisticConfig = { ...config, ...updates };
const optimisticConfig = {
...config,
...updates,
providers: { ...config.providers, ...updates.providers },
};
actions.setWebSearchConfig(optimisticConfig);
try {
@@ -68,52 +68,21 @@ export function useWebSearchConfig() {
const data = await res.json();
actions.setWebSearchConfig(data.websearch);
await fetchStatus();
actions.setWebSearchSuccess(true);
setTimeout(() => actions.setWebSearchSuccess(false), 1500);
return true;
} catch (err) {
actions.setWebSearchConfig(config);
actions.setWebSearchError((err as Error).message);
return false;
} finally {
actions.setWebSearchSaving(false);
}
},
[state.webSearchConfig, actions]
[state.webSearchConfig, actions, fetchStatus]
);
// Sync model inputs with config
useEffect(() => {
if (state.webSearchConfig) {
setGeminiModelInput(state.webSearchConfig.providers?.gemini?.model ?? 'gemini-2.5-flash');
setOpencodeModelInput(
state.webSearchConfig.providers?.opencode?.model ?? 'opencode/grok-code'
);
}
}, [state.webSearchConfig]);
const saveGeminiModel = useCallback(async () => {
const currentModel = state.webSearchConfig?.providers?.gemini?.model ?? 'gemini-2.5-flash';
if (geminiModelInput !== currentModel) {
const providers = state.webSearchConfig?.providers || {};
await saveConfig({
providers: { ...providers, gemini: { ...providers.gemini, model: geminiModelInput } },
});
setGeminiModelSaved(true);
setTimeout(() => setGeminiModelSaved(false), 2000);
}
}, [geminiModelInput, state.webSearchConfig, saveConfig]);
const saveOpencodeModel = useCallback(async () => {
const currentModel = state.webSearchConfig?.providers?.opencode?.model ?? 'opencode/grok-code';
if (opencodeModelInput !== currentModel) {
const providers = state.webSearchConfig?.providers || {};
await saveConfig({
providers: { ...providers, opencode: { ...providers.opencode, model: opencodeModelInput } },
});
setOpencodeModelSaved(true);
setTimeout(() => setOpencodeModelSaved(false), 2000);
}
}, [opencodeModelInput, state.webSearchConfig, saveConfig]);
return {
config: state.webSearchConfig,
status: state.webSearchStatus,
@@ -122,16 +91,8 @@ export function useWebSearchConfig() {
saving: state.webSearchSaving,
error: state.webSearchError,
success: state.webSearchSuccess,
geminiModelInput,
setGeminiModelInput,
opencodeModelInput,
setOpencodeModelInput,
geminiModelSaved,
opencodeModelSaved,
fetchConfig,
fetchStatus,
saveConfig,
saveGeminiModel,
saveOpencodeModel,
};
}
+645 -151
View File
@@ -1,16 +1,329 @@
/**
* WebSearch Section
* Settings section for WebSearch providers (Gemini, OpenCode, Grok)
* Settings section for real WebSearch backends and legacy fallbacks.
*/
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useEffect, useMemo, useState } from 'react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react';
import { useWebSearchConfig, useRawConfig } from '../../hooks';
import { ProviderCard } from './provider-card';
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronUp,
Globe,
KeyRound,
RefreshCw,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { useRawConfig, useWebSearchConfig } from '../../hooks';
import type { CliStatus, WebSearchProvidersConfig } from '../../types';
import { ProviderCard, type ProviderFieldConfig } from './provider-card';
type ProviderId = 'exa' | 'tavily' | 'brave' | 'duckduckgo' | 'gemini' | 'opencode' | 'grok';
type ProviderFieldKey = 'model' | 'timeout' | 'max_results';
interface ProviderFieldDefinition {
key: ProviderFieldKey;
label: string;
type: 'text' | 'number';
placeholder: string;
helpText: string;
defaultValue: string | number;
min?: number;
max?: number;
}
interface ProviderDefinition {
id: ProviderId;
title: string;
description: string;
badge: string;
badgeTone: 'green' | 'blue' | 'amber' | 'cyan' | 'slate';
defaultEnabled: boolean;
fallbackDetail: string;
footerNote: string;
fields?: ProviderFieldDefinition[];
}
const CHAIN_STEPS = [
{ id: 'exa', title: 'Exa', defaultEnabled: false },
{ id: 'tavily', title: 'Tavily', defaultEnabled: false },
{ id: 'brave', title: 'Brave', defaultEnabled: false },
{ id: 'duckduckgo', title: 'DuckDuckGo', defaultEnabled: true },
{ id: 'legacy', title: 'Legacy CLI', defaultEnabled: false },
] as const;
const BACKEND_PROVIDERS: ProviderDefinition[] = [
{
id: 'exa',
title: 'Exa',
description:
'Highest-priority API backend when enabled. Best for strong relevance and extracted text.',
badge: 'EXA_API_KEY',
badgeTone: 'amber',
defaultEnabled: false,
fallbackDetail: 'Set EXA_API_KEY',
footerNote: 'Runs before every other provider in the chain when enabled and ready.',
fields: [
{
key: 'max_results',
label: 'Max results',
type: 'number',
placeholder: '5',
helpText: 'Clamp between 1 and 10 results.',
defaultValue: 5,
min: 1,
max: 10,
},
],
},
{
id: 'tavily',
title: 'Tavily',
description: 'Agent-oriented API backend with concise web result content.',
badge: 'TAVILY_API_KEY',
badgeTone: 'cyan',
defaultEnabled: false,
fallbackDetail: 'Set TAVILY_API_KEY',
footerNote: 'Runs after Exa and before Brave when enabled and ready.',
fields: [
{
key: 'max_results',
label: 'Max results',
type: 'number',
placeholder: '5',
helpText: 'Clamp between 1 and 10 results.',
defaultValue: 5,
min: 1,
max: 10,
},
],
},
{
id: 'brave',
title: 'Brave Search',
description: 'API-backed search with clean metadata and broad web coverage.',
badge: 'BRAVE_API_KEY',
badgeTone: 'blue',
defaultEnabled: false,
fallbackDetail: 'Set BRAVE_API_KEY',
footerNote: 'Runs after Exa and Tavily, before DuckDuckGo.',
fields: [
{
key: 'max_results',
label: 'Max results',
type: 'number',
placeholder: '5',
helpText: 'Clamp between 1 and 10 results.',
defaultValue: 5,
min: 1,
max: 10,
},
],
},
{
id: 'duckduckgo',
title: 'DuckDuckGo',
description: 'Zero-setup floor. Keep this on unless you want no built-in fallback at all.',
badge: 'DEFAULT',
badgeTone: 'green',
defaultEnabled: true,
fallbackDetail: 'Built-in',
footerNote: 'Last real backend in the chain. No API key needed.',
fields: [
{
key: 'max_results',
label: 'Max results',
type: 'number',
placeholder: '5',
helpText: 'Clamp between 1 and 10 results.',
defaultValue: 5,
min: 1,
max: 10,
},
],
},
];
const LEGACY_PROVIDERS: ProviderDefinition[] = [
{
id: 'gemini',
title: 'Gemini CLI',
description: 'Optional legacy LLM fallback. Used only if every enabled real backend fails.',
badge: 'LEGACY',
badgeTone: 'slate',
defaultEnabled: false,
fallbackDetail: 'Optional fallback',
footerNote: 'Legacy path. Useful for compatibility, not the preferred search backend.',
fields: [
{
key: 'model',
label: 'Model',
type: 'text',
placeholder: 'gemini-2.5-flash',
helpText: 'CLI model passed to Gemini when this fallback runs.',
defaultValue: 'gemini-2.5-flash',
},
{
key: 'timeout',
label: 'Timeout (seconds)',
type: 'number',
placeholder: '55',
helpText: 'Clamp between 5 and 300 seconds.',
defaultValue: 55,
min: 5,
max: 300,
},
],
},
{
id: 'opencode',
title: 'OpenCode',
description: 'Optional legacy LLM fallback via OpenCode.',
badge: 'LEGACY',
badgeTone: 'slate',
defaultEnabled: false,
fallbackDetail: 'Optional fallback',
footerNote: 'Legacy path. Runs after Gemini in the fallback portion of the chain.',
fields: [
{
key: 'model',
label: 'Model',
type: 'text',
placeholder: 'opencode/grok-code',
helpText: 'OpenCode model passed to the CLI runner.',
defaultValue: 'opencode/grok-code',
},
{
key: 'timeout',
label: 'Timeout (seconds)',
type: 'number',
placeholder: '90',
helpText: 'Clamp between 5 and 300 seconds.',
defaultValue: 90,
min: 5,
max: 300,
},
],
},
{
id: 'grok',
title: 'Grok CLI',
description: 'Optional legacy xAI fallback. Requires the Grok CLI and GROK_API_KEY.',
badge: 'LEGACY',
badgeTone: 'slate',
defaultEnabled: false,
fallbackDetail: 'Optional fallback',
footerNote: 'Last step in the full fallback chain.',
fields: [
{
key: 'timeout',
label: 'Timeout (seconds)',
type: 'number',
placeholder: '55',
helpText: 'Clamp between 5 and 300 seconds.',
defaultValue: 55,
min: 5,
max: 300,
},
],
},
];
function getStatusTone(
provider: CliStatus | undefined,
enabled: boolean
): 'ready' | 'setup' | 'idle' {
if (enabled && provider?.available) {
return 'ready';
}
if (enabled) {
return 'setup';
}
return 'idle';
}
function getStatusLabel(provider: CliStatus | undefined, enabled: boolean): string {
if (enabled && provider?.available) {
return 'Ready';
}
if (enabled) {
return 'Needs setup';
}
return 'Disabled';
}
function getToneChipClass(tone: 'ready' | 'setup' | 'idle'): string {
switch (tone) {
case 'ready':
return 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200';
case 'setup':
return 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200';
case 'idle':
return 'border-border/80 bg-background/85 text-muted-foreground';
}
}
function getToneIndicatorClass(tone: 'ready' | 'setup' | 'idle'): string {
switch (tone) {
case 'ready':
return 'bg-emerald-500';
case 'setup':
return 'bg-amber-500';
case 'idle':
return 'bg-border';
}
}
function getToneRailDotClass(tone: 'ready' | 'setup' | 'idle'): string {
switch (tone) {
case 'ready':
return 'bg-emerald-500 shadow-[0_0_0_6px_rgba(16,185,129,0.12)]';
case 'setup':
return 'bg-amber-500 shadow-[0_0_0_6px_rgba(245,158,11,0.12)]';
case 'idle':
return 'bg-border';
}
}
function getToneLineClass(tone: 'ready' | 'setup' | 'idle'): string {
switch (tone) {
case 'ready':
return 'bg-gradient-to-b from-emerald-500/55 to-border';
case 'setup':
return 'bg-gradient-to-b from-amber-500/55 to-border';
case 'idle':
return 'bg-border';
}
}
function normalizeFieldValue(field: ProviderFieldDefinition, rawValue: string): string | number {
if (field.type === 'number') {
const parsed = Number.parseInt(rawValue.trim(), 10);
if (!Number.isFinite(parsed)) {
return field.defaultValue;
}
const min = field.min ?? parsed;
const max = field.max ?? parsed;
return Math.min(max, Math.max(min, parsed));
}
const trimmed = rawValue.trim();
return trimmed || String(field.defaultValue);
}
function getConfiguredValue(
config: WebSearchProvidersConfig | undefined,
providerId: ProviderId,
field: ProviderFieldDefinition
): string {
const configured = config?.[providerId]?.[field.key];
return String(configured ?? field.defaultValue);
}
export default function WebSearchSection() {
const { t } = useTranslation();
@@ -22,69 +335,136 @@ export default function WebSearchSection() {
saving,
error,
success,
geminiModelInput,
setGeminiModelInput,
opencodeModelInput,
setOpencodeModelInput,
geminiModelSaved,
opencodeModelSaved,
fetchConfig,
fetchStatus,
saveConfig,
saveGeminiModel,
saveOpencodeModel,
} = useWebSearchConfig();
const { fetchRawConfig } = useRawConfig();
const [fieldDrafts, setFieldDrafts] = useState<Record<string, string>>({});
const [savedFieldId, setSavedFieldId] = useState<string | null>(null);
const [legacyExpanded, setLegacyExpanded] = useState(false);
// Collapsible install hints state
const [showGeminiHint, setShowGeminiHint] = useState(false);
const [showOpencodeHint, setShowOpencodeHint] = useState(false);
const [showGrokHint, setShowGrokHint] = useState(false);
// Load data on mount
useEffect(() => {
fetchConfig();
fetchStatus();
fetchRawConfig();
}, [fetchConfig, fetchStatus, fetchRawConfig]);
const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false;
const isGrokEnabled = config?.providers?.grok?.enabled ?? false;
const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false;
useEffect(() => {
if (!config?.providers) {
return;
}
const toggleGemini = () => {
const providers = config?.providers || {};
const currentState = providers.gemini?.enabled ?? false;
saveConfig({
enabled: !currentState || isGrokEnabled || isOpenCodeEnabled,
providers: { ...providers, gemini: { ...providers.gemini, enabled: !currentState } },
const nextDrafts: Record<string, string> = {};
for (const provider of [...BACKEND_PROVIDERS, ...LEGACY_PROVIDERS]) {
for (const field of provider.fields ?? []) {
nextDrafts[`${provider.id}.${field.key}`] = getConfiguredValue(
config.providers,
provider.id,
field
);
}
}
setFieldDrafts(nextDrafts);
}, [config]);
const providerStatus = useMemo(
() => new Map((status?.providers ?? []).map((provider) => [provider.id, provider])),
[status?.providers]
);
const legacyEnabled = LEGACY_PROVIDERS.filter(
(provider) => config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled
);
const legacyReady = legacyEnabled.some((provider) => providerStatus.get(provider.id)?.available);
const legacySummary =
legacyEnabled.length === 0
? 'Off'
: legacyEnabled.length === 1
? `${legacyEnabled[0].title} enabled`
: `${legacyEnabled.length} enabled`;
const toggleProvider = async (providerId: ProviderId, enabled: boolean) => {
const currentProviders = (config?.providers ?? {}) as WebSearchProvidersConfig;
await saveConfig({
providers: {
...currentProviders,
[providerId]: {
...currentProviders[providerId],
enabled: !enabled,
},
},
});
};
const toggleGrok = () => {
const providers = config?.providers || {};
const currentState = providers.grok?.enabled ?? false;
saveConfig({
enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled,
providers: { ...providers, grok: { ...providers.grok, enabled: !currentState } },
});
const updateDraft = (providerId: ProviderId, fieldKey: ProviderFieldKey, value: string) => {
setFieldDrafts((current) => ({ ...current, [`${providerId}.${fieldKey}`]: value }));
};
const toggleOpenCode = () => {
const providers = config?.providers || {};
const currentState = providers.opencode?.enabled ?? false;
saveConfig({
enabled: isGeminiEnabled || isGrokEnabled || !currentState,
providers: { ...providers, opencode: { ...providers.opencode, enabled: !currentState } },
const commitField = async (providerId: ProviderId, field: ProviderFieldDefinition) => {
if (!config) {
return;
}
const fieldId = `${providerId}.${field.key}`;
const currentProviders = (config.providers ?? {}) as WebSearchProvidersConfig;
const currentProviderConfig = currentProviders[providerId] ?? {};
const currentValue = currentProviderConfig[field.key] ?? field.defaultValue;
const normalized = normalizeFieldValue(field, fieldDrafts[fieldId] ?? String(currentValue));
setFieldDrafts((current) => ({ ...current, [fieldId]: String(normalized) }));
if (String(currentValue) === String(normalized)) {
return;
}
const saved = await saveConfig({
providers: {
...currentProviders,
[providerId]: {
...currentProviderConfig,
[field.key]: normalized,
},
},
});
if (saved) {
setSavedFieldId(fieldId);
setTimeout(() => {
setSavedFieldId((current) => (current === fieldId ? null : current));
}, 1200);
}
};
const buildFields = (provider: ProviderDefinition): ProviderFieldConfig[] =>
(provider.fields ?? []).map((field) => {
const fieldId = `${provider.id}.${field.key}`;
return {
id: fieldId,
label: field.label,
value: fieldDrafts[fieldId] ?? String(field.defaultValue),
placeholder: field.placeholder,
type: field.type,
helpText: field.helpText,
saved: savedFieldId === fieldId,
onChange: (value) => updateDraft(provider.id, field.key, value),
onBlur: () => {
void commitField(provider.id, field);
},
onKeyDown: (event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
},
};
});
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-1 items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<RefreshCw className="h-5 w-5 animate-spin" />
<span>{t('settings.loading')}</span>
</div>
</div>
@@ -93,12 +473,11 @@ export default function WebSearchSection() {
return (
<>
{/* Toast-style alerts */}
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
? 'translate-y-0 opacity-100'
: 'pointer-events-none -translate-y-2 opacity-0'
}`}
>
{error && (
@@ -108,133 +487,248 @@ export default function WebSearchSection() {
</Alert>
)}
{success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<div className="flex items-center gap-2 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">{t('settings.saved')}</span>
</div>
)}
</div>
{/* Scrollable Content */}
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<p className="text-sm text-muted-foreground">{t('settingsWebsearch.description')}</p>
{/* Status Summary */}
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50">
<div>
<p className="font-medium">
{isGeminiEnabled ? t('settingsWebsearch.enabled') : t('settingsWebsearch.disabled')}
</p>
{statusLoading ? (
<p className="text-sm text-muted-foreground">{t('settingsWebsearch.checking')}</p>
) : status?.readiness ? (
<p className="text-sm text-muted-foreground">{status.readiness.message}</p>
) : null}
</div>
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
<RefreshCw className={`w-4 h-4 ${statusLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* CLI Providers */}
<div className="space-y-3">
<h3 className="text-base font-medium">{t('settingsWebsearch.providers')}</h3>
{/* Empty state when no providers available */}
{!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg text-center bg-muted/30">
<Package className="w-12 h-12 text-muted-foreground mb-3 opacity-30" />
<p className="font-medium text-foreground mb-1">
{t('settingsWebsearch.noneConfigured')}
</p>
<p className="text-sm text-muted-foreground mb-4">
{t('settingsWebsearch.noneConfiguredHint')}
</p>
<Button variant="outline" size="sm" onClick={fetchStatus}>
<RefreshCw className="w-4 h-4 mr-2" />
{t('settingsWebsearch.checkProviders')}
<div className="space-y-6 p-5">
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-background via-background to-muted/30 p-4 shadow-sm">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-primary/70 via-primary/20 to-transparent" />
<div
className="pointer-events-none absolute inset-0 opacity-[0.035]"
style={{
backgroundImage:
'repeating-linear-gradient(0deg, transparent, transparent 2px, currentColor 2px, currentColor 3px)',
}}
/>
<div className="relative">
<div className="mb-3 flex items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-primary/20 bg-primary/8 text-primary">
<Globe className="h-4 w-4" />
</div>
<p className="text-sm font-semibold tracking-tight">Execution chain</p>
</div>
<p className="text-sm text-muted-foreground">
{statusLoading ? t('settingsWebsearch.checking') : status?.readiness.message}
</p>
</div>
<Button variant="ghost" size="sm" onClick={fetchStatus} disabled={statusLoading}>
<RefreshCw className={`h-4 w-4 ${statusLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
{CHAIN_STEPS.map((step, index) => {
const stepEnabled =
step.id === 'legacy'
? legacyEnabled.length > 0
: (config?.providers?.[step.id]?.enabled ?? step.defaultEnabled);
const tone =
step.id === 'legacy'
? legacyReady
? 'ready'
: legacyEnabled.length > 0
? 'setup'
: 'idle'
: getStatusTone(providerStatus.get(step.id), stepEnabled);
const label =
step.id === 'legacy'
? legacySummary
: getStatusLabel(providerStatus.get(step.id), stepEnabled);
return (
<div key={step.id} className="flex items-center gap-2">
<div
className={cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] font-medium',
getToneChipClass(tone)
)}
>
<span
className={cn('h-1.5 w-1.5 rounded-full', getToneIndicatorClass(tone))}
/>
<span>
{step.title} · {label}
</span>
</div>
{index < CHAIN_STEPS.length - 1 && (
<span className="h-px w-4 rounded-full bg-border/80" />
)}
</div>
);
})}
</div>
</div>
</div>
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-background via-background to-muted/25 p-4 shadow-sm">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-sky-500/70 via-emerald-500/25 to-transparent" />
<div className="relative space-y-4">
<div className="flex items-start gap-3">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300">
<Globe className="h-4 w-4" />
</div>
<div>
<h3 className="text-base font-semibold tracking-tight">Primary backends</h3>
<p className="text-sm text-muted-foreground">
Real backends run top-down before any legacy CLI fallback.
</p>
</div>
</div>
<div className="space-y-3">
{BACKEND_PROVIDERS.map((provider, index) => {
const currentStatus = providerStatus.get(provider.id);
const enabled =
config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled;
const tone = getStatusTone(currentStatus, enabled);
return (
<div
key={provider.id}
className="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-3"
>
<div className="flex h-full justify-center">
<div className="flex h-full flex-col items-center">
<span
className={cn(
'mt-5 h-3 w-3 rounded-full ring-4 ring-background',
getToneRailDotClass(tone)
)}
/>
{index < BACKEND_PROVIDERS.length - 1 && (
<span className={cn('mt-1 w-px flex-1', getToneLineClass(tone))} />
)}
</div>
</div>
<ProviderCard
title={provider.title}
description={provider.description}
detail={currentStatus?.detail ?? provider.fallbackDetail}
badge={provider.badge}
badgeTone={provider.badgeTone}
statusLabel={getStatusLabel(currentStatus, enabled)}
statusTone={getStatusTone(currentStatus, enabled)}
enabled={enabled}
saving={saving}
onToggle={() => {
void toggleProvider(provider.id, enabled);
}}
fields={buildFields(provider)}
/>
</div>
);
})}
</div>
</div>
</div>
<div className="space-y-3">
<button
type="button"
onClick={() => setLegacyExpanded((current) => !current)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-1 py-1 text-left transition-colors',
'hover:bg-muted/10'
)}
>
<div className="flex items-center gap-3">
<div className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-slate-400/20 bg-slate-500/10 text-slate-700 dark:text-slate-300">
<KeyRound className="h-4 w-4" />
</div>
<div>
<h3 className="text-base font-semibold tracking-tight">Legacy CLI fallbacks</h3>
<p className="text-sm text-muted-foreground">
Runs only after every enabled real backend fails.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-border/80 bg-background/85 px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
{legacySummary}
</span>
{legacyExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
{legacyExpanded && (
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-background via-background to-muted/20 p-3 shadow-sm">
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-slate-500/60 via-slate-400/18 to-transparent" />
<div className="relative space-y-3">
{LEGACY_PROVIDERS.map((provider, index) => {
const currentStatus = providerStatus.get(provider.id);
const enabled =
config?.providers?.[provider.id]?.enabled ?? provider.defaultEnabled;
const tone = getStatusTone(currentStatus, enabled);
return (
<div
key={provider.id}
className="grid grid-cols-[20px_minmax(0,1fr)] items-start gap-3"
>
<div className="flex h-full justify-center">
<div className="flex h-full flex-col items-center">
<span
className={cn(
'mt-5 h-3 w-3 rounded-full ring-4 ring-background',
getToneRailDotClass(tone)
)}
/>
{index < LEGACY_PROVIDERS.length - 1 && (
<span className={cn('mt-1 w-px flex-1', getToneLineClass(tone))} />
)}
</div>
</div>
<ProviderCard
title={provider.title}
description={provider.description}
detail={currentStatus?.detail ?? provider.fallbackDetail}
badge={provider.badge}
badgeTone={provider.badgeTone}
statusLabel={getStatusLabel(currentStatus, enabled)}
statusTone={getStatusTone(currentStatus, enabled)}
enabled={enabled}
saving={saving}
onToggle={() => {
void toggleProvider(provider.id, enabled);
}}
fields={buildFields(provider)}
docsUrl={currentStatus?.docsUrl}
installCommand={currentStatus?.installCommand}
footerNote={provider.footerNote}
/>
</div>
);
})}
</div>
</div>
)}
<ProviderCard
name="gemini"
label="Google Gemini CLI (1000 req/day free)"
badge="FREE"
badgeColor="green"
enabled={isGeminiEnabled}
installed={status?.geminiCli?.installed ?? false}
statusLoading={statusLoading}
saving={saving}
onToggle={toggleGemini}
modelInput={geminiModelInput}
setModelInput={setGeminiModelInput}
onModelBlur={saveGeminiModel}
modelSaved={geminiModelSaved}
modelPlaceholder="gemini-2.5-flash"
showHint={showGeminiHint}
setShowHint={setShowGeminiHint}
installCmd="npm install -g @google/gemini-cli"
docsUrl="https://github.com/google-gemini/gemini-cli"
hintColor="amber"
/>
<ProviderCard
name="opencode"
label="OpenCode (web search via Zen)"
badge="FREE"
badgeColor="green"
enabled={isOpenCodeEnabled}
installed={status?.opencodeCli?.installed ?? false}
statusLoading={statusLoading}
saving={saving}
onToggle={toggleOpenCode}
modelInput={opencodeModelInput}
setModelInput={setOpencodeModelInput}
onModelBlur={saveOpencodeModel}
modelSaved={opencodeModelSaved}
modelPlaceholder="opencode/grok-code"
showHint={showOpencodeHint}
setShowHint={setShowOpencodeHint}
installCmd="curl -fsSL https://opencode.ai/install | bash"
docsUrl="https://github.com/sst/opencode"
hintColor="purple"
/>
<ProviderCard
name="grok"
label="xAI Grok CLI (web + X search)"
badge="GROK_API_KEY"
badgeColor="blue"
enabled={isGrokEnabled}
installed={status?.grokCli?.installed ?? false}
statusLoading={statusLoading}
saving={saving}
onToggle={toggleGrok}
showHint={showGrokHint}
setShowHint={setShowGrokHint}
installCmd="npm install -g @vibe-kit/grok-cli"
docsUrl="https://github.com/superagent-ai/grok-cli"
hintColor="blue"
/>
</div>
</div>
</ScrollArea>
{/* Footer */}
<div className="p-4 border-t bg-background">
<div className="border-t bg-background p-4">
<Button
variant="outline"
size="sm"
onClick={() => {
fetchConfig();
fetchStatus();
fetchRawConfig();
}}
disabled={loading || saving}
className="w-full"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
{t('settings.refresh')}
</Button>
</div>
@@ -1,176 +1,240 @@
/**
* Provider Card Component
* Reusable card for CLI provider configuration in WebSearch section
*/
import { Switch } from '@/components/ui/switch';
import type { KeyboardEvent } from 'react';
import { ExternalLink } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Terminal, ExternalLink, ChevronDown, ChevronUp, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
export interface ProviderFieldConfig {
id: string;
label: string;
value: string;
placeholder?: string;
type?: 'text' | 'number';
helpText?: string;
saved?: boolean;
onBlur: () => void;
onChange: (value: string) => void;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
}
export interface ProviderCardProps {
name: string;
label: string;
title: string;
description: string;
detail: string;
badge: string;
badgeColor: 'green' | 'blue' | 'purple';
badgeTone: 'green' | 'blue' | 'amber' | 'cyan' | 'slate';
statusLabel: string;
statusTone: 'ready' | 'setup' | 'idle';
enabled: boolean;
installed: boolean;
statusLoading: boolean;
saving: boolean;
onToggle: () => void;
modelInput?: string;
setModelInput?: (v: string) => void;
onModelBlur?: () => void;
modelSaved?: boolean;
modelPlaceholder?: string;
showHint: boolean;
setShowHint: (v: boolean) => void;
installCmd: string;
docsUrl: string;
hintColor: 'amber' | 'blue' | 'purple';
fields?: ProviderFieldConfig[];
docsUrl?: string;
installCommand?: string;
footerNote?: string;
}
const PROVIDER_TONE_STYLES = {
green: {
accent: 'from-emerald-500 via-lime-400 to-transparent',
badge: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
glow: 'bg-emerald-500/18 dark:bg-emerald-500/22',
surface:
'border-emerald-500/30 bg-gradient-to-br from-emerald-500/10 via-background to-background dark:from-emerald-500/14',
switchFrame: 'border-emerald-500/20 bg-emerald-500/10',
},
blue: {
accent: 'from-sky-500 via-blue-500 to-transparent',
badge: 'border-sky-500/25 bg-sky-500/10 text-sky-800 dark:text-sky-200',
glow: 'bg-sky-500/18 dark:bg-sky-500/22',
surface:
'border-sky-500/30 bg-gradient-to-br from-sky-500/10 via-background to-background dark:from-sky-500/14',
switchFrame: 'border-sky-500/20 bg-sky-500/10',
},
amber: {
accent: 'from-amber-500 via-orange-500 to-transparent',
badge: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
glow: 'bg-amber-500/18 dark:bg-amber-500/22',
surface:
'border-amber-500/30 bg-gradient-to-br from-amber-500/10 via-background to-background dark:from-amber-500/14',
switchFrame: 'border-amber-500/20 bg-amber-500/10',
},
cyan: {
accent: 'from-cyan-500 via-teal-400 to-transparent',
badge: 'border-cyan-500/25 bg-cyan-500/10 text-cyan-800 dark:text-cyan-200',
glow: 'bg-cyan-500/18 dark:bg-cyan-500/22',
surface:
'border-cyan-500/30 bg-gradient-to-br from-cyan-500/10 via-background to-background dark:from-cyan-500/14',
switchFrame: 'border-cyan-500/20 bg-cyan-500/10',
},
slate: {
accent: 'from-slate-500 via-slate-400 to-transparent',
badge: 'border-slate-500/20 bg-slate-500/10 text-slate-700 dark:text-slate-300',
glow: 'bg-slate-500/18 dark:bg-slate-500/22',
surface:
'border-slate-400/30 bg-gradient-to-br from-slate-500/8 via-background to-background dark:from-slate-500/12',
switchFrame: 'border-slate-400/20 bg-slate-500/10',
},
} as const;
function getStatusToneStyles(tone: ProviderCardProps['statusTone']) {
switch (tone) {
case 'ready':
return {
chip: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-800 dark:text-emerald-200',
dot: 'bg-emerald-500',
};
case 'setup':
return {
chip: 'border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-200',
dot: 'bg-amber-500',
};
case 'idle':
return {
chip: 'border-border/80 bg-muted/60 text-muted-foreground',
dot: 'bg-muted-foreground/70',
};
}
}
export function ProviderCard({
name,
label,
title,
description,
detail,
badge,
badgeColor,
badgeTone,
statusLabel,
statusTone,
enabled,
installed,
statusLoading,
saving,
onToggle,
modelInput,
setModelInput,
onModelBlur,
modelSaved,
modelPlaceholder,
showHint,
setShowHint,
installCmd,
fields = [],
docsUrl,
hintColor,
installCommand,
footerNote,
}: ProviderCardProps) {
const { t } = useTranslation();
const badgeClass =
badgeColor === 'green'
? 'bg-green-500/10 text-green-600'
: badgeColor === 'blue'
? 'bg-blue-500/10 text-blue-600'
: 'bg-purple-500/10 text-purple-600';
const hintBgClass =
hintColor === 'amber'
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300'
: hintColor === 'blue'
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300';
const hintCodeClass =
hintColor === 'amber'
? 'bg-amber-100 dark:bg-amber-900/40'
: hintColor === 'blue'
? 'bg-blue-100 dark:bg-blue-900/40'
: 'bg-purple-100 dark:bg-purple-900/40';
const hintTextClass =
hintColor === 'amber'
? 'text-amber-600 dark:text-amber-400'
: hintColor === 'blue'
? 'text-blue-600 dark:text-blue-400'
: 'text-purple-600 dark:text-purple-400';
const displayName =
name === 'opencode' ? 'OpenCode' : name.charAt(0).toUpperCase() + name.slice(1);
const tone = PROVIDER_TONE_STYLES[badgeTone];
const status = getStatusToneStyles(statusTone);
return (
<div
className={`rounded-lg border transition-colors ${
enabled ? 'border-primary border-l-4' : 'border-border'
}`}
className={cn(
'group relative overflow-hidden rounded-2xl border px-4 py-4 shadow-sm transition-all duration-200',
enabled
? tone.surface
: 'border-border/70 bg-gradient-to-br from-background via-background to-muted/25',
enabled && 'shadow-[0_18px_40px_-28px_rgba(15,23,42,0.55)]'
)}
>
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<Terminal className={`w-5 h-5 ${enabled ? 'text-primary' : 'text-muted-foreground'}`} />
<div>
<div className="flex items-center gap-2">
<p className="font-mono font-medium">{name}</p>
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${badgeClass}`}>
{badge}
</span>
{installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
{t('settingsWebsearch.installed')}
</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
{t('settingsWebsearch.notInstalled')}
</span>
<div className={cn('absolute inset-x-0 top-0 h-px bg-gradient-to-r', tone.accent)} />
<div
className={cn(
'pointer-events-none absolute -right-6 -top-8 h-28 w-28 rounded-full blur-3xl',
tone.glow
)}
/>
<div
className="pointer-events-none absolute inset-0 opacity-[0.045]"
style={{
backgroundImage: 'radial-gradient(circle at 1px 1px, currentColor 1px, transparent 0)',
backgroundSize: '22px 22px',
}}
/>
<div className="relative flex items-start justify-between gap-4">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2.5">
<p className="text-sm font-semibold tracking-tight">{title}</p>
<span
className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em]',
tone.badge
)}
</div>
<p className="text-sm text-muted-foreground">{label}</p>
>
{badge}
</span>
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] font-medium',
status.chip
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', status.dot)} />
{statusLabel}
</span>
</div>
<div className="space-y-1.5">
<p className="text-sm leading-6 text-foreground/90">{description}</p>
{detail && <p className="text-xs leading-5 text-muted-foreground">{detail}</p>}
</div>
</div>
<Switch checked={enabled} onCheckedChange={onToggle} disabled={saving || !installed} />
<div
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border bg-background/75 backdrop-blur-sm',
enabled ? tone.switchFrame : 'border-border/70'
)}
>
<Switch checked={enabled} onCheckedChange={onToggle} disabled={saving} />
</div>
</div>
{/* Model input when enabled */}
{enabled && modelInput !== undefined && setModelInput && onModelBlur && (
<div className="px-4 pb-4 pt-0">
<div className="flex items-center gap-2">
<label className="text-sm text-muted-foreground whitespace-nowrap">
{t('settingsWebsearch.model')}
</label>
<Input
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onBlur={onModelBlur}
placeholder={modelPlaceholder}
className="h-8 text-sm font-mono"
disabled={saving}
/>
{modelSaved && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs animate-in fade-in duration-200">
<Check className="w-3.5 h-3.5" />
{t('settings.saved')}
</span>
)}
{enabled && fields.length > 0 && (
<div className="relative mt-4 rounded-xl border border-border/65 bg-background/70 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] backdrop-blur-sm">
<div className="grid gap-3 md:grid-cols-2">
{fields.map((field) => (
<div key={field.id} className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<Label
htmlFor={field.id}
className="text-[11px] font-medium text-muted-foreground"
>
{field.label}
</Label>
{field.saved && (
<span className="text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
Saved
</span>
)}
</div>
<Input
id={field.id}
value={field.value}
type={field.type ?? 'text'}
placeholder={field.placeholder}
onBlur={field.onBlur}
onChange={(event) => field.onChange(event.target.value)}
onKeyDown={field.onKeyDown}
className="h-8 border-border/70 bg-background/80 text-sm"
disabled={saving}
/>
{field.helpText && (
<p className="text-[11px] text-muted-foreground">{field.helpText}</p>
)}
</div>
))}
</div>
</div>
)}
{/* Installation hint when not installed */}
{!installed && !statusLoading && (
<div className="px-4 pb-4 pt-0 border-t border-border/50">
<button
onClick={() => setShowHint(!showHint)}
className={`flex items-center gap-2 text-sm hover:underline w-full py-2 ${hintTextClass}`}
>
{showHint ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{t('settingsWebsearch.howInstall', { name: displayName })}
</button>
{showHint && (
<div className={`mt-2 p-3 rounded-md text-sm ${hintBgClass}`}>
<p className="mb-2">
{t('settingsWebsearch.installGlobally')}{' '}
{badge === 'GROK_API_KEY'
? t('settingsWebsearch.requiresKey')
: t('settingsWebsearch.freeTier')}
:
</p>
<code className={`text-sm px-2 py-1 rounded font-mono block mb-2 ${hintCodeClass}`}>
{installCmd}
</code>
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
{t('settingsWebsearch.viewDocs')}
</a>
</div>
{(footerNote || installCommand || docsUrl) && (
<div className="relative mt-4 space-y-2 rounded-xl border border-border/65 bg-muted/30 p-3">
{footerNote && <p className="text-xs text-muted-foreground">{footerNote}</p>}
{installCommand && (
<code className="block rounded-lg border border-border/60 bg-background/85 px-2.5 py-2 text-[11px] text-muted-foreground">
{installCommand}
</code>
)}
{docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
View docs
</a>
)}
</div>
)}
+19 -6
View File
@@ -11,9 +11,14 @@ export interface ProviderConfig {
enabled?: boolean;
model?: string;
timeout?: number;
max_results?: number;
}
export interface WebSearchProvidersConfig {
exa?: ProviderConfig;
tavily?: ProviderConfig;
duckduckgo?: ProviderConfig;
brave?: ProviderConfig;
gemini?: ProviderConfig;
grok?: ProviderConfig;
opencode?: ProviderConfig;
@@ -25,17 +30,25 @@ export interface WebSearchConfig {
}
export interface CliStatus {
installed: boolean;
path: string | null;
id: 'exa' | 'tavily' | 'duckduckgo' | 'brave' | 'gemini' | 'grok' | 'opencode';
kind: 'backend' | 'legacy-cli';
name?: string;
enabled: boolean;
available: boolean;
command?: string;
version: string | null;
installCommand?: string;
docsUrl?: string;
requiresApiKey: boolean;
apiKeyEnvVar?: string;
description: string;
detail: string;
}
export interface WebSearchStatus {
geminiCli: CliStatus;
grokCli: CliStatus;
opencodeCli: CliStatus;
providers: CliStatus[];
readiness: {
status: 'ready' | 'unavailable';
status: 'ready' | 'needs_setup' | 'unavailable';
message: string;
};
}