diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index c4b66e0a..5eb40ed2 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -479,6 +479,11 @@ const translations: Record> = { models_search_anysearch_anon_hint: '留空可启用匿名模式(无需 API Key,每日有免费额度)', models_search_serply_title: '配置 Serply API Key', models_search_serply_desc: '前往 serply.io 控制台创建 API Key。', + models_search_tavily_title: '配置 Tavily API Key', + models_search_tavily_desc: '前往 tavily.com 控制台创建 API Key。', + models_search_searxng_title: '配置 SearXNG 实例 URL', + models_search_searxng_desc: '输入自托管 SearXNG 实例的 URL(无需 API Key)。', + models_search_instance_url: '实例 URL', skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 工具和技能', skills_hub_btn: '探索技能广场', @@ -1192,6 +1197,11 @@ const translations: Record> = { models_search_anysearch_anon_hint: 'Leave empty to enable anonymous mode (no API key required, daily free quota)', models_search_serply_title: 'Configure Serply API Key', models_search_serply_desc: 'Create a key at the Serply console (serply.io).', + models_search_tavily_title: 'Configure Tavily API Key', + models_search_tavily_desc: 'Create a key at the Tavily console (tavily.com).', + models_search_searxng_title: 'Configure SearXNG Instance URL', + models_search_searxng_desc: 'Enter the URL of your self-hosted SearXNG instance (no API key required).', + models_search_instance_url: 'Instance URL', config_max_tokens: 'Max Context Tokens', config_max_turns: 'Max Memory Turns', config_max_steps: 'Max Steps', diff --git a/desktop/src/renderer/src/pages/settings/ModelsTab.tsx b/desktop/src/renderer/src/pages/settings/ModelsTab.tsx index d029dedf..64f2734c 100644 --- a/desktop/src/renderer/src/pages/settings/ModelsTab.tsx +++ b/desktop/src/renderer/src/pages/settings/ModelsTab.tsx @@ -236,8 +236,14 @@ const ModelsTab: React.FC = ({ baseUrl }) => { onSaveStrategy={(strategy, provider) => run('search', { action: 'set_capability', capability: 'search', strategy, provider }) } - onSaveSearchKey={(provider, key, anonymous) => - run('search_key', { action: 'set_search_credential', provider, api_key: key, anonymous }) + onSaveSearchKey={(provider, value, anonymous) => + run( + 'search_key', + provider === 'searxng' + ? // SearXNG persists an instance URL, not an API key. + { action: 'set_search_credential', provider, url: value } + : { action: 'set_search_credential', provider, api_key: value, anonymous } + ) } keyStatus={statusMap.search_key} keyBusy={busy === 'search_key'} @@ -793,10 +799,11 @@ const EmbeddingCard: React.FC<{ ) // Search providers that own a dedicated credential (as opposed to reusing a -// model-vendor key). AnySearch additionally supports an anonymous tier, so it +// model-vendor key): a dedicated API key, or — for SearXNG — a self-hosted +// instance URL. AnySearch additionally supports an anonymous tier, so it // counts as "configured" even without a key. Mirrors the web console flow. const isDedicatedKeyProvider = (p: SearchProviderMeta): boolean => - p.needs_dedicated_key || ['bocha', 'anysearch', 'serply'].includes(p.id) + p.needs_dedicated_key || p.needs_url || ['bocha', 'anysearch', 'serply', 'tavily', 'searxng'].includes(p.id) const SearchCard: React.FC<{ state: SearchCapabilityState @@ -945,27 +952,31 @@ const SearchCard: React.FC<{ ) } -// Dedicated-key editor for a single search provider. AnySearch adds an -// anonymous option: saving with an empty key enables the anonymous tier. +// Dedicated-credential editor for a single search provider. Most providers +// hold an API key; SearXNG holds a self-hosted instance URL (echoed back +// verbatim, not masked). AnySearch adds an anonymous option: saving with an +// empty key enables the anonymous tier. const SearchKeyModal: React.FC<{ provider: SearchProviderMeta | null busy: boolean status?: string onClose: () => void - onSave: (key: string, anonymous: boolean) => void + onSave: (value: string, anonymous: boolean) => void }> = ({ provider, busy, onClose, onSave }) => { const open = !!provider - const masked = provider?.api_key_masked || '' const isAnysearch = provider?.id === 'anysearch' - const [key, setKey] = useState('') + const isSearxng = provider?.id === 'searxng' || !!provider?.needs_url + // SearXNG prefills its plain instance URL; others prefill the masked key. + const initial = isSearxng ? provider?.url_masked || '' : provider?.api_key_masked || '' + const [value, setValue] = useState('') const [dirty, setDirty] = useState(false) useEffect(() => { if (open) { - setKey(masked) + setValue(initial) setDirty(false) } - }, [open, masked]) + }, [open, initial]) if (!provider) return null @@ -973,18 +984,37 @@ const SearchKeyModal: React.FC<{ const desc = t(`models_search_${provider.id}_desc`) const handleSave = () => { + if (isSearxng) { + // URL is plain text (never masked). Empty is a no-op here — use the + // configured chip / clear flow to remove it. + const trimmed = value.trim() + if (!dirty || !trimmed) { + onClose() + return + } + onSave(trimmed, false) + return + } // Kept the masked placeholder untouched -> nothing to persist. - if (!dirty || MASK_RE.test(key)) { + if (!dirty || MASK_RE.test(value)) { // AnySearch: an untouched-but-empty field still means "anonymous". - if (isAnysearch && !masked) onSave('', true) + if (isAnysearch && !initial) onSave('', true) else onClose() return } - const trimmed = key.trim() + const trimmed = value.trim() // AnySearch: empty key = enable anonymous mode. onSave(trimmed, isAnysearch && !trimmed) } + const fieldLabel = isSearxng ? t('models_search_instance_url') : 'API Key' + const fieldHint = isSearxng + ? undefined + : isAnysearch + ? t('models_search_anysearch_anon_hint') + : undefined + const placeholder = isSearxng ? 'https://searxng.example.com' : 'sk-...' + return (

{desc}

- + { - if (!dirty && MASK_RE.test(key)) setKey('') + // Only API keys use a masked sentinel; URLs stay as-is. + if (!isSearxng && !dirty && MASK_RE.test(value)) setValue('') }} onChange={(e) => { - setKey(e.target.value) + setValue(e.target.value) setDirty(true) }} /> diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index 264fb704..2c880dd6 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -600,6 +600,11 @@ export interface SearchProviderMeta { // AnySearch can be "configured" via anonymous mode (no key). The backend // sets this flag so the UI can badge it and offer the anonymous opt-in. anonymous?: boolean + // SearXNG holds a self-hosted instance URL instead of an API key. When + // needs_url is set the editor shows a URL field; url_masked echoes the saved + // URL (not a secret, so returned verbatim) for prefill/edit. + needs_url?: boolean + url_masked?: string } export interface CapabilityState { @@ -685,10 +690,11 @@ export type ModelsAction = // is accepted here alongside its opt-in fields. | { action: 'set_capability'; capability: CapabilityKey | 'chat_fallback'; provider_id?: string; model?: string; voice?: string; strategy?: string; provider?: string; enabled?: boolean; max_switches?: number } | { action: 'set_voice_reply_mode'; mode: 'off' | 'voice_if_voice' | 'always' } - // Dedicated search-provider credentials (bocha / anysearch / serply). The - // provider field defaults to bocha server-side when omitted; anonymous is - // AnySearch-only (save with an empty key to enable the anonymous tier). - | { action: 'set_search_credential'; provider?: string; api_key: string; anonymous?: boolean } + // Dedicated search-provider credentials (bocha / anysearch / serply / tavily + // use api_key; searxng uses url). The provider field defaults to bocha + // server-side when omitted; anonymous is AnySearch-only (save with an empty + // key to enable the anonymous tier). + | { action: 'set_search_credential'; provider?: string; api_key?: string; url?: string; anonymous?: boolean } // ============================================================ // Channels