mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 10:14:34 +00:00
feat(websearch): add real provider chain
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user