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:
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.
|
||||
|
||||
| Version | Supported |
|
||||
|-----------------|--------------------|
|
||||
| Latest (`main`) | ✅ Supported |
|
||||
| Previous minor | ✅ Supported |
|
||||
| Older versions | ❌ Not supported |
|
||||
| Version | Supported |
|
||||
| --------------- | ---------------- |
|
||||
| Latest (`main`) | ✅ Supported |
|
||||
| Previous minor | ✅ Supported |
|
||||
| Older versions | ❌ Not supported |
|
||||
|
||||
If you are using an unsupported version, we strongly recommend updating to the latest release to receive security fixes.
|
||||
|
||||
|
||||
@@ -42,11 +42,13 @@ In your code, you can call `logger` at any time to record logs. The supported me
|
||||
For the meaning of each level, please refer to the section below.
|
||||
|
||||
The following examples show how to use `logger.info` and `logger.error`. Other levels are used in the same way:
|
||||
|
||||
```typescript
|
||||
logger.info('message', CONTEXT)
|
||||
logger.info('message %s %d', 'hello', 123, CONTEXT)
|
||||
logger.error('message', new Error('error message'), CONTEXT)
|
||||
```
|
||||
|
||||
- `message` is a required string. All other options are optional.
|
||||
- `CONTEXT` as `{ key: value, ... }` is optional and will be recorded in the log file.
|
||||
- If an `Error` type is passed, the error stack will be automatically recorded.
|
||||
@@ -57,6 +59,7 @@ logger.error('message', new Error('error message'), CONTEXT)
|
||||
- In the production environment, the default log level is `info`. Logs are only recorded to the file and are not printed to the terminal.
|
||||
|
||||
Changing the log level:
|
||||
|
||||
- You can change the log level with `logger.setLevel('newLevel')`.
|
||||
- `logger.resetLevel()` resets it to the default level.
|
||||
- `logger.getLevel()` gets the current log level.
|
||||
@@ -65,7 +68,7 @@ Changing the log level:
|
||||
|
||||
## Usage in the `renderer` process
|
||||
|
||||
Usage in the `renderer` process for *importing*, *setting module information*, and *setting context information* is **exactly the same** as in the `main` process.
|
||||
Usage in the `renderer` process for _importing_, _setting module information_, and _setting context information_ is **exactly the same** as in the `main` process.
|
||||
The following section focuses on the differences.
|
||||
|
||||
### `initWindowSource`
|
||||
@@ -77,6 +80,7 @@ loggerService.initWindowSource('windowName')
|
||||
```
|
||||
|
||||
As a rule, we will set this in the `window`'s `entryPoint.tsx`. This ensures that `windowName` is set before it's used.
|
||||
|
||||
- An error will be thrown if `windowName` is not set, and the `logger` will not work.
|
||||
- `windowName` can only be set once; subsequent attempts to set it will have no effect.
|
||||
- `windowName` will not be printed in the `devTool`'s `console`, but it will be recorded in the `main` process terminal and the file log.
|
||||
@@ -109,8 +113,8 @@ logger.setLogToMainLevel('newLevel')
|
||||
logger.resetLogToMainLevel()
|
||||
logger.getLogToMainLevel()
|
||||
```
|
||||
**Note:** This method has a global effect. Please do not change it arbitrarily in your code unless you are very clear about what you are doing.
|
||||
|
||||
**Note:** This method has a global effect. Please do not change it arbitrarily in your code unless you are very clear about what you are doing.
|
||||
|
||||
##### Per-log Change
|
||||
|
||||
|
||||
@@ -6,12 +6,11 @@ CherryStudio使用统一的日志服务来打印和记录日志,**若无特殊
|
||||
|
||||
以下是详细说明
|
||||
|
||||
|
||||
## 在`main`进程中使用
|
||||
|
||||
### 引入
|
||||
|
||||
``` typescript
|
||||
```typescript
|
||||
import { loggerService } from '@logger'
|
||||
```
|
||||
|
||||
@@ -19,7 +18,7 @@ import { loggerService } from '@logger'
|
||||
|
||||
在import头之后,设置:
|
||||
|
||||
``` typescript
|
||||
```typescript
|
||||
const logger = loggerService.withContext('moduleName')
|
||||
```
|
||||
|
||||
@@ -30,7 +29,7 @@ const logger = loggerService.withContext('moduleName')
|
||||
|
||||
在`withContext`中,也可以设置其他`CONTEXT`信息:
|
||||
|
||||
``` typescript
|
||||
```typescript
|
||||
const logger = loggerService.withContext('moduleName', CONTEXT)
|
||||
```
|
||||
|
||||
@@ -43,11 +42,13 @@ const logger = loggerService.withContext('moduleName', CONTEXT)
|
||||
各级别的含义,请参考下面的章节。
|
||||
|
||||
以下以 `logger.info` 和 `logger.error` 举例如何使用,其他级别是一样的:
|
||||
``` typescript
|
||||
|
||||
```typescript
|
||||
logger.info('message', CONTEXT)
|
||||
logger.info('message %s %d', 'hello', 123, CONTEXT)
|
||||
logger.error('message', new Error('error message'), CONTEXT)
|
||||
```
|
||||
|
||||
- `message` 是必填的,`string`类型,其他选项都是可选的
|
||||
- `CONTEXT`为`{ key: value, ...}` 是可选的,会在日志文件中记录
|
||||
- 如果传递了`Error`类型,会自动记录错误堆栈
|
||||
@@ -58,6 +59,7 @@ logger.error('message', new Error('error message'), CONTEXT)
|
||||
- 生产环境下,默认记录级别为`info`,日志只会记录到文件,不会打印到终端
|
||||
|
||||
更改日志记录级别:
|
||||
|
||||
- 可以通过 `logger.setLevel('newLevel')` 来更改日志记录级别
|
||||
- `logger.resetLevel()` 可以重置为默认级别
|
||||
- `logger.getLevel()` 可以获取当前记录记录级别
|
||||
@@ -66,7 +68,7 @@ logger.error('message', new Error('error message'), CONTEXT)
|
||||
|
||||
## 在`renderer`进程中使用
|
||||
|
||||
在`renderer`进程中使用,*引入方法*、*设置`module`信息*、*设置`context`信息的方法*和`main`进程中是**完全一样**的。
|
||||
在`renderer`进程中使用,_引入方法_、_设置`module`信息_、*设置`context`信息的方法*和`main`进程中是**完全一样**的。
|
||||
下面着重讲一下不同之处。
|
||||
|
||||
### `initWindowSource`
|
||||
@@ -78,6 +80,7 @@ loggerService.initWindowSource('windowName')
|
||||
```
|
||||
|
||||
原则上,我们将在`window`的`entryPoint.tsx`中进行设置,这可以保证`windowName`在开始使用前已经设置好了。
|
||||
|
||||
- 未设置`windowName`会报错,`logger`将不起作用
|
||||
- `windowName`只能设置一次,重复设置将不生效
|
||||
- `windowName`不会在`devTool`的`console`中打印出来,但是会在`main`进程的终端和文件日志中记录
|
||||
@@ -110,8 +113,8 @@ logger.setLogToMainLevel('newLevel')
|
||||
logger.resetLogToMainLevel()
|
||||
logger.getLogToMainLevel()
|
||||
```
|
||||
**注意** 该方法是全局生效的,请不要在代码中随意更改,除非你非常清楚自己在做什么
|
||||
|
||||
**注意** 该方法是全局生效的,请不要在代码中随意更改,除非你非常清楚自己在做什么
|
||||
|
||||
##### 单条更改
|
||||
|
||||
@@ -165,11 +168,11 @@ CSLOGGER_MAIN_SHOW_MODULES=MCPService,SelectionService
|
||||
日志有很多级别,什么时候应该用哪个级别,下面是在CherryStudio中应该遵循的规范:
|
||||
(按日志级别从高到低排列)
|
||||
|
||||
| 日志级别 | 核心定义与使用场景 | 示例 |
|
||||
| :------------ | :------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`error`** | **严重错误,导致程序崩溃或核心功能无法使用。** <br> 这是最高优的日志,通常需要立即上报或提示用户。 | - 主进程或渲染进程崩溃。 <br> - 无法读写用户关键数据文件(如数据库、配置文件),导致应用无法运行。<br> - 所有未捕获的异常。` |
|
||||
| **`warn`** | **潜在问题或非预期情况,但不影响程序核心功能。** <br> 程序可以从中恢复或使用备用方案。 | - 配置文件 `settings.json` 缺失,已使用默认配置启动。 <br> - 自动更新检查失败,但不影响当前版本使用。<br> - 某个非核心插件加载失败。` |
|
||||
| **`info`** | **记录应用生命周期和关键用户行为。** <br> 这是发布版中默认应记录的级别,用于追踪用户的主要操作路径。 | - 应用启动、退出。<br> - 用户成功打开/保存文件。 <br> - 主窗口创建/关闭。<br> - 开始执行一项重要任务(如“开始导出视频”)。` |
|
||||
| **`verbose`** | **比 `info` 更详细的流程信息,用于追踪特定功能。** <br> 在诊断特定功能问题时开启,帮助理解内部执行流程。 | - 正在加载 `Toolbar` 模块。 <br> - IPC 消息 `open-file-dialog` 已从渲染进程发送。<br> - 正在应用滤镜 'Sepia' 到图像。` |
|
||||
| 日志级别 | 核心定义与使用场景 | 示例 |
|
||||
| :------------ | :------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`error`** | **严重错误,导致程序崩溃或核心功能无法使用。** <br> 这是最高优的日志,通常需要立即上报或提示用户。 | - 主进程或渲染进程崩溃。 <br> - 无法读写用户关键数据文件(如数据库、配置文件),导致应用无法运行。<br> - 所有未捕获的异常。` |
|
||||
| **`warn`** | **潜在问题或非预期情况,但不影响程序核心功能。** <br> 程序可以从中恢复或使用备用方案。 | - 配置文件 `settings.json` 缺失,已使用默认配置启动。 <br> - 自动更新检查失败,但不影响当前版本使用。<br> - 某个非核心插件加载失败。` |
|
||||
| **`info`** | **记录应用生命周期和关键用户行为。** <br> 这是发布版中默认应记录的级别,用于追踪用户的主要操作路径。 | - 应用启动、退出。<br> - 用户成功打开/保存文件。 <br> - 主窗口创建/关闭。<br> - 开始执行一项重要任务(如“开始导出视频”)。` |
|
||||
| **`verbose`** | **比 `info` 更详细的流程信息,用于追踪特定功能。** <br> 在诊断特定功能问题时开启,帮助理解内部执行流程。 | - 正在加载 `Toolbar` 模块。 <br> - IPC 消息 `open-file-dialog` 已从渲染进程发送。<br> - 正在应用滤镜 'Sepia' 到图像。` |
|
||||
| **`debug`** | **开发和调试时使用的详细诊断信息。** <br> **严禁在发布版中默认开启**,因为它可能包含敏感数据并影响性能。 | - 函数 `renderImage` 的入参: `{ width: 800, ... }`。<br> - IPC 消息 `save-file` 收到的具体数据内容。<br> - 渲染进程中 Redux/Vuex 的 state 变更详情。` |
|
||||
| **`silly`** | **最详尽的底层信息,仅用于极限调试。** <br> 几乎不在常规开发中使用,仅为解决棘手问题。 | - 鼠标移动的实时坐标 `(x: 150, y: 320)`。<br> - 读取文件时每个数据块(chunk)的大小。<br> - 每一次渲染帧的耗时。 |
|
||||
| **`silly`** | **最详尽的底层信息,仅用于极限调试。** <br> 几乎不在常规开发中使用,仅为解决棘手问题。 | - 鼠标移动的实时坐标 `(x: 150, y: 320)`。<br> - 读取文件时每个数据块(chunk)的大小。<br> - 每一次渲染帧的耗时。 |
|
||||
|
||||
@@ -80,7 +80,6 @@ import { ChunkType } from '@renderer/types' // 调整路径
|
||||
|
||||
export const createSimpleLoggingMiddleware = (): CompletionsMiddleware => {
|
||||
return (api: MiddlewareAPI<AiProviderMiddlewareCompletionsContext, [CompletionsParams]>) => {
|
||||
|
||||
return (next: (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise<any>) => {
|
||||
return async (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams): Promise<void> => {
|
||||
const startTime = Date.now()
|
||||
|
||||
@@ -4,15 +4,17 @@ import { motion } from 'motion/react'
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface ModelListSearchBarProps {
|
||||
interface CollapsibleSearchBarProps {
|
||||
onSearch: (text: string) => void
|
||||
icon?: React.ReactNode
|
||||
maxWidth?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsible search bar for the model list
|
||||
* A collapsible search bar for list headers
|
||||
* Renders as an icon initially, expands to full search input when clicked
|
||||
*/
|
||||
const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) => {
|
||||
const CollapsibleSearchBar: React.FC<CollapsibleSearchBarProps> = ({ onSearch, icon, maxWidth }) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
@@ -44,7 +46,7 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
|
||||
initial="collapsed"
|
||||
animate={searchVisible ? 'expanded' : 'collapsed'}
|
||||
variants={{
|
||||
expanded: { maxWidth: 360, opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||
expanded: { maxWidth: maxWidth || '100%', opacity: 1, transition: { duration: 0.3, ease: 'easeInOut' } },
|
||||
collapsed: { maxWidth: 0, opacity: 0, transition: { duration: 0.3, ease: 'easeInOut' } }
|
||||
}}
|
||||
style={{ overflow: 'hidden', flex: 1 }}>
|
||||
@@ -53,7 +55,7 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
|
||||
type="text"
|
||||
placeholder={t('models.search')}
|
||||
size="small"
|
||||
suffix={<Search size={14} />}
|
||||
suffix={icon || <Search size={14} color="var(--color-icon)" />}
|
||||
value={searchText}
|
||||
autoFocus
|
||||
allowClear
|
||||
@@ -80,12 +82,12 @@ const ModelListSearchBar: React.FC<ModelListSearchBarProps> = ({ onSearch }) =>
|
||||
}}
|
||||
style={{ cursor: 'pointer', display: 'flex' }}
|
||||
onClick={() => setSearchVisible(true)}>
|
||||
<Tooltip title={t('models.search')} mouseEnterDelay={0.5}>
|
||||
<Search size={14} color="var(--color-icon)" />
|
||||
<Tooltip title={t('models.search')} mouseLeaveDelay={0}>
|
||||
{icon || <Search size={14} color="var(--color-icon)" />}
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ModelListSearchBar)
|
||||
export default memo(CollapsibleSearchBar)
|
||||
@@ -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 ExpandableText from '@renderer/components/ExpandableText'
|
||||
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
|
||||
import NewApiAddModelPopup from '@renderer/components/ModelList/NewApiAddModelPopup'
|
||||
import NewApiBatchAddModelPopup from '@renderer/components/ModelList/NewApiBatchAddModelPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import {
|
||||
getModelLogo,
|
||||
groupQwenModels,
|
||||
@@ -18,8 +21,6 @@ import {
|
||||
} from '@renderer/config/models'
|
||||
import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/NewApiAddModelPopup'
|
||||
import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/NewApiBatchAddModelPopup'
|
||||
import { fetchModels } from '@renderer/services/ApiService'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { getDefaultGroupName, isFreeModel, runAsyncFunction } from '@renderer/utils'
|
||||
@@ -32,9 +33,8 @@ import { memo, useCallback, useEffect, useMemo, useOptimistic, useRef, useState,
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { TopView } from '../../../components/TopView'
|
||||
|
||||
const logger = loggerService.withContext('EditModelsPopup')
|
||||
|
||||
interface ShowParams {
|
||||
provider: Provider
|
||||
}
|
||||
@@ -218,7 +218,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
title={
|
||||
isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed')
|
||||
}
|
||||
mouseEnterDelay={0.5}
|
||||
mouseLeaveDelay={0}
|
||||
placement="top">
|
||||
<Button
|
||||
type="default"
|
||||
@@ -245,11 +245,11 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={list.length === 0}
|
||||
disabled={loading || list.length === 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}, [list, t, provider, onRemoveModel, models, onAddModel])
|
||||
}, [list, t, loading, provider, onRemoveModel, models, onAddModel])
|
||||
|
||||
const renderGroupTools = useCallback(
|
||||
(group: string) => {
|
||||
@@ -262,7 +262,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
||||
? t(`settings.models.manage.remove_whole_group`)
|
||||
: t(`settings.models.manage.add_whole_group`)
|
||||
}
|
||||
mouseEnterDelay={0.5}
|
||||
mouseLeaveDelay={0}
|
||||
placement="top">
|
||||
<Button
|
||||
type="text"
|
||||
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 WebSearchService from '@renderer/services/WebSearchService'
|
||||
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
|
||||
import { ApiKeyConnectivity, ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck'
|
||||
import { formatApiKeys, splitApiKeyString } from '@renderer/utils/api'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { TFunction } from 'i18next'
|
||||
@@ -11,7 +12,7 @@ import { isEmpty } from 'lodash'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ApiKeyConnectivity, ApiKeyValidity, ApiKeyWithStatus, ApiProviderKind, ApiProviderUnion } from './types'
|
||||
import { ApiKeyValidity, ApiProviderKind, ApiProviderUnion } from './types'
|
||||
|
||||
interface UseApiKeysProps {
|
||||
provider: ApiProviderUnion
|
||||
@@ -52,7 +53,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
|
||||
const keysWithStatus = useMemo((): ApiKeyWithStatus[] => {
|
||||
return keys.map((key) => {
|
||||
const connectivityState = connectivityStates.get(key) || {
|
||||
status: 'not_checked' as const,
|
||||
status: HealthStatus.NOT_CHECKED,
|
||||
checking: false,
|
||||
error: undefined,
|
||||
model: undefined,
|
||||
@@ -70,7 +71,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
|
||||
setConnectivityStates((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
const currentState = prev.get(key) || {
|
||||
status: 'not_checked' as const,
|
||||
status: HealthStatus.NOT_CHECKED,
|
||||
checking: false,
|
||||
error: undefined,
|
||||
model: undefined,
|
||||
@@ -170,10 +171,12 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
|
||||
|
||||
// 移除连通性检查失败的 keys
|
||||
const removeInvalidKeys = useCallback(() => {
|
||||
const validKeys = keysWithStatus.filter((keyStatus) => keyStatus.status !== 'error').map((k) => k.key)
|
||||
const validKeys = keysWithStatus.filter((keyStatus) => keyStatus.status !== HealthStatus.FAILED).map((k) => k.key)
|
||||
|
||||
// 清除被删除的 keys 的连通性状态
|
||||
const keysToRemove = keysWithStatus.filter((keyStatus) => keyStatus.status === 'error').map((k) => k.key)
|
||||
const keysToRemove = keysWithStatus
|
||||
.filter((keyStatus) => keyStatus.status === HealthStatus.FAILED)
|
||||
.map((k) => k.key)
|
||||
|
||||
setConnectivityStates((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
@@ -207,7 +210,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
|
||||
// 连通性检查成功
|
||||
updateConnectivityState(keyToCheck, {
|
||||
checking: false,
|
||||
status: 'success',
|
||||
status: HealthStatus.SUCCESS,
|
||||
model,
|
||||
latency,
|
||||
error: undefined
|
||||
@@ -216,7 +219,7 @@ export function useApiKeys({ provider, updateProvider, providerKind }: UseApiKey
|
||||
// 连通性检查失败
|
||||
updateConnectivityState(keyToCheck, {
|
||||
checking: false,
|
||||
status: 'error',
|
||||
status: HealthStatus.FAILED,
|
||||
error: formatErrorMessage(error),
|
||||
model: undefined,
|
||||
latency: undefined
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CheckCircleFilled, CloseCircleFilled, MinusOutlined } from '@ant-design/icons'
|
||||
import { MinusOutlined } from '@ant-design/icons'
|
||||
import { type HealthResult, HealthStatusIndicator } from '@renderer/components/HealthStatusIndicator'
|
||||
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
|
||||
import { ApiKeyWithStatus } from '@renderer/types/healthCheck'
|
||||
import { maskApiKey } from '@renderer/utils/api'
|
||||
import { Button, Flex, Input, InputRef, List, Popconfirm, Tooltip, Typography } from 'antd'
|
||||
import { Check, PenLine, X } from 'lucide-react'
|
||||
@@ -7,7 +9,7 @@ import { FC, memo, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { ApiKeyValidity, ApiKeyWithStatus } from './types'
|
||||
import { ApiKeyValidity } from './types'
|
||||
|
||||
export interface ApiKeyItemProps {
|
||||
keyStatus: ApiKeyWithStatus
|
||||
@@ -39,9 +41,6 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
|
||||
const disabled = keyStatus.checking || _disabled
|
||||
const isNotChecked = keyStatus.status === 'not_checked'
|
||||
const isSuccess = keyStatus.status === 'success'
|
||||
const statusColor = isSuccess ? 'var(--color-status-success)' : 'var(--color-status-error)'
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
@@ -83,41 +82,14 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const renderStatusIcon = () => {
|
||||
if (keyStatus.checking || isNotChecked) return null
|
||||
|
||||
const StatusIcon = isSuccess ? CheckCircleFilled : CloseCircleFilled
|
||||
return <StatusIcon style={{ color: statusColor }} />
|
||||
}
|
||||
|
||||
const renderKeyCheckResultTooltip = () => {
|
||||
if (keyStatus.checking) {
|
||||
return t('settings.models.check.checking')
|
||||
const healthResults: HealthResult[] = [
|
||||
{
|
||||
status: keyStatus.status,
|
||||
latency: keyStatus.latency,
|
||||
error: keyStatus.error,
|
||||
label: keyStatus.model?.name
|
||||
}
|
||||
|
||||
if (isNotChecked) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const statusTitle = isSuccess ? t('settings.models.check.passed') : t('settings.models.check.failed')
|
||||
|
||||
return (
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px', wordWrap: 'break-word' }}>
|
||||
<strong style={{ color: statusColor }}>{statusTitle}</strong>
|
||||
{keyStatus.model && (
|
||||
<div style={{ marginTop: 5 }}>
|
||||
{t('common.model')}: {keyStatus.model.name}
|
||||
</div>
|
||||
)}
|
||||
{keyStatus.latency && isSuccess && (
|
||||
<div style={{ marginTop: 5 }}>
|
||||
{t('settings.provider.api.key.check.latency')}: {(keyStatus.latency / 1000).toFixed(2)}s
|
||||
</div>
|
||||
)}
|
||||
{keyStatus.error && <div style={{ marginTop: 5 }}>{keyStatus.error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<List.Item>
|
||||
@@ -163,9 +135,7 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
|
||||
</Tooltip>
|
||||
|
||||
<Flex gap={10} align="center">
|
||||
<Tooltip title={renderKeyCheckResultTooltip()} styles={{ body: { userSelect: 'text' } }}>
|
||||
{renderStatusIcon()}
|
||||
</Tooltip>
|
||||
<HealthStatusIndicator results={healthResults} loading={false} />
|
||||
|
||||
<Flex gap={0} align="center">
|
||||
{showHealthCheck && (
|
||||
@@ -200,14 +170,10 @@ const ApiKeyItem: FC<ApiKeyItemProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const ItemInnerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
const ItemInnerContainer = styled(Flex)`
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
`
|
||||
|
||||
export default memo(ApiKeyItem)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useProvider } from '@renderer/hooks/useProvider'
|
||||
import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders'
|
||||
import { SettingHelpText } from '@renderer/pages/settings'
|
||||
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
|
||||
import { ApiKeyWithStatus, HealthStatus } from '@renderer/types/healthCheck'
|
||||
import { Button, Card, Flex, List, Popconfirm, Space, Tooltip, Typography } from 'antd'
|
||||
import { Trash } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
@@ -14,7 +15,7 @@ import styled from 'styled-components'
|
||||
|
||||
import { isLlmProvider, useApiKeys } from './hook'
|
||||
import ApiKeyItem from './item'
|
||||
import { ApiKeyWithStatus, ApiProviderKind, ApiProviderUnion } from './types'
|
||||
import { ApiProviderKind, ApiProviderUnion } from './types'
|
||||
|
||||
interface ApiKeyListProps {
|
||||
provider: ApiProviderUnion
|
||||
@@ -81,7 +82,7 @@ export const ApiKeyList: FC<ApiKeyListProps> = ({ provider, updateProvider, prov
|
||||
...keys,
|
||||
{
|
||||
key: pendingNewKey.key,
|
||||
status: 'not_checked',
|
||||
status: HealthStatus.NOT_CHECKED,
|
||||
checking: false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,22 +1,4 @@
|
||||
import { Model, PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
|
||||
|
||||
/**
|
||||
* API Key 连通性检查的状态
|
||||
*/
|
||||
export type ApiKeyConnectivity = {
|
||||
status: 'success' | 'error' | 'not_checked'
|
||||
checking?: boolean
|
||||
error?: string
|
||||
model?: Model
|
||||
latency?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* API key 及其连通性检查的状态
|
||||
*/
|
||||
export type ApiKeyWithStatus = {
|
||||
key: string
|
||||
} & ApiKeyConnectivity
|
||||
import { PreprocessProvider, Provider, WebSearchProvider } from '@renderer/types'
|
||||
|
||||
/**
|
||||
* API key 格式有效性
|
||||
|
||||
@@ -2165,6 +2165,7 @@
|
||||
"models.manage.add_listed": "Add models to the list",
|
||||
"models.manage.add_whole_group": "Add the whole group",
|
||||
"models.manage.remove_listed": "Remove models from the list",
|
||||
"models.manage.remove_model": "Remove model",
|
||||
"models.manage.remove_whole_group": "Remove the whole group",
|
||||
"models.provider_id": "Provider ID",
|
||||
"models.provider_key_add_confirm": "Do you want to add the API key for {{provider}}?",
|
||||
|
||||
@@ -2165,6 +2165,7 @@
|
||||
"models.manage.add_listed": "リストにモデルを追加",
|
||||
"models.manage.add_whole_group": "グループ全体を追加",
|
||||
"models.manage.remove_listed": "リストからモデルを削除",
|
||||
"models.manage.remove_model": "モデルを削除",
|
||||
"models.manage.remove_whole_group": "グループ全体を削除",
|
||||
"models.provider_id": "プロバイダー ID",
|
||||
"models.provider_key_add_confirm": "{{provider}} の API キーを追加しますか?",
|
||||
|
||||
@@ -2165,6 +2165,7 @@
|
||||
"models.manage.add_listed": "Добавить в список",
|
||||
"models.manage.add_whole_group": "Добавить всю группу",
|
||||
"models.manage.remove_listed": "Удалить из списка",
|
||||
"models.manage.remove_model": "Удалить модель",
|
||||
"models.manage.remove_whole_group": "Удалить всю группу",
|
||||
"models.provider_id": "ID провайдера",
|
||||
"models.provider_key_add_confirm": "Добавить API ключ для {{provider}}?",
|
||||
|
||||
@@ -2165,6 +2165,7 @@
|
||||
"models.manage.add_listed": "添加列表中的模型",
|
||||
"models.manage.add_whole_group": "添加整个分组",
|
||||
"models.manage.remove_listed": "移除列表中的模型",
|
||||
"models.manage.remove_model": "移除模型",
|
||||
"models.manage.remove_whole_group": "移除整个分组",
|
||||
"models.provider_id": "服务商 ID",
|
||||
"models.provider_key_add_confirm": "是否要为 {{provider}} 添加 API 密钥?",
|
||||
|
||||
@@ -2165,6 +2165,7 @@
|
||||
"models.manage.add_listed": "添加列表中的模型",
|
||||
"models.manage.add_whole_group": "新增整個分組",
|
||||
"models.manage.remove_listed": "移除列表中的模型",
|
||||
"models.manage.remove_model": "移除模型",
|
||||
"models.manage.remove_whole_group": "移除整個分組",
|
||||
"models.provider_id": "提供者 ID",
|
||||
"models.provider_key_add_confirm": "是否要為 {{provider}} 添加 API 密鑰?",
|
||||
|
||||
@@ -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 OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
|
||||
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { ApiKeyConnectivity, ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
|
||||
import { ModelList } from '@renderer/components/ModelList'
|
||||
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { PROVIDER_CONFIG } from '@renderer/config/providers'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { checkApi } from '@renderer/services/ApiService'
|
||||
import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService'
|
||||
import { isProviderSupportAuth } from '@renderer/services/ProviderService'
|
||||
import {
|
||||
formatApiHost,
|
||||
formatApiKeys,
|
||||
getFancyProviderName,
|
||||
isOpenAIProvider,
|
||||
splitApiKeyString
|
||||
} from '@renderer/utils'
|
||||
import { ApiKeyConnectivity, HealthStatus } from '@renderer/types/healthCheck'
|
||||
import { formatApiHost, formatApiKeys, getFancyProviderName, isOpenAIProvider } from '@renderer/utils'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { lightbulbVariants } from '@renderer/utils/motionVariants'
|
||||
import { Button, Divider, Flex, Input, Space, Switch, Tooltip } from 'antd'
|
||||
import Link from 'antd/es/typography/Link'
|
||||
import { debounce, isEmpty } from 'lodash'
|
||||
import { Settings2, SquareArrowOutUpRight } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { FC, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react'
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -41,10 +33,7 @@ import CustomHeaderPopup from './CustomHeaderPopup'
|
||||
import DMXAPISettings from './DMXAPISettings'
|
||||
import GithubCopilotSettings from './GithubCopilotSettings'
|
||||
import GPUStackSettings from './GPUStackSettings'
|
||||
import HealthCheckPopup from './HealthCheckPopup'
|
||||
import LMStudioSettings from './LMStudioSettings'
|
||||
import ModelList, { ModelStatus } from './ModelList'
|
||||
import ModelListSearchBar from './ModelListSearchBar'
|
||||
import ProviderOAuth from './ProviderOAuth'
|
||||
import ProviderSettingsPopup from './ProviderSettingsPopup'
|
||||
import SelectProviderModelPopup from './SelectProviderModelPopup'
|
||||
@@ -60,8 +49,6 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
const { updateProviders } = useProviders()
|
||||
const [apiHost, setApiHost] = useState(provider.apiHost)
|
||||
const [apiVersion, setApiVersion] = useState(provider.apiVersion)
|
||||
const [modelSearchText, setModelSearchText] = useState('')
|
||||
const deferredModelSearchText = useDeferredValue(modelSearchText)
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
|
||||
@@ -74,14 +61,11 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
const apiKeyWebsite = providerConfig?.websites?.apiKey
|
||||
const configedApiHost = providerConfig?.api?.url
|
||||
|
||||
const [modelStatuses, setModelStatuses] = useState<ModelStatus[]>([])
|
||||
const [isHealthChecking, setIsHealthChecking] = useState(false)
|
||||
|
||||
const fancyProviderName = getFancyProviderName(provider)
|
||||
|
||||
const [localApiKey, setLocalApiKey] = useState(provider.apiKey)
|
||||
const [apiKeyConnectivity, setApiKeyConnectivity] = useState<ApiKeyConnectivity>({
|
||||
status: 'not_checked',
|
||||
status: HealthStatus.NOT_CHECKED,
|
||||
checking: false
|
||||
})
|
||||
|
||||
@@ -97,7 +81,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
// 重置连通性检查状态
|
||||
useEffect(() => {
|
||||
setLocalApiKey(provider.apiKey)
|
||||
setApiKeyConnectivity({ status: 'not_checked' })
|
||||
setApiKeyConnectivity({ status: HealthStatus.NOT_CHECKED })
|
||||
}, [provider.apiKey])
|
||||
|
||||
// 同步 localApiKey 到 provider.apiKey(防抖)
|
||||
@@ -147,83 +131,6 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
})
|
||||
}
|
||||
|
||||
const onHealthCheck = async () => {
|
||||
const modelsToCheck = models.filter((model) => !isRerankModel(model))
|
||||
|
||||
if (isEmpty(modelsToCheck)) {
|
||||
window.message.error({
|
||||
key: 'no-models',
|
||||
style: { marginTop: '3vh' },
|
||||
duration: 5,
|
||||
content: t('settings.provider.no_models_for_check')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const keys = splitApiKeyString(provider.apiKey)
|
||||
|
||||
// Add an empty key to enable health checks for local models.
|
||||
// Error messages will be shown for each model if a valid key is needed.
|
||||
if (keys.length === 0) {
|
||||
keys.push('')
|
||||
}
|
||||
|
||||
// Show configuration dialog to get health check parameters
|
||||
const result = await HealthCheckPopup.show({
|
||||
title: t('settings.models.check.title'),
|
||||
provider: { ...provider, apiHost },
|
||||
apiKeys: keys
|
||||
})
|
||||
|
||||
if (result.cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare the list of models to be checked
|
||||
const initialStatuses = modelsToCheck.map((model) => ({
|
||||
model,
|
||||
checking: true,
|
||||
status: undefined
|
||||
}))
|
||||
setModelStatuses(initialStatuses)
|
||||
setIsHealthChecking(true)
|
||||
|
||||
const checkResults = await checkModelsHealth(
|
||||
{
|
||||
provider: { ...provider, apiHost },
|
||||
models: modelsToCheck,
|
||||
apiKeys: result.apiKeys,
|
||||
isConcurrent: result.isConcurrent
|
||||
},
|
||||
(checkResult, index) => {
|
||||
setModelStatuses((current) => {
|
||||
const updated = [...current]
|
||||
if (updated[index]) {
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
checking: false,
|
||||
status: checkResult.status,
|
||||
error: checkResult.error,
|
||||
keyResults: checkResult.keyResults,
|
||||
latency: checkResult.latency
|
||||
}
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
window.message.info({
|
||||
key: 'health-check-summary',
|
||||
style: { marginTop: '3vh' },
|
||||
duration: 5,
|
||||
content: getModelCheckSummary(checkResults, provider.name)
|
||||
})
|
||||
|
||||
// Reset health check status
|
||||
setIsHealthChecking(false)
|
||||
}
|
||||
|
||||
const onCheckApi = async () => {
|
||||
// 如果存在多个密钥,直接打开管理窗口
|
||||
if (provider.apiKey.includes(',')) {
|
||||
@@ -251,7 +158,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: 'not_checked' }))
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, checking: true, status: HealthStatus.NOT_CHECKED }))
|
||||
await checkApi({ ...provider, apiHost }, model)
|
||||
|
||||
window.message.success({
|
||||
@@ -261,9 +168,9 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
content: i18n.t('message.api.connection.success')
|
||||
})
|
||||
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, status: 'success' }))
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.SUCCESS }))
|
||||
setTimeout(() => {
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, status: 'not_checked' }))
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.NOT_CHECKED }))
|
||||
}, 3000)
|
||||
} catch (error: any) {
|
||||
window.message.error({
|
||||
@@ -273,7 +180,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
content: i18n.t('message.api.connection.failed')
|
||||
})
|
||||
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, status: 'error', error: formatErrorMessage(error) }))
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, status: HealthStatus.FAILED, error: formatErrorMessage(error) }))
|
||||
} finally {
|
||||
setApiKeyConnectivity((prev) => ({ ...prev, checking: false }))
|
||||
}
|
||||
@@ -300,7 +207,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
|
||||
// API key 连通性检查状态指示器,目前仅在失败时显示
|
||||
const renderStatusIndicator = () => {
|
||||
if (apiKeyConnectivity.checking || apiKeyConnectivity.status !== 'error') {
|
||||
if (apiKeyConnectivity.checking || apiKeyConnectivity.status !== HealthStatus.FAILED) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -466,32 +373,7 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
{provider.id === 'gpustack' && <GPUStackSettings />}
|
||||
{provider.id === 'copilot' && <GithubCopilotSettings providerId={provider.id} />}
|
||||
{provider.id === 'vertexai' && <VertexAISettings />}
|
||||
<SettingSubtitle style={{ marginBottom: 5 }}>
|
||||
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<HStack alignItems="center" gap={8} mb={5}>
|
||||
<SettingSubtitle style={{ marginTop: 0 }}>{t('common.models')}</SettingSubtitle>
|
||||
{!isEmpty(models) && <ModelListSearchBar onSearch={setModelSearchText} />}
|
||||
</HStack>
|
||||
{!isEmpty(models) && (
|
||||
<Tooltip title={t('settings.models.check.button_caption')} mouseEnterDelay={0.5}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={onHealthCheck}
|
||||
icon={
|
||||
<motion.span
|
||||
variants={lightbulbVariants}
|
||||
animate={isHealthChecking ? 'active' : 'idle'}
|
||||
initial="idle">
|
||||
<StreamlineGoodHealthAndWellBeing />
|
||||
</motion.span>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
</SettingSubtitle>
|
||||
<ModelList providerId={provider.id} modelStatuses={modelStatuses} searchText={deferredModelSearchText} />
|
||||
<ModelList providerId={provider.id} />
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,264 +1,92 @@
|
||||
import { loggerService } from '@logger'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { ApiKeyWithStatus, HealthStatus, ModelCheckOptions, ModelWithStatus } from '@renderer/types/healthCheck'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { aggregateApiKeyResults } from '@renderer/utils/healthCheck'
|
||||
|
||||
import { checkModel } from './ModelService'
|
||||
|
||||
const logger = loggerService.withContext('HealthCheckService')
|
||||
|
||||
/**
|
||||
* Model check status states
|
||||
*/
|
||||
export enum ModelCheckStatus {
|
||||
NOT_CHECKED = 'not_checked',
|
||||
SUCCESS = 'success',
|
||||
FAILED = 'failed',
|
||||
PARTIAL = 'partial' // Some API keys worked, some failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for model health check
|
||||
*/
|
||||
export interface ModelCheckOptions {
|
||||
provider: Provider
|
||||
models: Model[]
|
||||
apiKeys: string[]
|
||||
isConcurrent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Single API key check status
|
||||
*/
|
||||
export interface ApiKeyCheckStatus {
|
||||
key: string
|
||||
isValid: boolean
|
||||
error?: string
|
||||
latency?: number // Check latency in milliseconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a model health check
|
||||
*/
|
||||
export interface ModelCheckResult {
|
||||
model: Model
|
||||
keyResults: ApiKeyCheckStatus[]
|
||||
latency?: number // Smallest latency of all successful checks
|
||||
status?: ModelCheckStatus
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes model check results to determine overall status
|
||||
*/
|
||||
export function analyzeModelCheckResult(result: ModelCheckResult): {
|
||||
status: ModelCheckStatus
|
||||
error?: string
|
||||
latency?: number
|
||||
} {
|
||||
const validKeyCount = result.keyResults.filter((r) => r.isValid).length
|
||||
const totalKeyCount = result.keyResults.length
|
||||
|
||||
if (validKeyCount === totalKeyCount) {
|
||||
return {
|
||||
status: ModelCheckStatus.SUCCESS,
|
||||
latency: result.latency
|
||||
}
|
||||
} else if (validKeyCount === 0) {
|
||||
// All keys failed
|
||||
const errors = result.keyResults
|
||||
.filter((r) => r.error)
|
||||
.map((r) => r.error)
|
||||
.filter((v, i, a) => a.indexOf(v) === i) // Remove duplicates
|
||||
|
||||
return {
|
||||
status: ModelCheckStatus.FAILED,
|
||||
error: errors.join('; ')
|
||||
}
|
||||
} else {
|
||||
// Partial success
|
||||
return {
|
||||
status: ModelCheckStatus.PARTIAL,
|
||||
latency: result.latency,
|
||||
error: i18n.t('settings.models.check.keys_status_count', {
|
||||
count_passed: validKeyCount,
|
||||
count_failed: totalKeyCount - validKeyCount
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a model with multiple API keys
|
||||
* 用多个 API 密钥检查单个模型的连通性
|
||||
*/
|
||||
export async function checkModelWithMultipleKeys(
|
||||
provider: Provider,
|
||||
model: Model,
|
||||
apiKeys: string[],
|
||||
isParallel: boolean
|
||||
): Promise<Omit<ModelCheckResult, 'model' | 'status' | 'error'>> {
|
||||
let keyResults: ApiKeyCheckStatus[] = []
|
||||
apiKeys: string[]
|
||||
): Promise<ApiKeyWithStatus[]> {
|
||||
const checkPromises = apiKeys.map(async (key) => {
|
||||
const startTime = Date.now()
|
||||
// 如果 checkModel 抛出错误,让这个 promise 失败
|
||||
await checkModel({ ...provider, apiKey: key }, model)
|
||||
const latency = Date.now() - startTime
|
||||
|
||||
if (isParallel) {
|
||||
// Check all API keys in parallel
|
||||
const keyPromises = apiKeys.map(async (key) => {
|
||||
try {
|
||||
const result = await checkModel({ ...provider, apiKey: key }, model)
|
||||
return {
|
||||
key,
|
||||
isValid: true,
|
||||
latency: result.latency
|
||||
} as ApiKeyCheckStatus
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
key,
|
||||
isValid: false,
|
||||
error: error instanceof Error ? error.message.slice(0, 20) + '...' : String(error).slice(0, 20) + '...'
|
||||
} as ApiKeyCheckStatus
|
||||
}
|
||||
})
|
||||
return {
|
||||
key,
|
||||
status: HealthStatus.SUCCESS,
|
||||
latency
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(keyPromises)
|
||||
const results = await Promise.allSettled(checkPromises)
|
||||
|
||||
// Process results
|
||||
keyResults = results.map((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value
|
||||
} else {
|
||||
return {
|
||||
key: 'unknown', // This should not happen since we've caught errors internally
|
||||
isValid: false,
|
||||
error: 'Promise rejection: ' + result.reason
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Check all API keys serially
|
||||
for (const key of apiKeys) {
|
||||
try {
|
||||
const result = await checkModel({ ...provider, apiKey: key }, model)
|
||||
keyResults.push({
|
||||
key,
|
||||
isValid: true,
|
||||
latency: result.latency
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
keyResults.push({
|
||||
key,
|
||||
isValid: false,
|
||||
error: error instanceof Error ? error.message.slice(0, 20) + '...' : String(error).slice(0, 20) + '...'
|
||||
})
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value
|
||||
} else {
|
||||
return {
|
||||
key: apiKeys[index], // 对应失败的 promise 的 key
|
||||
status: HealthStatus.FAILED,
|
||||
error: formatErrorMessage(result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate fastest successful response time
|
||||
const successResults = keyResults.filter((r) => r.isValid && r.latency !== undefined)
|
||||
const latency = successResults.length > 0 ? Math.min(...successResults.map((r) => r.latency!)) : undefined
|
||||
|
||||
return { keyResults, latency }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs health checks for multiple models
|
||||
* 检查多个模型的连通性
|
||||
*/
|
||||
export async function checkModelsHealth(
|
||||
options: ModelCheckOptions,
|
||||
onModelChecked?: (result: ModelCheckResult, index: number) => void
|
||||
): Promise<ModelCheckResult[]> {
|
||||
onModelChecked?: (result: ModelWithStatus, index: number) => void
|
||||
): Promise<ModelWithStatus[]> {
|
||||
const { provider, models, apiKeys, isConcurrent } = options
|
||||
|
||||
// Results array
|
||||
const results: ModelCheckResult[] = []
|
||||
const results: ModelWithStatus[] = []
|
||||
|
||||
try {
|
||||
if (isConcurrent) {
|
||||
// Check all models concurrently
|
||||
const modelPromises = models.map(async (model, index) => {
|
||||
const checkResult = await checkModelWithMultipleKeys(provider, model, apiKeys, true)
|
||||
const analysisResult = analyzeModelCheckResult({
|
||||
model,
|
||||
...checkResult,
|
||||
status: undefined,
|
||||
error: undefined
|
||||
})
|
||||
const modelPromises = models.map(async (model, index) => {
|
||||
const keyResults = await checkModelWithMultipleKeys(provider, model, apiKeys)
|
||||
const analysis = aggregateApiKeyResults(keyResults)
|
||||
|
||||
const result: ModelCheckResult = {
|
||||
model,
|
||||
...checkResult,
|
||||
status: analysisResult.status,
|
||||
error: analysisResult.error
|
||||
}
|
||||
const result: ModelWithStatus = {
|
||||
model,
|
||||
keyResults,
|
||||
status: analysis.status,
|
||||
error: analysis.error,
|
||||
latency: analysis.latency
|
||||
}
|
||||
|
||||
if (isConcurrent) {
|
||||
results[index] = result
|
||||
|
||||
if (onModelChecked) {
|
||||
onModelChecked(result, index)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
await Promise.allSettled(modelPromises)
|
||||
} else {
|
||||
// Check all models serially
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const model = models[i]
|
||||
const checkResult = await checkModelWithMultipleKeys(provider, model, apiKeys, false)
|
||||
|
||||
const analysisResult = analyzeModelCheckResult({
|
||||
model,
|
||||
...checkResult,
|
||||
status: undefined,
|
||||
error: undefined
|
||||
})
|
||||
|
||||
const result: ModelCheckResult = {
|
||||
model,
|
||||
...checkResult,
|
||||
status: analysisResult.status,
|
||||
error: analysisResult.error
|
||||
}
|
||||
|
||||
} else {
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
if (onModelChecked) {
|
||||
onModelChecked(result, i)
|
||||
}
|
||||
onModelChecked?.(result, index)
|
||||
return result
|
||||
})
|
||||
|
||||
if (isConcurrent) {
|
||||
await Promise.all(modelPromises)
|
||||
} else {
|
||||
for (const promise of modelPromises) {
|
||||
await promise
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Model health check failed:', error)
|
||||
logger.error('[HealthCheckService] Model health check failed:', error)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export function getModelCheckSummary(results: ModelCheckResult[], providerName?: string): string {
|
||||
const t = i18n.t
|
||||
|
||||
// Show summary of results after checking
|
||||
const failedModels = results.filter((result) => result.status === ModelCheckStatus.FAILED)
|
||||
const partialModels = results.filter((result) => result.status === ModelCheckStatus.PARTIAL)
|
||||
const successModels = results.filter((result) => result.status === ModelCheckStatus.SUCCESS)
|
||||
|
||||
// Display statistics of all model check results
|
||||
const summaryParts: string[] = []
|
||||
|
||||
if (failedModels.length > 0) {
|
||||
summaryParts.push(t('settings.models.check.model_status_failed', { count: failedModels.length }))
|
||||
}
|
||||
if (successModels.length + partialModels.length > 0) {
|
||||
summaryParts.push(
|
||||
t('settings.models.check.model_status_passed', { count: successModels.length + partialModels.length })
|
||||
)
|
||||
}
|
||||
if (partialModels.length > 0) {
|
||||
summaryParts.push(t('settings.models.check.model_status_partial', { count: partialModels.length }))
|
||||
}
|
||||
|
||||
const summary = summaryParts.join(', ')
|
||||
return t('settings.models.check.model_status_summary', {
|
||||
provider: providerName ?? 'Unknown Provider',
|
||||
summary
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user