Merge remote-tracking branch 'origin/main' into feat/cherry-store

This commit is contained in:
MyPrototypeWhat 2025-07-31 14:03:26 +08:00
commit ce8808b023
19 changed files with 600 additions and 241 deletions

View File

@ -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",

View File

@ -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()}`

View File

@ -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)
}
})

View File

@ -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<CustomCollapseProps> = ({
defaultActiveKey = ['1'],
activeKey,
collapsible = undefined,
onChange,
style,
styles
}) => {
@ -78,7 +80,10 @@ const CustomCollapse: FC<CustomCollapseProps> = ({
activeKey={activeKey}
destroyInactivePanel={destroyInactivePanel}
collapsible={collapsible}
onChange={setActiveKeys}
onChange={(keys) => {
setActiveKeys(keys)
onChange?.(keys)
}}
expandIcon={({ isActive }) => (
<ChevronRight
size={16}

View File

@ -0,0 +1,57 @@
import ModelEditContent from '@renderer/components/ModelList/ModelEditContent'
import { TopView } from '@renderer/components/TopView'
import { Model, Provider } from '@renderer/types'
import React from 'react'
interface ShowParams {
provider: Provider
model: Model
}
interface Props extends ShowParams {
resolve: (data?: Model) => void
}
const PopupContainer: React.FC<Props> = ({ provider, model, resolve }) => {
const handleUpdateModel = (updatedModel: Model) => {
resolve(updatedModel)
}
const handleClose = () => {
resolve(undefined) // Resolve with no data on close
}
return (
<ModelEditContent
provider={provider}
model={model}
onUpdateModel={handleUpdateModel}
open={true} // Always open when rendered by TopView
onClose={handleClose}
key={model.id} // Ensure re-mount when model changes
/>
)
}
const TopViewKey = 'EditModelPopup'
export default class EditModelPopup {
static hide() {
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<Model | undefined>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>,
TopViewKey
)
})
}
}

View File

@ -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<string, Model[]>
provider: Provider
onAddModel: (model: Model) => void
onRemoveModel: (model: Model) => void
}
const ManageModelsList: React.FC<ManageModelsListProps> = ({ modelGroups, provider, onAddModel, onRemoveModel }) => {
const { t } = useTranslation()
const scrollerRef = useRef<HTMLDivElement>(null)
const activeStickyIndexRef = useRef(0)
const [collapsedGroups, setCollapsedGroups] = useState(new Set<string>())
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 (
<Tooltip
destroyTooltipOnHide
title={
isAllInProvider
? t('settings.models.manage.remove_whole_group')
: t('settings.models.manage.add_whole_group')
}
mouseLeaveDelay={0}
placement="top">
<Button
type="text"
icon={isAllInProvider ? <MinusOutlined /> : <PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
handleGroupAction()
}}
/>
</Tooltip>
)
},
[provider, onRemoveModel, onAddModel, t]
)
const virtualItems = virtualizer.getVirtualItems()
return (
<ListContainer ref={scrollerRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
{virtualItems.map((virtualItem) => {
const row = flatRows[virtualItem.index]
const isRowSticky = isSticky(virtualItem.index)
const isRowActiveSticky = isActiveSticky(virtualItem.index)
const isCollapsed = row.type === 'group' && collapsedGroups.has(row.groupName)
if (!row) return null
return (
<div
key={virtualItem.index}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
...(isRowSticky
? {
background: 'var(--color-background)',
zIndex: 1
}
: {}),
...(isRowActiveSticky
? {
position: 'sticky'
}
: {
position: 'absolute',
transform: `translateY(${virtualItem.start}px)`
}),
top: 0,
left: 0,
width: '100%'
}}>
{row.type === 'group' ? (
<GroupHeader onClick={() => handleGroupToggle(row.groupName)}>
<Flex align="center" gap={10} style={{ flex: 1 }}>
<ChevronRight
size={16}
color="var(--color-text-3)"
strokeWidth={1.5}
style={{ transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)' }}
/>
<span style={{ fontWeight: 'bold', fontSize: '14px' }}>{row.groupName}</span>
<CustomTag color="#02B96B" size={10}>
{row.models.length}
</CustomTag>
</Flex>
{renderGroupTools(row.models)}
</GroupHeader>
) : (
<div style={{ padding: '4px 0' }}>
<ModelListItem
model={row.model}
provider={provider}
onAddModel={onAddModel}
onRemoveModel={onRemoveModel}
/>
</div>
)}
</div>
)
})}
</div>
</ListContainer>
)
}
// 模型列表项组件
interface ModelListItemProps {
model: Model
provider: Provider
onAddModel: (model: Model) => void
onRemoveModel: (model: Model) => void
}
const ModelListItem: React.FC<ModelListItemProps> = memo(({ model, provider, onAddModel, onRemoveModel }) => {
const isAdded = useMemo(() => isModelInProvider(provider, model.id), [provider, model.id])
return (
<FileItem
style={{
backgroundColor: isAdded ? 'rgba(0, 126, 0, 0.06)' : '',
border: 'none',
boxShadow: 'none'
}}
fileInfo={{
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
name: <ModelIdWithTags model={model} />,
extra: model.description && <ExpandableText text={model.description} />,
ext: '.model',
actions: isAdded ? (
<Button type="text" onClick={() => onRemoveModel(model)} icon={<MinusOutlined />} />
) : (
<Button type="text" onClick={() => onAddModel(model)} icon={<PlusOutlined />} />
)
}}
/>
)
})
const ListContainer = styled.div`
height: calc(100vh - 300px);
overflow: auto;
padding-right: 10px;
`
const GroupHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
min-height: 48px;
color: var(--color-text);
cursor: pointer;
`
export default memo(ManageModelsList)

