mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 06:30:10 +08:00
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:
parent
f13ae2d3c1
commit
2b0c46bfdb
10
SECURITY.md
10
SECURITY.md
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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> - 每一次渲染帧的耗时。 |
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export { default as HealthStatusIndicator } from './indicator'
|
||||||
|
export * from './types'
|
||||||
@ -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)
|
||||||
12
src/renderer/src/components/HealthStatusIndicator/types.ts
Normal file
12
src/renderer/src/components/HealthStatusIndicator/types.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
@ -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"
|
||||||
188
src/renderer/src/components/ModelList/ModelList.tsx
Normal file
188
src/renderer/src/components/ModelList/ModelList.tsx
Normal 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)
|
||||||
84
src/renderer/src/components/ModelList/ModelListGroup.tsx
Normal file
84
src/renderer/src/components/ModelList/ModelListGroup.tsx
Normal 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)
|
||||||
86
src/renderer/src/components/ModelList/ModelListItem.tsx
Normal file
86
src/renderer/src/components/ModelList/ModelListItem.tsx
Normal 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)
|
||||||
7
src/renderer/src/components/ModelList/index.ts
Normal file
7
src/renderer/src/components/ModelList/index.ts
Normal 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'
|
||||||
97
src/renderer/src/components/ModelList/useHealthCheck.ts
Normal file
97
src/renderer/src/components/ModelList/useHealthCheck.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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 格式有效性
|
||||||
|
|||||||
@ -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}}?",
|
||||||
|
|||||||
@ -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 キーを追加しますか?",
|
||||||
|
|||||||
@ -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}}?",
|
||||||
|
|||||||
@ -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 密钥?",
|
||||||
|
|||||||
@ -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 密鑰?",
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
52
src/renderer/src/types/healthCheck.ts
Normal file
52
src/renderer/src/types/healthCheck.ts
Normal 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
|
||||||
|
}
|
||||||
78
src/renderer/src/utils/healthCheck.ts
Normal file
78
src/renderer/src/utils/healthCheck.ts
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user