From 2b0c46bfdb263d754cd8309593bdcd93b0e5617a Mon Sep 17 00:00:00 2001 From: one Date: Mon, 21 Jul 2025 15:57:08 +0800 Subject: [PATCH] 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 --- SECURITY.md | 10 +- docs/technical/how-to-use-logger-en.md | 8 +- docs/technical/how-to-use-logger-zh.md | 31 +- docs/technical/how-to-write-middlewares.md | 1 - .../CollapsibleSearchBar.tsx} | 18 +- .../HealthStatusIndicator/index.tsx | 2 + .../HealthStatusIndicator/indicator.tsx | 86 ++++ .../components/HealthStatusIndicator/types.ts | 12 + .../HealthStatusIndicator/useHealthStatus.tsx | 109 +++++ .../ModelList}/AddModelPopup.tsx | 0 .../ModelList}/EditModelsPopup.tsx | 16 +- .../ModelList}/HealthCheckPopup.tsx | 0 .../ModelList}/ModelEditContent.tsx | 0 .../src/components/ModelList/ModelList.tsx | 188 ++++++++ .../components/ModelList/ModelListGroup.tsx | 84 ++++ .../components/ModelList/ModelListItem.tsx | 86 ++++ .../ModelList}/NewApiAddModelPopup.tsx | 0 .../ModelList}/NewApiBatchAddModelPopup.tsx | 0 .../src/components/ModelList/index.ts | 7 + .../components/ModelList/useHealthCheck.ts | 97 +++++ .../components/Popups/ApiKeyListPopup/hook.ts | 17 +- .../Popups/ApiKeyListPopup/item.tsx | 64 +-- .../Popups/ApiKeyListPopup/list.tsx | 5 +- .../Popups/ApiKeyListPopup/types.ts | 20 +- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../settings/ProviderSettings/ModelList.tsx | 402 ------------------ .../ProviderSettings/ProviderSetting.tsx | 144 +------ .../src/services/HealthCheckService.ts | 280 +++--------- src/renderer/src/types/healthCheck.ts | 52 +++ src/renderer/src/utils/healthCheck.ts | 78 ++++ 34 files changed, 948 insertions(+), 874 deletions(-) rename src/renderer/src/{pages/settings/ProviderSettings/ModelListSearchBar.tsx => components/CollapsibleSearchBar.tsx} (80%) create mode 100644 src/renderer/src/components/HealthStatusIndicator/index.tsx create mode 100644 src/renderer/src/components/HealthStatusIndicator/indicator.tsx create mode 100644 src/renderer/src/components/HealthStatusIndicator/types.ts create mode 100644 src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx rename src/renderer/src/{pages/settings/ProviderSettings => components/ModelList}/AddModelPopup.tsx (100%) rename src/renderer/src/{pages/settings/ProviderSettings => components/ModelList}/EditModelsPopup.tsx (97%) rename src/renderer/src/{pages/settings/ProviderSettings => components/ModelList}/HealthCheckPopup.tsx (100%) rename src/renderer/src/{pages/settings/ProviderSettings => components/ModelList}/ModelEditContent.tsx (100%) create mode 100644 src/renderer/src/components/ModelList/ModelList.tsx create mode 100644 src/renderer/src/components/ModelList/ModelListGroup.tsx create mode 100644 src/renderer/src/components/ModelList/ModelListItem.tsx rename src/renderer/src/{pages/settings/ProviderSettings => components/ModelList}/NewApiAddModelPopup.tsx (100%) rename src/renderer/src/{pages/settings/ProviderSettings => components/ModelList}/NewApiBatchAddModelPopup.tsx (100%) create mode 100644 src/renderer/src/components/ModelList/index.ts create mode 100644 src/renderer/src/components/ModelList/useHealthCheck.ts delete mode 100644 src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx create mode 100644 src/renderer/src/types/healthCheck.ts create mode 100644 src/renderer/src/utils/healthCheck.ts diff --git a/SECURITY.md b/SECURITY.md index f3be8e0c9..7b95839ce 100644 --- a/SECURITY.md +++ b/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. diff --git a/docs/technical/how-to-use-logger-en.md b/docs/technical/how-to-use-logger-en.md index 0e77c883c..d208b7c55 100644 --- a/docs/technical/how-to-use-logger-en.md +++ b/docs/technical/how-to-use-logger-en.md @@ -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 diff --git a/docs/technical/how-to-use-logger-zh.md b/docs/technical/how-to-use-logger-zh.md index a49f1ef53..9082027b9 100644 --- a/docs/technical/how-to-use-logger-zh.md +++ b/docs/technical/how-to-use-logger-zh.md @@ -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`** | **严重错误,导致程序崩溃或核心功能无法使用。**
这是最高优的日志,通常需要立即上报或提示用户。 | - 主进程或渲染进程崩溃。
- 无法读写用户关键数据文件(如数据库、配置文件),导致应用无法运行。
- 所有未捕获的异常。` | -| **`warn`** | **潜在问题或非预期情况,但不影响程序核心功能。**
程序可以从中恢复或使用备用方案。 | - 配置文件 `settings.json` 缺失,已使用默认配置启动。
- 自动更新检查失败,但不影响当前版本使用。
- 某个非核心插件加载失败。` | -| **`info`** | **记录应用生命周期和关键用户行为。**
这是发布版中默认应记录的级别,用于追踪用户的主要操作路径。 | - 应用启动、退出。
- 用户成功打开/保存文件。
- 主窗口创建/关闭。
- 开始执行一项重要任务(如“开始导出视频”)。` | -| **`verbose`** | **比 `info` 更详细的流程信息,用于追踪特定功能。**
在诊断特定功能问题时开启,帮助理解内部执行流程。 | - 正在加载 `Toolbar` 模块。
- IPC 消息 `open-file-dialog` 已从渲染进程发送。
- 正在应用滤镜 'Sepia' 到图像。` | +| 日志级别 | 核心定义与使用场景 | 示例 | +| :------------ | :------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`error`** | **严重错误,导致程序崩溃或核心功能无法使用。**
这是最高优的日志,通常需要立即上报或提示用户。 | - 主进程或渲染进程崩溃。
- 无法读写用户关键数据文件(如数据库、配置文件),导致应用无法运行。
- 所有未捕获的异常。` | +| **`warn`** | **潜在问题或非预期情况,但不影响程序核心功能。**
程序可以从中恢复或使用备用方案。 | - 配置文件 `settings.json` 缺失,已使用默认配置启动。
- 自动更新检查失败,但不影响当前版本使用。
- 某个非核心插件加载失败。` | +| **`info`** | **记录应用生命周期和关键用户行为。**
这是发布版中默认应记录的级别,用于追踪用户的主要操作路径。 | - 应用启动、退出。
- 用户成功打开/保存文件。
- 主窗口创建/关闭。
- 开始执行一项重要任务(如“开始导出视频”)。` | +| **`verbose`** | **比 `info` 更详细的流程信息,用于追踪特定功能。**
在诊断特定功能问题时开启,帮助理解内部执行流程。 | - 正在加载 `Toolbar` 模块。
- IPC 消息 `open-file-dialog` 已从渲染进程发送。
- 正在应用滤镜 'Sepia' 到图像。` | | **`debug`** | **开发和调试时使用的详细诊断信息。**
**严禁在发布版中默认开启**,因为它可能包含敏感数据并影响性能。 | - 函数 `renderImage` 的入参: `{ width: 800, ... }`。
- IPC 消息 `save-file` 收到的具体数据内容。
- 渲染进程中 Redux/Vuex 的 state 变更详情。` | -| **`silly`** | **最详尽的底层信息,仅用于极限调试。**
几乎不在常规开发中使用,仅为解决棘手问题。 | - 鼠标移动的实时坐标 `(x: 150, y: 320)`。
- 读取文件时每个数据块(chunk)的大小。
- 每一次渲染帧的耗时。 | +| **`silly`** | **最详尽的底层信息,仅用于极限调试。**
几乎不在常规开发中使用,仅为解决棘手问题。 | - 鼠标移动的实时坐标 `(x: 150, y: 320)`。
- 读取文件时每个数据块(chunk)的大小。
- 每一次渲染帧的耗时。 | diff --git a/docs/technical/how-to-write-middlewares.md b/docs/technical/how-to-write-middlewares.md index 0960cf91e..fc4f3b9d9 100644 --- a/docs/technical/how-to-write-middlewares.md +++ b/docs/technical/how-to-write-middlewares.md @@ -80,7 +80,6 @@ import { ChunkType } from '@renderer/types' // 调整路径 export const createSimpleLoggingMiddleware = (): CompletionsMiddleware => { return (api: MiddlewareAPI) => { - return (next: (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams) => Promise) => { return async (context: AiProviderMiddlewareCompletionsContext, params: CompletionsParams): Promise => { const startTime = Date.now() diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelListSearchBar.tsx b/src/renderer/src/components/CollapsibleSearchBar.tsx similarity index 80% rename from src/renderer/src/pages/settings/ProviderSettings/ModelListSearchBar.tsx rename to src/renderer/src/components/CollapsibleSearchBar.tsx index 8a9e7cd68..0b7c038a6 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelListSearchBar.tsx +++ b/src/renderer/src/components/CollapsibleSearchBar.tsx @@ -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 = ({ onSearch }) => { +const CollapsibleSearchBar: React.FC = ({ onSearch, icon, maxWidth }) => { const { t } = useTranslation() const [searchVisible, setSearchVisible] = useState(false) const [searchText, setSearchText] = useState('') @@ -44,7 +46,7 @@ const ModelListSearchBar: React.FC = ({ 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 = ({ onSearch }) => type="text" placeholder={t('models.search')} size="small" - suffix={} + suffix={icon || } value={searchText} autoFocus allowClear @@ -80,12 +82,12 @@ const ModelListSearchBar: React.FC = ({ onSearch }) => }} style={{ cursor: 'pointer', display: 'flex' }} onClick={() => setSearchVisible(true)}> - - + + {icon || } ) } -export default memo(ModelListSearchBar) +export default memo(CollapsibleSearchBar) diff --git a/src/renderer/src/components/HealthStatusIndicator/index.tsx b/src/renderer/src/components/HealthStatusIndicator/index.tsx new file mode 100644 index 000000000..cbcd692cf --- /dev/null +++ b/src/renderer/src/components/HealthStatusIndicator/index.tsx @@ -0,0 +1,2 @@ +export { default as HealthStatusIndicator } from './indicator' +export * from './types' diff --git a/src/renderer/src/components/HealthStatusIndicator/indicator.tsx b/src/renderer/src/components/HealthStatusIndicator/indicator.tsx new file mode 100644 index 000000000..9dce1c21b --- /dev/null +++ b/src/renderer/src/components/HealthStatusIndicator/indicator.tsx @@ -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 = ({ + results, + loading = false, + showLatency = false +}) => { + const { overallStatus, tooltip, latencyText } = useHealthStatus({ + results, + showLatency + }) + + if (loading) { + return ( + + + + ) + } + + if (overallStatus === 'not_checked') return null + + let icon: React.ReactNode = null + switch (overallStatus) { + case 'success': + icon = + break + case 'error': + icon = + break + case 'partial': + icon = + break + default: + return null + } + + return ( + + {latencyText && {latencyText}} + + {icon} + + + ) +} + +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) diff --git a/src/renderer/src/components/HealthStatusIndicator/types.ts b/src/renderer/src/components/HealthStatusIndicator/types.ts new file mode 100644 index 000000000..f87376b78 --- /dev/null +++ b/src/renderer/src/components/HealthStatusIndicator/types.ts @@ -0,0 +1,12 @@ +import { HealthStatus } from '@renderer/types/healthCheck' + +/** + * 用于展示单个健康检查结果的必要数据 + */ +export interface HealthResult { + status: HealthStatus + latency?: number + error?: string + // 用于在 Tooltip 中显示额外上下文信息,例如 API Key 或模型名称 + label?: string +} diff --git a/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx b/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx new file mode 100644 index 000000000..456961c97 --- /dev/null +++ b/src/renderer/src/components/HealthStatusIndicator/useHealthStatus.tsx @@ -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 = ( +
    + {results.map((result, idx) => { + const statusText = getStatusText(result.status) + const statusColor = + result.status === HealthStatus.SUCCESS ? 'var(--color-status-success)' : 'var(--color-status-error)' + + return ( +
  • + + {statusText} + {result.label} + + {result.latency && result.status === HealthStatus.SUCCESS && ( +
    + {t('settings.provider.api.key.check.latency')}: {formatLatency(result.latency)} +
    + )} + {result.error && result.status === HealthStatus.FAILED && ( +
    {result.error}
    + )} +
  • + ) + })} +
