import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Eye, EyeOff } from "lucide-react"; import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types"; import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types"; import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter"; import { useI18n } from "../../../../application/i18n/I18nProvider"; import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui"; const SEARCH_ICON_PATHS: Record = { tavily: "/ai/search/tavily.svg", exa: "/ai/search/exa.png", bocha: "/ai/search/bocha.webp", zhipu: "/ai/search/zhipu.png", searxng: "/ai/search/searxng.svg", }; const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => ( ); const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries( WEB_SEARCH_PROVIDER_PRESETS, ).map(([id, preset]) => ({ value: id as WebSearchProviderId, label: preset.name, icon: , })); export const WebSearchSettings: React.FC<{ webSearchConfig: WebSearchConfig | null; setWebSearchConfig: (config: WebSearchConfig | null) => void; }> = ({ webSearchConfig, setWebSearchConfig }) => { const { t } = useI18n(); const [apiKeyInput, setApiKeyInput] = useState(""); const [showApiKey, setShowApiKey] = useState(false); const [isDecrypting, setIsDecrypting] = useState(false); const config = useMemo(() => webSearchConfig ?? { providerId: "tavily" as WebSearchProviderId, enabled: false, maxResults: 5, }, [webSearchConfig]); // Ref to always read the latest config in async callbacks (avoids stale closure) const configRef = useRef(config); configRef.current = config; const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId]; // Decrypt API key on mount or when provider changes (with cancellation guard) const decryptSeqRef = useRef(0); useEffect(() => { if (config.apiKey) { const seq = ++decryptSeqRef.current; setIsDecrypting(true); decryptField(config.apiKey) .then((decrypted) => { if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? ""); }) .catch(() => { if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? ""); }) .finally(() => { if (decryptSeqRef.current === seq) setIsDecrypting(false); }); } else { decryptSeqRef.current++; setApiKeyInput(""); setIsDecrypting(false); } }, [config.apiKey, config.providerId]); const updateConfig = useCallback( (updates: Partial) => { setWebSearchConfig({ ...configRef.current, ...updates }); }, [setWebSearchConfig], ); const handleProviderChange = useCallback( (val: string) => { const providerId = val as WebSearchProviderId; const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId]; setWebSearchConfig({ ...configRef.current, providerId, apiKey: undefined, apiHost: newPreset.defaultApiHost || undefined, }); setApiKeyInput(""); }, [setWebSearchConfig], ); // Sequence counter for blur saves — prevents out-of-order encryption results const blurSeqRef = useRef(0); const handleApiKeyBlur = useCallback(async () => { if (!apiKeyInput.trim()) { blurSeqRef.current++; updateConfig({ apiKey: undefined }); return; } const seq = ++blurSeqRef.current; const providerAtBlur = configRef.current.providerId; const encrypted = await encryptField(apiKeyInput.trim()); // Only apply if this is still the latest blur and provider hasn't changed if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) { updateConfig({ apiKey: encrypted }); } }, [apiKeyInput, updateConfig]); return ( updateConfig({ enabled })} /> {/* Provider */} setApiKeyInput(e.target.value)} onBlur={() => void handleApiKeyBlur()} className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" disabled={isDecrypting} /> )} {/* API Host */} updateConfig({ apiHost: e.target.value || undefined })} placeholder={preset.defaultApiHost || "https://..."} className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" /> {/* Max Results */} { const val = parseInt(e.target.value, 10); if (!isNaN(val) && val >= 1 && val <= 20) { updateConfig({ maxResults: val }); } }} min={1} max={20} className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" /> ); };