feat: support tavily and searxng in desktop search settings

Extend the desktop search credential UI to cover the two new providers,
matching the web console: Tavily uses the shared API-key modal, and SearXNG
gets an instance-URL field (prefilled from url_masked, no masking). Adds the
needs_url/url_masked types and the tavily/searxng i18n strings.

Co-authored-by: cowagent <cow@cowagent.ai>
This commit is contained in:
zhayujieandcowagent committed 2026-09-08 17:25:02 +08:00
1 parent 24c4105d93
commit ffb789bbb7
3 files changed
+71 -27

No files matched your search

+10
View File
@@ -479,6 +479,11 @@ const translations: Record<string, Record<string, string>> = {
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<string, Record<string, string>> = {
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',
@@ -236,8 +236,14 @@ const ModelsTab: React.FC<ModelsTabProps> = ({ 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 (
<Modal
open={open}
@@ -1002,19 +1032,17 @@ const SearchKeyModal: React.FC<{
}
>
<p className="text-xs text-content-tertiary mb-3">{desc}</p>
<Field
label="API Key"
hint={isAnysearch ? t('models_search_anysearch_anon_hint') : undefined}
>
<Field label={fieldLabel} hint={fieldHint}>
<TextInput
className="font-mono"
value={key}
placeholder="sk-..."
className={isSearxng ? '' : 'font-mono'}
value={value}
placeholder={placeholder}
onFocus={() => {
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)
}}
/>
+10 -4
View File
@@ -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