+ ) + + // 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 } +} diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx b/src/renderer/src/components/ModelList/AddModelPopup.tsx similarity index 100% rename from src/renderer/src/pages/settings/ProviderSettings/AddModelPopup.tsx rename to src/renderer/src/components/ModelList/AddModelPopup.tsx diff --git a/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx b/src/renderer/src/components/ModelList/EditModelsPopup.tsx similarity index 97% rename from src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx rename to src/renderer/src/components/ModelList/EditModelsPopup.tsx index 9d0bce970..ecd8694d8 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/EditModelsPopup.tsx +++ b/src/renderer/src/components/ModelList/EditModelsPopup.tsx @@ -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 = ({ provider: _provider, resolve }) => { title={ isAllFilteredInProvider ? t('settings.models.manage.remove_listed') : t('settings.models.manage.add_listed') } - mouseEnterDelay={0.5} + mouseLeaveDelay={0} placement="top"> - - - {models.map((model) => ( - 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) diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index e177cc014..f16e266da 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -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 = ({ 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 = ({ providerId }) => { const apiKeyWebsite = providerConfig?.websites?.apiKey const configedApiHost = providerConfig?.api?.url - const [modelStatuses, setModelStatuses] = useState([]) - const [isHealthChecking, setIsHealthChecking] = useState(false) - const fancyProviderName = getFancyProviderName(provider) const [localApiKey, setLocalApiKey] = useState(provider.apiKey) const [apiKeyConnectivity, setApiKeyConnectivity] = useState({ - status: 'not_checked', + status: HealthStatus.NOT_CHECKED, checking: false }) @@ -97,7 +81,7 @@ const ProviderSetting: FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ providerId }) => { {provider.id === 'gpustack' && } {provider.id === 'copilot' && } {provider.id === 'vertexai' && } - - - - {t('common.models')} - {!isEmpty(models) && } - - {!isEmpty(models) && ( - -