refactor(ApiKeyList): add a popup for api key list (#7491)

* refactor(ApiKeyList): add a popup for api key list

- ApiKeyList for key management
- ApiKeyListPopup triggerred by a button
- Move formatApiKeys to utils for better reuse
- Simplify apikey related states in ProviderSettings for better
  integration with ApiKeyList
- Modify `updateProvider` to accept partial updates
- Update api key placeholder

* fix: strict type

* refactor: support websearch provider

* refactor: remove ApiCheckPopup

* refactor: simplify interfaces for ProviderSetting and WebSearchProviderSetting

* fix: sync input api key between sub-pages, futher simplification

* fix: bold title

* refactor: extract status icon colors

* refactor: add a status indicator to input box on error, update type definitions

* refactor: further simplification, make data flow clearer

* feat: support api key list for preprocess settings

* refactor: better naming, less confusion
This commit is contained in:
one 2025-07-06 15:10:44 +08:00 committed by GitHub
parent bf7e713eec
commit a4620f8c68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1205 additions and 458 deletions

View File

@ -72,6 +72,10 @@
--chat-text-user: var(--color-black);
--list-item-border-radius: 20px;
--color-status-success: #52c41a;
--color-status-error: #ff4d4f;
--color-status-warning: #faad14;
}
[theme-mode='light'] {

View File

@ -1,14 +1,25 @@
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { motion } from 'framer-motion'
import { SVGProps } from 'react'
export const StreamlineGoodHealthAndWellBeing = (props: SVGProps<SVGSVGElement>) => {
export const StreamlineGoodHealthAndWellBeing = (
props: SVGProps<SVGSVGElement> & {
size?: number | string
isActive?: boolean
}
) => {
const { size = '1em', isActive, ...svgProps } = props
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 14 14" {...props}>
{/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */}
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="m10.097 12.468l-2.773-2.52c-1.53-1.522.717-4.423 2.773-2.045c2.104-2.33 4.303.57 2.773 2.045z"></path>
<path d="M.621 6.088h1.367l1.823 3.19l4.101-7.747l1.823 3.646"></path>
</g>
</svg>
<motion.span variants={lightbulbVariants} animate={isActive ? 'active' : 'idle'} initial="idle">
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 -2 14 16" {...svgProps}>
{/* Icon from Streamline by Streamline - https://creativecommons.org/licenses/by/4.0/ */}
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.2}>
<path d="m10.097 12.468l-2.773-2.52c-1.53-1.522.717-4.423 2.773-2.045c2.104-2.33 4.303.57 2.773 2.045z"></path>
<path d="M.621 6.088h1.367l1.823 3.19l4.101-7.747l1.823 3.646"></path>
</g>
</svg>
</motion.span>
)
}

View File

@ -0,0 +1,307 @@
import Logger from '@renderer/config/logger'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import SelectProviderModelPopup from '@renderer/pages/settings/ProviderSettings/SelectProviderModelPopup'
import { checkApi } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService'
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api'
import { formatErrorMessage } from '@renderer/utils/error'
import { TFunction } from 'i18next'
import { isEmpty } from 'lodash'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiKeyConnectivity, ApiKeyValidity, ApiKeyWithStatus, ApiProviderKind, ApiProviderUnion } from './types'
interface UseApiKeysProps {
provider: ApiProviderUnion
updateProvider: (provider: Partial<ApiProviderUnion>) => void
providerKind: ApiProviderKind
}
/**
* API Keys hook
*/
export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKeysProps) {
const { t } = useTranslation()
// 连通性检查的 UI 状态管理
const [connectivityStates, setConnectivityStates] = useState<Map<string, ApiKeyConnectivity>>(new Map())
// 保存 apiKey 到 provider
const updateProviderWithKey = useCallback(
(newKeys: string[]) => {
const validKeys = newKeys.filter((k) => k.trim())
const formattedKeyString = formatApiKeys(validKeys.join(','))
updateProvider({ apiKey: formattedKeyString })
},
[updateProvider]
)
// 解析 keyString 为数组
const keys = useMemo(() => {
if (!provider.apiKey) return []
const formattedApiKeys = formatApiKeys(provider.apiKey)
const keys = splitApiKeyString(formattedApiKeys)
return Array.from(new Set(keys))
}, [provider.apiKey])
// 合并基本数据和连通性状态
const keysWithStatus = useMemo((): ApiKeyWithStatus[] => {
return keys.map((key) => {
const connectivityState = connectivityStates.get(key) || {
status: 'not_checked' as const,
checking: false,
error: undefined,
model: undefined,
latency: undefined
}
return {
key,
...connectivityState
}
})
}, [keys, connectivityStates])
// 更新单个 key 的连通性状态
const updateConnectivityState = useCallback((key: string, state: Partial<ApiKeyConnectivity>) => {
setConnectivityStates((prev) => {
const newMap = new Map(prev)
const currentState = prev.get(key) || {
status: 'not_checked' as const,
checking: false,
error: undefined,
model: undefined,
latency: undefined
}
newMap.set(key, { ...currentState, ...state })
return newMap
})
}, [])
// 验证 API key 格式
const validateApiKey = useCallback(
(key: string, existingKeys: string[] = []): ApiKeyValidity => {
const trimmedKey = key.trim()
if (!trimmedKey) {
return { isValid: false, error: t('settings.provider.api.key.error.empty') }
}
if (existingKeys.includes(trimmedKey)) {
return { isValid: false, error: t('settings.provider.api.key.error.duplicate') }
}
return { isValid: true }
},
[t]
)
// 添加新 key
const addKey = useCallback(
(key: string): ApiKeyValidity => {
const validation = validateApiKey(key, keys)
if (!validation.isValid) {
return validation
}
updateProviderWithKey([...keys, key.trim()])
return { isValid: true }
},
[validateApiKey, keys, updateProviderWithKey]
)
// 更新 key
const updateKey = useCallback(
(index: number, key: string): ApiKeyValidity => {
if (index < 0 || index >= keys.length) {
Logger.error('[ApiKeyList] invalid key index', { index })
return { isValid: false, error: 'Invalid index' }
}
const otherKeys = keys.filter((_, i) => i !== index)
const validation = validateApiKey(key, otherKeys)
if (!validation.isValid) {
return validation
}
// 清除旧 key 的连通性状态
const oldKey = keys[index]
if (oldKey !== key.trim()) {
setConnectivityStates((prev) => {
const newMap = new Map(prev)
newMap.delete(oldKey)
return newMap
})
}
const newKeys = [...keys]
newKeys[index] = key.trim()
updateProviderWithKey(newKeys)
return { isValid: true }
},
[keys, validateApiKey, updateProviderWithKey]
)
// 移除 key
const removeKey = useCallback(
(index: number) => {
if (index < 0 || index >= keys.length) return
const keyToRemove = keys[index]
const newKeys = keys.filter((_, i) => i !== index)
// 清除对应的连通性状态
setConnectivityStates((prev) => {
const newMap = new Map(prev)
newMap.delete(keyToRemove)
return newMap
})
updateProviderWithKey(newKeys)
},
[keys, updateProviderWithKey]
)
// 移除连通性检查失败的 keys
const removeInvalidKeys = useCallback(() => {
const validKeys = keysWithStatus.filter((keyStatus) => keyStatus.status !== 'error').map((k) => k.key)
// 清除被删除的 keys 的连通性状态
const keysToRemove = keysWithStatus.filter((keyStatus) => keyStatus.status === 'error').map((k) => k.key)
setConnectivityStates((prev) => {
const newMap = new Map(prev)
keysToRemove.forEach((key) => newMap.delete(key))
return newMap
})
updateProviderWithKey(validKeys)
}, [keysWithStatus, updateProviderWithKey])
// 检查单个 key 的连通性,不负责选择和验证模型
const runConnectivityCheck = useCallback(
async (index: number, model?: Model): Promise<void> => {
const keyToCheck = keys[index]
const currentState = connectivityStates.get(keyToCheck)
if (currentState?.checking) return
// 设置检查状态
updateConnectivityState(keyToCheck, { checking: true })
try {
const startTime = Date.now()
if (isLlmProvider(provider, providerKind) && model) {
await checkApi({ ...provider, apiKey: keyToCheck }, model)
} else {
const result = await WebSearchService.checkSearch({ ...provider, apiKey: keyToCheck })
if (!result.valid) throw new Error(result.error)
}
const latency = Date.now() - startTime
// 连通性检查成功
updateConnectivityState(keyToCheck, {
checking: false,
status: 'success',
model,
latency,
error: undefined
})
} catch (error: any) {
// 连通性检查失败
updateConnectivityState(keyToCheck, {
checking: false,
status: 'error',
error: formatErrorMessage(error),
model: undefined,
latency: undefined
})
Logger.error('[ApiKeyList] failed to validate the connectivity of the api key', error)
}
},
[keys, connectivityStates, updateConnectivityState, provider, providerKind]
)
// 检查单个 key 的连通性
const checkKeyConnectivity = useCallback(
async (index: number): Promise<void> => {
if (!provider || index < 0 || index >= keys.length) return
const keyToCheck = keys[index]
const currentState = connectivityStates.get(keyToCheck)
if (currentState?.checking) return
const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined
if (model === null) return
await runConnectivityCheck(index, model)
},
[provider, keys, connectivityStates, providerKind, t, runConnectivityCheck]
)
// 检查所有 keys 的连通性
const checkAllKeysConnectivity = useCallback(async () => {
if (!provider || keys.length === 0) return
const model = isLlmProvider(provider, providerKind) ? await getModelForCheck(provider, t) : undefined
if (model === null) return
await Promise.allSettled(keys.map((_, index) => runConnectivityCheck(index, model)))
}, [provider, keys, providerKind, t, runConnectivityCheck])
// 计算是否有 key 正在检查
const isChecking = useMemo(() => {
return Array.from(connectivityStates.values()).some((state) => state.checking)
}, [connectivityStates])
return {
keys: keysWithStatus,
addKey,
updateKey,
removeKey,
removeInvalidKeys,
checkKeyConnectivity,
checkAllKeysConnectivity,
isChecking
}
}
export function isLlmProvider(obj: any, kind: ApiProviderKind): obj is Provider {
return kind === 'llm' && 'type' in obj && 'models' in obj
}
export function isWebSearchProvider(obj: any, kind: ApiProviderKind): obj is WebSearchProvider {
return kind === 'websearch' && ('url' in obj || 'engines' in obj)
}
export function isPreprocessProvider(obj: any, kind: ApiProviderKind): obj is PreprocessProvider {
return kind === 'doc-preprocess' && ('quota' in obj || 'options' in obj)
}
// 获取模型用于检查
async function getModelForCheck(provider: Provider, t: TFunction): Promise<Model | null> {
const modelsToCheck = provider.models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model))
if (isEmpty(modelsToCheck)) {
window.message.error({
key: 'no-models',
style: { marginTop: '3vh' },
duration: 5,
content: t('settings.provider.no_models_for_check')
})
return null
}
try {
const selectedModel = await SelectProviderModelPopup.show({ provider })
if (!selectedModel) return null
return selectedModel
} catch (error) {
Logger.error('[ApiKeyList] failed to select model', error)
return null
}
}

