feat(websearch): HTTP basic auth support for Searxng (#5009)

* feat(websearch): HTTP basic auth support for Searxng

* fix(websearch): schema migration

* refactor(i18n): update translations for basic authentication

* fix(websearch): 修正 `HTTP 认证` 相关翻译

* feat(websearch): 为 `HTTP 认证` 添加注释

---------

Co-authored-by: suyao <sy20010504@gmail.com>
This commit is contained in:
Hantong Chen 2025-04-19 01:58:03 +08:00 committed by GitHub
parent 3360905275
commit 1c5adc1329
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 141 additions and 5 deletions

View File

@ -1230,6 +1230,12 @@
"api_key": "API Key", "api_key": "API Key",
"api_key.tip": "Multiple keys separated by commas", "api_key.tip": "Multiple keys separated by commas",
"api_version": "API Version", "api_version": "API Version",
"basic_auth": "HTTP authentication",
"basic_auth.tip": "Applicable to instances deployed remotely (see the documentation). Currently, only the Basic scheme (RFC 7617) is supported.",
"basic_auth.user_name": "Username",
"basic_auth.user_name.tip": "Left empty to disable",
"basic_auth.password": "Password",
"basic_auth.password.tip": "",
"charge": "Charge", "charge": "Charge",
"check": "Check", "check": "Check",
"check_all_keys": "Check All Keys", "check_all_keys": "Check All Keys",

View File

@ -1229,6 +1229,12 @@
"api_key": "APIキー", "api_key": "APIキー",
"api_key.tip": "複数のキーはカンマで区切ります", "api_key.tip": "複数のキーはカンマで区切ります",
"api_version": "APIバージョン", "api_version": "APIバージョン",
"basic_auth": "HTTP 認証",
"basic_auth.tip": "サーバー展開によるインスタンスに適用されますドキュメントを参照。現在はBasicスキームRFC7617のみをサポートしています。",
"basic_auth.user_name": "ユーザー名",
"basic_auth.user_name.tip": "空欄で無効化",
"basic_auth.password": "パスワード",
"basic_auth.password.tip": "",
"charge": "充電", "charge": "充電",
"check": "チェック", "check": "チェック",
"check_all_keys": "すべてのキーをチェック", "check_all_keys": "すべてのキーをチェック",

View File

@ -1229,6 +1229,12 @@
"api_key": "Ключ API", "api_key": "Ключ API",
"api_key.tip": "Несколько ключей, разделенных запятыми", "api_key.tip": "Несколько ключей, разделенных запятыми",
"api_version": "Версия API", "api_version": "Версия API",
"basic_auth": "HTTP аутентификация",
"basic_auth.tip": "Применимо к экземплярам, развернутым через сервер (см. документацию). В настоящее время поддерживается только схема Basic (RFC7617).",
"basic_auth.user_name": "Имя пользователя",
"basic_auth.user_name.tip": "Оставить пустым для отключения",
"basic_auth.password": "Пароль",
"basic_auth.password.tip": "",
"charge": "Пополнить", "charge": "Пополнить",
"check": "Проверить", "check": "Проверить",
"check_all_keys": "Проверить все ключи", "check_all_keys": "Проверить все ключи",

View File

@ -1230,6 +1230,12 @@
"api_key": "API 密钥", "api_key": "API 密钥",
"api_key.tip": "多个密钥使用逗号分隔", "api_key.tip": "多个密钥使用逗号分隔",
"api_version": "API 版本", "api_version": "API 版本",
"basic_auth": "HTTP 认证",
"basic_auth.tip": "适用于通过服务器部署的实例(参见文档)。目前仅支持 Basic 方案RFC7617。",
"basic_auth.user_name": "用户名",
"basic_auth.user_name.tip": "留空以禁用",
"basic_auth.password": "密码",
"basic_auth.password.tip": "",
"charge": "充值", "charge": "充值",
"check": "检查", "check": "检查",
"check_all_keys": "检查所有密钥", "check_all_keys": "检查所有密钥",

View File

@ -1229,6 +1229,12 @@
"api_key": "API 金鑰", "api_key": "API 金鑰",
"api_key.tip": "多個金鑰使用逗號分隔", "api_key.tip": "多個金鑰使用逗號分隔",
"api_version": "API 版本", "api_version": "API 版本",
"basic_auth": "HTTP 認證",
"basic_auth.tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案RFC7617。",
"basic_auth.user_name": "用戶",
"basic_auth.user_name.tip": "留空以停用",
"basic_auth.password": "密碼",
"basic_auth.password.tip": "",
"charge": "儲值", "charge": "儲值",
"check": "檢查", "check": "檢查",
"check_all_keys": "檢查所有金鑰", "check_all_keys": "檢查所有金鑰",

View File

@ -5,14 +5,14 @@ import { formatApiKeys } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService' import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider } from '@renderer/types' import { WebSearchProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils' import { hasObjectKey } from '@renderer/utils'
import { Avatar, Button, Divider, Flex, Input } from 'antd' import { Avatar, Button, Divider, Flex, Form, Input, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link' import Link from 'antd/es/typography/Link'
import { Info } from 'lucide-react' import { Info } from 'lucide-react'
import { FC, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..' import { SettingDivider, SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle, SettingTitle } from '..'
import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup' import ApiCheckPopup from '../ProviderSettings/ApiCheckPopup'
interface Props { interface Props {
@ -25,6 +25,8 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
const [apiKey, setApiKey] = useState(provider.apiKey || '') const [apiKey, setApiKey] = useState(provider.apiKey || '')
const [apiHost, setApiHost] = useState(provider.apiHost || '') const [apiHost, setApiHost] = useState(provider.apiHost || '')
const [apiChecking, setApiChecking] = useState(false) const [apiChecking, setApiChecking] = useState(false)
const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '')
const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '')
const [apiValid, setApiValid] = useState(false) const [apiValid, setApiValid] = useState(false)
const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id] const webSearchProviderConfig = WEB_SEARCH_PROVIDER_CONFIG[provider.id]
@ -49,6 +51,26 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
} }
} }
const onUpdateBasicAuthUsername = () => {
const currentValue = basicAuthUsername || ''
const savedValue = provider.basicAuthUsername || ''
if (currentValue !== savedValue) {
updateProvider({ ...provider, basicAuthUsername: basicAuthUsername })
} else {
setBasicAuthUsername(provider.basicAuthUsername || '')
}
}
const onUpdateBasicAuthPassword = () => {
const currentValue = basicAuthPassword || ''
const savedValue = provider.basicAuthPassword || ''
if (currentValue !== savedValue) {
updateProvider({ ...provider, basicAuthPassword: basicAuthPassword })
} else {
setBasicAuthPassword(provider.basicAuthPassword || '')
}
}
async function checkSearch() { async function checkSearch() {
if (!provider) { if (!provider) {
window.message.error({ window.message.error({
@ -111,7 +133,9 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
useEffect(() => { useEffect(() => {
setApiKey(provider.apiKey ?? '') setApiKey(provider.apiKey ?? '')
setApiHost(provider.apiHost ?? '') setApiHost(provider.apiHost ?? '')
}, [provider.apiKey, provider.apiHost]) setBasicAuthUsername(provider.basicAuthUsername ?? '')
setBasicAuthPassword(provider.basicAuthPassword ?? '')
}, [provider.apiKey, provider.apiHost, provider.basicAuthUsername, provider.basicAuthPassword])
return ( return (
<> <>
@ -176,6 +200,50 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.websearch.check')} {apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.websearch.check')}
</Button> </Button>
</Flex> </Flex>
<SettingDivider style={{ marginTop: 12, marginBottom: 12 }} />
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>
{t('settings.provider.basic_auth')}
<Tooltip title={t('settings.provider.basic_auth.tip')} placement="right">
<Info size={16} color="var(--color-icon)" style={{ marginLeft: 5, cursor: 'pointer' }} />
</Tooltip>
</SettingSubtitle>
<Flex>
<Form
layout="inline"
initialValues={{
username: basicAuthUsername,
password: basicAuthPassword
}}
onValuesChange={(changedValues) => {
// Update local state when form values change
if ('username' in changedValues) {
setBasicAuthUsername(changedValues.username || '')
}
if ('password' in changedValues) {
setBasicAuthPassword(changedValues.password || '')
}
}}>
<Form.Item label={t('settings.provider.basic_auth.user_name')} name="username">
<Input
placeholder={t('settings.provider.basic_auth.user_name.tip')}
onBlur={onUpdateBasicAuthUsername}
/>
</Form.Item>
<Form.Item
label={t('settings.provider.basic_auth.password')}
name="password"
rules={[{ required: !!basicAuthUsername, validateTrigger: ['onBlur', 'onChange'] }]}
help=""
hidden={!basicAuthUsername}>
<Input.Password
placeholder={t('settings.provider.basic_auth.password.tip')}
onBlur={onUpdateBasicAuthPassword}
disabled={!basicAuthUsername}
visibilityToggle={true}
/>
</Form.Item>
</Form>
</Flex>
</> </>
)} )}
</> </>

View File

@ -2,6 +2,7 @@ import { SearxngClient } from '@agentic/searxng'
import { WebSearchState } from '@renderer/store/websearch' import { WebSearchState } from '@renderer/store/websearch'
import { WebSearchProvider, WebSearchResponse } from '@renderer/types' import { WebSearchProvider, WebSearchResponse } from '@renderer/types'
import axios from 'axios' import axios from 'axios'
import ky from 'ky'
import BaseWebSearchProvider from './BaseWebSearchProvider' import BaseWebSearchProvider from './BaseWebSearchProvider'
@ -9,6 +10,8 @@ export default class SearxngProvider extends BaseWebSearchProvider {
private searxng: SearxngClient private searxng: SearxngClient
private engines: string[] = [] private engines: string[] = []
private readonly apiHost: string private readonly apiHost: string
private readonly basicAuthUsername?: string
private readonly basicAuthPassword?: string
private isInitialized = false private isInitialized = false
constructor(provider: WebSearchProvider) { constructor(provider: WebSearchProvider) {
@ -16,9 +19,22 @@ export default class SearxngProvider extends BaseWebSearchProvider {
if (!provider.apiHost) { if (!provider.apiHost) {
throw new Error('API host is required for SearxNG provider') throw new Error('API host is required for SearxNG provider')
} }
this.apiHost = provider.apiHost this.apiHost = provider.apiHost
this.basicAuthUsername = provider.basicAuthUsername
this.basicAuthPassword = provider.basicAuthPassword ? provider.basicAuthPassword : ''
try { try {
this.searxng = new SearxngClient({ apiBaseUrl: this.apiHost }) // `ky` do not support basic auth directly
const headers = this.basicAuthUsername
? {
Authorization: `Basic ` + btoa(`${this.basicAuthUsername}:${this.basicAuthPassword}`)
}
: undefined
this.searxng = new SearxngClient({
apiBaseUrl: this.apiHost,
ky: ky.create({ headers })
})
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to initialize SearxNG client: ${error instanceof Error ? error.message : 'Unknown error'}` `Failed to initialize SearxNG client: ${error instanceof Error ? error.message : 'Unknown error'}`
@ -29,9 +45,16 @@ export default class SearxngProvider extends BaseWebSearchProvider {
private async initEngines(): Promise<void> { private async initEngines(): Promise<void> {
try { try {
console.log(`Initializing SearxNG with API host: ${this.apiHost}`) console.log(`Initializing SearxNG with API host: ${this.apiHost}`)
const auth = this.basicAuthUsername
? {
username: this.basicAuthUsername,
password: this.basicAuthPassword ? this.basicAuthPassword : ''
}
: undefined
const response = await axios.get(`${this.apiHost}/config`, { const response = await axios.get(`${this.apiHost}/config`, {
timeout: 5000, timeout: 5000,
validateStatus: (status) => status === 200 // 仅接受 200 状态码 validateStatus: (status) => status === 200, // 仅接受 200 状态码
auth
}) })
if (!response.data) { if (!response.data) {

View File

@ -1236,6 +1236,19 @@ const migrateConfig = {
} catch (error) { } catch (error) {
return state return state
} }
},
'98': (state: RootState) => {
try {
if (state.websearch && state.websearch.providers) {
state.websearch.providers.forEach((provider) => {
provider.basicAuthUsername = ''
provider.basicAuthPassword = ''
})
}
return state
} catch (error) {
return state
}
} }
} }

View File

@ -346,6 +346,8 @@ export type WebSearchProvider = {
apiHost?: string apiHost?: string
engines?: string[] engines?: string[]
url?: string url?: string
basicAuthUsername?: string
basicAuthPassword?: string
contentLimit?: number contentLimit?: number
usingBrowser?: boolean usingBrowser?: boolean
} }