feat: Add health check to check all the models at one time (#2613)

* feat: Add health check to check all the models at one time

* fix: add model avatars to the health-check list

* style: Use segmented instead of switch

* fix: remove redundant timing reports

* refactor: Extract small functions

* refactor: use more hooks to make the main component clearer

* fix: mask API keys with asterisks

* refactor: split health check popup and model list

- rename ModelHealthCheckPopup to HealthCheckPopup
- add HealthCheckModelList
- add maskApiKey to utils

* refactor: compute latency in checkApi

* fix: remove unused i18n keys

* refactor: use checkModel instead of checkApi for better semantics

* fix: update comments

* refactor: extract health checking functions to services

* refactor: extract model list

* refactor: render statuses on the existing model list

* fix: reset button style on completion

* fix: disable model card while checking

- remove unused i18n keys
- better window message

* refactor: show provider name in messages

* refactor: change default values

* refactor: fully migrate model list from ProviderSetting to ModelList
This commit is contained in:
one 2025-03-08 22:24:56 +08:00 committed by GitHub
parent 37ee092398
commit 1978cfc356
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1115 additions and 185 deletions

View File

@ -766,6 +766,22 @@
"models.translate_model_description": "Model used for translation service",
"models.translate_model_prompt_message": "Please enter the translate model prompt",
"models.translate_model_prompt_title": "Translate Model Prompt",
"models.check.button_caption": "Health check",
"models.check.title": "Model health check",
"models.check.passed": "Passed",
"models.check.failed": "Failed",
"models.check.single": "Single",
"models.check.all": "All",
"models.check.disabled": "Disabled",
"models.check.enabled": "Enabled",
"models.check.start": "Start",
"models.check.enable_concurrent": "Concurrent",
"models.check.use_all_keys": "Key(s)",
"models.check.all_models_passed": "All models check passed",
"models.check.model_status_summary": "{{provider}}: {{count_passed}} models passed all keys, {{count_failed}} models failed all keys, {{count_partial}} models failed some keys",
"models.check.no_api_keys": "No API keys found, please add API keys first.",
"models.check.select_api_key": "Select the API key to use:",
"models.check.keys_status_count": "Passed: {{count_passed}} keys, failed: {{count_failed}} keys",
"moresetting": "More Settings",
"moresetting.warn": "Risk Warning",
"moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!",

View File

@ -766,6 +766,22 @@
"models.translate_model_description": "翻訳サービスに使用されるモデル",
"models.translate_model_prompt_message": "翻訳モデルのプロンプトを入力してください",
"models.translate_model_prompt_title": "翻訳モデルのプロンプト",
"models.check.button_caption": "健康チェック",
"models.check.title": "モデル健康チェック",
"models.check.passed": "成功",
"models.check.failed": "失敗",
"models.check.single": "単一",
"models.check.all": "すべて",
"models.check.disabled": "閉じる",
"models.check.enabled": "開く",
"models.check.start": "開始",
"models.check.enable_concurrent": "並行チェック",
"models.check.use_all_keys": "キー",
"models.check.all_models_passed": "すべてのモデルチェックが成功しました",
"models.check.model_status_summary": "{{provider}}: {{count_passed}}個のモデルが成功しました、{{count_failed}}個のモデルが失敗しました、{{count_partial}}個のモデルが一部成功しました",
"models.check.no_api_keys": "APIキーが見つかりません。まずAPIキーを追加してください。",
"models.check.select_api_key": "使用するAPIキーを選択",
"models.check.keys_status_count": "合格:{{count_passed}}個のキー、不合格:{{count_failed}}個のキー",
"moresetting": "詳細設定",
"moresetting.warn": "リスク警告",
"moresetting.check.warn": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!",

View File

@ -766,6 +766,22 @@
"models.translate_model_description": "Модель, используемая для сервиса перевода",
"models.translate_model_prompt_message": "Введите модель перевода",
"models.translate_model_prompt_title": "Модель перевода",
"models.check.button_caption": "Проверка состояния",
"models.check.title": "Проверка состояния моделей",
"models.check.passed": "Прошло",
"models.check.failed": "Не прошло",
"models.check.single": "Один",
"models.check.all": "Все",
"models.check.disabled": "Отключено",
"models.check.enabled": "Включено",
"models.check.start": "Начать",
"models.check.enable_concurrent": "Параллельная проверка",
"models.check.use_all_keys": "Использовать все ключи",
"models.check.all_models_passed": "Все модели прошли проверку",
"models.check.model_status_summary": "{{provider}}: {{count_passed}} модели прошли все ключи, {{count_failed}} модели не прошли все ключи, {{count_partial}} модели не прошли некоторые ключи",
"models.check.no_api_keys": "API ключи не найдены, пожалуйста, добавьте API ключи.",
"models.check.select_api_key": "Выберите API ключ для использования:",
"models.check.keys_status_count": "Прошло: {{count_passed}} ключей, Не прошло: {{count_failed}} ключей",
"moresetting": "Дополнительные настройки",
"moresetting.warn": "Предупреждение о риске",
"moresetting.check.warn": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!",

View File

@ -767,6 +767,22 @@
"models.translate_model_description": "翻译服务使用的模型",
"models.translate_model_prompt_message": "请输入翻译模型提示词",
"models.translate_model_prompt_title": "翻译模型提示词",
"models.check.button_caption": "健康检查",
"models.check.title": "模型健康检查",
"models.check.passed": "通过",
"models.check.failed": "失败",
"models.check.single": "单个",
"models.check.all": "所有",
"models.check.disabled": "关闭",
"models.check.enabled": "开启",
"models.check.start": "开始",
"models.check.enable_concurrent": "并发检查",
"models.check.use_all_keys": "使用密钥",
"models.check.all_models_passed": "所有模型检查通过",
"models.check.model_status_summary": "{{provider}}: {{count_passed}}个模型通过所有密钥,{{count_failed}}个模型未通过任何密钥,{{count_partial}}个模型未通过某些密钥",
"models.check.no_api_keys": "未找到API密钥请先添加API密钥。",
"models.check.select_api_key": "选择要使用的API密钥",
"models.check.keys_status_count": "通过:{{count_passed}}个密钥,失败:{{count_failed}}个密钥",
"moresetting": "更多设置",
"moresetting.warn": "风险警告",
"moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!",

View File

@ -765,6 +765,22 @@
"models.translate_model_description": "翻譯服務使用的模型",
"models.translate_model_prompt_message": "請輸入翻譯模型提示詞",
"models.translate_model_prompt_title": "翻譯模型提示詞",
"models.check.button_caption": "健康檢查",
"models.check.title": "模型健康檢查",
"models.check.passed": "通過",
"models.check.failed": "失敗",
"models.check.single": "單個",
"models.check.all": "所有",
"models.check.disabled": "關閉",
"models.check.enabled": "開啟",
"models.check.start": "開始",
"models.check.enable_concurrent": "並行檢查",
"models.check.use_all_keys": "使用密鑰",
"models.check.all_models_passed": "所有模型檢查通過",
"models.check.model_status_summary": "{{provider}}: {{count_passed}}個模型通過所有密鑰,{{count_failed}}個模型未通過所有密鑰,{{count_partial}}個模型未通過某些密鑰",
"models.check.no_api_keys": "未找到API密鑰請先添加API密鑰。",
"models.check.select_api_key": "選擇要使用的API密鑰",
"models.check.keys_status_count": "通過:{{count_passed}}個密鑰,失敗:{{count_failed}}個密鑰",
"moresetting": "更多設定",
"moresetting.warn": "風險警告",
"moresetting.check.warn": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!",

View File

@ -4,6 +4,7 @@ import { TopView } from '@renderer/components/TopView'
import { checkApi } from '@renderer/services/ApiService'
import { Model } from '@renderer/types'
import { Provider } 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'
@ -103,9 +104,7 @@ const PopupContainer: React.FC<Props> = ({ title, provider, model, apiKeys, reso
renderItem={(status) => (
<List.Item>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text copyable={{ text: status.key }}>
{status.key.slice(0, 8)}...{status.key.slice(-8)}
</Typography.Text>
<Typography.Text copyable={{ text: status.key }}>{maskApiKey(status.key)}</Typography.Text>
<Space>
{status.checking && (
<Space>

View File

@ -0,0 +1,214 @@
import { Box } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView'
import { Provider } from '@renderer/types'
import { maskApiKey } from '@renderer/utils/api'
import { Button, Modal, Radio, Segmented, Space, Typography } from 'antd'
import { useCallback, useMemo, useReducer } from 'react'
import { useTranslation } from 'react-i18next'
interface ShowParams {
title: string
provider: Provider
apiKeys: string[]
}
interface ResolveData {
apiKeys: string[]
isConcurrent: boolean
cancelled?: boolean
}
interface Props extends ShowParams {
resolve: (data: ResolveData) => void
}
/**
* Component state type definition
*/
type State = {
open: boolean
selectedKeyIndex: number
keyCheckMode: 'single' | 'all' // Whether to check with single key or all keys
isConcurrent: boolean
}
/**
* Reducer action type definition
*/
type Action =
| { type: 'SET_OPEN'; payload: boolean }
| { type: 'SET_KEY_INDEX'; payload: number }
| { type: 'SET_KEY_CHECK_MODE'; payload: 'single' | 'all' }
| { type: 'SET_CONCURRENT'; payload: boolean }
/**
* Reducer function to handle state updates
*/
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_OPEN':
return { ...state, open: action.payload }
case 'SET_KEY_INDEX':
return { ...state, selectedKeyIndex: action.payload }
case 'SET_KEY_CHECK_MODE':
return { ...state, keyCheckMode: action.payload }
case 'SET_CONCURRENT':
return { ...state, isConcurrent: action.payload }
default:
return state
}
}
/**
* Hook for modal dialog actions
*/
function useModalActions(
resolve: (data: ResolveData) => void,
apiKeys: string[],
selectedKeyIndex: number,
keyCheckMode: 'single' | 'all',
isConcurrent: boolean,
dispatch: React.Dispatch<Action>
) {
const onStart = useCallback(() => {
// Determine which API keys to use
const keysToUse = keyCheckMode === 'single' ? [apiKeys[selectedKeyIndex]] : apiKeys
// Return config data
resolve({
apiKeys: keysToUse,
isConcurrent
})
dispatch({ type: 'SET_OPEN', payload: false })
}, [apiKeys, selectedKeyIndex, keyCheckMode, isConcurrent, resolve, dispatch])
const onCancel = useCallback(() => {
dispatch({ type: 'SET_OPEN', payload: false })
}, [dispatch])
const onClose = useCallback(() => {
resolve({ apiKeys: [], isConcurrent: false, cancelled: true })
}, [resolve])
return { onStart, onCancel, onClose }
}
/**
* Main container component for the health check configuration popup
*/
const PopupContainer: React.FC<Props> = ({ title, apiKeys, resolve }) => {
const { t } = useTranslation()
// Initialize state with reducer
const [state, dispatch] = useReducer(reducer, {
open: true,
selectedKeyIndex: 0,
keyCheckMode: 'all',
isConcurrent: true
})
const { open, selectedKeyIndex, keyCheckMode, isConcurrent } = state
// Use custom hooks
const { onStart, onCancel, onClose } = useModalActions(
resolve,
apiKeys,
selectedKeyIndex,
keyCheckMode,
isConcurrent,
dispatch
)
// Check if we have multiple API keys
const hasMultipleKeys = useMemo(() => apiKeys.length > 1, [apiKeys.length])
return (
<Modal
title={title}
open={open}
onCancel={onCancel}
afterClose={onClose}
centered
maskClosable={false}
width={500}
footer={
<Space style={{ display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Space align="center">
<Typography.Text strong>{t('settings.models.check.use_all_keys')}</Typography.Text>
<Segmented
value={keyCheckMode}
onChange={(value) => dispatch({ type: 'SET_KEY_CHECK_MODE', payload: value as 'single' | 'all' })}
size="small"
options={[
{ value: 'single', label: t('settings.models.check.single') },
{ value: 'all', label: t('settings.models.check.all') }
]}
/>
</Space>
<Space align="center">
<Typography.Text strong>{t('settings.models.check.enable_concurrent')}</Typography.Text>
<Segmented
value={isConcurrent ? 'enabled' : 'disabled'}
onChange={(value) => dispatch({ type: 'SET_CONCURRENT', payload: value === 'enabled' })}
size="small"
options={[
{ value: 'disabled', label: t('settings.models.check.disabled') },
{ value: 'enabled', label: t('settings.models.check.enabled') }
]}
/>
</Space>
</Space>
<Button key="start" type="primary" onClick={onStart}>
{t('settings.models.check.start')}
</Button>
</Space>
}>
{/* API key selection section - only shown for 'single' mode and multiple keys */}
{keyCheckMode === 'single' && hasMultipleKeys && (
<Box style={{ marginBottom: 16 }}>
<strong>{t('settings.models.check.select_api_key')}</strong>
<Radio.Group
value={selectedKeyIndex}
onChange={(e) => dispatch({ type: 'SET_KEY_INDEX', payload: e.target.value })}
style={{ display: 'block', marginTop: 8 }}>
{apiKeys.map((key, index) => (
<Radio key={index} value={index} style={{ display: 'block', marginBottom: 8 }}>
<Typography.Text copyable={{ text: key }} style={{ maxWidth: '450px' }}>
{maskApiKey(key)}
</Typography.Text>
</Radio>
))}
</Radio.Group>
</Box>
)}
</Modal>
)
}
/**
* Static class for showing the Health Check popup
*/
export default class HealthCheckPopup {
static readonly topviewId = 'HealthCheckPopup'
static hide(): void {
TopView.hide(this.topviewId)
}
static show(props: ShowParams): Promise<ResolveData> {
return new Promise<ResolveData>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(data: ResolveData) => {
resolve(data)
this.hide()
}}
/>,
this.topviewId
)
})
}
}

View File

@ -0,0 +1,383 @@
import {
CheckCircleFilled,
CloseCircleFilled,
EditOutlined,
ExclamationCircleFilled,
LoadingOutlined,
MinusCircleOutlined,
PlusOutlined,
SettingOutlined
} from '@ant-design/icons'
import ModelTags from '@renderer/components/ModelTags'
import { getModelLogo } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProvider'
import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants'
import { Model, Provider } from '@renderer/types'
import { maskApiKey } from '@renderer/utils/api'
import { Avatar, Button, Card, Flex, Space, Tooltip, Typography } from 'antd'
import { groupBy, sortBy, toPairs } from 'lodash'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow } from '..'
import AddModelPopup from './AddModelPopup'
import EditModelsPopup from './EditModelsPopup'
import ModelEditContent from './ModelEditContent'
const STATUS_COLORS = {
success: '#52c41a',
error: '#ff4d4f',
warning: '#faad14'
}
interface ModelListProps {
provider: Provider
modelStatuses?: ModelStatus[]
}
export interface ModelStatus {
model: Model
status?: ModelCheckStatus
checking?: boolean
error?: string
keyResults?: any[]
latency?: number
}
/**
* Format check time to a human-readable string
*/
function formatLatency(time: number): string {
return `${(time / 1000).toFixed(2)}s`
}
/**
* Hook for rendering model status UI elements
*/
function useModelStatusRendering() {
const { t } = useTranslation()
/**
* Generate tooltip content for model check results
*/
const renderKeyCheckResultTooltip = useCallback(
(status: ModelStatus) => {
const statusTitle =
status.status === ModelCheckStatus.SUCCESS
? t('settings.models.check.passed')
: t('settings.models.check.failed')
if (!status.keyResults || status.keyResults.length === 0) {
// Simple tooltip for single key result
return (
<div>
<strong>{statusTitle}</strong>
{status.error && <div style={{ marginTop: 5, color: STATUS_COLORS.error }}>{status.error}</div>}
</div>
)
}
// Detailed tooltip for multiple key results
return (
<div>
{statusTitle}
{status.error && <div style={{ marginTop: 5, marginBottom: 5 }}>{status.error}</div>}
<div style={{ marginTop: 5 }}>
<ul style={{ maxHeight: '300px', overflowY: 'auto', margin: 0, padding: 0, listStyleType: 'none' }}>
{status.keyResults.map((kr, idx) => {
// Mask API key for security
const maskedKey = maskApiKey(kr.key)
return (
<li
key={idx}
style={{ marginBottom: '5px', color: kr.isValid ? STATUS_COLORS.success : STATUS_COLORS.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)})`}
</li>
)
})}
</ul>
</div>
</div>
)
},
[t]
)
/**
* Render status indicator based on model check status
*/
function renderStatusIndicator(modelStatus: ModelStatus | undefined): React.ReactNode {
if (!modelStatus) return null
if (modelStatus.checking) {
return (
<StatusIndicator type="checking">
<LoadingOutlined spin />
</StatusIndicator>
)
}
if (!modelStatus.status) return null
let icon: React.ReactNode = null
let statusType = ''
switch (modelStatus.status) {
case ModelCheckStatus.SUCCESS:
icon = <CheckCircleFilled />
statusType = 'success'
break
case ModelCheckStatus.FAILED:
icon = <CloseCircleFilled />
statusType = 'error'
break
case ModelCheckStatus.PARTIAL:
icon = <ExclamationCircleFilled />
statusType = 'partial'
break
default:
return null
}
return (
<Tooltip title={renderKeyCheckResultTooltip(modelStatus)}>
<StatusIndicator type={statusType}>{icon}</StatusIndicator>
</Tooltip>
)
}
function renderLatencyText(modelStatus: ModelStatus | undefined): React.ReactNode {
if (!modelStatus?.latency) return null
if (modelStatus.status === ModelCheckStatus.SUCCESS || modelStatus.status === ModelCheckStatus.PARTIAL) {
return <ModelLatencyText type="secondary">{formatLatency(modelStatus.latency)}</ModelLatencyText>
}
return null
}
return { renderStatusIndicator, renderLatencyText }
}
const ModelList: React.FC<ModelListProps> = ({ provider: _provider, modelStatuses = [] }) => {
const { t } = useTranslation()
const { provider } = useProvider(_provider.id)
const { updateProvider, models, removeModel } = useProvider(_provider.id)
const { assistants } = useAssistants()
const dispatch = useAppDispatch()
const { defaultModel, setDefaultModel } = useDefaultModel()
const { renderStatusIndicator, renderLatencyText } = useModelStatusRendering()
const providerConfig = PROVIDER_CONFIG[provider.id]
const docsWebsite = providerConfig?.websites?.docs
const modelsWebsite = providerConfig?.websites?.models
const [editingModel, setEditingModel] = useState<Model | null>(null)
const modelGroups = groupBy(models, 'group')
const sortedModelGroups = sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
acc[key] = value
return acc
}, {})
const onManageModel = () => EditModelsPopup.show({ provider })
const onAddModel = () => AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
const onEditModel = (model: Model) => {
setEditingModel(model)
}
const onUpdateModel = (updatedModel: Model) => {
const updatedModels = models.map((m) => {
if (m.id === updatedModel.id) {
return updatedModel
}
return m
})
updateProvider({ ...provider, models: updatedModels })
// Update assistants using this model
assistants.forEach((assistant) => {
if (assistant?.model?.id === updatedModel.id && assistant.model.provider === provider.id) {
dispatch(
setModel({
assistantId: assistant.id,
model: updatedModel
})
)
}
})
// Update default model if needed
if (defaultModel?.id === updatedModel.id && defaultModel?.provider === provider.id) {
setDefaultModel(updatedModel)
}
}
return (
<>
{Object.keys(sortedModelGroups).map((group) => (
<Card
key={group}
type="inner"
title={group}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')}>
<HoveredRemoveIcon
onClick={() =>
modelGroups[group]
.filter((model) => provider.models.some((m) => m.id === model.id))
.forEach((model) => removeModel(model))
}
/>
</Tooltip>
}
style={{ marginBottom: '10px', border: '0.5px solid var(--color-border)' }}
size="small">
{sortedModelGroups[group].map((model) => {
const modelStatus = modelStatuses.find((status) => status.model.id === model.id)
const isChecking = modelStatus?.checking === true
return (
<ModelListItem key={model.id}>
<ModelListHeader>
<Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ModelNameRow>
<span>{model?.name}</span>
<ModelTags model={model} />
</ModelNameRow>
<SettingIcon
onClick={() => !isChecking && onEditModel(model)}
style={{ cursor: isChecking ? 'not-allowed' : 'pointer', opacity: isChecking ? 0.5 : 1 }}
/>
{renderLatencyText(modelStatus)}
</ModelListHeader>
<Space>
{renderStatusIndicator(modelStatus)}
<RemoveIcon
onClick={() => !isChecking && removeModel(model)}
style={{ cursor: isChecking ? 'not-allowed' : 'pointer', opacity: isChecking ? 0.5 : 1 }}
/>
</Space>
</ModelListItem>
)
})}
</Card>
))}
{docsWebsite && (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
<SettingHelpLink target="_blank" href={docsWebsite}>
{t(`provider.${provider.id}`) + ' '}
{t('common.docs')}
</SettingHelpLink>
<SettingHelpText>{t('common.and')}</SettingHelpText>
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
</SettingHelpTextRow>
)}
<Flex gap={10} style={{ marginTop: '10px' }}>
<Button type="primary" onClick={onManageModel} icon={<EditOutlined />}>
{t('button.manage')}
</Button>
<Button type="default" onClick={onAddModel} icon={<PlusOutlined />}>
{t('button.add')}
</Button>
</Flex>
{models.map((model) => (
<ModelEditContent
model={model}
onUpdateModel={onUpdateModel}
open={editingModel?.id === model.id}
onClose={() => setEditingModel(null)}
key={model.id}
/>
))}
</>
)
}
const ModelListItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 5px 0;
`
const ModelListHeader = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`
const ModelNameRow = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
`
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;
`
const HoveredRemoveIcon = styled(RemoveIcon)`
opacity: 0;
margin-top: 2px;
&:hover {
opacity: 1;
}
`
const SettingIcon = styled(SettingOutlined)`
margin-left: 2px;
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
color: var(--color-text-2);
}
`
const StatusIndicator = styled.div<{ type: string }>`
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
color: ${(props) => {
switch (props.type) {
case 'success':
return STATUS_COLORS.success
case 'error':
return STATUS_COLORS.error
case 'partial':
return STATUS_COLORS.warning
default:
return 'var(--color-text)'
}
}};
`
const ModelLatencyText = styled(Typography.Text)`
margin-left: 10px;
color: var(--color-text-secondary);
`
export default ModelList

View File

@ -1,32 +1,20 @@
import {
CheckOutlined,
EditOutlined,
ExportOutlined,
LoadingOutlined,
MinusCircleOutlined,
PlusOutlined,
SettingOutlined
} from '@ant-design/icons'
import { CheckOutlined, ExportOutlined, HeartOutlined, LoadingOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import ModelTags from '@renderer/components/ModelTags'
import OAuthButton from '@renderer/components/OAuth/OAuthButton'
import { getModelLogo } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProvider'
import i18n from '@renderer/i18n'
import { isOpenAIProvider } from '@renderer/providers/ProviderFactory'
import { checkApi } from '@renderer/services/ApiService'
import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { isProviderSupportAuth, isProviderSupportCharge } from '@renderer/services/ProviderService'
import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants'
import { Model, Provider } from '@renderer/types'
import { Provider } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { providerCharge } from '@renderer/utils/oauth'
import { Avatar, Button, Card, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
import { Button, Divider, Flex, Input, Space, Switch } from 'antd'
import Link from 'antd/es/typography/Link'
import { groupBy, isEmpty, sortBy, toPairs } from 'lodash'
import { isEmpty } from 'lodash'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -39,12 +27,11 @@ import {
SettingSubtitle,
SettingTitle
} from '..'
import AddModelPopup from './AddModelPopup'
import ApiCheckPopup from './ApiCheckPopup'
import EditModelsPopup from './EditModelsPopup'
import GraphRAGSettings from './GraphRAGSettings'
import HealthCheckPopup from './HealthCheckPopup'
import LMStudioSettings from './LMStudioSettings'
import ModelEditContent from './ModelEditContent'
import ModelList, { ModelStatus } from './ModelList'
import OllamSettings from './OllamaSettings'
import SelectProviderModelPopup from './SelectProviderModelPopup'
@ -59,30 +46,19 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
const [apiValid, setApiValid] = useState(false)
const [apiChecking, setApiChecking] = useState(false)
const { updateProvider, models, removeModel } = useProvider(provider.id)
const { assistants } = useAssistants()
const { updateProvider, models } = useProvider(provider.id)
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { defaultModel, setDefaultModel } = useDefaultModel()
const modelGroups = groupBy(models, 'group')
const sortedModelGroups = sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
acc[key] = value
return acc
}, {})
const isAzureOpenAI = provider.id === 'azure-openai' || provider.type === 'azure-openai'
const providerConfig = PROVIDER_CONFIG[provider.id]
const officialWebsite = providerConfig?.websites?.official
const apiKeyWebsite = providerConfig?.websites?.apiKey
const docsWebsite = providerConfig?.websites?.docs
const modelsWebsite = providerConfig?.websites?.models
const configedApiHost = providerConfig?.api?.url
const [editingModel, setEditingModel] = useState<Model | null>(null)
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
const [isHealthChecking, setIsHealthChecking] = useState(false)
const onUpdateApiKey = () => {
if (apiKey !== provider.apiKey) {
@ -99,8 +75,99 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
}
const onUpdateApiVersion = () => updateProvider({ ...provider, apiVersion })
const onManageModel = () => EditModelsPopup.show({ provider })
const onAddModel = () => AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
const onHealthCheck = async () => {
if (isEmpty(models)) {
window.message.error({
key: 'no-models',
style: { marginTop: '3vh' },
duration: 5,
content: t('settings.provider.no_models')
})
return
}
const keys = apiKey
.split(',')
.map((k) => k.trim())
.filter((k) => k)
if (keys.length === 0) {
window.message.error({
key: 'no-api-keys',
style: { marginTop: '3vh' },
duration: 5,
content: t('settings.models.check.no_api_keys')
})
return
}
// Show configuration dialog to get health check parameters
const result = await HealthCheckPopup.show({
title: t('settings.models.check.title'),
provider: { ...provider, apiHost },
apiKeys: keys
})
if (result.cancelled || result.apiKeys.length === 0) {
return
}
// Prepare the list of models to be checked
const initialStatuses = models.map((model) => ({
model,
checking: true,
status: undefined
}))
setModelStatuses(initialStatuses)
setIsHealthChecking(true)
const checkResults = await checkModelsHealth(
{
provider: { ...provider, apiHost },
models,
apiKeys: result.apiKeys,
isConcurrent: result.isConcurrent
},
(checkResult, index) => {
setModelStatuses((current) => {
const updated = [...current]
if (updated[index]) {
updated[index] = {
...updated[index],
checking: false,
status: checkResult.status,
error: checkResult.error,
keyResults: checkResult.keyResults,
latency: checkResult.latency
}
}
return updated
})
}
)
// Show summary of results after checking
const failedModels = checkResults.filter((result) => result.status === ModelCheckStatus.FAILED)
const partialModels = checkResults.filter((result) => result.status === ModelCheckStatus.PARTIAL)
const successModels = checkResults.filter((result) => result.status === ModelCheckStatus.SUCCESS)
// Display statistics of all model check results
window.message.info({
key: 'health-check-summary',
style: { marginTop: '3vh' },
duration: 10,
content: t('settings.models.check.model_status_summary', {
provider: provider.name,
count_passed: successModels.length,
count_failed: failedModels.length,
count_partial: partialModels.length
})
})
// Reset health check status
setIsHealthChecking(false)
}
const onCheckApi = async () => {
if (isEmpty(models)) {
@ -172,34 +239,6 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
return formatApiHost(apiHost) + 'chat/completions'
}
const onUpdateModel = (updatedModel: Model) => {
const updatedModels = models.map((m) => {
if (m.id === updatedModel.id) {
return updatedModel
}
return m
})
updateProvider({ ...provider, models: updatedModels })
// Update assistants using this model
assistants.forEach((assistant) => {
if (assistant?.model?.id === updatedModel.id && assistant.model.provider === provider.id) {
dispatch(
setModel({
assistantId: assistant.id,
model: updatedModel
})
)
}
})
// Update default model if needed
if (defaultModel?.id === updatedModel.id && defaultModel?.provider === provider.id) {
setDefaultModel(updatedModel)
}
}
const formatApiKeys = (value: string) => {
return value.replaceAll('', ',').replaceAll(' ', ',').replaceAll(' ', '').replaceAll('\n', ',')
}
@ -309,124 +348,27 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
{provider.id === 'graphrag-kylin-mountain' && provider.models.length > 0 && (
<GraphRAGSettings provider={provider} />
)}
<SettingSubtitle style={{ marginBottom: 5 }}>{t('common.models')}</SettingSubtitle>
{Object.keys(sortedModelGroups).map((group) => (
<Card
key={group}
type="inner"
title={group}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')}>
<HoveredRemoveIcon
onClick={() =>
modelGroups[group]
.filter((model) => provider.models.some((m) => m.id === model.id))
.forEach((model) => removeModel(model))
}
/>
</Tooltip>
}
style={{ marginBottom: '10px', border: '0.5px solid var(--color-border)' }}
size="small">
{sortedModelGroups[group].map((model) => (
<ModelListItem key={model.id}>
<ModelListHeader>
<Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ModelNameRow>
<span>{model?.name}</span>
<ModelTags model={model} />
</ModelNameRow>
<SettingIcon onClick={() => setEditingModel(model)} />
</ModelListHeader>
<RemoveIcon onClick={() => removeModel(model)} />
</ModelListItem>
))}
</Card>
))}
{docsWebsite && (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
<SettingHelpLink target="_blank" href={docsWebsite}>
{t(`provider.${provider.id}`) + ' '}
{t('common.docs')}
</SettingHelpLink>
<SettingHelpText>{t('common.and')}</SettingHelpText>
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
</SettingHelpTextRow>
<SettingSubtitle style={{ marginBottom: 5 }}>
<Flex align="center" justify="space-between" style={{ width: '100%' }}>
<span>{t('common.models')}</span>
<Space>
{!isEmpty(models) && (
<Button
type="text"
size="small"
icon={<HeartOutlined />}
onClick={onHealthCheck}
loading={isHealthChecking}
title={t('settings.models.check.button_caption')}></Button>
)}
<Flex gap={10} style={{ marginTop: '10px' }}>
<Button type="primary" onClick={onManageModel} icon={<EditOutlined />}>
{t('button.manage')}
</Button>
<Button type="default" onClick={onAddModel} icon={<PlusOutlined />}>
{t('button.add')}
</Button>
</Space>
</Flex>
{models.map((model) => (
<ModelEditContent
model={model}
onUpdateModel={onUpdateModel}
open={editingModel?.id === model.id}
onClose={() => setEditingModel(null)}
key={model.id}
/>
))}
</SettingSubtitle>
<ModelList provider={provider} modelStatuses={modelStatuses} />
</SettingContainer>
)
}
const ModelListItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 5px 0;
`
const ModelListHeader = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`
const ModelNameRow = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
`
const RemoveIcon = styled(MinusCircleOutlined)`
font-size: 18px;
margin-left: 10px;
color: var(--color-error);
cursor: pointer;
transition: all 0.2s ease-in-out;
`
const HoveredRemoveIcon = styled(RemoveIcon)`
opacity: 0;
margin-top: 2px;
&:hover {
opacity: 1;
}
`
const SettingIcon = styled(SettingOutlined)`
margin-left: 2px;
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
color: var(--color-text-2);
}
`
const ProviderName = styled.span`
font-size: 14px;
font-weight: 500;

View File

@ -19,6 +19,7 @@ import { EVENT_NAMES, EventEmitter } from './EventService'
import { filterMessages, filterUsefulMessages } from './MessagesService'
import { estimateMessagesUsage } from './TokenService'
import WebSearchService from './WebSearchService'
export async function fetchChatCompletion({
message,
messages,
@ -239,7 +240,11 @@ export async function fetchSuggestions({
}
}
export async function checkApi(provider: Provider, model: Model) {
// Helper function to validate provider's basic settings such as API key, host, and model list
export function checkApiProvider(provider: Provider): {
valid: boolean
error: Error | null
} {
const key = 'api-check'
const style = { marginTop: '3vh' }
@ -257,7 +262,7 @@ export async function checkApi(provider: Provider, model: Model) {
window.message.error({ content: i18n.t('message.error.enter.api.host'), key, style })
return {
valid: false,
error: new Error('message.error.enter.api.host')
error: new Error(i18n.t('message.error.enter.api.host'))
}
}
@ -265,7 +270,22 @@ export async function checkApi(provider: Provider, model: Model) {
window.message.error({ content: i18n.t('message.error.enter.model'), key, style })
return {
valid: false,
error: new Error('message.error.enter.model')
error: new Error(i18n.t('message.error.enter.model'))
}
}
return {
valid: true,
error: null
}
}
export async function checkApi(provider: Provider, model: Model) {
const validation = checkApiProvider(provider)
if (!validation.valid) {
return {
valid: validation.valid,
error: validation.error
}
}

View File

@ -0,0 +1,219 @@
import i18n from '@renderer/i18n'
import { Model, Provider } from '@renderer/types'
import { checkModel } from './ModelService'
/**
* Model check status states
*/
export enum ModelCheckStatus {
NOT_CHECKED = 'not_checked',
SUCCESS = 'success',
FAILED = 'failed',
PARTIAL = 'partial' // Some API keys worked, some failed
}
/**
* Options for model health check
*/
export interface ModelCheckOptions {
provider: Provider
models: Model[]
apiKeys: string[]
isConcurrent: boolean
}
/**
* Single API key check status
*/
export interface ApiKeyCheckStatus {
key: string
isValid: boolean
error?: string
latency?: number // Check latency in milliseconds
}
/**
* Result of a model health check
*/
export interface ModelCheckResult {
model: Model
keyResults: ApiKeyCheckStatus[]
latency?: number // Smallest latency of all successful checks
status?: ModelCheckStatus
error?: string
}
/**
* Analyzes model check results to determine overall status
*/
export function analyzeModelCheckResult(result: ModelCheckResult): {
status: ModelCheckStatus
error?: string
latency?: number
} {
const validKeyCount = result.keyResults.filter((r) => r.isValid).length
const totalKeyCount = result.keyResults.length
if (validKeyCount === totalKeyCount) {
return {
status: ModelCheckStatus.SUCCESS,
latency: result.latency
}
} else if (validKeyCount === 0) {
// All keys failed
const errors = result.keyResults
.filter((r) => r.error)
.map((r) => r.error)
.filter((v, i, a) => a.indexOf(v) === i) // Remove duplicates
return {
status: ModelCheckStatus.FAILED,
error: errors.join('; ')
}
} else {
// Partial success
return {
status: ModelCheckStatus.PARTIAL,
latency: result.latency,
error: i18n.t('settings.models.check.keys_status_count', {
count_passed: validKeyCount,
count_failed: totalKeyCount - validKeyCount
})
}
}
}
/**
* Checks a model with multiple API keys
*/
export async function checkModelWithMultipleKeys(
provider: Provider,
model: Model,
apiKeys: string[],
isParallel: boolean
): Promise<Omit<ModelCheckResult, 'model' | 'status' | 'error'>> {
let keyResults: ApiKeyCheckStatus[] = []
if (isParallel) {
// Check all API keys in parallel
const keyPromises = apiKeys.map(async (key) => {
const result = await checkModel({ ...provider, apiKey: key }, model)
return {
key,
isValid: result.valid,
error: result.error?.message,
latency: result.latency
} as ApiKeyCheckStatus
})
const results = await Promise.allSettled(keyPromises)
// Process results
keyResults = results.map((result) => {
if (result.status === 'fulfilled') {
return result.value
} else {
return {
key: 'unknown', // This should not happen since we've caught errors internally
isValid: false,
error: 'Promise rejection: ' + result.reason
}
}
})
} else {
// Check all API keys serially
for (const key of apiKeys) {
const result = await checkModel({ ...provider, apiKey: key }, model)
keyResults.push({
key,
isValid: result.valid,
error: result.error?.message,
latency: result.latency
})
}
}
// Calculate fastest successful response time
const successResults = keyResults.filter((r) => r.isValid && r.latency !== undefined)
const latency = successResults.length > 0 ? Math.min(...successResults.map((r) => r.latency!)) : undefined
return { keyResults, latency }
}
/**
* Performs health checks for multiple models
*/
export async function checkModelsHealth(
options: ModelCheckOptions,
onModelChecked?: (result: ModelCheckResult, index: number) => void
): Promise<ModelCheckResult[]> {
const { provider, models, apiKeys, isConcurrent } = options
// Results array
const results: ModelCheckResult[] = []
try {
if (isConcurrent) {
// Check all models concurrently
const modelPromises = models.map(async (model, index) => {
const checkResult = await checkModelWithMultipleKeys(provider, model, apiKeys, true)
const analysisResult = analyzeModelCheckResult({
model,
...checkResult,
status: undefined,
error: undefined
})
const result: ModelCheckResult = {
model,
...checkResult,
status: analysisResult.status,
error: analysisResult.error
}
results[index] = result
if (onModelChecked) {
onModelChecked(result, index)
}
return result
})
await Promise.allSettled(modelPromises)
} else {
// Check all models serially
for (let i = 0; i < models.length; i++) {
const model = models[i]
const checkResult = await checkModelWithMultipleKeys(provider, model, apiKeys, false)
const analysisResult = analyzeModelCheckResult({
model,
...checkResult,
status: undefined,
error: undefined
})
const result: ModelCheckResult = {
model,
...checkResult,
status: analysisResult.status,
error: analysisResult.error
}
results.push(result)
if (onModelChecked) {
onModelChecked(result, i)
}
}
}
} catch (error) {
console.error('Model health check failed:', error)
}
return results
}

View File

@ -1,8 +1,12 @@
import { isEmbeddingModel } from '@renderer/config/models'
import AiProvider from '@renderer/providers/AiProvider'
import store from '@renderer/store'
import { Model } from '@renderer/types'
import { Model, Provider } from '@renderer/types'
import { t } from 'i18next'
import { pick } from 'lodash'
import { checkApiProvider } from './ApiService'
export const getModelUniqId = (m?: Model) => {
return m?.id ? JSON.stringify(pick(m, ['id', 'provider'])) : ''
}
@ -28,3 +32,58 @@ export function getModelName(model?: Model) {
return modelName
}
// Generic function to perform model checks
// Abstracts provider validation and error handling, allowing different types of check logic
async function performModelCheck<T>(
provider: Provider,
model: Model,
checkFn: (ai: AiProvider, model: Model) => Promise<T>,
processResult: (result: T) => { valid: boolean; error: Error | null }
): Promise<{ valid: boolean; error: Error | null; latency?: number }> {
const validation = checkApiProvider(provider)
if (!validation.valid) {
return {
valid: validation.valid,
error: validation.error
}
}
const AI = new AiProvider(provider)
try {
const startTime = performance.now()
const result = await checkFn(AI, model)
const latency = performance.now() - startTime
return {
...processResult(result),
latency
}
} catch (error: any) {
return {
valid: false,
error
}
}
}
// Unified model check function
// Automatically selects appropriate check method based on model type
export async function checkModel(provider: Provider, model: Model) {
if (isEmbeddingModel(model)) {
return performModelCheck(
provider,
model,
(ai, model) => ai.getEmbeddingDimensions(model),
(dimensions) => ({ valid: dimensions > 0, error: null })
)
} else {
return performModelCheck(
provider,
model,
(ai, model) => ai.check(model),
({ valid, error }) => ({ valid, error: error || null })
)
}
}

View File

@ -13,3 +13,17 @@ export function formatApiHost(host: string) {
return forceUseOriginalHost() ? host : `${host}/v1/`
}
export function maskApiKey(key: string): string {
if (!key) return ''
if (key.length > 24) {
return `${key.slice(0, 8)}****${key.slice(-8)}`
} else if (key.length > 16) {
return `${key.slice(0, 4)}****${key.slice(-4)}`
} else if (key.length > 8) {
return `${key.slice(0, 2)}****${key.slice(-2)}`
} else {
return key
}
}