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:
parent
37ee092398
commit
1978cfc356
@ -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!",
|
||||
|
||||
@ -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": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!",
|
||||
|
||||
@ -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": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!",
|
||||
|
||||
@ -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": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!",
|
||||
|
||||
@ -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": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
383
src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx
Normal file
383
src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx
Normal 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
|
||||
@ -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>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
</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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
219
src/renderer/src/services/HealthCheckService.ts
Normal file
219
src/renderer/src/services/HealthCheckService.ts
Normal 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
|
||||
}
|
||||
@ -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 })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user