feat: local model list page

This commit is contained in:
ningyv
2025-02-12 15:28:17 +08:00
parent 2f435d561e
commit 7ac385b317
7 changed files with 457 additions and 40 deletions
@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { AiSettingProvider } from './contexts/AiSettingContext'
import OnlineModelList from './OnlineModelList'
import LocalModelList from './LocalModelList'
const CONTENT_STYLE = { height: 'calc(-300px + 100vh)' } as const
@@ -47,7 +48,9 @@ const AiSettingContent = () => {
{
key: 'config',
label: $t('本地模型'),
children: <div className="overflow-auto" style={CONTENT_STYLE}></div>
children: <div className="overflow-auto" style={CONTENT_STYLE}>
<LocalModelList />
</div>
}
]}
/>
@@ -0,0 +1,347 @@
import { ActionType } from '@ant-design/pro-components'
import PageList, { PageProColumns } from '@common/components/aoplatform/PageList'
import TableBtnWithPermission from '@common/components/aoplatform/TableBtnWithPermission'
import { BasicResponse, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const'
import { useFetch } from '@common/hooks/http'
import { $t } from '@common/locales'
import { App, Divider, Form, Space, Switch, Tag } from 'antd'
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { ModelListData } from './types'
import LocalAiDeploy, { LocalAiDeployHandle } from '../guide/LocalAiDeploy'
import { ServiceDeployment } from '../system/serviceDeployment/ServiceDeployment'
import { LogsFooter } from '../system/serviceDeployment/ServiceDeployMentFooter'
import WithPermission from '@common/components/aoplatform/WithPermission'
type EditLocalModelModalHandle = {
save: () => Promise<boolean | string>
}
type EditLocalModelModalProps = {
enable: boolean
modelID?: string
}
const EditLocalModelModal = forwardRef<EditLocalModelModalHandle, EditLocalModelModalProps>((props: EditLocalModelModalProps, ref) => {
const { enable, modelID } = props
const { fetchData } = useFetch()
const { message } = App.useApp()
const [form] = Form.useForm()
const [currentStatus, setCurrentStatus] = useState<boolean>(enable)
useEffect(() => {
form.setFieldsValue({ enable })
}, [])
/**
* 保存
* @returns
*/
const save: () => Promise<boolean | string> = () => {
return new Promise((resolve, reject) => {
try {
form
.validateFields()
.then((value) => {
const finalValue = {
disable: !value.enable
}
fetchData<BasicResponse<null>>('model/local/info', {
method: 'PUT',
eoParams: { model: modelID },
eoBody: finalValue,
})
.then((response) => {
const { code, msg } = response
if (code === STATUS_CODE.SUCCESS) {
message.success(msg || $t(RESPONSE_TIPS.success))
resolve(true)
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
reject(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo) => reject(errorInfo))
})
.catch((errorInfo) => reject(errorInfo))
} catch (error) {
reject(error)
}
})
}
useImperativeHandle(ref, () => ({
save
}))
return (
<WithPermission access="">
<Form
layout="vertical"
labelAlign="left"
scrollToFirstError
form={form}
className="mx-auto "
name="partitionInsideCert"
autoComplete="off"
>
<Form.Item className="p-4 bg-white rounded-lg" label={$t('LLM 状态管理')}>
<div className="flex justify-between items-center">
<div>
<span className="text-gray-600">{$t('当前调用状态:')}</span>
{currentStatus && <Tag color="success">{$t('正常')}</Tag>}
{!currentStatus && <Tag color="warning">{$t('停用')}</Tag>}
</div>
<Form.Item name="enable" valuePropName="checked" noStyle>
<Switch
checkedChildren={$t('启用')}
unCheckedChildren={$t('停用')}
onChange={(checked) => {
form.setFieldsValue({ enable: checked })
setCurrentStatus(checked)
}}
/>
</Form.Item>
</div>
</Form.Item>
</Form>
</WithPermission>
)
})
const LocalModelList: React.FC = () => {
const pageListRef = useRef<ActionType>(null)
const { message, modal } = App.useApp()
const { fetchData } = useFetch()
const [searchWord, setSearchWord] = useState<string>('')
const localAiDeployRef = useRef<LocalAiDeployHandle>()
const EditLocalModelModalRef = useRef<EditLocalModelModalHandle>()
const handleEdit = (record: ModelListData) => {
modal.confirm({
title: $t('部署 AI 模型'),
content: <EditLocalModelModal ref={EditLocalModelModalRef} modelID={record.id} enable={record.state !== 'disabled'}/>,
onOk: () => {
return EditLocalModelModalRef.current?.save().then((res) => {
if (res === true) {
pageListRef.current?.reload()
}
})
},
width: 600,
okText: $t('确认'),
cancelText: $t('取消'),
closable: true,
icon: <></>
})
}
const handleAdd = () => {
const modalInstance = modal.confirm({
title: $t('部署 AI 模型'),
content: (
<LocalAiDeploy
ref={localAiDeployRef}
onClose={() => {
modalInstance.destroy()
pageListRef.current?.reload()
}}
></LocalAiDeploy>
),
onOk: () => {
return localAiDeployRef.current?.deployLocalAIServer().then((res) => {
if (res === true) {
pageListRef.current?.reload()
}
})
},
width: 600,
okText: $t('确认'),
cancelText: $t('取消'),
closable: true,
icon: <></>
})
}
const handleDelete = async (id: string, apiCount: number) => {
modal.confirm({
title: $t('停止部署'),
content: `${$t('有')} ${apiCount} ${$t('个API使用当前模型,删除当前的模型配置后,该模型相关的API将会切换为使用负载均衡中优先级最高的可用模型。并且当前模型下的所有API KEY和相关数据将会被清空,是否确认删除当前模型?')}`,
onOk: () => {
return new Promise((resolve, reject) => {
try {
fetchData<BasicResponse<any>>('ai/provider', {
method: 'DELETE',
eoParams: {
provider: id
}
})
.then((response) => {
if (response.code === STATUS_CODE.SUCCESS) {
message.success($t('删除成功'))
pageListRef.current?.reload()
} else {
message.error(response.msg || RESPONSE_TIPS.error)
}
resolve(true)
})
.catch((error) => {
message.error(RESPONSE_TIPS.error)
resolve(true)
})
} catch (error) {
message.error(RESPONSE_TIPS.error)
resolve(true)
}
})
},
width: 600,
okText: $t('确认'),
cancelText: $t('取消'),
closable: true,
icon: <></>
})
}
const requestList = async (params: any) => {
try {
const response = await fetchData<BasicResponse<{ data: ModelListData[] }>>('model/local/list', {
method: 'GET',
eoParams: {
page_size: params.pageSize,
keyword: searchWord,
page: params.current
},
eoApiPrefix: 'http://uat.apikit.com:11204/mockApi/aoplatform/api/v1/',
eoTransformKeys: ['can_delete', 'api_count']
})
if (response.code === STATUS_CODE.SUCCESS) {
return {
data: response.data.models,
success: true,
total: response.data.total
}
} else {
message.error(response.msg || $t(RESPONSE_TIPS.error))
return {
data: [],
success: false,
total: response.data.total
}
}
} catch (error) {
return {
data: [],
success: false,
total: 0
}
}
}
const operation: PageProColumns<ModelListData>[] = [
{
title: '',
key: 'option',
btnNums: 4,
fixed: 'right',
valueType: 'option',
render: (_: React.ReactNode, entity: ModelListData) => [
<TableBtnWithPermission
access="system.devops.ai_provider.edit"
key="edit"
btnType="edit"
onClick={() => handleEdit(entity)}
btnTitle={$t('设置')}
/>,
<Divider type="vertical" className="mx-0" />,
<TableBtnWithPermission
disabled={!entity?.canDelete}
tooltip={$t('当前模型为最后一个模型,不支持删除')}
access="system.devops.ai_provider.edit"
key="delete"
btnType="delete"
onClick={() => handleDelete(entity.id as string, entity?.apiCount)}
btnTitle={$t('删除')}
/>
]
}
]
const openLogsModal = (record: any) => {
const modalInstance = modal.confirm({
title: $t('部署过程'),
content: <ServiceDeployment record={record} />,
footer: () => {
return <LogsFooter record={record} modalInstance={modalInstance} />
},
width: 600,
okText: $t('确认'),
cancelText: $t('取消'),
closable: true,
icon: <></>
})
}
const columns: PageProColumns<ModelListData>[] = [
{
title: $t('名称'),
dataIndex: 'name',
render: (dom: React.ReactNode, entity: ModelListData) => <Space>{entity.name}</Space>
},
{
title: $t('状态'),
width: 140,
dataIndex: 'state',
ellipsis: true,
render: (dom: React.ReactNode, entity: ModelListData) => (
<span
className={`text-[13px] ${entity?.state === 'deploying' ? '[&>.ant-typography]:text-[#2196f3] cursor-pointer' : entity?.state === 'error' ? '[&>.ant-typography]:text-[#ff4d4f] cursor-pointer' : ''}`}
onClick={(e) => {
if (['deploying', 'error'].includes(entity?.state as string)) {
e?.stopPropagation()
openLogsModal(entity)
}
}}
>
{dom}
</span>
)
},
{
title: $t('Apis'),
dataIndex: 'apiCount',
render: (dom: React.ReactNode, record: ModelListData) => (
<span className="[&>.key-link]:text-[#2196f3] cursor-pointer">
<a
href={`/aiApis?modelId=${record?.id}`}
target="_blank"
className="key-link"
style={{
fontWeight: 500,
cursor: 'pointer',
pointerEvents: 'all',
textDecoration: 'none'
}}
>
{record.apiCount || '0'}
</a>
</span>
)
},
...operation
]
return (
<PageList
ref={pageListRef}
rowKey="id"
request={requestList}
onSearchWordChange={(e) => {
setSearchWord(e.target.value)
pageListRef.current?.reload()
}}
showPagination={true}
searchPlaceholder={$t('请输入名称搜索')}
columns={columns}
addNewBtnTitle={$t('部署模型')}
onAddNewBtnClick={handleAdd}
/>
)
}
export default LocalModelList
@@ -11,39 +11,62 @@ import { AiSettingListItem, ModelListData } from './types'
const OnlineModelList: React.FC = () => {
const pageListRef = useRef<ActionType>(null)
const { message } = App.useApp()
const { message, modal } = App.useApp()
const { fetchData } = useFetch()
const [searchWord, setSearchWord] = useState<string>('')
const [total, setTotal] = useState<number>(0)
const { openConfigModal } = useAiSetting()
const handleEdit = (record: ModelListData) => {
openConfigModal({ id: record.id, defaultLlm: record.defaultLlm } as AiSettingListItem)
openConfigModal({ id: record.id, defaultLlm: record.defaultLlm } as AiSettingListItem, () => {
pageListRef.current?.reload()
})
}
const handleAdd = () => {
openConfigModal()
openConfigModal(undefined, () => {
pageListRef.current?.reload()
})
}
const handleDelete = async (id: string) => {
try {
const response = await fetchData<BasicResponse<any>>('ai/provider', {
method: 'DELETE',
eoParams: {
provider: id
}
// eoApiPrefix: 'http://uat.apikit.com:11204/mockApi/aoplatform/api/v1/'
})
const handleDelete = async (id: string, apiCount: number) => {
modal.confirm({
title: $t('停止部署'),
content: `${$t('有')} ${apiCount} ${$t('个API使用当前模型,删除当前的模型配置后,该模型相关的API将会切换为使用负载均衡中优先级最高的可用模型。并且当前模型下的所有API KEY和相关数据将会被清空,是否确认删除当前模型?')}`,
onOk: () => {
return new Promise((resolve, reject) => {
try {
fetchData<BasicResponse<any>>('ai/provider', {
method: 'DELETE',
eoParams: {
provider: id
}
// eoApiPrefix: 'http://uat.apikit.com:11204/mockApi/aoplatform/api/v1/'
}).then((response) => {
if (response.code === STATUS_CODE.SUCCESS) {
message.success($t('删除成功'))
pageListRef.current?.reload()
} else {
message.error(response.msg || RESPONSE_TIPS.error)
}
resolve(true)
}).catch((error) => {
message.error(RESPONSE_TIPS.error)
resolve(true)
})
} catch (error) {
message.error(RESPONSE_TIPS.error)
resolve(true)
}
})
},
width: 600,
okText: $t('确认'),
cancelText: $t('取消'),
closable: true,
icon: <></>
})
if (response.code === STATUS_CODE.SUCCESS) {
message.success($t('删除成功'))
pageListRef.current?.reload()
} else {
message.error(response.msg || RESPONSE_TIPS.error)
}
} catch (error) {
message.error(RESPONSE_TIPS.error)
}
}
const requestList = async (params: any) => {
@@ -55,7 +78,7 @@ const OnlineModelList: React.FC = () => {
keyword: searchWord,
page: params.current
},
eoTransformKeys: ['default_llm']
eoTransformKeys: ['default_llm', 'api_count', 'key_count', 'can_delete']
// eoApiPrefix: 'http://uat.apikit.com:11204/mockApi/aoplatform/api/v1/'
})
@@ -107,8 +130,10 @@ const OnlineModelList: React.FC = () => {
<TableBtnWithPermission
access="system.devops.ai_provider.edit"
key="delete"
disabled={!entity?.canDelete}
tooltip={$t('当前模型为最后一个模型,不支持删除')}
btnType="delete"
onClick={() => handleDelete(entity.id as string)}
onClick={() => handleDelete(entity.id as string, entity.apiCount)}
btnTitle={$t('删除')}
/>
]
@@ -137,11 +162,45 @@ const OnlineModelList: React.FC = () => {
},
{
title: $t('Apis'),
dataIndex: 'api_count'
dataIndex: 'apiCount',
render: (dom: React.ReactNode, record: ModelListData) => (
<span className="[&>.key-link]:text-[#2196f3] cursor-pointer">
<a
href={`/aiApis?modelId=${record?.id}`}
target="_blank"
className="key-link"
style={{
fontWeight: 500,
cursor: 'pointer',
pointerEvents: 'all',
textDecoration: 'none'
}}
>
{record.apiCount || '0'}
</a>
</span>
)
},
{
title: $t('Keys'),
dataIndex: 'key_count'
dataIndex: 'keyCount',
render: (dom: React.ReactNode, record: ModelListData) => (
<span className="[&>.key-link]:text-[#2196f3] cursor-pointer">
<a
href={`/keysetting?modelId=${record?.id}`}
target="_blank"
className="key-link"
style={{
fontWeight: 500,
cursor: 'pointer',
pointerEvents: 'all',
textDecoration: 'none'
}}
>
{record.keyCount || '0'}
</a>
</span>
)
},
...operation
]
@@ -8,7 +8,7 @@ import AiSettingModalContent, { AiSettingModalContentHandle } from '../AiSetting
import { AiSettingListItem } from '../types'
interface AiSettingContextType {
openConfigModal: (entity?: AiSettingListItem) => Promise<void>
openConfigModal: (entity?: AiSettingListItem, callback?: () => void) => Promise<void>
}
const AiSettingContext = createContext<AiSettingContextType | undefined>(undefined)
@@ -19,7 +19,7 @@ export const AiSettingProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const modalRef = useRef<AiSettingModalContentHandle>()
const entityData = useRef<any>(null)
const openConfigModal = async (entity?: AiSettingListItem) => {
const openConfigModal = async (entity?: AiSettingListItem, callback?: () => void) => {
// 更新弹窗
const updateEntityData = (data: any) => {
entityData.current = data
@@ -41,6 +41,7 @@ export const AiSettingProvider: React.FC<{ children: React.ReactNode }> = ({ chi
return modalRef.current?.save().then((res) => {
if (res === true) {
setAiConfigFlushed(!aiConfigFlushed)
callback?.()
}
})
},
@@ -1,5 +1,6 @@
export type ModelStatus = 'enabled' | 'abnormal' | 'disabled'
export type KeyStatus = 'normal' | 'abnormal' | 'disabled'
export type ModelDeployStatus = 'normal' | 'disabled' | 'deploying' | 'error' | undefined
export interface KeyData {
id: string
@@ -14,9 +15,12 @@ export interface ModelListData {
defaultLlm: string | undefined
modelMode?: string
status: ModelStatus
api_count: number
key_count: number
state?: ModelDeployStatus
apiCount: number
keyCount: number
isDisabled?: boolean
keys: KeyData[]
canDelete: boolean
}
export interface AISettingEntityItem {
@@ -208,7 +208,7 @@ const LoadBalancingPage = () => {
ellipsis: true,
width: 100,
key: 'provider',
render: (text: string, record: LoadBalancingItems) => (
render: (dom: React.ReactNode, record: LoadBalancingItems) => (
<span>
{record.provider?.name} / {record.model?.name}
</span>
@@ -220,7 +220,7 @@ const LoadBalancingPage = () => {
width: 100,
ellipsis: true,
key: 'type',
render: (text: string, record: LoadBalancingItems) => (
render: (dom: React.ReactNode, record: LoadBalancingItems) => (
<span>{record.type === 'online' ? $t('线上模型') : $t('本地模型')}</span>
)
},
@@ -230,15 +230,15 @@ const LoadBalancingPage = () => {
width: 120,
ellipsis: true,
key: 'state',
render: (text: string, record: LoadBalancingItems) => <span>{statusEnum[record.state]?.text || '-'}</span>
render: (dom: React.ReactNode, record: LoadBalancingItems) => <span>{statusEnum[record.state]?.text || '-'}</span>
},
{
title: $t('API 数量'),
title: $t('Apis'),
dataIndex: 'api_count',
ellipsis: true,
width: 80,
key: 'api_count',
render: (text: string, record: LoadBalancingItems) => (
render: (dom: React.ReactNode, record: LoadBalancingItems) => (
<span className="[&>.key-link]:text-[#2196f3] cursor-pointer">
<a
href={`/aiApis?modelId=${record.model?.id}`}
@@ -251,18 +251,18 @@ const LoadBalancingPage = () => {
textDecoration: 'none'
}}
>
{record.api_count || '-'}
{record.api_count || '0'}
</a>
</span>
)
},
{
title: $t('KEY 数量'),
title: $t('Keys'),
dataIndex: 'key_count',
ellipsis: true,
width: 80,
key: 'key_count',
render: (text: string, record: LoadBalancingItems) => (
render: (dom: React.ReactNode, record: LoadBalancingItems) => (
<span className="[&>.key-link]:text-[#2196f3] cursor-pointer">
<a
href={`/keysetting?modelId=${record.model?.id}`}
@@ -275,7 +275,7 @@ const LoadBalancingPage = () => {
textDecoration: 'none'
}}
>
{record.key_count || '-'}
{record.key_count || '0'}
</a>
</span>
)
@@ -65,7 +65,10 @@ export const ServiceDeployment = (props: { record: SystemTableListItem }) => {
{
method: 'POST',
eoBody: { recordId: record.id },
custom: true,
eoApiPrefix: '',
headers: {
'Content-Type': 'event-stream'
},
isStream: true,
handleStream: (chunk) => {
const parsedChunk = JSON.parse(chunk)