refactor: model list and health check (#7997)

* refactor(ProviderSetting): add a backtop to provider setting

* refactor: decouple ModelList from ProviderSetting

* refactor: move modellist to a single dir

* refactor: allow more props for CollapsibleSearchBar

* refactor: split ModelList into ModelList, ModelListGroup and ModelListItem

* refactor: simplify health check types, improve file structure

* refactor: split HealthStatusIndicator from list items

* refactor: better indicator tooltip

* refactor: improve model search, simplify some expressions

* refactor: further simplify ModelList by extracting onHealthCheck

* refactor: remove double scroller from EditModelsPopup

* revert: remove backtop

* fix: i18n order

* refactor: sort buttons
This commit is contained in:
one
2025-07-21 15:57:08 +08:00
committed by GitHub
parent f13ae2d3c1
commit 2b0c46bfdb
34 changed files with 948 additions and 874 deletions

View File

@@ -18,11 +18,11 @@ We will acknowledge your report within **72 hours** and provide a status update
We aim to support the latest released version and one previous minor release.
| Version | Supported |
|-----------------|--------------------|
| Latest (`main`) | ✅ Supported |
| Previous minor | ✅ Supported |
| Older versions | ❌ Not supported |
| Version | Supported |
| --------------- | ---------------- |
| Latest (`main`) | ✅ Supported |
| Previous minor | ✅ Supported |
| Older versions | ❌ Not supported |
If you are using an unsupported version, we strongly recommend updating to the latest release to receive security fixes.

View File

@@ -42,11 +42,13 @@ In your code, you can call `logger` at any time to record logs. The supported me
For the meaning of each level, please refer to the section below.
The following examples show how to use `logger.info` and `logger.error`. Other levels are used in the same way:
```typescript
logger.info('message', CONTEXT)
logger.info('message %s %d', 'hello', 123, CONTEXT)
logger.error('message', new Error('error message'), CONTEXT)
```
- `message` is a required string. All other options are optional.
- `CONTEXT` as `{ key: value, ... }` is optional and will be recorded in the log file.
- If an `Error` type is passed, the error stack will be automatically recorded.
@@ -57,6 +59,7 @@ logger.error('message', new Error('error message'), CONTEXT)
- In the production environment, the default log level is `info`. Logs are only recorded to the file and are not printed to the terminal.
Changing the log level:
- You can change the log level with `logger.setLevel('newLevel')`.
- `logger.resetLevel()` resets it to the default level.
- `logger.getLevel()` gets the current log level.
@@ -65,7 +68,7 @@ Changing the log level:
## Usage in the `renderer` process
Usage in the `renderer` process for *importing*, *setting module information*, and *setting context information* is **exactly the same** as in the `main` process.
Usage in the `renderer` process for _importing_, _setting module information_, and _setting context information_ is **exactly the same** as in the `main` process.
The following section focuses on the differences.
### `initWindowSource`
@@ -77,6 +80,7 @@ loggerService.initWindowSource('windowName')
```
As a rule, we will set this in the `window`'s `entryPoint.tsx`. This ensures that `windowName` is set before it's used.
- An error will be thrown if `windowName` is not set, and the `logger` will not work.
- `windowName` can only be set once; subsequent attempts to set it will have no effect.
- `windowName` will not be printed in the `devTool`'s `console`, but it will be recorded in the `main` process terminal and the file log.
@@ -109,8 +113,8 @@ logger.setLogToMainLevel('newLevel')
logger.resetLogToMainLevel()
logger.getLogToMainLevel()
```
**Note:** This method has a global effect. Please do not change it arbitrarily in your code unless you are very clear about what you are doing.
**Note:** This method has a global effect. Please do not change it arbitrarily in your code unless you are very clear about what you are doing.
##### Per-log Change

View File

@@ -6,12 +6,11 @@ CherryStudio使用统一的日志服务来打印和记录日志**若无特殊
以下是详细说明
## 在`main`进程中使用
### 引入
``` typescript
```typescript
import { loggerService } from '@logger'
```
@@ -19,7 +18,7 @@ import { loggerService } from '@logger'
在import头之后设置
``` typescript
```typescript
const logger = loggerService.withContext('moduleName')
```
@@ -30,7 +29,7 @@ const logger = loggerService.withContext('moduleName')
`withContext`中,也可以设置其他`CONTEXT`信息:
``` typescript
```typescript
const logger = loggerService.withContext('moduleName', CONTEXT)
```
@@ -43,11 +42,13 @@ const logger = loggerService.withContext('moduleName', CONTEXT)
各级别的含义,请参考下面的章节。
以下以 `logger.info``logger.error` 举例如何使用,其他级别是一样的:
``` typescript
```typescript
logger.info('message', CONTEXT)
logger.info('message %s %d', 'hello', 123, CONTEXT)
logger.error('message', new Error('error message'), CONTEXT)
```
- `message` 是必填的,`string`类型,其他选项都是可选的
- `CONTEXT``{ key: value, ...}` 是可选的,会在日志文件中记录
- 如果传递了`Error`类型,会自动记录错误堆栈
@@ -58,6 +59,7 @@ logger.error('message', new Error('error message'), CONTEXT)
- 生产环境下,默认记录级别为`info`,日志只会记录到文件,不会打印到终端
更改日志记录级别:
- 可以通过 `logger.setLevel('newLevel')` 来更改日志记录级别
- `logger.resetLevel()` 可以重置为默认级别
- `logger.getLevel()` 可以获取当前记录记录级别
@@ -66,7 +68,7 @@ logger.error('message', new Error('error message'), CONTEXT)
## 在`renderer`进程中使用
在`renderer`进程中使用,*引入方法*、*设置`module`信息*、*设置`context`信息的方法*和`main`进程中是**完全一样**的。
`renderer`进程中使用,_引入方法_、_设置`module`信息_、*设置`context`信息的方法*和`main`进程中是**完全一样**的。
下面着重讲一下不同之处。
### `initWindowSource`
@@ -78,6 +80,7 @@ loggerService.initWindowSource('windowName')
```
原则上,我们将在`window``entryPoint.tsx`中进行设置,这可以保证`windowName`在开始使用前已经设置好了。
- 未设置`windowName`会报错,`logger`将不起作用
- `windowName`只能设置一次,重复设置将不生效
- `windowName`不会在`devTool``console`中打印出来,但是会在`main`进程的终端和文件日志中记录
@@ -110,8 +113,8 @@ logger.setLogToMainLevel('newLevel')
logger.resetLogToMainLevel()
logger.getLogToMainLevel()
```
**注意** 该方法是全局生效的,请不要在代码中随意更改,除非你非常清楚自己在做什么
**注意** 该方法是全局生效的,请不要在代码中随意更改,除非你非常清楚自己在做什么
##### 单条更改
@@ -165,11 +168,11 @@ CSLOGGER_MAIN_SHOW_MODULES=MCPService,SelectionService
日志有很多级别什么时候应该用哪个级别下面是在CherryStudio中应该遵循的规范
(按日志级别从高到低排列)
| 日志级别 | 核心定义与使用场景 | 示例 |
| :------------ | :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`error`** | **严重错误,导致程序崩溃或核心功能无法使用。** <br> 这是最高优的日志,通常需要立即上报或提示用户。 | - 主进程或渲染进程崩溃。 <br> - 无法读写用户关键数据文件(如数据库、配置文件),导致应用无法运行。<br> - 所有未捕获的异常。` |
| **`warn`** | **潜在问题或非预期情况,但不影响程序核心功能。** <br> 程序可以从中恢复或使用备用方案。 | - 配置文件 `settings.json` 缺失,已使用默认配置启动。 <br> - 自动更新检查失败,但不影响当前版本使用。<br> - 某个非核心插件加载失败。` |
| **`info`** | **记录应用生命周期和关键用户行为。** <br> 这是发布版中默认应记录的级别,用于追踪用户的主要操作路径。 | - 应用启动、退出。<br> - 用户成功打开/保存文件。 <br> - 主窗口创建/关闭。<br> - 开始执行一项重要任务(如“开始导出视频”)。` |
| **`verbose`** | **比 `info` 更详细的流程信息,用于追踪特定功能。** <br> 在诊断特定功能问题时开启,帮助理解内部执行流程。 | - 正在加载 `Toolbar` 模块。 <br> - IPC 消息 `open-file-dialog` 已从渲染进程发送。<br> - 正在应用滤镜 'Sepia' 到图像。` |
| 日志级别 | 核心定义与使用场景 | 示例 |
| :------------ | :------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`error`** | **严重错误,导致程序崩溃或核心功能无法使用。** <br> 这是最高优的日志,通常需要立即上报或提示用户。 | - 主进程或渲染进程崩溃。 <br> - 无法读写用户关键数据文件(如数据库、配置文件),导致应用无法运行。<br> - 所有未捕获的异常。` |
| **`warn`** | **潜在问题或非预期情况,但不影响程序核心功能。** <br> 程序可以从中恢复或使用备用方案。 | - 配置文件 `settings.json` 缺失,已使用默认配置启动。 <br> - 自动更新检查失败,但不影响当前版本使用。<br> - 某个非核心插件加载失败。` |
| **`info`** | **记录应用生命周期和关键用户行为。** <br> 这是发布版中默认应记录的级别,用于追踪用户的主要操作路径。 | - 应用启动、退出。<br> - 用户成功打开/保存文件。 <br> - 主窗口创建/关闭。<br> - 开始执行一项重要任务(如“开始导出视频”)。` |
| **`verbose`** | **比 `info` 更详细的流程信息,用于追踪特定功能。** <br> 在诊断特定功能问题时开启,帮助理解内部执行流程。 | - 正在加载 `Toolbar` 模块。 <br> - IPC 消息 `open-file-dialog` 已从渲染进程发送。<br> - 正在应用滤镜 'Sepia' 到图像。` |
| **`debug`** | **开发和调试时使用的详细诊断信息。** <br> **严禁在发布版中默认开启**,因为它可能包含敏感数据并影响性能。 | - 函数 `renderImage` 的入参: `{ width: 800, ... }`<br> - IPC 消息 `save-file` 收到的具体数据内容。<br> - 渲染进程中 Redux/Vuex 的 state 变更详情。` |
| **`silly`** | **最详尽的底层信息,仅用于极限调试。** <br> 几乎不在常规开发中使用,仅为解决棘手问题。 | - 鼠标移动的实时坐标 `(x: 150, y: 320)`<br> - 读取文件时每个数据块chunk的大小。<br> - 每一次渲染帧的耗时。 |
| **`silly`** | **最详尽的底层信息,仅用于极限调试。** <br> 几乎不在常规开发中使用,仅为解决棘手问题。 | - 鼠标移动的实时坐标 `(x: 150, y: 320)`。<br> - 读取文件时每个数据块chunk的大小。<br> - 每一次渲染帧的耗时。 |

View File

@@ -80,7 +80,6 @@ import { ChunkType } from '@renderer/types' // 调整路径
export const createSimpleLoggingMiddleware = (): CompletionsMiddleware => {
return (api: MiddlewareAPI<AiProviderMiddlewareCompletionsContext, [CompletionsParams]>) => {
return (next: (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<any>) => {
return async (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams): Promise<void> => {
const startTime = Date.now()

View File

@@ -4,15 +4,17 @@ import { motion } from 'motion/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface ModelListSearchBarProps {
interface CollapsibleSearchBarProps {
onSearch: (text: string) => void
icon?: React.ReactNode
maxWidth?: string | number
}
/**
* A collapsible search bar for the model list
* A collapsible search bar for list headers
* Renders as an icon initially, expands to full search input when clicked
*/
const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) => {
const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, icon, maxWidth }) => {
const { t } = useTranslation()
const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('')
@@ -44,7 +46,7 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
initial="collapsed"
animate={searchVisible ? 'expanded' : 'collapsed'}
variants={{
expanded: { maxWidth: 360, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
expanded: { maxWidth: maxWidth || '100%', opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
}}
style={{ overflow: 'hidden', flex: 1 }}>
@@ -53,7 +55,7 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
type="text"
placeholder={t('models.search')}
size="small"
suffix={<Search size={14} />}
suffix={icon || <Search size={14} color="var(--color-icon)" />}
value={searchText}
autoFocus
allowClear
@@ -80,12 +82,12 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
}}
style={{ cursor: 'pointer', display: 'flex' }}
onClick={() => setSearchVisible(true)}>
<Tooltip title={t('models.search')} mouseEnterDelay={0.5}>
<Search size={14} color="var(--color-icon)" />
<Tooltip title={t('models.search')} mouseLeaveDelay={0}>
{icon || <Search size={14} color="var(--color-icon)" />}
</Tooltip>
</motion.div>
</div>
)
}
export default memo(ModelListSearchBar)
export default memo(CollapsibleSearchBar)

View File

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

View File

@@ -0,0 +1,86 @@
import { CheckCircleFilled, CloseCircleFilled, ExclamationCircleFilled, LoadingOutlined } from '@ant-design/icons'
import { Flex, Tooltip, Typography } from 'antd'
import React, { memo } from 'react'
import styled from 'styled-components'
import { HealthResult } from './types'
import { useHealthStatus } from './useHealthStatus'
export interface HealthStatusIndicatorProps {
results: HealthResult[]
loading?: boolean
showLatency?: boolean
}
const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
results,
loading = false,
showLatency = false
}) => {
const { overallStatus, tooltip, latencyText } = useHealthStatus({
results,
showLatency
})
if (loading) {
return (
<IndicatorWrapper $type="checking">
<LoadingOutlined spin />
</IndicatorWrapper>
)
}
if (overallStatus === 'not_checked') return null
let icon: React.ReactNode = null
switch (overallStatus) {
case 'success':
icon = <CheckCircleFilled />
break
case 'error':
icon = <CloseCircleFilled />
break
case 'partial':
icon = <ExclamationCircleFilled />
break
default:
return null
}
return (
<Flex align="center" gap={6}>
{latencyText && <LatencyText type="secondary">{latencyText}</LatencyText>}
<Tooltip title={tooltip} styles={{ body: { userSelect: 'text' } }}>
<IndicatorWrapper $type={overallStatus}>{icon}</IndicatorWrapper>
</Tooltip>
</Flex>
)
}
const IndicatorWrapper = styled.div<{ $type: string }>`
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: ${(props) => {
switch (props.$type) {
case 'success':
return 'var(--color-status-success)'
case 'error':
return 'var(--color-status-error)'
case 'partial':
return 'var(--color-status-warning)'
case 'checking':
default:
return 'var(--color-text)'
}
}};
`
const LatencyText = styled(Typography.Text)`
margin-left: 10px;
color: var(--color-text-secondary);
font-size: 12px;
`
export default memo(HealthStatusIndicator)

View File

@@ -0,0 +1,12 @@
import { HealthStatus } from '@renderer/types/healthCheck'
/**
* 用于展示单个健康检查结果的必要数据
*/
export interface HealthResult {
status: HealthStatus
latency?: number
error?: string
// 用于在 Tooltip 中显示额外上下文信息,例如 API Key 或模型名称
label?: string
}

View File

@@ -0,0 +1,109 @@
import { HealthStatus } from '@renderer/types/healthCheck'
import { Flex } from 'antd'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { HealthResult } from './types'
interface UseHealthStatusProps {
results: HealthResult[]
showLatency?: boolean
}
interface UseHealthStatusReturn {
overallStatus: 'success' | 'error' | 'partial' | 'not_checked'
latencyText: string | null
tooltip: React.ReactNode | null
}
/**
* Format check time to a human-readable string
*/
function formatLatency(time: number): string {
return `${(time / 1000).toFixed(2)}s`
}
export const useHealthStatus = ({ results, showLatency = false }: UseHealthStatusProps): UseHealthStatusReturn => {
const { t } = useTranslation()
if (!results || results.length === 0) {
return { overallStatus: 'not_checked', tooltip: null, latencyText: null }
}
const numSuccess = results.filter((r) => r.status === HealthStatus.SUCCESS).length
const numFailed = results.filter((r) => r.status === HealthStatus.FAILED).length
let overallStatus: 'success' | 'error' | 'partial' | 'not_checked' = 'not_checked'
if (numSuccess > 0 && numFailed === 0) {
overallStatus = 'success'
} else if (numSuccess === 0 && numFailed > 0) {
overallStatus = 'error'
} else if (numSuccess > 0 && numFailed > 0) {
overallStatus = 'partial'
}
// Don't render anything if not checked yet
if (overallStatus === 'not_checked') {
return { overallStatus, tooltip: null, latencyText: null }
}
const getStatusText = (s: HealthStatus) => {
switch (s) {
case HealthStatus.SUCCESS:
return t('settings.models.check.passed')
case HealthStatus.FAILED:
return t('settings.models.check.failed')
default:
return ''
}
}
// Generate Tooltip
const tooltip = (
<ul
style={{
maxHeight: '300px',
overflowY: 'auto',
margin: 0,
padding: 0,
listStyleType: 'none',
maxWidth: '300px',
wordWrap: 'break-word'
}}>
{results.map((result, idx) => {
const statusText = getStatusText(result.status)
const statusColor =
result.status === HealthStatus.SUCCESS ? 'var(--color-status-success)' : 'var(--color-status-error)'
return (
<li key={idx} style={{ marginBottom: idx === results.length - 1 ? 0 : '10px' }}>
<Flex align="center" justify="space-between">
<strong style={{ color: statusColor }}>{statusText}</strong>
{result.label}
</Flex>
{result.latency && result.status === HealthStatus.SUCCESS && (
<div style={{ marginTop: 2 }}>
{t('settings.provider.api.key.check.latency')}: {formatLatency(result.latency)}
</div>
)}
{result.error && result.status === HealthStatus.FAILED && (
<div style={{ marginTop: 2 }}>{result.error}</div>
)}
</li>
)
})}
</ul>
)
// Calculate latency
let latencyText: string | null = null
if (showLatency && overallStatus !== 'error') {
const latencies = results.filter((r) => r.status === HealthStatus.SUCCESS && r.latency).map((r) => r.latency!)
if (latencies.length > 0) {
const minLatency = Math.min(...latencies)
latencyText = formatLatency(minLatency)
}
}
return { overallStatus, tooltip, latencyText }
}

View File

@@ -4,7 +4,10 @@ import CustomCollapse from '@renderer/components/CustomCollapse'
import CustomTag from '@renderer/components/CustomTag'
import ExpandableText from '@renderer/components/ExpandableText'
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
import NewApiAddModelPopup from '@renderer/components/ModelList/NewApiAddModelPopup'
import NewApiBatchAddModelPopup from '@renderer/components/ModelList/NewApiBatchAddModelPopup'
import Scrollbar from '@renderer/components/Scrollbar'
import { TopView } from '@renderer/components/TopView'
import {
getModelLogo,
groupQwenModels,
@@ -18,8 +21,6 @@ import {
} from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider'
import FileItem from '@renderer/pages/files/FileItem'
import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/NewApiAddModelPopup'
import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/NewApiBatchAddModelPopup'
import { fetchModels } from '@renderer/services/ApiService'
import { Model, Provider } from '@renderer/types'
import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils'
@@ -32,9 +33,8 @@ import { memo, useCallback, useEffect, useMemo, useOptimistic, useRef, useState,
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { TopView } from '../../../components/TopView'
const logger = loggerService.withContext('EditModelsPopup')
interface ShowParams {
provider: Provider
}
@@ -218,7 +218,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
title={
isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed')
}
mouseEnterDelay={0.5}
mouseLeaveDelay={0}
placement="top">
<Button
type="default"
@@ -245,11 +245,11 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
}
}
}}
disabled={list.length === 0}
disabled={loading || list.length === 0}
/>
</Tooltip>
)
}, [list, t, provider, onRemoveModel, models, onAddModel])
}, [list, t, loading, provider, onRemoveModel, models, onAddModel])
const renderGroupTools = useCallback(
(group: string) => {
@@ -262,7 +262,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
? t(`settings.models.manage.remove_whole_group`)
: t(`settings.models.manage.add_whole_group`)
}
mouseEnterDelay={0.5}
mouseLeaveDelay={0}
placement="top">
<Button
type="text"

View File

@@ -0,0 +1,188 @@
import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { HStack } from '@renderer/components/Layout'
import AddModelPopup from '@renderer/components/ModelList/AddModelPopup'
import EditModelsPopup from '@renderer/components/ModelList/EditModelsPopup'
import ModelEditContent from '@renderer/components/ModelList/ModelEditContent'
import NewApiAddModelPopup from '@renderer/components/ModelList/NewApiAddModelPopup'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
import { useProvider } from '@renderer/hooks/useProvider'
import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '@renderer/pages/settings'
import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants'
import { Model } from '@renderer/types'
import { Button, Flex, Tooltip } from 'antd'
import { groupBy, isEmpty, sortBy, toPairs } from 'lodash'
import { ListCheck, Plus } from 'lucide-react'
import React, { memo, startTransition, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ModelListGroup from './ModelListGroup'
import { useHealthCheck } from './useHealthCheck'
interface ModelListProps {
providerId: string
}
/**
* 模型列表组件,用于 CRUD 操作和健康检查
*/
const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
const dispatch = useAppDispatch()
const { t } = useTranslation()
const { provider, updateProvider, models, removeModel } = useProvider(providerId)
const { assistants } = useAssistants()
const { defaultModel, setDefaultModel } = useDefaultModel()
const providerConfig = PROVIDER_CONFIG[provider.id]
const docsWebsite = providerConfig?.websites?.docs
const modelsWebsite = providerConfig?.websites?.models
const [editingModel, setEditingModel] = useState<Model | null>(null)
const [searchText, _setSearchText] = useState('')
const { isChecking: isHealthChecking, modelStatuses, runHealthCheck } = useHealthCheck(provider, models)
const setSearchText = useCallback((text: string) => {
startTransition(() => {
_setSearchText(text)
})
}, [])
const modelGroups = useMemo(() => {
const filteredModels = searchText
? models.filter((model) => model.name.toLowerCase().includes(searchText.toLowerCase()))
: models
return groupBy(filteredModels, 'group')
}, [searchText, models])
const sortedModelGroups = useMemo(() => {
return sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
acc[key] = value
return acc
}, {})
}, [modelGroups])
const onManageModel = useCallback(() => {
EditModelsPopup.show({ provider })
}, [provider])
const onAddModel = useCallback(() => {
if (provider.id === 'new-api') {
NewApiAddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
} else {
AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
}
}, [provider, t])
const onEditModel = useCallback((model: Model) => {
setEditingModel(model)
}, [])
const onUpdateModel = useCallback(
(updatedModel: Model) => {
const updatedModels = models.map((m) => (m.id === updatedModel.id ? updatedModel : m))
updateProvider({ models: updatedModels })
assistants.forEach((assistant) => {
if (assistant?.model?.id === updatedModel.id && assistant.model.provider === provider.id) {
dispatch(
setModel({
assistantId: assistant.id,
model: updatedModel
})
)
}
})
if (defaultModel?.id === updatedModel.id && defaultModel?.provider === provider.id) {
setDefaultModel(updatedModel)
}
},
[models, updateProvider, provider.id, assistants, defaultModel, dispatch, setDefaultModel]
)
return (
<>
<SettingSubtitle style={{ marginBottom: 5 }}>
<HStack alignItems="center" justifyContent="space-between" style={{ width: '100%' }}>
<HStack alignItems="center" gap={8}>
<SettingSubtitle style={{ marginTop: 0 }}>{t('common.models')}</SettingSubtitle>
{!isEmpty(models) && <CollapsibleSearchBar onSearch={setSearchText} />}
</HStack>
{!isEmpty(models) && (
<HStack>
<Tooltip title={t('button.manage')} mouseLeaveDelay={0}>
<Button
type="text"
onClick={onManageModel}
icon={<ListCheck size={16} />}
disabled={isHealthChecking}
/>
</Tooltip>
<Tooltip title={t('button.add')} mouseLeaveDelay={0}>
<Button type="text" onClick={onAddModel} icon={<Plus size={16} />} disabled={isHealthChecking} />
</Tooltip>
<Tooltip title={t('settings.models.check.button_caption')} mouseLeaveDelay={0}>
<Button
type="text"
onClick={runHealthCheck}
icon={<StreamlineGoodHealthAndWellBeing size={16} isActive={isHealthChecking} />}
/>
</Tooltip>
</HStack>
)}
</HStack>
</SettingSubtitle>
<Flex gap={12} vertical>
{Object.keys(sortedModelGroups).map((group, i) => (
<ModelListGroup
key={group}
groupName={group}
models={sortedModelGroups[group]}
modelStatuses={modelStatuses}
defaultOpen={i <= 5}
disabled={isHealthChecking}
onEditModel={onEditModel}
onRemoveModel={removeModel}
onRemoveGroup={() => modelGroups[group].forEach((model) => removeModel(model))}
/>
))}
{docsWebsite || modelsWebsite ? (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
{docsWebsite && (
<SettingHelpLink target="_blank" href={docsWebsite}>
{t(`provider.${provider.id}`) + ' '}
{t('common.docs')}
</SettingHelpLink>
)}
{docsWebsite && modelsWebsite && <SettingHelpText>{t('common.and')}</SettingHelpText>}
{modelsWebsite && (
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
)}
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
</SettingHelpTextRow>
) : (
<div style={{ height: 5 }} />
)}
</Flex>
{models.map((model) => (
<ModelEditContent
provider={provider}
model={model}
onUpdateModel={onUpdateModel}
open={editingModel?.id === model.id}
onClose={() => setEditingModel(null)}
key={model.id}
/>
))}
</>
)
}
export default memo(ModelList)

View File

@@ -0,0 +1,84 @@
import { MinusOutlined } from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse'
import { Model } from '@renderer/types'
import { ModelWithStatus } from '@renderer/types/healthCheck'
import { Button, Flex, Tooltip } from 'antd'
import React, { memo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ModelListItem from './ModelListItem'
interface ModelListGroupProps {
groupName: string
models: Model[]
modelStatuses: ModelWithStatus[]
defaultOpen: boolean
disabled?: boolean
onEditModel: (model: Model) => void
onRemoveModel: (model: Model) => void
onRemoveGroup: () => void
}
const ModelListGroup: React.FC<ModelListGroupProps> = ({
groupName,
models,
modelStatuses,
defaultOpen,
disabled,
onEditModel,
onRemoveModel,
onRemoveGroup
}) => {
const { t } = useTranslation()
return (
<CustomCollapseWrapper>
<CustomCollapse
defaultActiveKey={defaultOpen ? ['1'] : []}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 'bold' }}>{groupName}</span>
</Flex>
}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')} mouseLeaveDelay={0}>
<Button
type="text"
className="toolbar-item"
icon={<MinusOutlined />}
onClick={onRemoveGroup}
disabled={disabled}
/>
</Tooltip>
}>
<Flex gap={10} vertical style={{ marginTop: 10 }}>
{models.map((model) => (
<ModelListItem
key={model.id}
model={model}
modelStatus={modelStatuses.find((status) => status.model.id === model.id)}
onEdit={onEditModel}
onRemove={onRemoveModel}
disabled={disabled}
/>
))}
</Flex>
</CustomCollapse>
</CustomCollapseWrapper>
)
}
const CustomCollapseWrapper = styled.div`
.toolbar-item {
transform: translateZ(0);
will-change: opacity;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .toolbar-item {
opacity: 1;
}
`
export default memo(ModelListGroup)

View File

@@ -0,0 +1,86 @@
import { MinusOutlined } from '@ant-design/icons'
import { type HealthResult, HealthStatusIndicator } from '@renderer/components/HealthStatusIndicator'
import { HStack } from '@renderer/components/Layout'
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
import { getModelLogo } from '@renderer/config/models'
import { Model } from '@renderer/types'
import { ModelWithStatus } from '@renderer/types/healthCheck'
import { maskApiKey } from '@renderer/utils/api'
import { Avatar, Button, Tooltip } from 'antd'
import { Bolt } from 'lucide-react'
import React, { memo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ModelListItemProps {
ref?: React.RefObject<HTMLDivElement>
model: Model
modelStatus: ModelWithStatus | undefined
disabled?: boolean
onEdit: (model: Model) => void
onRemove: (model: Model) => void
}
const ModelListItem: React.FC<ModelListItemProps> = ({ ref, model, modelStatus, disabled, onEdit, onRemove }) => {
const { t } = useTranslation()
const isChecking = modelStatus?.checking === true
const healthResults: HealthResult[] =
modelStatus?.keyResults?.map((kr) => ({
status: kr.status,
latency: kr.latency,
error: kr.error,
label: maskApiKey(kr.key)
})) || []
return (
<ListItem ref={ref}>
<HStack alignItems="center" gap={10} style={{ flex: 1 }}>
<Avatar src={getModelLogo(model.id)} size={24}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ModelIdWithTags
model={model}
style={{
flex: 1,
width: 0,
overflow: 'hidden'
}}
/>
</HStack>
<HStack alignItems="center" gap={6}>
<HealthStatusIndicator results={healthResults} loading={isChecking} showLatency />
<HStack alignItems="center" gap={0}>
<Tooltip title={t('models.edit')} mouseLeaveDelay={0}>
<Button
type="text"
onClick={() => onEdit(model)}
disabled={disabled || isChecking}
icon={<Bolt size={16} />}
/>
</Tooltip>
<Tooltip title={t('settings.models.manage.remove_model')} mouseLeaveDelay={0}>
<Button
type="text"
onClick={() => onRemove(model)}
disabled={disabled || isChecking}
icon={<MinusOutlined />}
/>
</Tooltip>
</HStack>
</HStack>
</ListItem>
)
}
const ListItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--color-text);
font-size: 14px;
line-height: 1;
`
export default memo(ModelListItem)

View File

@@ -0,0 +1,7 @@
export { default as AddModelPopup } from './AddModelPopup'
export { default as EditModelsPopup } from './EditModelsPopup'
export { default as HealthCheckPopup } from './HealthCheckPopup'
export { default as ModelEditContent } from './ModelEditContent'
export { default as ModelList } from './ModelList'
export { default as NewApiAddModelPopup } from './NewApiAddModelPopup'
export { default as NewApiBatchAddModelPopup } from './NewApiBatchAddModelPopup'

View File

@@ -0,0 +1,97 @@
import { isRerankModel } from '@renderer/config/models'
import { checkModelsHealth } from '@renderer/services/HealthCheckService'
import { Model, Provider } from '@renderer/types'
import { HealthStatus, ModelWithStatus } from '@renderer/types/healthCheck'
import { splitApiKeyString } from '@renderer/utils/api'
import { summarizeHealthResults } from '@renderer/utils/healthCheck'
import { isEmpty } from 'lodash'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import HealthCheckPopup from './HealthCheckPopup'
export const useHealthCheck = (provider: Provider, models: Model[]) => {
const { t } = useTranslation()
const [modelStatuses, setModelStatuses] = useState<ModelWithStatus[]>([])
const [isChecking, setIsChecking] = useState(false)
const runHealthCheck = useCallback(async () => {
const modelsToCheck = models.filter((model) => !isRerankModel(model))
if (isEmpty(modelsToCheck)) {
window.message.error({
key: 'no-models',
style: { marginTop: '3vh' },
duration: 5,
content: t('settings.provider.no_models_for_check')
})
return
}
const keys = splitApiKeyString(provider.apiKey)
// 若无 key插入空字符串以支持本地模型健康检查
if (keys.length === 0) {
keys.push('')
}
// 弹出健康检查参数配置弹窗
const result = await HealthCheckPopup.show({
title: t('settings.models.check.title'),
provider,
apiKeys: keys
})
if (result.cancelled) {
return
}
// 初始化健康检查状态
const initialStatuses: ModelWithStatus[] = modelsToCheck.map((model) => ({
model,
checking: true,
status: HealthStatus.NOT_CHECKED,
keyResults: []
}))
setModelStatuses(initialStatuses)
setIsChecking(true)
// 执行健康检查,逐步更新每个模型的状态
const checkResults = await checkModelsHealth(
{
provider,
models: modelsToCheck,
apiKeys: result.apiKeys,
isConcurrent: result.isConcurrent
},
(checkResult, index) => {
setModelStatuses((current) => {
const updated = [...current]
if (updated[index]) {
updated[index] = {
...updated[index],
...checkResult,
checking: false
}
}
return updated
})
}
)
window.message.info({
key: 'health-check-summary',
style: { marginTop: '3vh' },
duration: 5,
content: summarizeHealthResults(checkResults, provider.name)
})
setIsChecking(false)
}, [models, provider, t])
return {
isChecking,
modelStatuses,
runHealthCheck
}
}

View File

@@ -4,6 +4,7 @@ import SelectProviderModelPopup from '@renderer/pages/settings/ProviderSettings/
import { checkApi } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService'
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
import { ApiKeyConnectivity, ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck'
import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api'
import { formatErrorMessage } from '@renderer/utils/error'
import { TFunction } from 'i18next'
@@ -11,7 +12,7 @@ import { isEmpty } from 'lodash'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiKeyConnectivity, ApiKeyValidity, ApiKeyWithStatus, ApiProviderKind, ApiProviderUnion } from './types'
import { ApiKeyValidity, ApiProviderKind, ApiProviderUnion } from './types'
interface UseApiKeysProps {
provider: ApiProviderUnion
@@ -52,7 +53,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
const keysWithStatus = useMemo((): ApiKeyWithStatus[] => {
return keys.map((key) => {
const connectivityState = connectivityStates.get(key) || {
status: 'not_checked' as const,
status: HealthStatus.NOT_CHECKED,
checking: false,
error: undefined,
model: undefined,
@@ -70,7 +71,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
setConnectivityStates((prev) => {
const newMap = new Map(prev)
const currentState = prev.get(key) || {
status: 'not_checked' as const,
status: HealthStatus.NOT_CHECKED,
checking: false,
error: undefined,
model: undefined,
@@ -170,10 +171,12 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
// 移除连通性检查失败的 keys
const removeInvalidKeys = useCallback(() => {
const validKeys = keysWithStatus.filter((keyStatus) => keyStatus.status !== 'error').map((k) => k.key)
const validKeys = keysWithStatus.filter((keyStatus) => keyStatus.status !== HealthStatus.FAILED).map((k) => k.key)
// 清除被删除的 keys 的连通性状态
const keysToRemove = keysWithStatus.filter((keyStatus) => keyStatus.status === 'error').map((k) => k.key)
const keysToRemove = keysWithStatus
.filter((keyStatus) => keyStatus.status === HealthStatus.FAILED)
.map((k) => k.key)
setConnectivityStates((prev) => {
const newMap = new Map(prev)
@@ -207,7 +210,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
// 连通性检查成功
updateConnectivityState(keyToCheck, {
checking: false,
status: 'success',
status: HealthStatus.SUCCESS,
model,
latency,
error: undefined
@@ -216,7 +219,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
// 连通性检查失败
updateConnectivityState(keyToCheck, {
checking: false,
status: 'error',
status: HealthStatus.FAILED,
error: formatErrorMessage(error),
model: undefined,
latency: undefined

View File

@@ -1,5 +1,7 @@
import { CheckCircleFilled, CloseCircleFilled, MinusOutlined } from '@ant-design/icons'
import { MinusOutlined } from '@ant-design/icons'
import { type HealthResult, HealthStatusIndicator } from '@renderer/components/HealthStatusIndicator'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { ApiKeyWithStatus } from '@renderer/types/healthCheck'
import { maskApiKey } from '@renderer/utils/api'
import { Button, Flex, Input, InputRef, List, Popconfirm, Tooltip, Typography } from 'antd'
import { Check, PenLine, X } from 'lucide-react'
@@ -7,7 +9,7 @@ import { FC, memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { ApiKeyValidity, ApiKeyWithStatus } from './types'
import { ApiKeyValidity } from './types'
export interface ApiKeyItemProps {
keyStatus: ApiKeyWithStatus
@@ -39,9 +41,6 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
const inputRef = useRef<InputRef>(null)
const disabled = keyStatus.checking || _disabled
const isNotChecked = keyStatus.status === 'not_checked'
const isSuccess = keyStatus.status === 'success'
const statusColor = isSuccess ? 'var(--color-status-success)' : 'var(--color-status-error)'
useEffect(() => {
if (isEditing && inputRef.current) {
@@ -83,41 +82,14 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
}
}
const renderStatusIcon = () => {
if (keyStatus.checking || isNotChecked) return null
const StatusIcon = isSuccess ? CheckCircleFilled : CloseCircleFilled
return <StatusIcon style={{ color: statusColor }} />
}
const renderKeyCheckResultTooltip = () => {
if (keyStatus.checking) {
return t('settings.models.check.checking')
const healthResults: HealthResult[] = [
{
status: keyStatus.status,
latency: keyStatus.latency,
error: keyStatus.error,
label: keyStatus.model?.name
}
if (isNotChecked) {
return ''
}
const statusTitle = isSuccess ? t('settings.models.check.passed') : t('settings.models.check.failed')
return (
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px', wordWrap: 'break-word' }}>
<strong style={{ color: statusColor }}>{statusTitle}</strong>
{keyStatus.model && (
<div style={{ marginTop: 5 }}>
{t('common.model')}: {keyStatus.model.name}
</div>
)}
{keyStatus.latency && isSuccess && (
<div style={{ marginTop: 5 }}>
{t('settings.provider.api.key.check.latency')}: {(keyStatus.latency / 1000).toFixed(2)}s
</div>
)}
{keyStatus.error && <div style={{ marginTop: 5 }}>{keyStatus.error}</div>}
</div>
)
}
]
return (
<List.Item>
@@ -163,9 +135,7 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
</Tooltip>
<Flex gap={10} align="center">
<Tooltip title={renderKeyCheckResultTooltip()} styles={{ body: { userSelect: 'text' } }}>
{renderStatusIcon()}
</Tooltip>
<HealthStatusIndicator results={healthResults} loading={false} />
<Flex gap={0} align="center">
{showHealthCheck && (
@@ -200,14 +170,10 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
)
}
const ItemInnerContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
const ItemInnerContainer = styled(Flex)`
flex: 1;
justify-content: space-between;
width: 100%;
padding: 0;
margin: 0;
align-items: center;
`
export default memo(ApiKeyItem)

View File

@@ -6,6 +6,7 @@ import { useProvider } from '@renderer/hooks/useProvider'
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
import { SettingHelpText } from '@renderer/pages/settings'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck'
import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from 'antd'
import { Trash } from 'lucide-react'
import { FC, useState } from 'react'
@@ -14,7 +15,7 @@ import styled from 'styled-components'
import { isLlmProvider, useApiKeys } from './hook'
import ApiKeyItem from './item'
import { ApiKeyWithStatus, ApiProviderKind, ApiProviderUnion } from './types'
import { ApiProviderKind, ApiProviderUnion } from './types'
interface ApiKeyListProps {
provider: ApiProviderUnion
@@ -81,7 +82,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
...keys,
{
key: pendingNewKey.key,
status: 'not_checked',
status: HealthStatus.NOT_CHECKED,
checking: false
}
]

View File

@@ -1,22 +1,4 @@
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
/**
* API Key 连通性检查的状态
*/
export type ApiKeyConnectivity = {
status: 'success' | 'error' | 'not_checked'
checking?: boolean
error?: string
model?: Model
latency?: number
}
/**
* API key 及其连通性检查的状态
*/
export type ApiKeyWithStatus = {
key: string
} & ApiKeyConnectivity
import { PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
/**
* API key 格式有效性

View File

@@ -2165,6 +2165,7 @@
"models.manage.add_listed": "Add models to the list",
"models.manage.add_whole_group": "Add the whole group",
"models.manage.remove_listed": "Remove models from the list",
"models.manage.remove_model": "Remove model",
"models.manage.remove_whole_group": "Remove the whole group",
"models.provider_id": "Provider ID",
"models.provider_key_add_confirm": "Do you want to add the API key for {{provider}}?",

View File

@@ -2165,6 +2165,7 @@
"models.manage.add_listed": "リストにモデルを追加",
"models.manage.add_whole_group": "グループ全体を追加",
"models.manage.remove_listed": "リストからモデルを削除",
"models.manage.remove_model": "モデルを削除",
"models.manage.remove_whole_group": "グループ全体を削除",
"models.provider_id": "プロバイダー ID",
"models.provider_key_add_confirm": "{{provider}} の API キーを追加しますか?",

View File

@@ -2165,6 +2165,7 @@
"models.manage.add_listed": "Добавить в список",
"models.manage.add_whole_group": "Добавить всю группу",
"models.manage.remove_listed": "Удалить из списка",
"models.manage.remove_model": "Удалить модель",
"models.manage.remove_whole_group": "Удалить всю группу",
"models.provider_id": "ID провайдера",
"models.provider_key_add_confirm": "Добавить API ключ для {{provider}}?",

View File

@@ -2165,6 +2165,7 @@
"models.manage.add_listed": "添加列表中的模型",
"models.manage.add_whole_group": "添加整个分组",
"models.manage.remove_listed": "移除列表中的模型",
"models.manage.remove_model": "移除模型",
"models.manage.remove_whole_group": "移除整个分组",
"models.provider_id": "服务商 ID",
"models.provider_key_add_confirm": "是否要为 {{provider}} 添加 API 密钥?",

View File

@@ -2165,6 +2165,7 @@
"models.manage.add_listed": "添加列表中的模型",
"models.manage.add_whole_group": "新增整個分組",
"models.manage.remove_listed": "移除列表中的模型",
"models.manage.remove_model": "移除模型",
"models.manage.remove_whole_group": "移除整個分組",
"models.provider_id": "提供者 ID",
"models.provider_key_add_confirm": "是否要為 {{provider}} 添加 API 密鑰?",

View File

@@ -1,402 +0,0 @@
import {
CheckCircleFilled,
CloseCircleFilled,
ExclamationCircleFilled,
LoadingOutlined,
MinusOutlined,
PlusOutlined
} from '@ant-design/icons'
import CustomCollapse from '@renderer/components/CustomCollapse'
import { HStack } from '@renderer/components/Layout'
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
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 NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/NewApiAddModelPopup'
import { ModelCheckStatus } from '@renderer/services/HealthCheckService'
import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants'
import { Model } from '@renderer/types'
import { maskApiKey } from '@renderer/utils/api'
import { Avatar, Button, Flex, Tooltip, Typography } from 'antd'
import { groupBy, sortBy, toPairs } from 'lodash'
import { Bolt, ListCheck } from 'lucide-react'
import React, { memo, useCallback, useMemo, 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'
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: 'var(--color-status-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 ? 'var(--color-status-success)' : 'var(--color-status-error)'
}}>
{maskedKey}: {kr.isValid ? t('settings.models.check.passed') : t('settings.models.check.failed')}
{kr.error && !kr.isValid && ` (${kr.error})`}
{kr.latency && kr.isValid && ` (${formatLatency(kr.latency)})`}
</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)} mouseEnterDelay={0.5}>
<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 }
}
interface ModelListProps {
providerId: string
modelStatuses?: ModelStatus[]
searchText?: string
}
/**
* Model list component
*/
const ModelList: React.FC<ModelListProps> = ({ providerId, modelStatuses = [], searchText = '' }) => {
const { t } = useTranslation()
const { provider, updateProvider, models, removeModel } = useProvider(providerId)
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 = useMemo(() => {
const filteredModels = searchText
? models.filter((model) => model.name.toLowerCase().includes(searchText.toLowerCase()))
: models
return groupBy(filteredModels, 'group')
}, [searchText, models])
const sortedModelGroups = useMemo(() => {
return sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
acc[key] = value
return acc
}, {})
}, [modelGroups])
const onManageModel = useCallback(() => {
EditModelsPopup.show({ provider })
}, [provider])
const onAddModel = useCallback(() => {
if (provider.id === 'new-api') {
NewApiAddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
} else {
AddModelPopup.show({ title: t('settings.models.add.add_model'), provider })
}
}, [provider, t])
const onEditModel = useCallback((model: Model) => {
setEditingModel(model)
}, [])
const onUpdateModel = useCallback(
(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)
}
},
[models, updateProvider, provider, assistants, defaultModel?.id, defaultModel?.provider, dispatch, setDefaultModel]
)
return (
<>
<Flex gap={12} vertical>
{Object.keys(sortedModelGroups).map((group, i) => (
<CustomCollapseWrapper key={group}>
<CustomCollapse
defaultActiveKey={i <= 5 ? ['1'] : []}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 600 }}>{group}</span>
</Flex>
}
extra={
<Tooltip title={t('settings.models.manage.remove_whole_group')} mouseEnterDelay={0.5}>
<Button
type="text"
className="toolbar-item"
icon={<MinusOutlined />}
onClick={() => modelGroups[group].forEach((model) => removeModel(model))}
/>
</Tooltip>
}>
<Flex gap={10} vertical style={{ marginTop: 10 }}>
{sortedModelGroups[group].map((model) => {
const modelStatus = modelStatuses.find((status) => status.model.id === model.id)
const isChecking = modelStatus?.checking === true
return (
<ListItem key={model.id}>
<HStack alignItems="center" gap={10} style={{ flex: 1 }}>
<Avatar src={getModelLogo(model.id)} style={{ width: 26, height: 26 }}>
{model?.name?.[0]?.toUpperCase()}
</Avatar>
<ModelIdWithTags
model={model}
style={{
flex: 1,
width: 0,
overflow: 'hidden'
}}
/>
</HStack>
<Flex gap={4} align="center">
{renderLatencyText(modelStatus)}
{renderStatusIndicator(modelStatus)}
<Button
type="text"
onClick={() => !isChecking && onEditModel(model)}
disabled={isChecking}
icon={<Bolt size={16} />}
/>
<Button
type="text"
onClick={() => !isChecking && removeModel(model)}
disabled={isChecking}
icon={<MinusOutlined />}
/>
</Flex>
</ListItem>
)
})}
</Flex>
</CustomCollapse>
</CustomCollapseWrapper>
))}
{docsWebsite || modelsWebsite ? (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
{docsWebsite && (
<SettingHelpLink target="_blank" href={docsWebsite}>
{t(`provider.${provider.id}`) + ' '}
{t('common.docs')}
</SettingHelpLink>
)}
{docsWebsite && modelsWebsite && <SettingHelpText>{t('common.and')}</SettingHelpText>}
{modelsWebsite && (
<SettingHelpLink target="_blank" href={modelsWebsite}>
{t('common.models')}
</SettingHelpLink>
)}
<SettingHelpText>{t('settings.provider.docs_more_details')}</SettingHelpText>
</SettingHelpTextRow>
) : (
<div style={{ height: 5 }} />
)}
</Flex>
<Flex gap={10} style={{ marginTop: '10px' }}>
<Button type="primary" onClick={onManageModel} icon={<ListCheck size={18} />}>
{t('button.manage')}
</Button>
<Button type="default" onClick={onAddModel} icon={<PlusOutlined />}>
{t('button.add')}
</Button>
</Flex>
{models.map((model) => (
<ModelEditContent
provider={provider}
model={model}
onUpdateModel={onUpdateModel}
open={editingModel?.id === model.id}
onClose={() => setEditingModel(null)}
key={model.id}
/>
))}
</>
)
}
const CustomCollapseWrapper = styled.div`
.toolbar-item {
margin-top: 2px;
transform: translateZ(0);
will-change: opacity;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .toolbar-item {
opacity: 1;
}
`
const ListItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--color-text);
font-size: 14px;
line-height: 1;
`
const StatusIndicator = styled.div<{ $type: string }>`
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: ${(props) => {
switch (props.$type) {
case 'success':
return 'var(--color-status-success)'
case 'error':
return 'var(--color-status-error)'
case 'partial':
return 'var(--color-status-warning)'
default:
return 'var(--color-text)'
}
}};
`
const ModelLatencyText = styled(Typography.Text)`
margin-left: 10px;
color: var(--color-text-secondary);
font-size: 12px;
`
export default memo(ModelList)

View File

@@ -1,31 +1,23 @@
import { CheckOutlined, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { HStack } from '@renderer/components/Layout'
import { ApiKeyConnectivity, ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
import { ModelList } from '@renderer/components/ModelList'
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider'
import i18n from '@renderer/i18n'
import { checkApi } from '@renderer/services/ApiService'
import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import {
formatApiHost,
formatApiKeys,
getFancyProviderName,
isOpenAIProvider,
splitApiKeyString
} from '@renderer/utils'
import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck'
import { formatApiHost, formatApiKeys, getFancyProviderName, isOpenAIProvider } from '@renderer/utils'
import { formatErrorMessage } from '@renderer/utils/error'
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash'
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
import { motion } from 'motion/react'
import { FC, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -41,10 +33,7 @@ import CustomHeaderPopup from './CustomHeaderPopup'
import DMXAPISettings from './DMXAPISettings'
import GithubCopilotSettings from './GithubCopilotSettings'
import GPUStackSettings from './GPUStackSettings'
import HealthCheckPopup from './HealthCheckPopup'
import LMStudioSettings from './LMStudioSettings'
import ModelList, { ModelStatus } from './ModelList'
import ModelListSearchBar from './ModelListSearchBar'
import ProviderOAuth from './ProviderOAuth'
import ProviderSettingsPopup from './ProviderSettingsPopup'
import SelectProviderModelPopup from './SelectProviderModelPopup'
@@ -60,8 +49,6 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const { updateProviders } = useProviders()
const [apiHost, setApiHost] = useState(provider.apiHost)
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
const [modelSearchText, setModelSearchText] = useState('')
const deferredModelSearchText = useDeferredValue(modelSearchText)
const { t } = useTranslation()
const { theme } = useTheme()
@@ -74,14 +61,11 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const apiKeyWebsite = providerConfig?.websites?.apiKey
const configedApiHost = providerConfig?.api?.url
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
const [isHealthChecking, setIsHealthChecking] = useState(false)
const fancyProviderName = getFancyProviderName(provider)
const [localApiKey, setLocalApiKey] = useState(provider.apiKey)
const [apiKeyConnectivity, setApiKeyConnectivity] = useState<ApiKeyConnectivity>({
status: 'not_checked',
status: HealthStatus.NOT_CHECKED,
checking: false
})
@@ -97,7 +81,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
// 重置连通性检查状态
useEffect(() => {
setLocalApiKey(provider.apiKey)
setApiKeyConnectivity({ status: 'not_checked' })
setApiKeyConnectivity({ status: HealthStatus.NOT_CHECKED })
}, [provider.apiKey])
// 同步 localApiKey 到 provider.apiKey防抖
@@ -147,83 +131,6 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
})
}
const onHealthCheck = async () => {
const modelsToCheck = models.filter((model) => !isRerankModel(model))
if (isEmpty(modelsToCheck)) {
window.message.error({
key: 'no-models',
style: { marginTop: '3vh' },
duration: 5,
content: t('settings.provider.no_models_for_check')
})
return
}
const keys = splitApiKeyString(provider.apiKey)
// Add an empty key to enable health checks for local models.
// Error messages will be shown for each model if a valid key is needed.
if (keys.length === 0) {
keys.push('')
}
// 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) {
return
}
// Prepare the list of models to be checked
const initialStatuses = modelsToCheck.map((model) => ({
model,
checking: true,
status: undefined
}))
setModelStatuses(initialStatuses)
setIsHealthChecking(true)
const checkResults = await checkModelsHealth(
{
provider: { ...provider, apiHost },
models: modelsToCheck,
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
})
}
)
window.message.info({
key: 'health-check-summary',
style: { marginTop: '3vh' },
duration: 5,
content: getModelCheckSummary(checkResults, provider.name)
})
// Reset health check status
setIsHealthChecking(false)
}
const onCheckApi = async () => {
// 如果存在多个密钥,直接打开管理窗口
if (provider.apiKey.includes(',')) {
@@ -251,7 +158,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
}
try {
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: 'not_checked' }))
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: HealthStatus.NOT_CHECKED }))
await checkApi({ ...provider, apiHost }, model)
window.message.success({
@@ -261,9 +168,9 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
content: i18n.t('message.api.connection.success')
})
setApiKeyConnectivity((prev) => ({ ...prev, status: 'success' }))
setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.SUCCESS }))
setTimeout(() => {
setApiKeyConnectivity((prev) => ({ ...prev, status: 'not_checked' }))
setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.NOT_CHECKED }))
}, 3000)
} catch (error: any) {
window.message.error({
@@ -273,7 +180,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
content: i18n.t('message.api.connection.failed')
})
setApiKeyConnectivity((prev) => ({ ...prev, status: 'error', error: formatErrorMessage(error) }))
setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.FAILED, error: formatErrorMessage(error) }))
} finally {
setApiKeyConnectivity((prev) => ({ ...prev, checking: false }))
}
@@ -300,7 +207,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
// API key 连通性检查状态指示器,目前仅在失败时显示
const renderStatusIndicator = () => {
if (apiKeyConnectivity.checking || apiKeyConnectivity.status !== 'error') {
if (apiKeyConnectivity.checking || apiKeyConnectivity.status !== HealthStatus.FAILED) {
return null
}
@@ -466,32 +373,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
{provider.id === 'gpustack' && <GPUStackSettings />}
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
{provider.id === 'vertexai' && <VertexAISettings />}
<SettingSubtitle style={{ marginBottom: 5 }}>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<HStack alignItems="center" gap={8} mb={5}>
<SettingSubtitle style={{ marginTop: 0 }}>{t('common.models')}</SettingSubtitle>
{!isEmpty(models) && <ModelListSearchBar onSearch={setModelSearchText} />}
</HStack>
{!isEmpty(models) && (
<Tooltip title={t('settings.models.check.button_caption')} mouseEnterDelay={0.5}>
<Button
type="text"
size="small"
onClick={onHealthCheck}
icon={
<motion.span
variants={lightbulbVariants}
animate={isHealthChecking ? 'active' : 'idle'}
initial="idle">
<StreamlineGoodHealthAndWellBeing />
</motion.span>
}
/>
</Tooltip>
)}
</Space>
</SettingSubtitle>
<ModelList providerId={provider.id} modelStatuses={modelStatuses} searchText={deferredModelSearchText} />
<ModelList providerId={provider.id} />
</SettingContainer>
)
}

View File

@@ -1,264 +1,92 @@
import { loggerService } from '@logger'
import i18n from '@renderer/i18n'
import { Model, Provider } from '@renderer/types'
import { ApiKeyWithStatus, HealthStatus, ModelCheckOptions, ModelWithStatus } from '@renderer/types/healthCheck'
import { formatErrorMessage } from '@renderer/utils/error'
import { aggregateApiKeyResults } from '@renderer/utils/healthCheck'
import { checkModel } from './ModelService'
const logger = loggerService.withContext('HealthCheckService')
/**
* 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
* 用多个 API 密钥检查单个模型的连通性
*/
export async function checkModelWithMultipleKeys(
provider: Provider,
model: Model,
apiKeys: string[],
isParallel: boolean
): Promise<Omit<ModelCheckResult, 'model' | 'status' | 'error'>> {
let keyResults: ApiKeyCheckStatus[] = []
apiKeys: string[]
): Promise<ApiKeyWithStatus[]> {
const checkPromises = apiKeys.map(async (key) => {
const startTime = Date.now()
// 如果 checkModel 抛出错误,让这个 promise 失败
await checkModel({ ...provider, apiKey: key }, model)
const latency = Date.now() - startTime
if (isParallel) {
// Check all API keys in parallel
const keyPromises = apiKeys.map(async (key) => {
try {
const result = await checkModel({ ...provider, apiKey: key }, model)
return {
key,
isValid: true,
latency: result.latency
} as ApiKeyCheckStatus
} catch (error: unknown) {
return {
key,
isValid: false,
error: error instanceof Error ? error.message.slice(0, 20) + '...' : String(error).slice(0, 20) + '...'
} as ApiKeyCheckStatus
}
})
return {
key,
status: HealthStatus.SUCCESS,
latency
}
})
const results = await Promise.allSettled(keyPromises)
const results = await Promise.allSettled(checkPromises)
// 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) {
try {
const result = await checkModel({ ...provider, apiKey: key }, model)
keyResults.push({
key,
isValid: true,
latency: result.latency
})
} catch (error: unknown) {
keyResults.push({
key,
isValid: false,
error: error instanceof Error ? error.message.slice(0, 20) + '...' : String(error).slice(0, 20) + '...'
})
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value
} else {
return {
key: apiKeys[index], // 对应失败的 promise 的 key
status: HealthStatus.FAILED,
error: formatErrorMessage(result.reason)
}
}
}
// 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[]> {
onModelChecked?: (result: ModelWithStatus, index: number) => void
): Promise<ModelWithStatus[]> {
const { provider, models, apiKeys, isConcurrent } = options
// Results array
const results: ModelCheckResult[] = []
const results: ModelWithStatus[] = []
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 modelPromises = models.map(async (model, index) => {
const keyResults = await checkModelWithMultipleKeys(provider, model, apiKeys)
const analysis = aggregateApiKeyResults(keyResults)
const result: ModelCheckResult = {
model,
...checkResult,
status: analysisResult.status,
error: analysisResult.error
}
const result: ModelWithStatus = {
model,
keyResults,
status: analysis.status,
error: analysis.error,
latency: analysis.latency
}
if (isConcurrent) {
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
}
} else {
results.push(result)
}
if (onModelChecked) {
onModelChecked(result, i)
}
onModelChecked?.(result, index)
return result
})
if (isConcurrent) {
await Promise.all(modelPromises)
} else {
for (const promise of modelPromises) {
await promise
}
}
} catch (error) {
logger.error('Model health check failed:', error)
logger.error('[HealthCheckService] Model health check failed:', error)
}
return results
}
export function getModelCheckSummary(results: ModelCheckResult[], providerName?: string): string {
const t = i18n.t
// Show summary of results after checking
const failedModels = results.filter((result) => result.status === ModelCheckStatus.FAILED)
const partialModels = results.filter((result) => result.status === ModelCheckStatus.PARTIAL)
const successModels = results.filter((result) => result.status === ModelCheckStatus.SUCCESS)
// Display statistics of all model check results
const summaryParts: string[] = []
if (failedModels.length > 0) {
summaryParts.push(t('settings.models.check.model_status_failed', { count: failedModels.length }))
}
if (successModels.length + partialModels.length > 0) {
summaryParts.push(
t('settings.models.check.model_status_passed', { count: successModels.length + partialModels.length })
)
}
if (partialModels.length > 0) {
summaryParts.push(t('settings.models.check.model_status_partial', { count: partialModels.length }))
}
const summary = summaryParts.join(', ')
return t('settings.models.check.model_status_summary', {
provider: providerName ?? 'Unknown Provider',
summary
})
}

View File

@@ -0,0 +1,52 @@
import { Model, Provider } from '@types'
/**
* 健康检查的通用状态枚举
* - SUCCESS: 用于表达“所有都成功”
* - FAILED: 用于表达“至少一个失败”
*/
export enum HealthStatus {
SUCCESS = 'success',
FAILED = 'failed',
NOT_CHECKED = 'not_checked'
}
/**
* API Key 连通性检查的状态
*/
export interface ApiKeyConnectivity {
status: HealthStatus
checking?: boolean
error?: string
model?: Model
latency?: number
}
/**
* API key 及其连通性检查的状态
*/
export interface ApiKeyWithStatus extends ApiKeyConnectivity {
key: string
}
/**
* 模型及其连通性检查的状态
*/
export interface ModelWithStatus {
model: Model
status: HealthStatus
keyResults: ApiKeyWithStatus[]
checking?: boolean
latency?: number
error?: string
}
/**
* 模型健康检查选项
*/
export interface ModelCheckOptions {
provider: Provider
models: Model[]
apiKeys: string[]
isConcurrent: boolean
}

View File

@@ -0,0 +1,78 @@
import i18n from '@renderer/i18n'
import { ApiKeyWithStatus, HealthStatus, ModelWithStatus } from '@renderer/types/healthCheck'
/**
* 聚合多个 API 密钥检查结果,得到模型健康检查的整体状态
*/
export function aggregateApiKeyResults(keyResults: ApiKeyWithStatus[]): {
status: HealthStatus
error?: string
latency?: number
} {
const successResults = keyResults.filter((r) => r.status === HealthStatus.SUCCESS)
const failedResults = keyResults.filter((r) => r.status === HealthStatus.FAILED)
if (failedResults.length > 0) {
// 只要有一个密钥失败,整个检查就失败
const errors = failedResults
.map((r) => r.error)
.filter((v, i, a) => a.indexOf(v) === i) // 去重
.join('; ')
return {
status: HealthStatus.FAILED,
error: errors,
latency: successResults.length > 0 ? Math.min(...successResults.map((r) => r.latency!)) : undefined
}
}
// 所有密钥都成功
return {
status: HealthStatus.SUCCESS,
latency: successResults.length > 0 ? Math.min(...successResults.map((r) => r.latency!)) : undefined
}
}
/**
* 将多个模型的健康检查结果汇总为字符串
*/
export function summarizeHealthResults(results: ModelWithStatus[], providerName?: string): string {
const t = i18n.t
let successCount = 0
let partialCount = 0
let failedCount = 0
for (const result of results) {
if (result.status === HealthStatus.SUCCESS) {
successCount++
} else if (result.status === HealthStatus.FAILED) {
const hasSuccessKey = result.keyResults.some((r) => r.status === HealthStatus.SUCCESS)
if (hasSuccessKey) {
partialCount++
} else {
failedCount++
}
}
}
const summaryParts: string[] = []
if (successCount > 0) {
summaryParts.push(t('settings.models.check.model_status_passed', { count: successCount }))
}
if (partialCount > 0) {
summaryParts.push(t('settings.models.check.model_status_partial', { count: partialCount }))
}
if (failedCount > 0) {
summaryParts.push(t('settings.models.check.model_status_failed', { count: failedCount }))
}
if (summaryParts.length === 0) {
return t('settings.models.check.no_results')
}
const summary = summaryParts.join(', ')
return t('settings.models.check.model_status_summary', {
provider: providerName ?? 'Unknown Provider',
summary
})
}