diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index be52ae37..89c9ce0b 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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!", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index b051a155..d7b2a224 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -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": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index bf0eb4f3..bcdc9bab 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -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": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index db7a4ec1..0d9e5edb 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 3925f5a8..ab835727 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!", diff --git a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx index b3c80dbc..033e32a5 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ApiCheckPopup.tsx @@ -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 = ({ title, provider, model, apiKeys, reso renderItem={(status) => ( - - {status.key.slice(0, 8)}...{status.key.slice(-8)} - + {maskApiKey(status.key)} {status.checking && ( diff --git a/src/renderer/src/pages/settings/ProviderSettings/HealthCheckPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/HealthCheckPopup.tsx new file mode 100644 index 00000000..22aac71c --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/HealthCheckPopup.tsx @@ -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 +) { + 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 = ({ 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 ( + + + + {t('settings.models.check.use_all_keys')} + 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') } + ]} + /> + + + {t('settings.models.check.enable_concurrent')} + 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') } + ]} + /> + + + + + }> + {/* API key selection section - only shown for 'single' mode and multiple keys */} + {keyCheckMode === 'single' && hasMultipleKeys && ( + + {t('settings.models.check.select_api_key')} + dispatch({ type: 'SET_KEY_INDEX', payload: e.target.value })} + style={{ display: 'block', marginTop: 8 }}> + {apiKeys.map((key, index) => ( + + + {maskApiKey(key)} + + + ))} + + + )} + + ) +} + +/** + * 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 { + return new Promise((resolve) => { + TopView.show( + { + resolve(data) + this.hide() + }} + />, + this.topviewId + ) + }) + } +} diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx new file mode 100644 index 00000000..b501abb8 --- /dev/null +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx @@ -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 ( +
+ {statusTitle} + {status.error &&
{status.error}
} +
+ ) + } + + // Detailed tooltip for multiple key results + return ( +
+ {statusTitle} + {status.error &&
{status.error}
} +
+
    + {status.keyResults.map((kr, idx) => { + // Mask API key for security + const maskedKey = maskApiKey(kr.key) + + return ( +
  • + {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)})`} +
  • + ) + })} +
+
+
+ ) + }, + [t] + ) + + /** + * Render status indicator based on model check status + */ + function renderStatusIndicator(modelStatus: ModelStatus | undefined): React.ReactNode { + if (!modelStatus) return null + + if (modelStatus.checking) { + return ( + + + + ) + } + + if (!modelStatus.status) return null + + let icon: React.ReactNode = null + let statusType = '' + + switch (modelStatus.status) { + case ModelCheckStatus.SUCCESS: + icon = + statusType = 'success' + break + case ModelCheckStatus.FAILED: + icon = + statusType = 'error' + break + case ModelCheckStatus.PARTIAL: + icon = + statusType = 'partial' + break + default: + return null + } + + return ( + + {icon} + + ) + } + + function renderLatencyText(modelStatus: ModelStatus | undefined): React.ReactNode { + if (!modelStatus?.latency) return null + if (modelStatus.status === ModelCheckStatus.SUCCESS || modelStatus.status === ModelCheckStatus.PARTIAL) { + return {formatLatency(modelStatus.latency)} + } + return null + } + + return { renderStatusIndicator, renderLatencyText } +} + +const ModelList: React.FC = ({ 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(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) => ( + + + modelGroups[group] + .filter((model) => provider.models.some((m) => m.id === model.id)) + .forEach((model) => removeModel(model)) + } + /> + + } + 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 ( + + + + {model?.name?.[0]?.toUpperCase()} + + + {model?.name} + + + !isChecking && onEditModel(model)} + style={{ cursor: isChecking ? 'not-allowed' : 'pointer', opacity: isChecking ? 0.5 : 1 }} + /> + {renderLatencyText(modelStatus)} + + + {renderStatusIndicator(modelStatus)} + !isChecking && removeModel(model)} + style={{ cursor: isChecking ? 'not-allowed' : 'pointer', opacity: isChecking ? 0.5 : 1 }} + /> + + + ) + })} + + ))} + {docsWebsite && ( + + {t('settings.provider.docs_check')} + + {t(`provider.${provider.id}`) + ' '} + {t('common.docs')} + + {t('common.and')} + + {t('common.models')} + + {t('settings.provider.docs_more_details')} + + )} + + + + + {models.map((model) => ( + 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 diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 3124c04d..6ddd8caf 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -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 = ({ 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(null) + const [modelStatuses, setModelStatuses] = useState([]) + const [isHealthChecking, setIsHealthChecking] = useState(false) const onUpdateApiKey = () => { if (apiKey !== provider.apiKey) { @@ -99,8 +75,99 @@ const ProviderSetting: FC = ({ 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 = ({ 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 = ({ provider: _provider }) => { {provider.id === 'graphrag-kylin-mountain' && provider.models.length > 0 && ( )} - {t('common.models')} - {Object.keys(sortedModelGroups).map((group) => ( - - - modelGroups[group] - .filter((model) => provider.models.some((m) => m.id === model.id)) - .forEach((model) => removeModel(model)) - } - /> - - } - style={{ marginBottom: '10px', border: '0.5px solid var(--color-border)' }} - size="small"> - {sortedModelGroups[group].map((model) => ( - - - - {model?.name?.[0]?.toUpperCase()} - - - {model?.name} - - - setEditingModel(model)} /> - - removeModel(model)} /> - - ))} - - ))} - {docsWebsite && ( - - {t('settings.provider.docs_check')} - - {t(`provider.${provider.id}`) + ' '} - {t('common.docs')} - - {t('common.and')} - - {t('common.models')} - - {t('settings.provider.docs_more_details')} - - )} - - - - - {models.map((model) => ( - setEditingModel(null)} - key={model.id} - /> - ))} + + + {t('common.models')} + + {!isEmpty(models) && ( + + )} + + + + ) } -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; diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index b8d61b2b..537627df 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -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 } } diff --git a/src/renderer/src/services/HealthCheckService.ts b/src/renderer/src/services/HealthCheckService.ts new file mode 100644 index 00000000..bc6be27b --- /dev/null +++ b/src/renderer/src/services/HealthCheckService.ts @@ -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> { + 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 { + 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 +} diff --git a/src/renderer/src/services/ModelService.ts b/src/renderer/src/services/ModelService.ts index 1629b39e..55201598 100644 --- a/src/renderer/src/services/ModelService.ts +++ b/src/renderer/src/services/ModelService.ts @@ -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( + provider: Provider, + model: Model, + checkFn: (ai: AiProvider, model: Model) => Promise, + 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 }) + ) + } +} diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 66000242..77dd21f8 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -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 + } +}