diff --git a/package.json b/package.json index 36cd499ed3..2d6afec174 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "dotenv electron-vite dev", "debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222", "build": "npm run typecheck && electron-vite build", - "build:check": "yarn typecheck && yarn check:i18n && yarn test", + "build:check": "yarn lint && yarn test", "build:unpack": "dotenv npm run build && electron-builder --dir", "build:win": "dotenv npm run build && electron-builder --win --x64 --arm64", "build:win:x64": "dotenv npm run build && electron-builder --win --x64", @@ -66,7 +66,7 @@ "test:lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", "test:scripts": "vitest scripts", "format": "prettier --write .", - "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", + "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix && yarn typecheck && yarn check:i18n", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky" }, "dependencies": { @@ -74,6 +74,7 @@ "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", "@strongtz/win32-arm64-msvc": "^0.4.7", + "express": "^5.1.0", "graceful-fs": "^4.2.11", "jsdom": "26.1.0", "node-stream-zip": "^1.15.0", @@ -212,7 +213,6 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", - "express": "^5.1.0", "fast-diff": "^1.3.0", "fast-xml-parser": "^5.2.0", "fetch-socks": "1.3.2", diff --git a/src/main/apiServer/config.ts b/src/main/apiServer/config.ts index d9b73d521a..15d7a244a3 100644 --- a/src/main/apiServer/config.ts +++ b/src/main/apiServer/config.ts @@ -22,12 +22,14 @@ class ConfigManager { }) this._config = { + enabled: settings?.apiServer?.enabled ?? false, port: settings?.apiServer?.port ?? 23333, host: 'localhost', apiKey: generatedKey } } else { this._config = { + enabled: settings?.apiServer?.enabled ?? false, port: settings?.apiServer?.port ?? 23333, host: 'localhost', apiKey: settings.apiServer.apiKey @@ -38,6 +40,7 @@ class ConfigManager { } catch (error: any) { logger.warn('Failed to load config from Redux, using defaults:', error) this._config = { + enabled: false, port: 23333, host: 'localhost', apiKey: `cs-sk-${uuidv4()}` diff --git a/src/main/index.ts b/src/main/index.ts index 5b61299e4f..d724efdbec 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -143,9 +143,13 @@ if (!app.requestSingleInstanceLock()) { // Start API server if enabled try { - await apiServerService.start() + const config = await apiServerService.getCurrentConfig() + logger.info('API server config:', config) + if (config.enabled) { + await apiServerService.start() + } } catch (error: any) { - logger.error('Failed to start API server:', error) + logger.error('Failed to check/start API server:', error) } }) diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index c6f4f79a78..d41a9ffd60 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -11,6 +11,7 @@ interface CustomCollapseProps { defaultActiveKey?: string[] activeKey?: string[] collapsible?: 'header' | 'icon' | 'disabled' + onChange?: (activeKeys: string | string[]) => void style?: React.CSSProperties styles?: { header?: React.CSSProperties @@ -26,6 +27,7 @@ const CustomCollapse: FC = ({ defaultActiveKey = ['1'], activeKey, collapsible = undefined, + onChange, style, styles }) => { @@ -78,7 +80,10 @@ const CustomCollapse: FC = ({ activeKey={activeKey} destroyInactivePanel={destroyInactivePanel} collapsible={collapsible} - onChange={setActiveKeys} + onChange={(keys) => { + setActiveKeys(keys) + onChange?.(keys) + }} expandIcon={({ isActive }) => ( void +} + +const PopupContainer: React.FC = ({ provider, model, resolve }) => { + const handleUpdateModel = (updatedModel: Model) => { + resolve(updatedModel) + } + + const handleClose = () => { + resolve(undefined) // Resolve with no data on close + } + + return ( + + ) +} + +const TopViewKey = 'EditModelPopup' + +export default class EditModelPopup { + static hide() { + TopView.hide(TopViewKey) + } + + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + this.hide() + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/ModelList/ManageModelsList.tsx b/src/renderer/src/components/ModelList/ManageModelsList.tsx new file mode 100644 index 0000000000..bede6b5b74 --- /dev/null +++ b/src/renderer/src/components/ModelList/ManageModelsList.tsx @@ -0,0 +1,281 @@ +import { MinusOutlined, PlusOutlined } from '@ant-design/icons' +import CustomTag from '@renderer/components/CustomTag' +import ExpandableText from '@renderer/components/ExpandableText' +import ModelIdWithTags from '@renderer/components/ModelIdWithTags' +import NewApiBatchAddModelPopup from '@renderer/components/ModelList/NewApiBatchAddModelPopup' +import { getModelLogo } from '@renderer/config/models' +import FileItem from '@renderer/pages/files/FileItem' +import { Model, Provider } from '@renderer/types' +import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual' +import { Button, Flex, Tooltip } from 'antd' +import { Avatar } from 'antd' +import { ChevronRight } from 'lucide-react' +import React, { memo, useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { isModelInProvider, isValidNewApiModel } from './utils' + +// 列表项类型定义 +interface GroupRowData { + type: 'group' + groupName: string + models: Model[] +} + +interface ModelRowData { + type: 'model' + model: Model +} + +type RowData = GroupRowData | ModelRowData + +interface ManageModelsListProps { + modelGroups: Record + provider: Provider + onAddModel: (model: Model) => void + onRemoveModel: (model: Model) => void +} + +const ManageModelsList: React.FC = ({ modelGroups, provider, onAddModel, onRemoveModel }) => { + const { t } = useTranslation() + const scrollerRef = useRef(null) + const activeStickyIndexRef = useRef(0) + const [collapsedGroups, setCollapsedGroups] = useState(new Set()) + + const handleGroupToggle = useCallback((groupName: string) => { + setCollapsedGroups((prev) => { + const newSet = new Set(prev) + if (newSet.has(groupName)) { + newSet.delete(groupName) // 如果已折叠,则展开 + } else { + newSet.add(groupName) // 如果已展开,则折叠 + } + return newSet + }) + }, []) + + // 将分组数据扁平化为单一列表,过滤掉空组 + const flatRows = useMemo(() => { + const rows: RowData[] = [] + + Object.entries(modelGroups).forEach(([groupName, models]) => { + if (models.length > 0) { + // 只添加非空组 + rows.push({ type: 'group', groupName, models }) + if (!collapsedGroups.has(groupName)) { + models.forEach((model) => { + rows.push({ type: 'model', model }) + }) + } + } + }) + + return rows + }, [modelGroups, collapsedGroups]) + + // 找到所有组 header 的索引 + const stickyIndexes = useMemo(() => { + return flatRows.map((row, index) => (row.type === 'group' ? index : -1)).filter((index) => index !== -1) + }, [flatRows]) + + const isSticky = useCallback((index: number) => stickyIndexes.includes(index), [stickyIndexes]) + + const isActiveSticky = useCallback((index: number) => activeStickyIndexRef.current === index, []) + + // 自定义 range extractor 用于 sticky header + const rangeExtractor = useCallback( + (range: any) => { + activeStickyIndexRef.current = [...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? 0 + const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)]) + return [...next].sort((a, b) => a - b) + }, + [stickyIndexes] + ) + + const virtualizer = useVirtualizer({ + count: flatRows.length, + getScrollElement: () => scrollerRef.current, + estimateSize: () => 42, + rangeExtractor, + overscan: 5 + }) + + const renderGroupTools = useCallback( + (models: Model[]) => { + const isAllInProvider = models.every((model) => isModelInProvider(provider, model.id)) + + const handleGroupAction = () => { + if (isAllInProvider) { + // 移除整组 + models.filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel) + } else { + // 添加整组 + const wouldAddModels = models.filter((model) => !isModelInProvider(provider, model.id)) + + if (provider.id === 'new-api') { + if (wouldAddModels.every(isValidNewApiModel)) { + wouldAddModels.forEach(onAddModel) + } else { + NewApiBatchAddModelPopup.show({ + title: t('settings.models.add.batch_add_models'), + batchModels: wouldAddModels, + provider + }) + } + } else { + wouldAddModels.forEach(onAddModel) + } + } + } + + return ( + + - {models.map((model) => ( - setEditingModel(null)} - key={model.id} - /> - ))} ) } diff --git a/src/renderer/src/components/ModelList/ModelListGroup.tsx b/src/renderer/src/components/ModelList/ModelListGroup.tsx index b4a84ce77d..1e92a7f9b3 100644 --- a/src/renderer/src/components/ModelList/ModelListGroup.tsx +++ b/src/renderer/src/components/ModelList/ModelListGroup.tsx @@ -2,8 +2,9 @@ import { MinusOutlined } from '@ant-design/icons' import CustomCollapse from '@renderer/components/CustomCollapse' import { Model } from '@renderer/types' import { ModelWithStatus } from '@renderer/types/healthCheck' +import { useVirtualizer } from '@tanstack/react-virtual' import { Button, Flex, Tooltip } from 'antd' -import React, { memo } from 'react' +import React, { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -31,11 +32,35 @@ const ModelListGroup: React.FC = ({ onRemoveGroup }) => { const { t } = useTranslation() + const scrollerRef = useRef(null) + const [isExpanded, setIsExpanded] = useState(defaultOpen) + + const virtualizer = useVirtualizer({ + count: models.length, + getScrollElement: () => scrollerRef.current, + estimateSize: () => 52, + overscan: 5 + }) + + const virtualItems = virtualizer.getVirtualItems() + + // 监听折叠面板状态变化,确保虚拟列表在展开时正确渲染 + useEffect(() => { + if (isExpanded && scrollerRef.current) { + requestAnimationFrame(() => virtualizer.measure()) + } + }, [isExpanded, virtualizer]) + + const handleCollapseChange = (activeKeys: string[] | string) => { + const isNowExpanded = Array.isArray(activeKeys) ? activeKeys.length > 0 : !!activeKeys + setIsExpanded(isNowExpanded) + } return ( {groupName} @@ -47,23 +72,53 @@ const ModelListGroup: React.FC = ({ type="text" className="toolbar-item" icon={} - onClick={onRemoveGroup} + onClick={(e) => { + e.stopPropagation() + onRemoveGroup() + }} disabled={disabled} /> }> - - {models.map((model) => ( - status.model.id === model.id)} - onEdit={onEditModel} - onRemove={onRemoveModel} - disabled={disabled} - /> - ))} - + +
+
+ {virtualItems.map((virtualItem) => { + const model = models[virtualItem.index] + return ( +
+ status.model.id === model.id)} + onEdit={onEditModel} + onRemove={onRemoveModel} + disabled={disabled} + /> +
+ ) + })} +
+
+
) @@ -79,6 +134,17 @@ const CustomCollapseWrapper = styled.div` &:hover .toolbar-item { opacity: 1; } + + /* 移除 collapse 的 padding,转而在 scroller 内部调整 */ + .ant-collapse-content-box { + padding: 0 !important; + } +` + +const ScrollContainer = styled.div` + overflow-y: auto; + max-height: 390px; + padding: 4px 16px; ` export default memo(ModelListGroup) diff --git a/src/renderer/src/components/ModelList/index.ts b/src/renderer/src/components/ModelList/index.ts index db47dd39d3..937c8f6617 100644 --- a/src/renderer/src/components/ModelList/index.ts +++ b/src/renderer/src/components/ModelList/index.ts @@ -1,6 +1,7 @@ export { default as AddModelPopup } from './AddModelPopup' -export { default as EditModelsPopup } from './EditModelsPopup' +export { default as EditModelPopup } from './EditModelPopup' export { default as HealthCheckPopup } from './HealthCheckPopup' +export { default as ManageModelsPopup } from './ManageModelsPopup' export { default as ModelEditContent } from './ModelEditContent' export { default as ModelList } from './ModelList' export { default as NewApiAddModelPopup } from './NewApiAddModelPopup' diff --git a/src/renderer/src/components/ModelList/utils.ts b/src/renderer/src/components/ModelList/utils.ts new file mode 100644 index 0000000000..3ced576219 --- /dev/null +++ b/src/renderer/src/components/ModelList/utils.ts @@ -0,0 +1,10 @@ +import { Model, Provider } from '@renderer/types' + +// Check if the model exists in the provider's model list +export const isModelInProvider = (provider: Provider, modelId: string): boolean => { + return provider.models.some((m) => m.id === modelId) +} + +export const isValidNewApiModel = (model: Model): boolean => { + return !!(model.supported_endpoint_types && model.supported_endpoint_types.length > 0) +} diff --git a/src/renderer/src/pages/discover/pages/agents/components/AddAgentPopup.tsx b/src/renderer/src/pages/discover/pages/agents/components/AddAgentPopup.tsx index 087ab3d6c6..2550e82f11 100644 --- a/src/renderer/src/pages/discover/pages/agents/components/AddAgentPopup.tsx +++ b/src/renderer/src/pages/discover/pages/agents/components/AddAgentPopup.tsx @@ -199,7 +199,8 @@ const PopupContainer: React.FC = ({ resolve }) => { }} /> } - arrow> + arrow + trigger="click"> diff --git a/src/renderer/src/pages/settings/ApiServerSettings/ApiServerSettings.tsx b/src/renderer/src/pages/settings/ApiServerSettings/ApiServerSettings.tsx index f612e2f93d..2fb22e9588 100644 --- a/src/renderer/src/pages/settings/ApiServerSettings/ApiServerSettings.tsx +++ b/src/renderer/src/pages/settings/ApiServerSettings/ApiServerSettings.tsx @@ -1,7 +1,7 @@ import { useTheme } from '@renderer/context/ThemeProvider' import { loggerService } from '@renderer/services/LoggerService' import { RootState, useAppDispatch } from '@renderer/store' -import { setApiServerApiKey, setApiServerPort } from '@renderer/store/settings' +import { setApiServerApiKey, setApiServerEnabled, setApiServerPort } from '@renderer/store/settings' import { IpcChannel } from '@shared/IpcChannel' import { Button, Input, InputNumber, Tooltip, Typography } from 'antd' import { Copy, ExternalLink, Play, RotateCcw, Square } from 'lucide-react' @@ -64,6 +64,7 @@ const ApiServerSettings: FC = () => { } catch (error) { window.message.error(t('apiServer.messages.operationFailed') + (error as Error).message) } finally { + dispatch(setApiServerEnabled(enabled)) setApiServerLoading(false) } } diff --git a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx index edf96505bd..026d148448 100644 --- a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx @@ -109,7 +109,7 @@ const AssistantSettings: FC = () => { theme={theme}> {t('common.name')} - } arrow> + } arrow trigger="click"> {emoji && ( diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index 351c5a58f2..711f503c86 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -19,7 +19,7 @@ import { } from '@renderer/utils' import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd' import { Eye, EyeOff, Search, UserPen } from 'lucide-react' -import { FC, useEffect, useState } from 'react' +import { FC, startTransition, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router-dom' import styled from 'styled-components' @@ -34,12 +34,19 @@ const ProvidersList: FC = () => { const [searchParams] = useSearchParams() const providers = useAllProviders() const { updateProviders, addProvider, removeProvider, updateProvider } = useProviders() - const [selectedProvider, setSelectedProvider] = useState(providers[0]) + const [selectedProvider, _setSelectedProvider] = useState(providers[0]) const { t } = useTranslation() const [searchText, setSearchText] = useState('') const [dragging, setDragging] = useState(false) const [providerLogos, setProviderLogos] = useState>({}) + const setSelectedProvider = useCallback( + (provider: Provider) => { + startTransition(() => _setSelectedProvider(provider)) + }, + [_setSelectedProvider] + ) + useEffect(() => { const loadAllLogos = async () => { const logos: Record = {} @@ -71,7 +78,7 @@ const ProvidersList: FC = () => { setSelectedProvider(providers[0]) } } - }, [providers, searchParams]) + }, [providers, searchParams, setSelectedProvider]) // Handle provider add key from URL schema useEffect(() => { diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 841d9de934..8976780449 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -312,16 +312,18 @@ async function fetchExternalTool( let memorySearchReferences: MemoryItem[] | undefined const parentSpanId = currentSpan(lastUserMessage.topicId, assistant.model?.name)?.spanContext().spanId - // 并行执行搜索 - if (shouldWebSearch || shouldKnowledgeSearch || shouldSearchMemory) { - ;[webSearchResponseFromSearch, knowledgeReferencesFromSearch, memorySearchReferences] = await Promise.all([ - searchTheWeb(extractResults, parentSpanId), - searchKnowledgeBase(extractResults, parentSpanId, assistant.model?.name), - searchMemory() - ]) + if (shouldWebSearch) { + webSearchResponseFromSearch = await searchTheWeb(extractResults, parentSpanId) + } + + if (shouldKnowledgeSearch) { + knowledgeReferencesFromSearch = await searchKnowledgeBase(extractResults, parentSpanId, assistant.model?.name) + } + + if (shouldSearchMemory) { + memorySearchReferences = await searchMemory() } - // 存储搜索结果 if (lastUserMessage) { if (webSearchResponseFromSearch) { window.keyv.set(`web-search-${lastUserMessage.id}`, webSearchResponseFromSearch) @@ -492,7 +494,7 @@ export async function fetchChatCompletion({ // Post-conversation memory processing const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState()) if (globalMemoryEnabled && assistant.enableMemory) { - await processConversationMemory(messages, assistant) + processConversationMemory(messages, assistant) } return await AI.completionsForTrace(completionsParams, requestOptions) diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 128cc7c251..91b40e78a0 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1925,6 +1925,7 @@ const migrateConfig = { // Initialize API server configuration if not present if (!state.settings.apiServer) { state.settings.apiServer = { + enabled: false, host: 'localhost', port: 23333, apiKey: `cs-sk-${uuid()}` diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 296537cae5..e12d15de5f 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -391,6 +391,7 @@ export const initialState: SettingsState = { navbarPosition: 'left', // API Server apiServer: { + enabled: false, host: 'localhost', port: 23333, apiKey: `cs-sk-${uuid()}` @@ -800,6 +801,12 @@ const settingsSlice = createSlice({ state.navbarPosition = action.payload }, // API Server actions + setApiServerEnabled: (state, action: PayloadAction) => { + state.apiServer = { + ...state.apiServer, + enabled: action.payload + } + }, setApiServerPort: (state, action: PayloadAction) => { state.apiServer = { ...state.apiServer, @@ -935,6 +942,7 @@ export const { setEnableDeveloperMode, setNavbarPosition, // API Server actions + setApiServerEnabled, setApiServerPort, setApiServerApiKey } = settingsSlice.actions diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 9eff1e5c6a..d2ee286be7 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -842,6 +842,7 @@ export type S3Config = { export type { Message } from './newMessage' export interface ApiServerConfig { + enabled: boolean host: string port: number apiKey: string