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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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. We aim to support the latest released version and one previous minor release.
| Version | Supported | | Version | Supported |
|-----------------|--------------------| | --------------- | ---------------- |
| Latest (`main`) | ✅ Supported | | Latest (`main`) | ✅ Supported |
| Previous minor | ✅ Supported | | Previous minor | ✅ Supported |
| Older versions | ❌ Not supported | | Older versions | ❌ Not supported |
If you are using an unsupported version, we strongly recommend updating to the latest release to receive security fixes. 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. 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: The following examples show how to use `logger.info` and `logger.error`. Other levels are used in the same way:
```typescript ```typescript
logger.info('message', CONTEXT) logger.info('message', CONTEXT)
logger.info('message %s %d', 'hello', 123, CONTEXT) logger.info('message %s %d', 'hello', 123, CONTEXT)
logger.error('message', new Error('error message'), CONTEXT) logger.error('message', new Error('error message'), CONTEXT)
``` ```
- `message` is a required string. All other options are optional. - `message` is a required string. All other options are optional.
- `CONTEXT` as `{ key: value, ... }` is optional and will be recorded in the log file. - `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. - 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. - 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: Changing the log level:
- You can change the log level with `logger.setLevel('newLevel')`. - You can change the log level with `logger.setLevel('newLevel')`.
- `logger.resetLevel()` resets it to the default level. - `logger.resetLevel()` resets it to the default level.
- `logger.getLevel()` gets the current log 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
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. The following section focuses on the differences.
### `initWindowSource` ### `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. 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. - 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` 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. - `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.resetLogToMainLevel()
logger.getLogToMainLevel() 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 ##### Per-log Change

View File