View File

@ -0,0 +1,2 @@
export { default as ApiKeyListPopup } from './popup'
export * from './types'

View File

@ -0,0 +1,213 @@
import { CheckCircleFilled, CloseCircleFilled, MinusOutlined } from '@ant-design/icons'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { maskApiKey } from '@renderer/utils/api'
import { Button, Flex, Input, InputRef, List, Popconfirm, Tooltip, Typography } from 'antd'
import { Check, PenLine, X } from 'lucide-react'
import { FC, memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { ApiKeyValidity, ApiKeyWithStatus } from './types'
export interface ApiKeyItemProps {
keyStatus: ApiKeyWithStatus
onUpdate: (newKey: string) => ApiKeyValidity
onRemove: () => void
onCheck: () => Promise<void>
disabled?: boolean
showHealthCheck?: boolean
isNew?: boolean
}
/**
* API Key
*
*/
const ApiKeyItem: FC<ApiKeyItemProps> = ({
keyStatus,
onUpdate,
onRemove,
onCheck,
disabled: _disabled = false,
showHealthCheck = true,
isNew = false
}) => {
const { t } = useTranslation()
const [isEditing, setIsEditing] = useState(isNew || !keyStatus.key.trim())
const [editValue, setEditValue] = useState(keyStatus.key)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const inputRef = useRef<InputRef>(null)
const disabled = keyStatus.checking || _disabled
const isNotChecked = keyStatus.status === 'not_checked'
const isSuccess = keyStatus.status === 'success'
const statusColor = isSuccess ? 'var(--color-status-success)' : 'var(--color-status-error)'
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
}
}, [isEditing])
useEffect(() => {
setHasUnsavedChanges(editValue.trim() !== keyStatus.key.trim())
}, [editValue, keyStatus.key])
const handleEdit = () => {
if (disabled) return
setIsEditing(true)
setEditValue(keyStatus.key)
}
const handleSave = () => {
const result = onUpdate(editValue)
if (!result.isValid) {
window.message.warning({
key: 'api-key-error',
content: result.error
})
return
}
setIsEditing(false)
}
const handleCancelEdit = () => {
if (isNew || !keyStatus.key.trim()) {
// 临时项取消时直接移除
onRemove()
} else {
// 现有项取消时恢复原值
setEditValue(keyStatus.key)
setIsEditing(false)
}
}
const renderStatusIcon = () => {
if (keyStatus.checking || isNotChecked) return null
const StatusIcon = isSuccess ? CheckCircleFilled : CloseCircleFilled
return <StatusIcon style={{ color: statusColor }} />
}
const renderKeyCheckResultTooltip = () => {
if (keyStatus.checking) {
return t('settings.models.check.checking')
}
if (isNotChecked) {
return ''
}
const statusTitle = isSuccess ? t('settings.models.check.passed') : t('settings.models.check.failed')
return (
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px', wordWrap: 'break-word' }}>
<strong style={{ color: statusColor }}>{statusTitle}</strong>
{keyStatus.model && (
<div style={{ marginTop: 5 }}>
{t('common.model')}: {keyStatus.model.name}
</div>
)}
{keyStatus.latency && isSuccess && (
<div style={{ marginTop: 5 }}>
{t('settings.provider.api.key.check.latency')}: {(keyStatus.latency / 1000).toFixed(2)}s
</div>
)}
{keyStatus.error && <div style={{ marginTop: 5 }}>{keyStatus.error}</div>}
</div>
)
}
return (
<List.Item>
{isEditing ? (
<ItemInnerContainer style={{ gap: '10px' }}>
<Input.Password
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onPressEnter={handleSave}
placeholder={t('settings.provider.api.key.new_key.placeholder')}
style={{ flex: 1, fontSize: '14px', marginLeft: '-10px' }}
spellCheck={false}
disabled={disabled}
/>
<Flex gap={0} align="center">
<Tooltip title={t('common.save')}>
<Button
type={hasUnsavedChanges ? 'primary' : 'text'}
icon={<Check size={16} />}
onClick={handleSave}
disabled={disabled}
/>
</Tooltip>
<Tooltip title={t('common.cancel')}>
<Button type="text" icon={<X size={16} />} onClick={handleCancelEdit} disabled={disabled} />
</Tooltip>
</Flex>
</ItemInnerContainer>
) : (
<ItemInnerContainer style={{ gap: '10px' }}>
<Tooltip
title={
<Typography.Text style={{ color: 'white' }} copyable={{ text: keyStatus.key }}>
{keyStatus.key}
</Typography.Text>
}
mouseEnterDelay={0.5}
placement="top"
// 确保不留下明文
destroyTooltipOnHide>
<span style={{ cursor: 'help' }}>{maskApiKey(keyStatus.key)}</span>
</Tooltip>
<Flex gap={10} align="center">
<Tooltip title={renderKeyCheckResultTooltip()} styles={{ body: { userSelect: 'text' } }}>
{renderStatusIcon()}
</Tooltip>
<Flex gap={0} align="center">
{showHealthCheck && (
<Tooltip title={t('settings.provider.check')} mouseLeaveDelay={0}>
<Button
type="text"
icon={<StreamlineGoodHealthAndWellBeing size={'1.2em'} isActive={keyStatus.checking} />}
onClick={onCheck}
disabled={disabled}
/>
</Tooltip>
)}
<Tooltip title={t('common.edit')} mouseLeaveDelay={0}>
<Button type="text" icon={<PenLine size={16} />} onClick={handleEdit} disabled={disabled} />
</Tooltip>
<Popconfirm
title={t('common.delete_confirm')}
onConfirm={onRemove}
disabled={disabled}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}>
<Tooltip title={t('common.delete')} mouseLeaveDelay={0}>
<Button type="text" icon={<MinusOutlined />} disabled={disabled} />
</Tooltip>
</Popconfirm>
</Flex>
</Flex>
</ItemInnerContainer>
)}
</List.Item>
)
}
const ItemInnerContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
margin: 0;
`
export default memo(ApiKeyItem)

View File

@ -0,0 +1,224 @@
import { PlusOutlined } from '@ant-design/icons'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import Scrollbar from '@renderer/components/Scrollbar'
import { usePreprocessProvider } from '@renderer/hooks/usePreprocess'
import { useProvider } from '@renderer/hooks/useProvider'
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
import { SettingHelpText } from '@renderer/pages/settings'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from 'antd'
import { Trash } from 'lucide-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isLlmProvider, useApiKeys } from './hook'
import ApiKeyItem from './item'
import { ApiKeyWithStatus, ApiProviderKind, ApiProviderUnion } from './types'
interface ApiKeyListProps {
provider: ApiProviderUnion
updateProvider: (provider: Partial<ApiProviderUnion>) => void
providerKind: ApiProviderKind
showHealthCheck?: boolean
}
/**
* Api key CRUD
*/
export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, providerKind, showHealthCheck = true }) => {
const { t } = useTranslation()
// 临时新项状态
const [pendingNewKey, setPendingNewKey] = useState<{ key: string; id: string } | null>(null)
const {
keys,
addKey,
updateKey,
removeKey,
removeInvalidKeys,
checkKeyConnectivity,
checkAllKeysConnectivity,
isChecking
} = useApiKeys({ provider, updateProvider, providerKind: providerKind })
// 创建一个临时新项
const handleAddNew = () => {
setPendingNewKey({ key: '', id: Date.now().toString() })
}
const handleUpdate = (index: number, newKey: string, isNew: boolean) => {
if (isNew) {
// 新项保存时,调用真正的 addKey然后清除临时状态
const result = addKey(newKey)
if (result.isValid) {
setPendingNewKey(null)
}
return result
} else {
// 现有项更新
return updateKey(index, newKey)
}
}
const handleRemove = (index: number, isNew: boolean) => {
if (isNew) {
setPendingNewKey(null) // 新项取消时,直接清除临时状态
} else {
removeKey(index) // 现有项删除
}
}
const shouldAutoFocus = () => {
if (provider.apiKey) return false
return isLlmProvider(provider, providerKind) && provider.enabled && !isProviderSupportAuth(provider)
}
// 合并真实 keys 和临时新项
const displayKeys: ApiKeyWithStatus[] = pendingNewKey
? [
...keys,
{
key: pendingNewKey.key,
status: 'not_checked',
checking: false
}
]
: keys
return (
<>
{/* Keys 列表 */}
<Card
size="small"
type="inner"
styles={{ body: { padding: 0 } }}
style={{ marginBottom: '5px', border: '0.5px solid var(--color-border)' }}>
{displayKeys.length === 0 ? (
<Typography.Text type="secondary" style={{ padding: '4px 11px', display: 'block' }}>
{t('error.no_api_key')}
</Typography.Text>
) : (
<Scrollbar style={{ maxHeight: '60vh', overflowX: 'hidden' }}>
<List
size="small"
dataSource={displayKeys}
renderItem={(keyStatus, index) => {
const isNew = pendingNewKey && index === displayKeys.length - 1
return (
<ApiKeyItem
key={isNew ? pendingNewKey.id : index}
keyStatus={keyStatus}
showHealthCheck={showHealthCheck}
isNew={!!isNew}
onUpdate={(newKey) => handleUpdate(index, newKey, !!isNew)}
onRemove={() => handleRemove(index, !!isNew)}
onCheck={() => checkKeyConnectivity(index)}
/>
)
}}
/>
</Scrollbar>
)}
</Card>
<Flex align="center" justify="space-between" style={{ marginTop: '0.5rem' }}>
{/* 帮助文本 */}
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
{/* 标题和操作按钮 */}
<Space style={{ gap: 6 }}>
{/* 批量删除无效 keys */}
{showHealthCheck && keys.length > 1 && (
<Space style={{ gap: 0 }}>
<Popconfirm
title={t('common.delete_confirm')}
onConfirm={removeInvalidKeys}
okText={t('common.confirm')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}>
<Tooltip title={t('settings.provider.remove_invalid_keys')} placement="top" mouseLeaveDelay={0}>
<Button type="text" icon={<Trash size={16} />} disabled={isChecking || !!pendingNewKey} danger />
</Tooltip>
</Popconfirm>
{/* 批量检查 */}
<Tooltip title={t('settings.provider.check_all_keys')} placement="top" mouseLeaveDelay={0}>
<Button
type="text"
icon={<StreamlineGoodHealthAndWellBeing size={'1.2em'} />}
onClick={checkAllKeysConnectivity}
disabled={isChecking || !!pendingNewKey}
/>
</Tooltip>
</Space>
)}
{/* 添加新 key */}
<Button
key="add"
type="primary"
onClick={handleAddNew}
icon={<PlusOutlined />}
autoFocus={shouldAutoFocus()}
disabled={isChecking || !!pendingNewKey}>
{t('common.add')}
</Button>
</Space>
</Flex>
</>
)
}
interface SpecificApiKeyListProps {
providerId: string
providerKind: ApiProviderKind
showHealthCheck?: boolean
}
export const LlmApiKeyList: FC<SpecificApiKeyListProps> = ({ providerId, providerKind, showHealthCheck = true }) => {
const { provider, updateProvider } = useProvider(providerId)
return (
<ApiKeyList
provider={provider}
updateProvider={updateProvider}
providerKind={providerKind}
showHealthCheck={showHealthCheck}
/>
)
}
export const WebSearchApiKeyList: FC<SpecificApiKeyListProps> = ({
providerId,
providerKind,
showHealthCheck = true
}) => {
const { provider, updateProvider } = useWebSearchProvider(providerId)
return (
<ApiKeyList
provider={provider}
updateProvider={updateProvider}
providerKind={providerKind}
showHealthCheck={showHealthCheck}
/>
)
}
export const DocPreprocessApiKeyList: FC<SpecificApiKeyListProps> = ({
providerId,
providerKind,
showHealthCheck = true
}) => {
const { provider, updateProvider } = usePreprocessProvider(providerId)
return (
<ApiKeyList
provider={provider}
updateProvider={updateProvider}
providerKind={providerKind}
showHealthCheck={showHealthCheck}
/>
)
}

View File

@ -0,0 +1,88 @@
import { TopView } from '@renderer/components/TopView'
import { Modal } from 'antd'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DocPreprocessApiKeyList, LlmApiKeyList, WebSearchApiKeyList } from './list'
import { ApiProviderKind } from './types'
interface ShowParams {
providerId: string
providerKind: ApiProviderKind
title?: string
showHealthCheck?: boolean
}
interface Props extends ShowParams {
resolve: (value: any) => void
}
/**
* API Key
*/
const PopupContainer: React.FC<Props> = ({ providerId, providerKind, title, resolve, showHealthCheck = true }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve(null)
}
const ListComponent = useMemo(() => {
switch (providerKind) {
case 'llm':
return LlmApiKeyList
case 'websearch':
return WebSearchApiKeyList
case 'doc-preprocess':
return DocPreprocessApiKeyList
default:
return null
}
}, [providerKind])
return (
<Modal
title={title || t('settings.provider.api.key.list.title')}
open={open}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
centered
width={600}
footer={null}>
{ListComponent && (
<ListComponent providerId={providerId} providerKind={providerKind} showHealthCheck={showHealthCheck} />
)}
</Modal>
)
}
const TopViewKey = 'ApiKeyListPopup'
export default class ApiKeyListPopup {
static topviewId = 0
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
TopView.hide(TopViewKey)
}}
/>,
TopViewKey
)
})
}
}

View File

@ -0,0 +1,31 @@
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
/**
* API Key
*/
export type ApiKeyConnectivity = {
status: 'success' | 'error' | 'not_checked'
checking?: boolean
error?: string
model?: Model
latency?: number
}
/**
* API key
*/
export type ApiKeyWithStatus = {
key: string
} & ApiKeyConnectivity
/**
* API key
*/
export type ApiKeyValidity = {
isValid: boolean
error?: string
}
export type ApiProviderUnion = Provider | WebSearchProvider | PreprocessProvider
export type ApiProviderKind = 'llm' | 'websearch' | 'doc-preprocess'

View File

@ -14,10 +14,11 @@ export const usePreprocessProvider = (id: string) => {
if (!provider) {
throw new Error(`preprocess provider with id ${id} not found`)
}
const updatePreprocessProvider = (preprocessProvider: PreprocessProvider) => {
dispatch(_updatePreprocessProvider(preprocessProvider))
return {
provider,
updateProvider: (updates: Partial<PreprocessProvider>) => dispatch(_updatePreprocessProvider({ id, ...updates }))
}
return { provider, updatePreprocessProvider }
}
export const usePreprocessProviders = () => {

View File

@ -26,7 +26,7 @@ export function useProviders() {
providers: providers || {},
addProvider: (provider: Provider) => dispatch(addProvider(provider)),
removeProvider: (provider: Provider) => dispatch(removeProvider(provider)),
updateProvider: (provider: Provider) => dispatch(updateProvider(provider)),
updateProvider: (updates: Partial<Provider> & { id: string }) => dispatch(updateProvider(updates)),
updateProviders: (providers: Provider[]) => dispatch(updateProviders(providers))
}
}
@ -50,7 +50,7 @@ export function useProvider(id: string) {
return {
provider,
models: provider?.models || [],
updateProvider: (provider: Provider) => dispatch(updateProvider(provider)),
updateProvider: (updates: Partial<Provider>) => dispatch(updateProvider({ id, ...updates })),
addModel: (model: Model) => dispatch(addModel({ providerId: id, model })),
removeModel: (model: Model) => dispatch(removeModel({ providerId: id, model })),
updateModel: (model: Model) => dispatch(updateModel({ providerId: id, model }))

View File

@ -58,11 +58,10 @@ export const useWebSearchProvider = (id: string) => {
throw new Error(`Web search provider with id ${id} not found`)
}
const updateProvider = (provider: WebSearchProvider) => {
dispatch(updateWebSearchProvider(provider))
return {
provider,
updateProvider: (updates: Partial<WebSearchProvider>) => dispatch(updateWebSearchProvider({ id, ...updates }))
}
return { provider, updateProvider }
}
export const useBlacklist = () => {

View File

@ -385,6 +385,7 @@
"cut": "Cut",
"default": "Default",
"delete": "Delete",
"delete_confirm": "Are you sure you want to delete?",
"description": "Description",
"docs": "Docs",
"download": "Download",
@ -1707,8 +1708,14 @@
"api.url.tip": "Ending with / ignores v1, ending with # forces use of input address",
"api_host": "API Host",
"api_key": "API Key",
"api_key.tip": "Multiple keys separated by commas",
"api_key.tip": "Multiple keys separated by commas or spaces",
"api_version": "API Version",
"api.key.new_key.placeholder": "Enter one or more keys",
"api.key.error.duplicate": "API key already exists",
"api.key.error.empty": "API key cannot be empty",
"api.key.check.latency": "Latency",
"api.key.list.open": "Open Management Interface",
"api.key.list.title": "API Key Management",
"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",
@ -1891,7 +1898,6 @@
"check": "Check",
"check_failed": "Verification failed",
"check_success": "Verification successful",
"get_api_key": "Get API Key",
"no_provider_selected": "Please select a search service provider before checking.",
"search_max_result": "Number of search results",
"search_provider": "Search service provider",

View File

@ -385,6 +385,7 @@
"cut": "切り取り",
"default": "デフォルト",
"delete": "削除",
"delete_confirm": "削除してもよろしいですか?",
"description": "説明",
"docs": "ドキュメント",
"download": "ダウンロード",
@ -1689,8 +1690,14 @@
"api.url.tip": "/で終わる場合、v1を無視します。#で終わる場合、入力されたアドレスを強制的に使用します",
"api_host": "APIホスト",
"api_key": "APIキー",
"api_key.tip": "複数のキーはカンマで区切ります",
"api_key.tip": "複数のキーはカンマまたはスペースで区切ります",
"api_version": "APIバージョン",
"api.key.new_key.placeholder": "1つ以上のキーを入力してください",
"api.key.error.duplicate": "APIキーはすでに存在します",
"api.key.error.empty": "APIキーは空にできません",
"api.key.check.latency": "遅延",
"api.key.list.open": "管理インターフェースを開く",
"api.key.list.title": "APIキー管理",
"basic_auth": "HTTP 認証",
"basic_auth.tip": "サーバー展開によるインスタンスに適用されますドキュメントを参照。現在はBasicスキームRFC7617のみをサポートしています。",
"basic_auth.user_name": "ユーザー名",
@ -1846,7 +1853,6 @@
"check": "チェック",
"check_failed": "検証に失敗しました",
"check_success": "検証に成功しました",
"get_api_key": "APIキーを取得",
"no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。",
"search_max_result": "検索結果の数",
"search_provider": "検索サービスプロバイダー",

View File

@ -385,6 +385,7 @@
"cut": "Вырезать",
"default": "По умолчанию",
"delete": "Удалить",
"delete_confirm": "Вы уверены, что хотите удалить?",
"description": "Описание",
"docs": "Документы",
"download": "Скачать",
@ -1689,8 +1690,14 @@
"api.url.tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес",
"api_host": "Хост API",
"api_key": "Ключ API",
"api_key.tip": "Несколько ключей, разделенных запятыми",
"api_key.tip": "Несколько ключей, разделенных запятыми или пробелами",
"api_version": "Версия API",
"api.key.new_key.placeholder": "Введите один или несколько ключей",
"api.key.error.duplicate": "API ключ уже существует",
"api.key.error.empty": "API ключ не может быть пустым",
"api.key.check.latency": "Задержка",
"api.key.list.open": "Открыть интерфейс управления",
"api.key.list.title": "Управление ключами API",
"basic_auth": "HTTP аутентификация",
"basic_auth.tip": "Применимо к экземплярам, развернутым через сервер (см. документацию). В настоящее время поддерживается только схема Basic (RFC7617).",
"basic_auth.user_name": "Имя пользователя",
@ -1846,7 +1853,6 @@
"check": "проверка",
"check_failed": "Проверка не прошла",
"check_success": "Проверка успешна",
"get_api_key": "Получить ключ API",
"no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.",
"search_max_result": "Количество результатов поиска",
"search_max_result.tooltip": "При отключенном сжатии результатов поиска, количество результатов может быть слишком большим, что приведет к исчерпанию токенов",

View File

@ -385,6 +385,7 @@
"cut": "剪切",
"default": "默认",
"delete": "删除",
"delete_confirm": "确定要删除吗?",
"description": "描述",
"docs": "文档",
"download": "下载",
@ -1707,8 +1708,14 @@
"api.url.tip": "/ 结尾忽略 v1 版本,# 结尾强制使用输入地址",
"api_host": "API 地址",
"api_key": "API 密钥",
"api_key.tip": "多个密钥使用逗号分隔",
"api_key.tip": "多个密钥使用逗号或空格分隔",
"api_version": "API 版本",
"api.key.new_key.placeholder": "输入一个或多个密钥",
"api.key.error.duplicate": "API 密钥已存在",
"api.key.error.empty": "API 密钥不能为空",
"api.key.check.latency": "耗时",
"api.key.list.open": "打开管理界面",
"api.key.list.title": "API 密钥管理",
"basic_auth": "HTTP 认证",
"basic_auth.tip": "适用于通过服务器部署的实例(参见文档)。目前仅支持 Basic 方案RFC7617",
"basic_auth.user_name": "用户名",
@ -1938,7 +1945,6 @@
"check_success": "验证成功",
"overwrite": "覆盖服务商搜索",
"overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索",
"get_api_key": "点击这里获取密钥",
"no_provider_selected": "请选择搜索服务商后再检测",
"search_max_result": "搜索结果个数",
"search_max_result.tooltip": "未开启搜索结果压缩的情况下,数量过大可能会消耗过多 tokens",

View File

@ -385,6 +385,7 @@
"cut": "剪下",
"default": "預設",
"delete": "刪除",
"delete_confirm": "確定要刪除嗎?",
"description": "描述",
"docs": "文件",
"download": "下載",
@ -1692,8 +1693,14 @@
"api.url.tip": "/ 結尾忽略 v1 版本,# 結尾強制使用輸入位址",
"api_host": "API 主機地址",
"api_key": "API 金鑰",
"api_key.tip": "多個金鑰使用逗號分隔",
"api_key.tip": "多個金鑰使用逗號或空格分隔",
"api_version": "API 版本",
"api.key.new_key.placeholder": "輸入一個或多個密鑰",
"api.key.error.duplicate": "API 密鑰已存在",
"api.key.error.empty": "API 密鑰不能為空",
"api.key.check.latency": "耗時",
"api.key.list.open": "打開管理界面",
"api.key.list.title": "API 密鑰管理",
"basic_auth": "HTTP 認證",
"basic_auth.tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案RFC7617",
"basic_auth.user_name": "用戶",
@ -1849,7 +1856,6 @@
"check": "檢查",
"check_failed": "驗證失敗",
"check_success": "驗證成功",
"get_api_key": "點選這裡取得金鑰",
"no_provider_selected": "請選擇搜尋服務商後再檢查",
"search_max_result": "搜尋結果個數",
"search_max_result.tooltip": "未開啟搜尋結果壓縮的情況下,數量過大可能會消耗過多 tokens",

View File

@ -12,7 +12,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }>
quota: _quota
}) => {
const { t } = useTranslation()
const { provider, updatePreprocessProvider } = usePreprocessProvider(providerId)
const { provider, updateProvider } = usePreprocessProvider(providerId)
const [quota, setQuota] = useState<number | undefined>(_quota)
useEffect(() => {
@ -21,7 +21,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }>
// 使用用户的key时quota为无限
if (provider.apiKey) {
setQuota(-9999)
updatePreprocessProvider({ ...provider, quota: -9999 })
updateProvider({ quota: -9999 })
return
}
if (quota === undefined) {
@ -39,7 +39,7 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: string; quota?: number }>
}
}
if (_quota !== undefined) {
updatePreprocessProvider({ ...provider, quota: _quota })
updateProvider({ quota: _quota })
return
}
checkQuota()

View File

@ -1,231 +0,0 @@
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined, MinusCircleOutlined } from '@ant-design/icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { TopView } from '@renderer/components/TopView'
import { checkApi } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService'
import { Model, Provider, WebSearchProvider } from '@renderer/types'
import { maskApiKey } from '@renderer/utils/api'
import { Button, List, Modal, Space, Spin, Typography } from 'antd'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ShowParams {
title: string
provider: Provider | WebSearchProvider
model?: Model
apiKeys: string[]
type: 'provider' | 'websearch'
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
interface KeyStatus {
key: string
isValid?: boolean
checking?: boolean
}
const PopupContainer: React.FC<Props> = ({ title, provider, model, apiKeys, type, resolve }) => {
const [open, setOpen] = useState(true)
const [keyStatuses, setKeyStatuses] = useState<KeyStatus[]>(() => {
const uniqueKeys = new Set(apiKeys)
return Array.from(uniqueKeys).map((key) => ({ key }))
})
const { t } = useTranslation()
const [isChecking, setIsChecking] = useState(false)
const [isCheckingSingle, setIsCheckingSingle] = useState(false)
const checkAllKeys = async () => {
setIsChecking(true)
const newStatuses = [...keyStatuses]
try {
// 使用Promise.all并行处理所有API验证请求
const checkPromises = newStatuses.map(async (status, i) => {
// 先更新当前密钥为检查中状态
setKeyStatuses((prev) => prev.map((status, idx) => (idx === i ? { ...status, checking: true } : status)))
try {
let valid = false
if (type === 'provider' && model) {
await checkApi({ ...(provider as Provider), apiKey: status.key }, model)
valid = true
} else {
const result = await WebSearchService.checkSearch({
...(provider as WebSearchProvider),
apiKey: status.key
})
valid = result.valid
}
// 更新验证结果
setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: valid } : s)))
return { index: i, valid }
} catch (error: unknown) {
// 处理错误情况
setKeyStatuses((prev) => prev.map((s, idx) => (idx === i ? { ...s, checking: false, isValid: false } : s)))
return { index: i, valid: false }
}
})
// 等待所有请求完成
await Promise.all(checkPromises)
} finally {
setIsChecking(false)
}
}
const checkSingleKey = async (keyIndex: number) => {
if (isChecking || keyStatuses[keyIndex].checking) {
return
}
setIsCheckingSingle(true)
setKeyStatuses((prev) => prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: true } : status)))
try {
let valid = false
if (type === 'provider' && model) {
await checkApi({ ...(provider as Provider), apiKey: keyStatuses[keyIndex].key }, model)
valid = true
} else {
const result = await WebSearchService.checkSearch({
...(provider as WebSearchProvider),
apiKey: keyStatuses[keyIndex].key
})
valid = result.valid
}
setKeyStatuses((prev) =>
prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: valid } : status))
)
} catch (error: unknown) {
setKeyStatuses((prev) =>
prev.map((status, idx) => (idx === keyIndex ? { ...status, checking: false, isValid: false } : status))
)
} finally {
setIsCheckingSingle(false)
}
}
const removeInvalidKeys = () => {
setKeyStatuses((prev) => prev.filter((status) => status.isValid !== false))
}
const removeKey = (keyIndex: number) => {
setKeyStatuses((prev) => prev.filter((_, idx) => idx !== keyIndex))
}
const onOk = () => {
const allKeys = keyStatuses.map((status) => status.key)
resolve({ validKeys: allKeys })
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
return (
<Modal
title={title}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="animation-move-down"
centered
maskClosable={false}
footer={
<Space style={{ display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Button key="remove" danger onClick={removeInvalidKeys} disabled={isChecking || isCheckingSingle}>
{t('settings.provider.remove_invalid_keys')}
</Button>
</Space>
<Space>
<Button key="check" type="primary" ghost onClick={checkAllKeys} disabled={isChecking || isCheckingSingle}>
{t('settings.provider.check_all_keys')}
</Button>
<Button key="save" type="primary" onClick={onOk}>
{t('common.save')}
</Button>
</Space>
</Space>
}>
<Scrollbar style={{ maxHeight: '70vh', overflowX: 'hidden' }}>
<List
dataSource={keyStatuses}
renderItem={(status, index) => (
<List.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text copyable={{ text: status.key }}>{maskApiKey(status.key)}</Typography.Text>
<Space>
{status.checking && (
<Space>
<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} />
</Space>
)}
{status.isValid === true && !status.checking && <CheckCircleFilled style={{ color: '#52c41a' }} />}
{status.isValid === false && !status.checking && <CloseCircleFilled style={{ color: '#ff4d4f' }} />}
{status.isValid === undefined && !status.checking && (
<span>{t('settings.provider.not_checked')}</span>
)}
<Button size="small" onClick={() => checkSingleKey(index)} disabled={isChecking || isCheckingSingle}>
{t('settings.provider.check')}
</Button>
<RemoveIcon
onClick={() => !isChecking && !isCheckingSingle && removeKey(index)}
style={{
cursor: isChecking || isCheckingSingle ? 'not-allowed' : 'pointer',
opacity: isChecking || isCheckingSingle ? 0.5 : 1
}}
/>
</Space>
</Space>
</List.Item>
)}
/>
</Scrollbar>
</Modal>
)
}
export default class ApiCheckPopup {
static topviewId = 0
static hide() {
TopView.hide('ApiCheckPopup')
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>,
'ApiCheckPopup'
)
})
}
}
const RemoveIcon = styled(MinusCircleOutlined)`
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--color-error);
cursor: pointer;
transition: all 0.2s ease-in-out;
`

View File

@ -2,7 +2,6 @@ import DmxapiLogo from '@renderer/assets/images/providers/dmxapi-logo.webp'
import DmxapiLogoDark from '@renderer/assets/images/providers/dmxapi-logo-dark.webp'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useProvider } from '@renderer/hooks/useProvider'
import { Provider } from '@renderer/types'
import { Radio, RadioChangeEvent, Space } from 'antd'
import { FC, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -11,8 +10,7 @@ import styled from 'styled-components'
import { SettingSubtitle } from '..'
interface DMXAPISettingsProps {
provider: Provider
setApiKey: (apiKey: string) => void
providerId: string
}
// DMXAPI平台选项
@ -40,8 +38,8 @@ const PlatformOptions = [
}
]
const DMXAPISettings: FC<DMXAPISettingsProps> = ({ provider: initialProvider }) => {
const { provider, updateProvider } = useProvider(initialProvider.id)
const DMXAPISettings: FC<DMXAPISettingsProps> = ({ providerId }) => {
const { provider, updateProvider } = useProvider(providerId)
const { theme } = useTheme()
const { t } = useTranslation()

View File

@ -1,7 +1,6 @@
import { CheckCircleOutlined, CopyOutlined, ExclamationCircleOutlined } from '@ant-design/icons'
import { useCopilot } from '@renderer/hooks/useCopilot'
import { useProvider } from '@renderer/hooks/useProvider'
import { Provider } from '@renderer/types'
import { Alert, Button, Input, message, Popconfirm, Slider, Space, Tooltip, Typography } from 'antd'
import { FC, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -10,8 +9,7 @@ import styled from 'styled-components'
import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingTitle } from '..'
interface GithubCopilotSettingsProps {
provider: Provider
setApiKey: (apiKey: string) => void
providerId: string
}
enum AuthStatus {
@ -20,9 +18,9 @@ enum AuthStatus {
AUTHENTICATED
}
const GithubCopilotSettings: FC<GithubCopilotSettingsProps> = ({ provider: initialProvider, setApiKey }) => {
const GithubCopilotSettings: FC<GithubCopilotSettingsProps> = ({ providerId }) => {
const { t } = useTranslation()
const { provider, updateProvider } = useProvider(initialProvider.id)
const { provider, updateProvider } = useProvider(providerId)
const { username, avatar, defaultHeaders, updateState, updateDefaultHeaders } = useCopilot()
// 状态管理
const [authStatus, setAuthStatus] = useState<AuthStatus>(AuthStatus.NOT_STARTED)
@ -79,7 +77,6 @@ const GithubCopilotSettings: FC<GithubCopilotSettingsProps> = ({ provider: initi
setAuthStatus(AuthStatus.AUTHENTICATED)
updateState({ username: login, avatar: avatar })
updateProvider({ ...provider, apiKey: token, isAuthed: true })
setApiKey(token)
message.success(t('settings.provider.copilot.auth_success'))
}
} catch (error) {
@ -88,7 +85,7 @@ const GithubCopilotSettings: FC<GithubCopilotSettingsProps> = ({ provider: initi
} finally {
setLoading(false)
}
}, [deviceCode, t, updateProvider, provider, setApiKey, updateState, defaultHeaders])
}, [deviceCode, t, updateProvider, provider, updateState, defaultHeaders])
// 登出
const handleLogout = useCallback(async () => {
@ -97,7 +94,6 @@ const GithubCopilotSettings: FC<GithubCopilotSettingsProps> = ({ provider: initi
// 1. 保存登出状态到本地
updateProvider({ ...provider, apiKey: '', isAuthed: false })
setApiKey('')
// 3. 清除本地存储的token
await window.api.copilot.logout()
@ -114,11 +110,10 @@ const GithubCopilotSettings: FC<GithubCopilotSettingsProps> = ({ provider: initi
message.error(t('settings.provider.copilot.logout_failed'))
// 如果登出失败,重置登出状态
updateProvider({ ...provider, apiKey: '', isAuthed: false })
setApiKey('')
} finally {
setLoading(false)
}
}, [t, updateProvider, provider, setApiKey])
}, [t, updateProvider, provider])
// 复制用户代码
const handleCopyUserCode = useCallback(() => {

View File

@ -31,12 +31,6 @@ import AddModelPopup from './AddModelPopup'
import EditModelsPopup from './EditModelsPopup'
import ModelEditContent from './ModelEditContent'
const STATUS_COLORS = {
success: '#52c41a',
error: '#ff4d4f',
warning: '#faad14'
}
export interface ModelStatus {
model: Model
status?: ModelCheckStatus
@ -74,7 +68,7 @@ function useModelStatusRendering() {
return (
<div>
<strong>{statusTitle}</strong>
{status.error && <div style={{ marginTop: 5, color: STATUS_COLORS.error }}>{status.error}</div>}
{status.error && <div style={{ marginTop: 5, color: 'var(--color-status-error)' }}>{status.error}</div>}
</div>
)
}
@ -93,7 +87,10 @@ function useModelStatusRendering() {
return (
<li
key={idx}
style={{ marginBottom: '5px', color: kr.isValid ? STATUS_COLORS.success : STATUS_COLORS.error }}>
style={{
marginBottom: '5px',
color: kr.isValid ? 'var(--color-status-success)' : 'var(--color-status-error)'
}}>
{maskedKey}: {kr.isValid ? t('settings.models.check.passed') : t('settings.models.check.failed')}
{kr.error && !kr.isValid && ` (${kr.error})`}
{kr.latency && kr.isValid && ` (${formatLatency(kr.latency)})`}
@ -383,11 +380,11 @@ const StatusIndicator = styled.div<{ $type: string }>`
color: ${(props) => {
switch (props.$type) {
case 'success':
return STATUS_COLORS.success
return 'var(--color-status-success)'
case 'error':
return STATUS_COLORS.error
return 'var(--color-status-error)'
case 'partial':
return STATUS_COLORS.warning
return 'var(--color-status-warning)'
default:
return 'var(--color-text)'
}

View File

@ -5,7 +5,7 @@ import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.p
import { HStack } from '@renderer/components/Layout'
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { Provider } from '@renderer/types'
import { useProvider } from '@renderer/hooks/useProvider'
import { providerBills, providerCharge } from '@renderer/utils/oauth'
import { Button } from 'antd'
import { isEmpty } from 'lodash'
@ -15,8 +15,7 @@ import { Trans, useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
provider: Provider
setApiKey: (apiKey: string) => void
providerId: string
}
const PROVIDER_LOGO_MAP = {
@ -26,8 +25,13 @@ const PROVIDER_LOGO_MAP = {
tokenflux: TokenFluxProviderLogo
}
const ProviderOAuth: FC<Props> = ({ provider, setApiKey }) => {
const ProviderOAuth: FC<Props> = ({ providerId }) => {
const { t } = useTranslation()
const { provider, updateProvider } = useProvider(providerId)
const setApiKey = (newKey: string) => {
updateProvider({ apiKey: newKey })
}
let providerWebsite =
PROVIDER_CONFIG[provider.id]?.api?.url.replace('https://', '').replace('api.', '') || provider.name

View File

@ -1,25 +1,26 @@
import { CheckOutlined, LoadingOutlined } from '@ant-design/icons'
import { CheckOutlined, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'
import { isOpenAIProvider } from '@renderer/aiCore/clients/ApiClientFactory'
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { HStack } from '@renderer/components/Layout'
import { ApiKeyConnectivity, ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider'
import i18n from '@renderer/i18n'
import { checkApi, formatApiKeys } from '@renderer/services/ApiService'
import { checkApi } from '@renderer/services/ApiService'
import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { Provider } from '@renderer/types'
import { formatApiHost, splitApiKeyString } from '@renderer/utils/api'
import { formatApiHost, formatApiKeys, getFancyProviderName, splitApiKeyString } from '@renderer/utils'
import { formatErrorMessage } from '@renderer/utils/error'
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash'
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
import { List, Settings2, SquareArrowOutUpRight } from 'lucide-react'
import { motion } from 'motion/react'
import { FC, useCallback, useDeferredValue, useEffect, useState } from 'react'
import { FC, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -31,7 +32,6 @@ import {
SettingSubtitle,
SettingTitle
} from '..'
import ApiCheckPopup from './ApiCheckPopup'
import DMXAPISettings from './DMXAPISettings'
import GithubCopilotSettings from './GithubCopilotSettings'
import GPUStackSettings from './GPUStackSettings'
@ -45,24 +45,19 @@ import SelectProviderModelPopup from './SelectProviderModelPopup'
import VertexAISettings from './VertexAISettings'
interface Props {
provider: Provider
providerId: string
}
const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
const { provider } = useProvider(_provider.id)
const ProviderSetting: FC<Props> = ({ providerId }) => {
const { provider, updateProvider, models } = useProvider(providerId)
const allProviders = useAllProviders()
const { updateProviders } = useProviders()
const [apiKey, setApiKey] = useState(provider.apiKey)
const [apiHost, setApiHost] = useState(provider.apiHost)
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
const [apiValid, setApiValid] = useState(false)
const [apiChecking, setApiChecking] = useState(false)
const [modelSearchText, setModelSearchText] = useState('')
const deferredModelSearchText = useDeferredValue(modelSearchText)
const { updateProvider, models } = useProvider(provider.id)
const { t } = useTranslation()
const { theme } = useTheme()
const [inputValue, setInputValue] = useState(apiKey)
const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai'
@ -76,14 +71,43 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
const [isHealthChecking, setIsHealthChecking] = useState(false)
const fancyProviderName = getFancyProviderName(provider)
const [localApiKey, setLocalApiKey] = useState(provider.apiKey)
const [apiKeyConnectivity, setApiKeyConnectivity] = useState<ApiKeyConnectivity>({
status: 'not_checked',
checking: false
})
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedSetApiKey = useCallback(
const debouncedUpdateApiKey = useCallback(
debounce((value) => {
setApiKey(formatApiKeys(value))
}, 100),
updateProvider({ apiKey: formatApiKeys(value) })
}, 150),
[]
)
// 同步 provider.apiKey 到 localApiKey
// 重置连通性检查状态
useEffect(() => {
setLocalApiKey(provider.apiKey)
setApiKeyConnectivity({ status: 'not_checked' })
}, [provider.apiKey])
// 同步 localApiKey 到 provider.apiKey防抖
useEffect(() => {
if (localApiKey !== provider.apiKey) {
debouncedUpdateApiKey(localApiKey)
}
// 卸载时取消任何待执行的更新
return () => debouncedUpdateApiKey.cancel()
}, [localApiKey, provider.apiKey, debouncedUpdateApiKey])
const isApiKeyConnectable = useMemo(() => {
return apiKeyConnectivity.status === 'success'
}, [apiKeyConnectivity])
const moveProviderToTop = useCallback(
(providerId: string) => {
const reorderedProviders = [...allProviders]
@ -99,21 +123,23 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
[allProviders, updateProviders]
)
const onUpdateApiKey = () => {
if (apiKey !== provider.apiKey) {
updateProvider({ ...provider, apiKey })
}
}
const onUpdateApiHost = () => {
if (apiHost.trim()) {
updateProvider({ ...provider, apiHost })
updateProvider({ apiHost })
} else {
setApiHost(provider.apiHost)
}
}
const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion })
const onUpdateApiVersion = () => updateProvider({ apiVersion })
const openApiKeyList = async () => {
await ApiKeyListPopup.show({
providerId: provider.id,
providerKind: 'llm',
title: `${fancyProviderName} ${t('settings.provider.api.key.list.title')}`
})
}
const onHealthCheck = async () => {
const modelsToCheck = models.filter((model) => !isRerankModel(model))
@ -128,7 +154,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
return
}
const keys = splitApiKeyString(apiKey)
const keys = splitApiKeyString(provider.apiKey)
// Add an empty key to enable health checks for local models.
// Error messages will be shown for each model if a valid key is needed.
@ -193,6 +219,12 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
}
const onCheckApi = async () => {
// 如果存在多个密钥,直接打开管理窗口
if (provider.apiKey.includes(',')) {
await openApiKeyList()
return
}
const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model))
if (isEmpty(modelsToCheck)) {
@ -212,58 +244,38 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
return
}
if (apiKey.includes(',')) {
const keys = splitApiKeyString(apiKey)
try {
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: 'not_checked' }))
await checkApi({ ...provider, apiHost }, model)
const result = await ApiCheckPopup.show({
title: t('settings.provider.check_multiple_keys'),
provider: { ...provider, apiHost },
model,
apiKeys: keys,
type: 'provider'
window.message.success({
key: 'api-check',
style: { marginTop: '3vh' },
duration: 2,
content: i18n.t('message.api.connection.success')
})
if (result?.validKeys) {
const newApiKey = result.validKeys.join(',')
setInputValue(newApiKey)
setApiKey(newApiKey)
updateProvider({ ...provider, apiKey: newApiKey })
}
} else {
setApiChecking(true)
setApiKeyConnectivity((prev) => ({ ...prev, status: 'success' }))
setTimeout(() => {
setApiKeyConnectivity((prev) => ({ ...prev, status: 'not_checked' }))
}, 3000)
} catch (error: any) {
window.message.error({
key: 'api-check',
style: { marginTop: '3vh' },
duration: 8,
content: i18n.t('message.api.connection.failed')
})
try {
await checkApi({ ...provider, apiKey, apiHost }, model)
window.message.success({
key: 'api-check',
style: { marginTop: '3vh' },
duration: 2,
content: i18n.t('message.api.connection.success')
})
setApiValid(true)
setTimeout(() => setApiValid(false), 3000)
} catch (error: any) {
const errorMessage = error?.message ? ' ' + error.message : ''
window.message.error({
key: 'api-check',
style: { marginTop: '3vh' },
duration: 8,
content: i18n.t('message.api.connection.failed') + errorMessage
})
setApiValid(false)
} finally {
setApiChecking(false)
}
setApiKeyConnectivity((prev) => ({ ...prev, status: 'error', error: formatErrorMessage(error) }))
} finally {
setApiKeyConnectivity((prev) => ({ ...prev, checking: false }))
}
}
const onReset = () => {
setApiHost(configedApiHost)
updateProvider({ ...provider, apiHost: configedApiHost })
updateProvider({ apiHost: configedApiHost })
}
const hostPreview = () => {
@ -276,28 +288,31 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
return formatApiHost(apiHost) + 'responses'
}
// API key 连通性检查状态指示器,目前仅在失败时显示
const renderStatusIndicator = () => {
if (apiKeyConnectivity.checking || apiKeyConnectivity.status !== 'error') {
return null
}
return (
<Tooltip title={<ErrorOverlay>{apiKeyConnectivity.error}</ErrorOverlay>}>
<CloseCircleFilled style={{ color: 'var(--color-status-error)' }} />
</Tooltip>
)
}
useEffect(() => {
if (provider.id === 'copilot') {
return
}
setApiKey(provider.apiKey)
setApiHost(provider.apiHost)
}, [provider.apiKey, provider.apiHost, provider.id])
// Save apiKey to provider when unmount
useEffect(() => {
return () => {
if (apiKey.trim() && apiKey !== provider.apiKey) {
updateProvider({ ...provider, apiKey })
}
}
}, [apiKey, provider, updateProvider])
}, [provider.apiHost, provider.id])
return (
<SettingContainer theme={theme} style={{ background: 'var(--color-background)' }}>
<SettingTitle>
<Flex align="center" gap={5}>
<ProviderName>{provider.isSystem ? t(`provider.${provider.id}`) : provider.name}</ProviderName>
<ProviderName>{fancyProviderName}</ProviderName>
{officialWebsite && (
<Link target="_blank" href={providerConfig.websites.official} style={{ display: 'flex' }}>
<Button type="text" size="small" icon={<SquareArrowOutUpRight size={14} />} />
@ -316,7 +331,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
value={provider.enabled}
key={provider.id}
onChange={(enabled) => {
updateProvider({ ...provider, apiKey, apiHost, enabled })
updateProvider({ apiHost, enabled })
if (enabled) {
moveProviderToTop(provider.id)
}
@ -324,45 +339,48 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
/>
</SettingTitle>
<Divider style={{ width: '100%', margin: '10px 0' }} />
{isProviderSupportAuth(provider) && (
<ProviderOAuth
provider={provider}
setApiKey={(v) => {
setApiKey(v)
setInputValue(v)
updateProvider({ ...provider, apiKey: v })
}}
/>
)}
{isProviderSupportAuth(provider) && <ProviderOAuth providerId={provider.id} />}
{provider.id === 'openai' && <OpenAIAlert />}
{isDmxapi && <DMXAPISettings provider={provider} setApiKey={setApiKey} />}
{isDmxapi && <DMXAPISettings providerId={provider.id} />}
{provider.id !== 'vertexai' && (
<>
<SettingSubtitle style={{ marginTop: 5 }}>{t('settings.provider.api_key')}</SettingSubtitle>
<SettingSubtitle
style={{
marginTop: 5,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
{t('settings.provider.api_key')}
{provider.id !== 'copilot' && (
<Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}>
<Button type="text" size="small" onClick={openApiKeyList} icon={<List size={14} />} />
</Tooltip>
)}
</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input.Password
value={inputValue}
value={localApiKey}
placeholder={t('settings.provider.api_key')}
onChange={(e) => {
setInputValue(e.target.value)
debouncedSetApiKey(e.target.value)
}}
onBlur={() => {
const formattedValue = formatApiKeys(inputValue)
setInputValue(formattedValue)
setApiKey(formattedValue)
onUpdateApiKey()
}}
onChange={(e) => setLocalApiKey(e.target.value)}
spellCheck={false}
autoFocus={provider.enabled && apiKey === '' && !isProviderSupportAuth(provider)}
autoFocus={provider.enabled && provider.apiKey === '' && !isProviderSupportAuth(provider)}
disabled={provider.id === 'copilot'}
// FIXME暂时用 prefix。因为 suffix 会被覆盖,实际上不起作用。
prefix={renderStatusIndicator()}
/>
<Button
type={apiValid ? 'primary' : 'default'}
ghost={apiValid}
type={isApiKeyConnectable ? 'primary' : 'default'}
ghost={isApiKeyConnectable}
onClick={onCheckApi}
disabled={!apiHost || apiChecking}>
{apiChecking ? <LoadingOutlined spin /> : apiValid ? <CheckOutlined /> : t('settings.provider.check')}
disabled={!apiHost || apiKeyConnectivity.checking}>
{apiKeyConnectivity.checking ? (
<LoadingOutlined spin />
) : apiKeyConnectivity.status === 'success' ? (
<CheckOutlined />
) : (
t('settings.provider.check')
)}
</Button>
</Space.Compact>
{apiKeyWebsite && (
@ -423,7 +441,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
)}
{provider.id === 'lmstudio' && <LMStudioSettings />}
{provider.id === 'gpustack' && <GPUStackSettings />}
{provider.id === 'copilot' && <GithubCopilotSettings provider={provider} setApiKey={setApiKey} />}
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
{provider.id === 'vertexai' && <VertexAISettings />}
<SettingSubtitle style={{ marginBottom: 5 }}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
@ -461,4 +479,12 @@ const ProviderName = styled.span`
margin-right: -2px;
`
const ErrorOverlay = styled.div`
max-height: 200px;
overflow-y: auto;
max-width: 300px;
word-wrap: break-word;
user-select: text;
`
export default ProviderSetting

View File

@ -52,8 +52,10 @@ const PopupContainer: React.FC<Props> = ({ provider, resolve, reject }) => {
centered>
<Select
value={model?.id}
placeholder={i18n.t('settings.models.empty')}
options={models.map((m) => ({ label: m.name, value: m.id }))}
style={{ width: '100%' }}
showSearch
onChange={(value) => {
setModel(provider.models.find((m) => m.id === value)!)
}}

View File

@ -515,7 +515,7 @@ const ProvidersList: FC = () => {
</Button>
</AddButtonWrapper>
</ProviderListContainer>
<ProviderSetting provider={selectedProvider} key={JSON.stringify(selectedProvider)} />
<ProviderSetting providerId={selectedProvider.id} key={selectedProvider.id} />
</Container>
)
}

View File

@ -1,9 +1,8 @@
import { ExportOutlined } from '@ant-design/icons'
import { getOcrProviderLogo, OCR_PROVIDER_CONFIG } from '@renderer/config/ocrProviders'
import { useOcrProvider } from '@renderer/hooks/useOcr'
import { formatApiKeys } from '@renderer/services/ApiService'
import { OcrProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { formatApiKeys, hasObjectKey } from '@renderer/utils'
import { Avatar, Divider, Flex, Input, InputNumber, Segmented } from 'antd'
import Link from 'antd/es/typography/Link'
import { FC, useEffect, useState } from 'react'

View File

@ -1,11 +1,12 @@
import { ExportOutlined } from '@ant-design/icons'
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
import { getPreprocessProviderLogo, PREPROCESS_PROVIDER_CONFIG } from '@renderer/config/preprocessProviders'
import { usePreprocessProvider } from '@renderer/hooks/usePreprocess'
import { formatApiKeys } from '@renderer/services/ApiService'
import { PreprocessProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { Avatar, Divider, Flex, Input, InputNumber, Segmented } from 'antd'
import { formatApiKeys, hasObjectKey } from '@renderer/utils'
import { Avatar, Button, Divider, Flex, Input, InputNumber, Segmented, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link'
import { List } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -26,7 +27,7 @@ interface Props {
}
const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
const { provider: preprocessProvider, updatePreprocessProvider } = usePreprocessProvider(_provider.id)
const { provider: preprocessProvider, updateProvider } = usePreprocessProvider(_provider.id)
const { t } = useTranslation()
const [apiKey, setApiKey] = useState(preprocessProvider.apiKey || '')
const [apiHost, setApiHost] = useState(preprocessProvider.apiHost || '')
@ -44,17 +45,26 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
const onUpdateApiKey = () => {
if (apiKey !== preprocessProvider.apiKey) {
updatePreprocessProvider({ ...preprocessProvider, apiKey, quota: undefined })
updateProvider({ apiKey, quota: undefined })
}
}
const openApiKeyList = async () => {
await ApiKeyListPopup.show({
providerId: preprocessProvider.id,
providerKind: 'doc-preprocess',
title: `${preprocessProvider.name} ${t('settings.provider.api.key.list.title')}`,
showHealthCheck: false // FIXME: 目前还没有检查功能
})
}
const onUpdateApiHost = () => {
let trimmedHost = apiHost?.trim() || ''
if (trimmedHost.endsWith('/')) {
trimmedHost = trimmedHost.slice(0, -1)
}
if (trimmedHost !== preprocessProvider.apiHost) {
updatePreprocessProvider({ ...preprocessProvider, apiHost: trimmedHost })
updateProvider({ apiHost: trimmedHost })
} else {
setApiHost(preprocessProvider.apiHost || '')
}
@ -63,7 +73,7 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
const onUpdateOptions = (key: string, value: any) => {
const newOptions = { ...options, [key]: value }
setOptions(newOptions)
updatePreprocessProvider({ ...preprocessProvider, options: newOptions })
updateProvider({ options: newOptions })
}
return (
@ -83,7 +93,19 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
<Divider style={{ width: '100%', margin: '10px 0' }} />
{hasObjectKey(preprocessProvider, 'apiKey') && (
<>
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>{t('settings.provider.api_key')}</SettingSubtitle>
<SettingSubtitle
style={{
marginTop: 5,
marginBottom: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
{t('settings.provider.api_key')}
<Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}>
<Button type="text" size="small" onClick={openApiKeyList} icon={<List size={14} />} />
</Tooltip>
</SettingSubtitle>
<Flex gap={8}>
<Input.Password
value={apiKey}

View File

@ -1,13 +1,12 @@
import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons'
import ApiKeyListPopup from '@renderer/components/Popups/ApiKeyListPopup/popup'
import { getWebSearchProviderLogo, WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders'
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
import { formatApiKeys } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService'
import { WebSearchProvider } from '@renderer/types'
import { hasObjectKey } from '@renderer/utils'
import { Button, Divider, Flex, Form, Input, Tooltip } from 'antd'
import { formatApiKeys, hasObjectKey } from '@renderer/utils'
import { Button, Divider, Flex, Form, Input, Space, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link'
import { Info } from 'lucide-react'
import { Info, List } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -20,14 +19,13 @@ import {
SettingSubtitle,
SettingTitle
} from '../..'
import ApiCheckPopup from '../../ProviderSettings/ApiCheckPopup'
interface Props {
provider: WebSearchProvider
providerId: string
}
const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
const { provider, updateProvider } = useWebSearchProvider(_provider.id)
const WebSearchProviderSetting: FC<Props> = ({ providerId }) => {
const { provider, updateProvider } = useWebSearchProvider(providerId)
const { t } = useTranslation()
const [apiKey, setApiKey] = useState(provider.apiKey || '')
const [apiHost, setApiHost] = useState(provider.apiHost || '')
@ -42,7 +40,7 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
const onUpdateApiKey = () => {
if (apiKey !== provider.apiKey) {
updateProvider({ ...provider, apiKey })
updateProvider({ apiKey })
}
}
@ -52,7 +50,7 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
trimmedHost = trimmedHost.slice(0, -1)
}
if (trimmedHost !== provider.apiHost) {
updateProvider({ ...provider, apiHost: trimmedHost })
updateProvider({ apiHost: trimmedHost })
} else {
setApiHost(provider.apiHost || '')
}
@ -62,7 +60,7 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
const currentValue = basicAuthUsername || ''
const savedValue = provider.basicAuthUsername || ''
if (currentValue !== savedValue) {
updateProvider({ ...provider, basicAuthUsername: basicAuthUsername })
updateProvider({ basicAuthUsername })
} else {
setBasicAuthUsername(provider.basicAuthUsername || '')
}
@ -72,12 +70,20 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
const currentValue = basicAuthPassword || ''
const savedValue = provider.basicAuthPassword || ''
if (currentValue !== savedValue) {
updateProvider({ ...provider, basicAuthPassword: basicAuthPassword })
updateProvider({ basicAuthPassword })
} else {
setBasicAuthPassword(provider.basicAuthPassword || '')
}
}
const openApiKeyList = async () => {
await ApiKeyListPopup.show({
providerId: provider.id,
providerKind: 'websearch',
title: `${provider.name} ${t('settings.provider.api.key.list.title')}`
})
}
async function checkSearch() {
if (!provider) {
window.message.error({
@ -90,22 +96,7 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
}
if (apiKey.includes(',')) {
const keys = apiKey
.split(',')
.map((k) => k.trim())
.filter((k) => k)
const result = await ApiCheckPopup.show({
title: t('settings.provider.check_multiple_keys'),
provider: { ...provider, apiHost },
apiKeys: keys,
type: 'websearch'
})
if (result?.validKeys) {
setApiKey(result.validKeys.join(','))
updateProvider({ ...provider, apiKey: result.validKeys.join(',') })
}
await openApiKeyList()
return
}
@ -162,8 +153,20 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
<Divider style={{ width: '100%', margin: '10px 0' }} />
{hasObjectKey(provider, 'apiKey') && (
<>
<SettingSubtitle style={{ marginTop: 5, marginBottom: 10 }}>{t('settings.provider.api_key')}</SettingSubtitle>
<Flex gap={8}>
<SettingSubtitle
style={{
marginTop: 5,
marginBottom: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
{t('settings.provider.api_key')}
<Tooltip title={t('settings.provider.api.key.list.open')} mouseEnterDelay={0.5}>
<Button type="text" size="small" onClick={openApiKeyList} icon={<List size={14} />} />
</Tooltip>
</SettingSubtitle>
<Space.Compact style={{ width: '100%' }}>
<Input.Password
value={apiKey}
placeholder={t('settings.provider.api_key')}
@ -186,10 +189,10 @@ const WebSearchProviderSetting: FC<Props> = ({ provider: _provider }) => {
t('settings.tool.websearch.check')
)}
</Button>
</Flex>
</Space.Compact>
<SettingHelpTextRow style={{ justifyContent: 'space-between', marginTop: 5 }}>
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.tool.websearch.get_api_key')}
{t('settings.provider.api_key.tip')}
</SettingHelpLink>
<SettingHelpText>{t('settings.provider.api_key.tip')}</SettingHelpText>
</SettingHelpTextRow>

View File

@ -53,7 +53,7 @@ const WebSearchSettings: FC = () => {
</SettingGroup>
{!isLocalProvider && (
<SettingGroup theme={themeMode}>
{selectedProvider && <WebSearchProviderSetting provider={selectedProvider} />}
{selectedProvider && <WebSearchProviderSetting providerId={selectedProvider.id} />}
</SettingGroup>
)}
<BasicSettings />

View File

@ -557,10 +557,6 @@ export async function fetchModels(provider: Provider): Promise<SdkModel[]> {
}
}
export const formatApiKeys = (value: string) => {
return value.replaceAll('', ',').replaceAll(' ', ',').replaceAll(' ', '').replaceAll('\n', ',')
}
export function checkApiProvider(provider: Provider): void {
const key = 'api-check'
const style = { marginTop: '3vh' }

View File

@ -614,8 +614,11 @@ const llmSlice = createSlice({
name: 'llm',
initialState: isLocalAi ? getIntegratedInitialState() : initialState,
reducers: {
updateProvider: (state, action: PayloadAction<Provider>) => {
state.providers = state.providers.map((p) => (p.id === action.payload.id ? { ...p, ...action.payload } : p))
updateProvider: (state, action: PayloadAction<Partial<Provider> & { id: string }>) => {
const index = state.providers.findIndex((p) => p.id === action.payload.id)
if (index !== -1) {
Object.assign(state.providers[index], action.payload)
}
},
updateProviders: (state, action: PayloadAction<Provider[]>) => {
state.providers = action.payload

View File

@ -43,10 +43,10 @@ const preprocessSlice = createSlice({
updatePreprocessProviders(state, action: PayloadAction<PreprocessProvider[]>) {
state.providers = action.payload
},
updatePreprocessProvider(state, action: PayloadAction<PreprocessProvider>) {
updatePreprocessProvider(state, action: PayloadAction<Partial<PreprocessProvider>>) {
const index = state.providers.findIndex((provider) => provider.id === action.payload.id)
if (index !== -1) {
state.providers[index] = action.payload
Object.assign(state.providers[index], action.payload)
}
}
}

View File

@ -111,10 +111,10 @@ const websearchSlice = createSlice({
updateWebSearchProviders: (state, action: PayloadAction<WebSearchProvider[]>) => {
state.providers = action.payload
},
updateWebSearchProvider: (state, action: PayloadAction<WebSearchProvider>) => {
updateWebSearchProvider: (state, action: PayloadAction<Partial<WebSearchProvider> & { id: string }>) => {
const index = state.providers.findIndex((provider) => provider.id === action.payload.id)
if (index !== -1) {
state.providers[index] = action.payload
Object.assign(state.providers[index], action.payload)
}
},
setSearchWithTime: (state, action: PayloadAction<boolean>) => {

View File

@ -1,3 +1,13 @@
/**
* API key
*
* @param {string} value - API key
* @returns {string} API key
*/
export function formatApiKeys(value: string): string {
return value.replaceAll('', ',').replaceAll(' ', ',').replaceAll('\n', ',')
}
/**
* API
*

View File

@ -227,6 +227,7 @@ export function getMcpConfigSampleFromReadme(readme: string): Record<string, any
return null
}
export * from './api'
export * from './file'
export * from './image'
export * from './json'

View File

@ -1,3 +1,6 @@
import i18n from '@renderer/i18n'
import { Provider } from '@renderer/types'
/**
* ID
*
@ -73,6 +76,15 @@ export const getLowerBaseModelName = (id: string, delimiter: string = '/'): stri
return getBaseModelName(id, delimiter).toLowerCase()
}
/**
*
* @param provider
* @returns
*/
export const getFancyProviderName = (provider: Provider) => {
return provider.isSystem ? i18n.t(`provider.${provider.id}`) : provider.name
}
/**
* avatar
* @param {string} str