View File

@ -1,15 +1,10 @@
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
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 SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
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,
isEmbeddingModel,
isFunctionCallingModel,
@ -21,7 +16,6 @@ import {
SYSTEM_MODELS
} from '@renderer/config/models'
import { useProvider } from '@renderer/hooks/useProvider'
import FileItem from '@renderer/pages/files/FileItem'
import { fetchModels } from '@renderer/services/ApiService'
import { Model, Provider } from '@renderer/types'
import {
@ -31,16 +25,19 @@ import {
isFreeModel,
runAsyncFunction
} from '@renderer/utils'
import { Avatar, Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd'
import { Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd'
import Input from 'antd/es/input/Input'
import { groupBy, isEmpty, uniqBy } from 'lodash'
import { debounce } from 'lodash'
import { Search } from 'lucide-react'
import { memo, useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from 'react'
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const logger = loggerService.withContext('EditModelsPopup')
import ManageModelsList from './ManageModelsList'
import { isModelInProvider, isValidNewApiModel } from './utils'
const logger = loggerService.withContext('ManageModelsPopup')
interface ShowParams {
provider: Provider
@ -50,15 +47,6 @@ interface Props extends ShowParams {
resolve: (data: any) => void
}
// Check if the model exists in the provider's model list
const isModelInProvider = (provider: Provider, modelId: string): boolean => {
return provider.models.some((m) => m.id === modelId)
}
const isValidNewApiModel = (model: Model): boolean => {
return !!(model.supported_endpoint_types && model.supported_endpoint_types.length > 0)
}
const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
const [open, setOpen] = useState(true)
const { provider, models, addModel, removeModel } = useProvider(_provider.id)
@ -286,50 +274,6 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
)
}, [list, t, loading, provider, onRemoveModel, models, onAddModel])
const renderGroupTools = useCallback(
(group: string) => {
const isAllInProvider = modelGroups[group].every((model) => isModelInProvider(provider, model.id))
return (
<Tooltip
destroyTooltipOnHide
title={
isAllInProvider
? t('settings.models.manage.remove_whole_group')
: t('settings.models.manage.add_whole_group')
}
mouseLeaveDelay={0}
placement="top">
<Button
type="text"
icon={isAllInProvider ? <MinusOutlined /> : <PlusOutlined />}
onClick={(e) => {
e.stopPropagation()
if (isAllInProvider) {
modelGroups[group].filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel)
} else {
const wouldAddModel = modelGroups[group].filter((model) => !isModelInProvider(provider, model.id))
if (provider.id === 'new-api') {
if (wouldAddModel.every(isValidNewApiModel)) {
wouldAddModel.forEach(onAddModel)
} else {
NewApiBatchAddModelPopup.show({
title: t('settings.models.add.batch_add_models'),
batchModels: wouldAddModel,
provider
})
}
} else {
wouldAddModel.forEach(onAddModel)
}
}
}}
/>
</Tooltip>
)
},
[modelGroups, provider, onRemoveModel, onAddModel, t]
)
return (
<Modal
title={<ModalHeader />}
@ -388,38 +332,15 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
<ListContainer>
{loading || isFilterTypePending || isSearchPending ? (
<Flex justify="center" align="center" style={{ height: '70%' }}>
<Spin size="large" />
<Spin indicator={<SvgSpinners180Ring color="var(--color-text-2)" />} />
</Flex>
) : (
Object.keys(modelGroups).map((group, i) => {
return (
<CustomCollapse
key={i}
defaultActiveKey={['1']}
styles={{ body: { padding: '0 10px' } }}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 600 }}>{group}</span>
<CustomTag color="#02B96B" size={10}>
{modelGroups[group].length}
</CustomTag>
</Flex>
}
extra={renderGroupTools(group)}>
<FlexColumn style={{ margin: '10px 0' }}>
{modelGroups[group].map((model) => (
<ModelListItem
key={model.id}
model={model}
provider={provider}
onAddModel={onAddModel}
onRemoveModel={onRemoveModel}
/>
))}
</FlexColumn>
</CustomCollapse>
)
})
<ManageModelsList
modelGroups={modelGroups}
provider={provider}
onAddModel={onAddModel}
onRemoveModel={onRemoveModel}
/>
)}
{!(loading || isFilterTypePending || isSearchPending) && isEmpty(list) && (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('settings.models.empty')} />
@ -429,38 +350,6 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
)
}
interface ModelListItemProps {
model: Model
provider: Provider
onAddModel: (model: Model) => void
onRemoveModel: (model: Model) => void
}
const ModelListItem: React.FC<ModelListItemProps> = memo(({ model, provider, onAddModel, onRemoveModel }) => {
const isAdded = useMemo(() => isModelInProvider(provider, model.id), [provider, model.id])
return (
<FileItem
style={{
backgroundColor: isAdded ? 'rgba(0, 126, 0, 0.06)' : '',
border: 'none',
boxShadow: 'none'
}}
fileInfo={{
icon: <Avatar src={getModelLogo(model.id)}>{model?.name?.[0]?.toUpperCase()}</Avatar>,
name: <ModelIdWithTags model={model} />,
extra: model.description && <ExpandableText text={model.description} />,
ext: '.model',
actions: isAdded ? (
<Button type="text" onClick={() => onRemoveModel(model)} icon={<MinusOutlined />} />
) : (
<Button type="text" onClick={() => onAddModel(model)} icon={<PlusOutlined />} />
)
}}
/>
)
})
const SearchContainer = styled.div`
display: flex;
flex-direction: column;
@ -480,19 +369,8 @@ const TopToolsWrapper = styled.div`
margin-bottom: 0;
`
const ListContainer = styled(Scrollbar)`
const ListContainer = styled.div`
height: calc(100vh - 300px);
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 30px;
`
const FlexColumn = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
`
const ModelHeaderTitle = styled.div`
@ -502,10 +380,12 @@ const ModelHeaderTitle = styled.div`
margin-right: 10px;
`
export default class EditModelsPopup {
const TopViewKey = 'ManageModelsPopup'
export default class ManageModelsPopup {
static topviewId = 0
static hide() {
TopView.hide('EditModelsPopup')
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
@ -517,7 +397,7 @@ export default class EditModelsPopup {
this.hide()
}}
/>,
'EditModelsPopup'
TopViewKey
)
})
}

