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.tip": "Multiple keys separated by commas",
"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",
"check": "Check",
"check_all_keys": "Check All Keys",

View File

@ -1229,6 +1229,12 @@
"api_key": "APIキー",
"api_key.tip": "複数のキーはカンマで区切ります",
"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": "充電",
"check": "チェック",
"check_all_keys": "すべてのキーをチェック",

View File

@ -1229,6 +1229,12 @@
"api_key": "Ключ API",
"api_key.tip": "Несколько ключей, разделенных запятыми",
"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": "Пополнить",
"check": "Проверить",
"check_all_keys": "Проверить все ключи",

View File

@ -1230,6 +1230,12 @@
"api_key": "API 密钥",
"api_key.tip": "多个密钥使用逗号分隔",
"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": "充值",
"check": "检查",
"check_all_keys": "检查所有密钥",

View File

@ -1229,6 +1229,12 @@
"api_key": "API 金鑰",
"api_key.tip": "多個金鑰使用逗號分隔",
"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": "儲值",
"check": "檢查",
"check_all_keys": "檢查所有金鑰",

View File

@ -5,14 +5,14 @@ import { formatApiKeys } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider } from '@renderer/types'
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 { Info } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
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'
interface Props {
@ -25,6 +25,8 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
const [apiKey, setApiKey] = useState(provider.apiKey || '')
const [apiHost, setApiHost] = useState(provider.apiHost || '')
const [apiChecking, setApiChecking] = useState(false)
const [basicAuthUsername, setBasicAuthUsername] = useState(provider.basicAuthUsername || '')
const [basicAuthPassword, setBasicAuthPassword] = useState(provider.basicAuthPassword || '')
const [apiValid, setApiValid] = useState(false)
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() {
if (!provider) {
window.message.error({
@ -111,7 +133,9 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
useEffect(() => {
setApiKey(provider.apiKey ?? '')
setApiHost(provider.apiHost ?? '')
}, [provider.apiKey, provider.apiHost])
setBasicAuthUsername(provider.basicAuthUsername ?? '')
setBasicAuthPassword(provider.basicAuthPassword ?? '')
}, [provider.apiKey, provider.apiHost, provider.basicAuthUsername, provider.basicAuthPassword])
return (
<>
@ -176,6 +200,50 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.websearch.check')}
</Button>
</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 { WebSearchProvider, WebSearchResponse } from '@renderer/types'
import axios from 'axios'
import ky from 'ky'
import BaseWebSearchProvider from './BaseWebSearchProvider'
@ -9,6 +10,8 @@ export default class SearxngProvider extends BaseWebSearchProvider {
private searxng: SearxngClient
private engines: string[] = []
private readonly apiHost: string
private readonly basicAuthUsername?: string
private readonly basicAuthPassword?: string
private isInitialized = false
constructor(provider: WebSearchProvider) {
@ -16,9 +19,22 @@ export default class SearxngProvider extends BaseWebSearchProvider {
if (!provider.apiHost) {
throw new Error('API host is required for SearxNG provider')
}
this.apiHost = provider.apiHost
this.basicAuthUsername = provider.basicAuthUsername
this.basicAuthPassword = provider.basicAuthPassword ? provider.basicAuthPassword : ''
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) {
throw new 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> {
try {
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`, {
timeout: 5000,
validateStatus: (status) => status === 200 // 仅接受 200 状态码
validateStatus: (status) => status === 200, // 仅接受 200 状态码
auth
})
if (!response.data) {

View File

@ -1236,6 +1236,19 @@ const migrateConfig = {
} catch (error) {
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
engines?: string[]
url?: string
basicAuthUsername?: string
basicAuthPassword?: string
contentLimit?: number
usingBrowser?: boolean
}