mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-04 20:00:00 +08:00
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:
parent
bf7e713eec
commit
a4620f8c68
@ -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'] {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
307
src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts
Normal file
307
src/renderer/src/components/Popups/ApiKeyListPopup/hook.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { default as ApiKeyListPopup } from './popup'
|
||||
export * from './types'
|
||||
213
src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx
Normal file
213
src/renderer/src/components/Popups/ApiKeyListPopup/item.tsx
Normal 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)
|
||||
224
src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx
Normal file
224
src/renderer/src/components/Popups/ApiKeyListPopup/list.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
88
src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx
Normal file
88
src/renderer/src/components/Popups/ApiKeyListPopup/popup.tsx
Normal 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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
31
src/renderer/src/components/Popups/ApiKeyListPopup/types.ts
Normal file
31
src/renderer/src/components/Popups/ApiKeyListPopup/types.ts
Normal 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'
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "検索サービスプロバイダー",
|
||||
|
||||
@ -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": "При отключенном сжатии результатов поиска, количество результатов может быть слишком большим, что приведет к исчерпанию токенов",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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;
|
||||
`
|
||||
@ -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()
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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)'
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)!)
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -53,7 +53,7 @@ const WebSearchSettings: FC = () => {
|
||||
</SettingGroup>
|
||||
{!isLocalProvider && (
|
||||
<SettingGroup theme={themeMode}>
|
||||
{selectedProvider && <WebSearchProviderSetting provider={selectedProvider} />}
|
||||
{selectedProvider && <WebSearchProviderSetting providerId={selectedProvider.id} />}
|
||||
</SettingGroup>
|
||||
)}
|
||||
<BasicSettings />
|
||||
|
||||
@ -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' }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>) => {
|
||||
|
||||
@ -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 主机地址。
|
||||
*
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 输入字符串
|
||||
|
||||
Loading…
Reference in New Issue
Block a user