View File

@ -1,9 +1,11 @@
import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar'
import CustomTag from '@renderer/components/CustomTag'
import { StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons/SVGIcon'
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
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 EditModelPopup from '@renderer/components/ModelList/EditModelPopup'
import ManageModelsPopup from '@renderer/components/ModelList/ManageModelsPopup'
import NewApiAddModelPopup from '@renderer/components/ModelList/NewApiAddModelPopup'
import { PROVIDER_CONFIG } from '@renderer/config/providers'
import { useAssistants, useDefaultModel } from '@renderer/hooks/useAssistant'
@ -14,10 +16,10 @@ import { useAppDispatch } from '@renderer/store'
import { setModel } from '@renderer/store/assistants'
import { Model } from '@renderer/types'
import { filterModelsByKeywords } from '@renderer/utils'
import { Button, Flex, Tooltip } from 'antd'
import { Button, Flex, Spin, Tooltip } from 'antd'
import { groupBy, sortBy, toPairs } from 'lodash'
import { ListCheck, Plus } from 'lucide-react'
import React, { memo, startTransition, useCallback, useMemo, useState } from 'react'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ModelListGroup from './ModelListGroup'
@ -27,6 +29,21 @@ interface ModelListProps {
providerId: string
}
type ModelGroups = Record<string, Model[]>
const MODEL_COUNT_THRESHOLD = 10
/**
*
*/
const calculateModelGroups = (models: Model[], searchText: string): ModelGroups => {
const filteredModels = searchText ? filterModelsByKeywords(searchText, models) : models
const grouped = groupBy(filteredModels, 'group')
return sortBy(toPairs(grouped), [0]).reduce((acc, [key, value]) => {
acc[key] = value
return acc
}, {})
}
/**
* CRUD
*/
@ -41,8 +58,13 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
const docsWebsite = providerConfig?.websites?.docs
const modelsWebsite = providerConfig?.websites?.models
const [editingModel, setEditingModel] = useState<Model | null>(null)
const [searchText, _setSearchText] = useState('')
const [displayedModelGroups, setDisplayedModelGroups] = useState<ModelGroups | null>(() => {
if (models.length > MODEL_COUNT_THRESHOLD) {
return null
}
return calculateModelGroups(models, '')
})
const { isChecking: isHealthChecking, modelStatuses, runHealthCheck } = useHealthCheck(provider, models)
@ -52,20 +74,22 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
})
}, [])
const modelGroups = useMemo(() => {
const filteredModels = searchText ? filterModelsByKeywords(searchText, models) : models
return groupBy(filteredModels, 'group')
}, [searchText, models])
useEffect(() => {
if (models.length > MODEL_COUNT_THRESHOLD) {
startTransition(() => {
setDisplayedModelGroups(calculateModelGroups(models, searchText))
})
} else {
setDisplayedModelGroups(calculateModelGroups(models, searchText))
}
}, [models, searchText])
const sortedModelGroups = useMemo(() => {
return sortBy(toPairs(modelGroups), [0]).reduce((acc, [key, value]) => {
acc[key] = value
return acc
}, {})
}, [modelGroups])
const modelCount = useMemo(() => {
return Object.values(displayedModelGroups ?? {}).reduce((acc, group) => acc + group.length, 0)
}, [displayedModelGroups])
const onManageModel = useCallback(() => {
EditModelsPopup.show({ provider })
ManageModelsPopup.show({ provider })
}, [provider])
const onAddModel = useCallback(() => {
@ -76,10 +100,6 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
}
}, [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))
@ -104,12 +124,27 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
[models, updateProvider, provider.id, assistants, defaultModel, dispatch, setDefaultModel]
)
const onEditModel = useCallback(
async (model: Model) => {
const updatedModel = await EditModelPopup.show({ provider, model })
if (updatedModel) {
onUpdateModel(updatedModel)
}
},
[provider, onUpdateModel]
)
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>
{modelCount > 0 && (
<CustomTag color="#8c8c8c" size={10}>
{modelCount}
</CustomTag>
)}
<CollapsibleSearchBar onSearch={setSearchText} />
</HStack>
<HStack>
@ -123,41 +158,47 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
</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}>
{getProviderLabel(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>
{displayedModelGroups === null ? (
<Flex align="center" justify="center" style={{ minHeight: '8rem' }}>
<Spin indicator={<SvgSpinners180Ring color="var(--color-text-2)" />} />
</Flex>
) : (
<Flex gap={12} vertical>
{Object.keys(displayedModelGroups).map((group, i) => (
<ModelListGroup
key={group}
groupName={group}
models={displayedModelGroups[group]}
modelStatuses={modelStatuses}
defaultOpen={i <= 5}
disabled={isHealthChecking}
onEditModel={onEditModel}
onRemoveModel={removeModel}
onRemoveGroup={() => displayedModelGroups[group].forEach((model) => removeModel(model))}
/>
))}
{docsWebsite || modelsWebsite ? (
<SettingHelpTextRow>
<SettingHelpText>{t('settings.provider.docs_check')} </SettingHelpText>
{docsWebsite && (
<SettingHelpLink target="_blank" href={docsWebsite}>
{getProviderLabel(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: 10 }}>
<Button type="primary" onClick={onManageModel} icon={<ListCheck size={16} />} disabled={isHealthChecking}>
{t('button.manage')}
@ -166,16 +207,6 @@ const ModelList: React.FC<ModelListProps> = ({ providerId }) => {
{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}
/>
))}
</>
)
}

View File

@ -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<ModelListGroupProps> = ({
onRemoveGroup
}) => {
const { t } = useTranslation()
const scrollerRef = useRef<HTMLDivElement>(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 (
<CustomCollapseWrapper>
<CustomCollapse
defaultActiveKey={defaultOpen ? ['1'] : []}
onChange={handleCollapseChange}
label={
<Flex align="center" gap={10}>
<span style={{ fontWeight: 'bold' }}>{groupName}</span>
@ -47,23 +72,53 @@ const ModelListGroup: React.FC<ModelListGroupProps> = ({
type="text"
className="toolbar-item"
icon={<MinusOutlined />}
onClick={onRemoveGroup}
onClick={(e) => {
e.stopPropagation()
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>
<ScrollContainer ref={scrollerRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`
}}>
{virtualItems.map((virtualItem) => {
const model = models[virtualItem.index]
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
/* 在这里调整 item 间距 */
padding: '4px 0'
}}>
<ModelListItem
model={model}
modelStatus={modelStatuses.find((status) => status.model.id === model.id)}
onEdit={onEditModel}
onRemove={onRemoveModel}
disabled={disabled}
/>
</div>
)
})}
</div>
</div>
</ScrollContainer>
</CustomCollapse>
</CustomCollapseWrapper>
)
@ -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)

View File

@ -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'

View File

@ -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)
}

View File

@ -199,7 +199,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
}}
/>
}
arrow>
arrow
trigger="click">
<Button icon={emoji && <span style={{ fontSize: 20 }}>{emoji}</span>}>{t('common.select')}</Button>
</Popover>
</Form.Item>

View File

@ -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)
}
}

View File

@ -109,7 +109,7 @@ const AssistantSettings: FC = () => {
theme={theme}>
<SettingSubtitle style={{ marginTop: 0 }}>{t('common.name')}</SettingSubtitle>
<HStack gap={8} alignItems="center">
<Popover content={<EmojiPicker onEmojiClick={handleEmojiSelect} />} arrow>
<Popover content={<EmojiPicker onEmojiClick={handleEmojiSelect} />} arrow trigger="click">
<EmojiButtonWrapper>
<Button style={{ fontSize: 20, padding: '4px', minWidth: '30px', height: '30px' }}>{emoji}</Button>
{emoji && (

View File

@ -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<Provider>(providers[0])
const [selectedProvider, _setSelectedProvider] = useState<Provider>(providers[0])
const { t } = useTranslation()
const [searchText, setSearchText] = useState<string>('')
const [dragging, setDragging] = useState(false)
const [providerLogos, setProviderLogos] = useState<Record<string, string>>({})
const setSelectedProvider = useCallback(
(provider: Provider) => {
startTransition(() => _setSelectedProvider(provider))
},
[_setSelectedProvider]
)
useEffect(() => {
const loadAllLogos = async () => {
const logos: Record<string, string> = {}
@ -71,7 +78,7 @@ const ProvidersList: FC = () => {
setSelectedProvider(providers[0])
}
}
}, [providers, searchParams])
}, [providers, searchParams, setSelectedProvider])
// Handle provider add key from URL schema
useEffect(() => {

View File

@ -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)

View File

@ -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()}`

View File

@ -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<boolean>) => {
state.apiServer = {
...state.apiServer,
enabled: action.payload
}
},
setApiServerPort: (state, action: PayloadAction<number>) => {
state.apiServer = {
...state.apiServer,
@ -935,6 +942,7 @@ export const {
setEnableDeveloperMode,
setNavbarPosition,
// API Server actions
setApiServerEnabled,
setApiServerPort,
setApiServerApiKey
} = settingsSlice.actions

View File

@ -842,6 +842,7 @@ export type S3Config = {
export type { Message } from './newMessage'
export interface ApiServerConfig {
enabled: boolean
host: string
port: number
apiKey: string