@ -6,12 +6,11 @@ CherryStudio使用统一的日志服务来打印和记录日志**若无特殊
以下是详细说明 以下是详细说明
## 在`main`进程中使用 ## 在`main`进程中使用
### 引入 ### 引入
``` typescript ```typescript
import { loggerService } from '@logger' import { loggerService } from '@logger'
``` ```
@ -19,7 +18,7 @@ import { loggerService } from '@logger'
在import头之后设置 在import头之后设置
``` typescript ```typescript
const logger = loggerService.withContext('moduleName') const logger = loggerService.withContext('moduleName')
``` ```
@ -30,7 +29,7 @@ const logger = loggerService.withContext('moduleName')
在`withContext`中,也可以设置其他`CONTEXT`信息: 在`withContext`中,也可以设置其他`CONTEXT`信息:
``` typescript ```typescript
const logger = loggerService.withContext('moduleName', CONTEXT) const logger = loggerService.withContext('moduleName', CONTEXT)
``` ```
@ -43,11 +42,13 @@ const logger = loggerService.withContext('moduleName', CONTEXT)
各级别的含义,请参考下面的章节。 各级别的含义,请参考下面的章节。
以下以 `logger.info``logger.error` 举例如何使用,其他级别是一样的: 以下以 `logger.info``logger.error` 举例如何使用,其他级别是一样的:
``` typescript
```typescript
logger.info('message', CONTEXT) logger.info('message', CONTEXT)
logger.info('message %s %d', 'hello', 123, CONTEXT) logger.info('message %s %d', 'hello', 123, CONTEXT)
logger.error('message', new Error('error message'), CONTEXT) logger.error('message', new Error('error message'), CONTEXT)
``` ```
- `message` 是必填的,`string`类型,其他选项都是可选的 - `message` 是必填的,`string`类型,其他选项都是可选的
- `CONTEXT`为`{ key: value, ...}` 是可选的,会在日志文件中记录 - `CONTEXT`为`{ key: value, ...}` 是可选的,会在日志文件中记录
- 如果传递了`Error`类型,会自动记录错误堆栈 - 如果传递了`Error`类型,会自动记录错误堆栈
@ -58,6 +59,7 @@ logger.error('message', new Error('error message'), CONTEXT)
- 生产环境下,默认记录级别为`info`,日志只会记录到文件,不会打印到终端 - 生产环境下,默认记录级别为`info`,日志只会记录到文件,不会打印到终端
更改日志记录级别: 更改日志记录级别:
- 可以通过 `logger.setLevel('newLevel')` 来更改日志记录级别 - 可以通过 `logger.setLevel('newLevel')` 来更改日志记录级别
- `logger.resetLevel()` 可以重置为默认级别 - `logger.resetLevel()` 可以重置为默认级别
- `logger.getLevel()` 可以获取当前记录记录级别 - `logger.getLevel()` 可以获取当前记录记录级别
@ -66,7 +68,7 @@ logger.error('message', new Error('error message'), CONTEXT)
## 在`renderer`进程中使用 ## 在`renderer`进程中使用
在`renderer`进程中使用,*引入方法*、*设置`module`信息*、*设置`context`信息的方法*和`main`进程中是**完全一样**的。 在`renderer`进程中使用,_引入方法_、_设置`module`信息_、*设置`context`信息的方法*和`main`进程中是**完全一样**的。
下面着重讲一下不同之处。 下面着重讲一下不同之处。
### `initWindowSource` ### `initWindowSource`
@ -78,6 +80,7 @@ loggerService.initWindowSource('windowName')
``` ```
原则上,我们将在`window`的`entryPoint.tsx`中进行设置,这可以保证`windowName`在开始使用前已经设置好了。 原则上,我们将在`window`的`entryPoint.tsx`中进行设置,这可以保证`windowName`在开始使用前已经设置好了。
- 未设置`windowName`会报错,`logger`将不起作用 - 未设置`windowName`会报错,`logger`将不起作用
- `windowName`只能设置一次,重复设置将不生效 - `windowName`只能设置一次,重复设置将不生效
- `windowName`不会在`devTool`的`console`中打印出来,但是会在`main`进程的终端和文件日志中记录 - `windowName`不会在`devTool`的`console`中打印出来,但是会在`main`进程的终端和文件日志中记录
@ -110,8 +113,8 @@ logger.setLogToMainLevel('newLevel')
logger.resetLogToMainLevel() logger.resetLogToMainLevel()
logger.getLogToMainLevel() logger.getLogToMainLevel()
``` ```
**注意** 该方法是全局生效的,请不要在代码中随意更改,除非你非常清楚自己在做什么
**注意** 该方法是全局生效的,请不要在代码中随意更改,除非你非常清楚自己在做什么
##### 单条更改 ##### 单条更改
@ -165,11 +168,11 @@ CSLOGGER_MAIN_SHOW_MODULES=MCPService,SelectionService
日志有很多级别什么时候应该用哪个级别下面是在CherryStudio中应该遵循的规范 日志有很多级别什么时候应该用哪个级别下面是在CherryStudio中应该遵循的规范
(按日志级别从高到低排列) (按日志级别从高到低排列)
| 日志级别 | 核心定义与使用场景 | 示例 | | 日志级别 | 核心定义与使用场景 | 示例 |
| :------------ | :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | | :------------ | :------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`error`** | **严重错误,导致程序崩溃或核心功能无法使用。** <br> 这是最高优的日志,通常需要立即上报或提示用户。 | - 主进程或渲染进程崩溃。 <br> - 无法读写用户关键数据文件(如数据库、配置文件),导致应用无法运行。<br> - 所有未捕获的异常。` | | **`error`** | **严重错误,导致程序崩溃或核心功能无法使用。** <br> 这是最高优的日志,通常需要立即上报或提示用户。 | - 主进程或渲染进程崩溃。 <br> - 无法读写用户关键数据文件(如数据库、配置文件),导致应用无法运行。<br> - 所有未捕获的异常。` |
| **`warn`** | **潜在问题或非预期情况,但不影响程序核心功能。** <br> 程序可以从中恢复或使用备用方案。 | - 配置文件 `settings.json` 缺失,已使用默认配置启动。 <br> - 自动更新检查失败,但不影响当前版本使用。<br> - 某个非核心插件加载失败。` | | **`warn`** | **潜在问题或非预期情况,但不影响程序核心功能。** <br> 程序可以从中恢复或使用备用方案。 | - 配置文件 `settings.json` 缺失,已使用默认配置启动。 <br> - 自动更新检查失败,但不影响当前版本使用。<br> - 某个非核心插件加载失败。` |
| **`info`** | **记录应用生命周期和关键用户行为。** <br> 这是发布版中默认应记录的级别,用于追踪用户的主要操作路径。 | - 应用启动、退出。<br> - 用户成功打开/保存文件。 <br> - 主窗口创建/关闭。<br> - 开始执行一项重要任务(如“开始导出视频”)。` | | **`info`** | **记录应用生命周期和关键用户行为。** <br> 这是发布版中默认应记录的级别,用于追踪用户的主要操作路径。 | - 应用启动、退出。<br> - 用户成功打开/保存文件。 <br> - 主窗口创建/关闭。<br> - 开始执行一项重要任务(如“开始导出视频”)。` |
| **`verbose`** | **比 `info` 更详细的流程信息,用于追踪特定功能。** <br> 在诊断特定功能问题时开启,帮助理解内部执行流程。 | - 正在加载 `Toolbar` 模块。 <br> - IPC 消息 `open-file-dialog` 已从渲染进程发送。<br> - 正在应用滤镜 'Sepia' 到图像。` | | **`verbose`** | **比 `info` 更详细的流程信息,用于追踪特定功能。** <br> 在诊断特定功能问题时开启,帮助理解内部执行流程。 | - 正在加载 `Toolbar` 模块。 <br> - IPC 消息 `open-file-dialog` 已从渲染进程发送。<br> - 正在应用滤镜 'Sepia' 到图像。` |
| **`debug`** | **开发和调试时使用的详细诊断信息。** <br> **严禁在发布版中默认开启**,因为它可能包含敏感数据并影响性能。 | - 函数 `renderImage` 的入参: `{ width: 800, ... }`<br> - IPC 消息 `save-file` 收到的具体数据内容。<br> - 渲染进程中 Redux/Vuex 的 state 变更详情。` | | **`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 => { export const createSimpleLoggingMiddleware = (): CompletionsMiddleware => {
return (api: MiddlewareAPI<AiProviderMiddlewareCompletionsContext, [CompletionsParams]>) => { return (api: MiddlewareAPI<AiProviderMiddlewareCompletionsContext, [CompletionsParams]>) => {
return (next: (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<any>) => { return (next: (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<any>) => {
return async (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams): Promise<void> => { return async (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams): Promise<void> => {
const startTime = Date.now() 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 React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface ModelListSearchBarProps { interface CollapsibleSearchBarProps {
onSearch: (text: string) => void 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 * 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 { t } = useTranslation()
const [searchVisible, setSearchVisible] = useState(false) const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
@ -44,7 +46,7 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
initial="collapsed" initial="collapsed"
animate={searchVisible ? 'expanded' : 'collapsed'} animate={searchVisible ? 'expanded' : 'collapsed'}
variants={{ 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' } } collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
}} }}
style={{ overflow: 'hidden', flex: 1 }}> style={{ overflow: 'hidden', flex: 1 }}>
@ -53,7 +55,7 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
type="text" type="text"
placeholder={t('models.search')} placeholder={t('models.search')}
size="small" size="small"
suffix={<Search size={14} />} suffix={icon || <Search size={14} color="var(--color-icon)" />}
value={searchText} value={searchText}
autoFocus autoFocus
allowClear allowClear
@ -80,12 +82,12 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
}} }}
style={{ cursor: 'pointer', display: 'flex' }} style={{ cursor: 'pointer', display: 'flex' }}
onClick={() => setSearchVisible(true)}> onClick={() => setSearchVisible(true)}>
<Tooltip title={t('models.search')} mouseEnterDelay={0.5}> <Tooltip title={t('models.search')} mouseLeaveDelay={0}>
<Search size={14} color="var(--color-icon)" /> {icon || <Search size={14} color="var(--color-icon)" />}
</Tooltip> </Tooltip>
</motion.div> </motion.div>
</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 CustomTag from '@renderer/components/CustomTag'
import ExpandableText from '@renderer/components/ExpandableText' import ExpandableText from '@renderer/components/ExpandableText'
import ModelIdWithTags from '@renderer/components/ModelIdWithTags' 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 Scrollbar from '@renderer/components/Scrollbar'
import { TopView } from '@renderer/components/TopView'
import { import {
getModelLogo, getModelLogo,
groupQwenModels, groupQwenModels,
@ -18,8 +21,6 @@ import {
} from '@renderer/config/models' } from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider' import { useProvider } from '@renderer/hooks/useProvider'
import FileItem from '@renderer/pages/files/FileItem' 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 { fetchModels } from '@renderer/services/ApiService'
import { Model, Provider } from '@renderer/types' import { Model, Provider } from '@renderer/types'
import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { TopView } from '../../../components/TopView'
const logger = loggerService.withContext('EditModelsPopup') const logger = loggerService.withContext('EditModelsPopup')
interface ShowParams { interface ShowParams {
provider: Provider provider: Provider
} }
@ -218,7 +218,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
title={ title={
isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed') isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed')
} }
mouseEnterDelay={0.5} mouseLeaveDelay={0}
placement="top"> placement="top">
<Button <Button
type="default" type="default"
@ -245,11 +245,11 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
} }
} }
}} }}
disabled={list.length === 0} disabled={loading || list.length === 0}
/> />
</Tooltip> </Tooltip>
) )
}, [list, t, provider, onRemoveModel, models, onAddModel]) }, [list, t, loading, provider, onRemoveModel, models, onAddModel])
const renderGroupTools = useCallback( const renderGroupTools = useCallback(
(group: string) => { (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.remove_whole_group`)
: t(`settings.models.manage.add_whole_group`) : t(`settings.models.manage.add_whole_group`)
} }
mouseEnterDelay={0.5} mouseLeaveDelay={0}
placement="top"> placement="top">
<Button <Button
type="text" 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 { checkApi } from '@renderer/services/ApiService'
import WebSearchService from '@renderer/services/WebSearchService' import WebSearchService from '@renderer/services/WebSearchService'
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
import { ApiKeyConnectivity, ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck'
import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api' import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api'
import { formatErrorMessage } from '@renderer/utils/error' import { formatErrorMessage } from '@renderer/utils/error'
import { TFunction } from 'i18next' import { TFunction } from 'i18next'
@ -11,7 +12,7 @@ import { isEmpty } from 'lodash'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ApiKeyConnectivity, ApiKeyValidity, ApiKeyWithStatus, ApiProviderKind, ApiProviderUnion } from './types' import { ApiKeyValidity, ApiProviderKind, ApiProviderUnion } from './types'
interface UseApiKeysProps { interface UseApiKeysProps {
provider: ApiProviderUnion provider: ApiProviderUnion
@ -52,7 +53,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
const keysWithStatus = useMemo((): ApiKeyWithStatus[] => { const keysWithStatus = useMemo((): ApiKeyWithStatus[] => {
return keys.map((key) => { return keys.map((key) => {
const connectivityState = connectivityStates.get(key) || { const connectivityState = connectivityStates.get(key) || {
status: 'not_checked' as const, status: HealthStatus.NOT_CHECKED,
checking: false, checking: false,
error: undefined, error: undefined,
model: undefined, model: undefined,
@ -70,7 +71,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
setConnectivityStates((prev) => { setConnectivityStates((prev) => {
const newMap = new Map(prev) const newMap = new Map(prev)
const currentState = prev.get(key) || { const currentState = prev.get(key) || {
status: 'not_checked' as const, status: HealthStatus.NOT_CHECKED,
checking: false, checking: false,
error: undefined, error: undefined,
model: undefined, model: undefined,
@ -170,10 +171,12 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
// 移除连通性检查失败的 keys // 移除连通性检查失败的 keys
const removeInvalidKeys = useCallback(() => { 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 的连通性状态 // 清除被删除的 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) => { setConnectivityStates((prev) => {
const newMap = new Map(prev) const newMap = new Map(prev)
@ -207,7 +210,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
// 连通性检查成功 // 连通性检查成功
updateConnectivityState(keyToCheck, { updateConnectivityState(keyToCheck, {
checking: false, checking: false,
status: 'success', status: HealthStatus.SUCCESS,
model, model,
latency, latency,
error: undefined error: undefined
@ -216,7 +219,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
// 连通性检查失败 // 连通性检查失败
updateConnectivityState(keyToCheck, { updateConnectivityState(keyToCheck, {
checking: false, checking: false,
status: 'error', status: HealthStatus.FAILED,
error: formatErrorMessage(error), error: formatErrorMessage(error),
model: undefined, model: undefined,
latency: 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 { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { ApiKeyWithStatus } from '@renderer/types/healthCheck'
import { maskApiKey } from '@renderer/utils/api' import { maskApiKey } from '@renderer/utils/api'
import { Button, Flex, Input, InputRef, List, Popconfirm, Tooltip, Typography } from 'antd' import { Button, Flex, Input, InputRef, List, Popconfirm, Tooltip, Typography } from 'antd'
import { Check, PenLine, X } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import { ApiKeyValidity, ApiKeyWithStatus } from './types' import { ApiKeyValidity } from './types'
export interface ApiKeyItemProps { export interface ApiKeyItemProps {
keyStatus: ApiKeyWithStatus keyStatus: ApiKeyWithStatus
@ -39,9 +41,6 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
const inputRef = useRef<InputRef>(null) const inputRef = useRef<InputRef>(null)
const disabled = keyStatus.checking || _disabled 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(() => { useEffect(() => {
if (isEditing && inputRef.current) { if (isEditing && inputRef.current) {
@ -83,41 +82,14 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
} }
} }
const renderStatusIcon = () => { const healthResults: HealthResult[] = [
if (keyStatus.checking || isNotChecked) return null {
status: keyStatus.status,
const StatusIcon = isSuccess ? CheckCircleFilled : CloseCircleFilled latency: keyStatus.latency,
return <StatusIcon style={{ color: statusColor }} /> error: keyStatus.error,
} label: keyStatus.model?.name
const renderKeyCheckResultTooltip = () => {
if (keyStatus.checking) {
return t('settings.models.check.checking')
} }
]
if (isNotChecked) {
return ''
}
const statusTitle = isSuccess ? t('settings.models.check.passed') : t('settings.models.check.failed')
return (
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px', wordWrap: 'break-word' }}>
<strong style={{ color: statusColor }}>{statusTitle}</strong>
{keyStatus.model && (
<div style={{ marginTop: 5 }}>
{t('common.model')}: {keyStatus.model.name}
</div>
)}
{keyStatus.latency && isSuccess && (
<div style={{ marginTop: 5 }}>
{t('settings.provider.api.key.check.latency')}: {(keyStatus.latency / 1000).toFixed(2)}s
</div>
)}
{keyStatus.error && <div style={{ marginTop: 5 }}>{keyStatus.error}</div>}
</div>
)
}
return ( return (
<List.Item> <List.Item>
@ -163,9 +135,7 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
</Tooltip> </Tooltip>
<Flex gap={10} align="center"> <Flex gap={10} align="center">
<Tooltip title={renderKeyCheckResultTooltip()} styles={{ body: { userSelect: 'text' } }}> <HealthStatusIndicator results={healthResults} loading={false} />
{renderStatusIcon()}
</Tooltip>
<Flex gap={0} align="center"> <Flex gap={0} align="center">
{showHealthCheck && ( {showHealthCheck && (
@ -200,14 +170,10 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
) )
} }
const ItemInnerContainer = styled.div` const ItemInnerContainer = styled(Flex)`
display: flex; flex: 1;
flex-direction: row;
align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%; align-items: center;
padding: 0;
margin: 0;
` `
export default memo(ApiKeyItem) export default memo(ApiKeyItem)

View File

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

View File

@ -1,22 +1,4 @@
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types' import { PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
/**
* API Key
*/
export type ApiKeyConnectivity = {
status: 'success' | 'error' | 'not_checked'
checking?: boolean
error?: string
model?: Model
latency?: number
}
/**
* API key
*/
export type ApiKeyWithStatus = {
key: string
} & ApiKeyConnectivity
/** /**
* API key * API key

View File

@ -2165,6 +2165,7 @@
"models.manage.add_listed": "Add models to the list", "models.manage.add_listed": "Add models to the list",
"models.manage.add_whole_group": "Add the whole group", "models.manage.add_whole_group": "Add the whole group",
"models.manage.remove_listed": "Remove models from the list", "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.manage.remove_whole_group": "Remove the whole group",
"models.provider_id": "Provider ID", "models.provider_id": "Provider ID",
"models.provider_key_add_confirm": "Do you want to add the API key for {{provider}}?", "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_listed": "リストにモデルを追加",
"models.manage.add_whole_group": "グループ全体を追加", "models.manage.add_whole_group": "グループ全体を追加",
"models.manage.remove_listed": "リストからモデルを削除", "models.manage.remove_listed": "リストからモデルを削除",
"models.manage.remove_model": "モデルを削除",
"models.manage.remove_whole_group": "グループ全体を削除", "models.manage.remove_whole_group": "グループ全体を削除",
"models.provider_id": "プロバイダー ID", "models.provider_id": "プロバイダー ID",
"models.provider_key_add_confirm": "{{provider}} の API キーを追加しますか?", "models.provider_key_add_confirm": "{{provider}} の API キーを追加しますか?",

View File

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

View File

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

View File

@ -2165,6 +2165,7 @@
"models.manage.add_listed": "添加列表中的模型", "models.manage.add_listed": "添加列表中的模型",
"models.manage.add_whole_group": "新增整個分組", "models.manage.add_whole_group": "新增整個分組",
"models.manage.remove_listed": "移除列表中的模型", "models.manage.remove_listed": "移除列表中的模型",
"models.manage.remove_model": "移除模型",
"models.manage.remove_whole_group": "移除整個分組", "models.manage.remove_whole_group": "移除整個分組",
"models.provider_id": "提供者 ID", "models.provider_id": "提供者 ID",
"models.provider_key_add_confirm": "是否要為 {{provider}} 添加 API 密鑰?", "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 { CheckOutlined, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import { HStack } from '@renderer/components/Layout' 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 { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { PROVIDER_CONFIG } from '@renderer/config/providers' import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { checkApi } from '@renderer/services/ApiService' import { checkApi } from '@renderer/services/ApiService'
import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService'
import { isProviderSupportAuth } from '@renderer/services/ProviderService' import { isProviderSupportAuth } from '@renderer/services/ProviderService'
import { import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck'
formatApiHost, import { formatApiHost, formatApiKeys, getFancyProviderName, isOpenAIProvider } from '@renderer/utils'
formatApiKeys,
getFancyProviderName,
isOpenAIProvider,
splitApiKeyString
} from '@renderer/utils'
import { formatErrorMessage } from '@renderer/utils/error' import { formatErrorMessage } from '@renderer/utils/error'
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd' import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
import Link from 'antd/es/typography/Link' import Link from 'antd/es/typography/Link'
import { debounce, isEmpty } from 'lodash' import { debounce, isEmpty } from 'lodash'
import { Settings2, SquareArrowOutUpRight } from 'lucide-react' import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
import { motion } from 'motion/react' import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { FC, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -41,10 +33,7 @@ import CustomHeaderPopup from './CustomHeaderPopup'
import DMXAPISettings from './DMXAPISettings' import DMXAPISettings from './DMXAPISettings'
import GithubCopilotSettings from './GithubCopilotSettings' import GithubCopilotSettings from './GithubCopilotSettings'
import GPUStackSettings from './GPUStackSettings' import GPUStackSettings from './GPUStackSettings'
import HealthCheckPopup from './HealthCheckPopup'
import LMStudioSettings from './LMStudioSettings' import LMStudioSettings from './LMStudioSettings'
import ModelList, { ModelStatus } from './ModelList'
import ModelListSearchBar from './ModelListSearchBar'
import ProviderOAuth from './ProviderOAuth' import ProviderOAuth from './ProviderOAuth'
import ProviderSettingsPopup from './ProviderSettingsPopup' import ProviderSettingsPopup from './ProviderSettingsPopup'
import SelectProviderModelPopup from './SelectProviderModelPopup' import SelectProviderModelPopup from './SelectProviderModelPopup'
@ -60,8 +49,6 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const { updateProviders } = useProviders() const { updateProviders } = useProviders()
const [apiHost, setApiHost] = useState(provider.apiHost) const [apiHost, setApiHost] = useState(provider.apiHost)
const [apiVersion, setApiVersion] = useState(provider.apiVersion) const [apiVersion, setApiVersion] = useState(provider.apiVersion)
const [modelSearchText, setModelSearchText] = useState('')
const deferredModelSearchText = useDeferredValue(modelSearchText)
const { t } = useTranslation() const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
@ -74,14 +61,11 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const apiKeyWebsite = providerConfig?.websites?.apiKey const apiKeyWebsite = providerConfig?.websites?.apiKey
const configedApiHost = providerConfig?.api?.url const configedApiHost = providerConfig?.api?.url
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
const [isHealthChecking, setIsHealthChecking] = useState(false)
const fancyProviderName = getFancyProviderName(provider) const fancyProviderName = getFancyProviderName(provider)
const [localApiKey, setLocalApiKey] = useState(provider.apiKey) const [localApiKey, setLocalApiKey] = useState(provider.apiKey)
const [apiKeyConnectivity, setApiKeyConnectivity] = useState<ApiKeyConnectivity>({ const [apiKeyConnectivity, setApiKeyConnectivity] = useState<ApiKeyConnectivity>({
status: 'not_checked', status: HealthStatus.NOT_CHECKED,
checking: false checking: false
}) })
@ -97,7 +81,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
// 重置连通性检查状态 // 重置连通性检查状态
useEffect(() => { useEffect(() => {
setLocalApiKey(provider.apiKey) setLocalApiKey(provider.apiKey)
setApiKeyConnectivity({ status: 'not_checked' }) setApiKeyConnectivity({ status: HealthStatus.NOT_CHECKED })
}, [provider.apiKey]) }, [provider.apiKey])
// 同步 localApiKey 到 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 () => { const onCheckApi = async () => {
// 如果存在多个密钥,直接打开管理窗口 // 如果存在多个密钥,直接打开管理窗口
if (provider.apiKey.includes(',')) { if (provider.apiKey.includes(',')) {
@ -251,7 +158,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
} }
try { try {
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: 'not_checked' })) setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: HealthStatus.NOT_CHECKED }))
await checkApi({ ...provider, apiHost }, model) await checkApi({ ...provider, apiHost }, model)
window.message.success({ window.message.success({
@ -261,9 +168,9 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
content: i18n.t('message.api.connection.success') content: i18n.t('message.api.connection.success')
}) })
setApiKeyConnectivity((prev) => ({ ...prev, status: 'success' })) setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.SUCCESS }))
setTimeout(() => { setTimeout(() => {
setApiKeyConnectivity((prev) => ({ ...prev, status: 'not_checked' })) setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.NOT_CHECKED }))
}, 3000) }, 3000)
} catch (error: any) { } catch (error: any) {
window.message.error({ window.message.error({
@ -273,7 +180,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
content: i18n.t('message.api.connection.failed') 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 { } finally {
setApiKeyConnectivity((prev) => ({ ...prev, checking: false })) setApiKeyConnectivity((prev) => ({ ...prev, checking: false }))
} }
@ -300,7 +207,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
// API key 连通性检查状态指示器,目前仅在失败时显示 // API key 连通性检查状态指示器,目前仅在失败时显示
const renderStatusIndicator = () => { const renderStatusIndicator = () => {
if (apiKeyConnectivity.checking || apiKeyConnectivity.status !== 'error') { if (apiKeyConnectivity.checking || apiKeyConnectivity.status !== HealthStatus.FAILED) {
return null return null
} }
@ -466,32 +373,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
{provider.id === 'gpustack' && <GPUStackSettings />} {provider.id === 'gpustack' && <GPUStackSettings />}
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />} {provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
{provider.id === 'vertexai' && <VertexAISettings />} {provider.id === 'vertexai' && <VertexAISettings />}
<SettingSubtitle style={{ marginBottom: 5 }}> <ModelList providerId={provider.id} />
<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} />
</SettingContainer> </SettingContainer>
) )
} }

View File

@ -1,264 +1,92 @@
import { loggerService } from '@logger' import { loggerService } from '@logger'
import i18n from '@renderer/i18n'
import { Model, Provider } from '@renderer/types' 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' import { checkModel } from './ModelService'
const logger = loggerService.withContext('HealthCheckService') const logger = loggerService.withContext('HealthCheckService')
/** /**
* Model check status states * API
*/
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( export async function checkModelWithMultipleKeys(
provider: Provider, provider: Provider,
model: Model, model: Model,
apiKeys: string[], apiKeys: string[]
isParallel: boolean ): Promise<ApiKeyWithStatus[]> {
): Promise<Omit<ModelCheckResult, 'model' | 'status' | 'error'>> { const checkPromises = apiKeys.map(async (key) => {
let keyResults: ApiKeyCheckStatus[] = [] const startTime = Date.now()
// 如果 checkModel 抛出错误,让这个 promise 失败
await checkModel({ ...provider, apiKey: key }, model)
const latency = Date.now() - startTime
if (isParallel) { return {
// Check all API keys in parallel key,
const keyPromises = apiKeys.map(async (key) => { status: HealthStatus.SUCCESS,
try { latency
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
}
})
const results = await Promise.allSettled(keyPromises) const results = await Promise.allSettled(checkPromises)
// Process results return results.map((result, index) => {
keyResults = results.map((result) => { if (result.status === 'fulfilled') {
if (result.status === 'fulfilled') { return result.value
return result.value } else {
} else { return {
return { key: apiKeys[index], // 对应失败的 promise 的 key
key: 'unknown', // This should not happen since we've caught errors internally status: HealthStatus.FAILED,
isValid: false, error: formatErrorMessage(result.reason)
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) + '...'
})
} }
} }
} })
// 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( export async function checkModelsHealth(
options: ModelCheckOptions, options: ModelCheckOptions,
onModelChecked?: (result: ModelCheckResult, index: number) => void onModelChecked?: (result: ModelWithStatus, index: number) => void
): Promise<ModelCheckResult[]> { ): Promise<ModelWithStatus[]> {
const { provider, models, apiKeys, isConcurrent } = options const { provider, models, apiKeys, isConcurrent } = options
const results: ModelWithStatus[] = []
// Results array
const results: ModelCheckResult[] = []
try { try {
if (isConcurrent) { const modelPromises = models.map(async (model, index) => {
// Check all models concurrently const keyResults = await checkModelWithMultipleKeys(provider, model, apiKeys)
const modelPromises = models.map(async (model, index) => { const analysis = aggregateApiKeyResults(keyResults)
const checkResult = await checkModelWithMultipleKeys(provider, model, apiKeys, true)
const analysisResult = analyzeModelCheckResult({
model,
...checkResult,
status: undefined,
error: undefined
})
const result: ModelCheckResult = { const result: ModelWithStatus = {
model, model,
...checkResult, keyResults,
status: analysisResult.status, status: analysis.status,
error: analysisResult.error error: analysis.error,
} latency: analysis.latency
}
if (isConcurrent) {
results[index] = result results[index] = result
} else {
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) results.push(result)
}
if (onModelChecked) { onModelChecked?.(result, index)
onModelChecked(result, i) return result
} })
if (isConcurrent) {
await Promise.all(modelPromises)
} else {
for (const promise of modelPromises) {
await promise
} }
} }
} catch (error) { } catch (error) {
logger.error('Model health check failed:', error) logger.error('[HealthCheckService] Model health check failed:', error)
} }
return results 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
})
}