feat: merge api model page

This commit is contained in:
ningyv
2025-02-12 11:27:47 +08:00
21 changed files with 258 additions and 765 deletions
+3 -3
View File
@@ -25,7 +25,7 @@ jobs:
echo "Build frontend..."
cd ./frontend && pnpm run build
- name: upload frontend release
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: frontend-package
path: frontend/dist
@@ -41,7 +41,7 @@ jobs:
- name: Checkout #Checkout代码
uses: actions/checkout@v3
- name: download frontend release
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: frontend-package
path: frontend/dist
@@ -71,7 +71,7 @@ jobs:
- uses: actions/checkout@v3
- name: download frontend release
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: frontend-package
path: frontend/dist
+1 -1
View File
@@ -210,7 +210,7 @@ APIPark uses the Apache 2.0 License. For more details, please refer to the LICEN
For enterprise-level features and professional technical support, contact our pre-sales experts for personalized demos, customized solutions, and pricing.
- Website: https://apipark.com
- Email: dev@apipark.com
- Email: contact@apipark.com
<br>
@@ -1,269 +0,0 @@
'use client'
import { BasicResponse } from '@common/const/const'
import { useGlobalContext } from '@common/contexts/GlobalStateContext'
import { useFetch } from '@common/hooks/http'
import { $t } from '@common/locales'
import {
CoordinateExtent,
Edge,
EdgeTypes,
Node,
NodeTypes,
PanOnScrollMode,
ReactFlow,
useEdgesState,
useNodesState
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { Button, Space, Spin } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import CustomEdge from './components/CustomEdge'
import { KeyStatusNode } from './components/KeyStatusNode'
import { ModelCardNode } from './components/ModelCardNode'
import { ServiceCardNode } from './components/NodeComponents'
import { LAYOUT } from './constants'
import './styles.css'
import { ModelListData } from './types'
export type ApiResponse = BasicResponse<{
backup: {
id: string
name: string
}
providers: ModelListData[]
}>
const calculateNodePositions = (models: ModelListData[], startY = LAYOUT.NODE_START_Y, gap = LAYOUT.NODE_GAP) => {
return models.reduce(
(acc, model, index) => {
const y = startY + index * gap
return {
...acc,
[model.id]: {
x: LAYOUT.MODEL_NODE_X,
y
},
[`${model.id}-keys`]: {
x: LAYOUT.KEY_NODE_X,
y: y + 16
}
}
},
{} as Record<string, { x: number; y: number }>
)
}
const nodeTypes: NodeTypes = {
modelCard: ModelCardNode,
keyCard: KeyStatusNode,
serviceCard: ServiceCardNode
} as const
const edgeTypes: EdgeTypes = {
custom: CustomEdge
}
const AIFlowChart = () => {
const [modelData, setModelData] = useState<ModelListData[]>([])
const [loading, setLoading] = useState(false)
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
const { fetchData } = useFetch()
const { aiConfigFlushed } = useGlobalContext()
const navigate = useNavigate()
useEffect(() => {
setLoading(true)
fetchData<ApiResponse>('ai/providers/configured', {
method: 'GET',
eoTransformKeys: ['default_llm']
// eoApiPrefix: 'http://uat.apikit.com:11204/mockApi/aoplatform/api/v1/'
})
.then((response) => {
const mockApiResponse: ApiResponse = response as ApiResponse
setModelData(mockApiResponse.data.providers)
})
.finally(() => {
setLoading(false)
})
}, [aiConfigFlushed])
useEffect(() => {
if (!modelData.length) return
const positions = calculateNodePositions(modelData)
const firstSuccessModel = modelData.find((model) => model.status === 'enabled')
console.log(firstSuccessModel)
// subtract 5 to make sure the service node is aligned with the top model node
const serviceY = positions[modelData[0].id].y - 5
const newNodes = [
{
id: 'apiService',
type: 'serviceCard',
position: { x: LAYOUT.SERVICE_NODE_X, y: serviceY },
draggable: false,
data: {
title: 'API Services',
count: modelData.length
}
},
...modelData.map((model) => ({
id: model.id,
type: 'modelCard',
position: positions[model.id],
data: {
name: model.name,
status: model.status,
defaultLlm: model.defaultLlm,
logo: model.logo,
id: model.id,
alternativeModel: firstSuccessModel
}
})),
...modelData.map((model) => ({
id: `${model.id}-keys`,
type: 'keyCard',
position: positions[`${model.id}-keys`],
data: {
title: '',
keys: (model.keys || []).map((key, index) => ({
id: key.id,
status: key.status,
priority: index + 1
}))
}
}))
]
const newEdges: any = [
...modelData.map((model) => ({
id: `service-${model.id}`,
source: 'apiService',
target: model.id,
label: `${model.api_count} apis`,
data: {
id: model.id,
status: model.status
},
animated: true,
style: { stroke: model.status === 'enabled' ? '#52c41a' : '#ff4d4f' }
})),
...modelData.map((model) => ({
id: `${model.id}-keys-edge`,
source: model.id,
target: `${model.id}-keys`,
label: `${model.key_count} keys`,
data: { id: model.id },
animated: true
}))
]
setNodes(newNodes)
setEdges(newEdges)
}, [modelData])
const calculateExtent = useCallback(() => {
const left = LAYOUT.SERVICE_NODE_X
const right = LAYOUT.KEY_NODE_X
const top = 0 // Allow slight negative scroll to reduce top padding
const bottom = LAYOUT.NODE_START_Y + modelData.length * LAYOUT.NODE_GAP
return [
[left, top],
[right, bottom < 100 ? 5000 : bottom]
] as CoordinateExtent
}, [modelData.length])
const updateProviderOrder = async (sortedProviderIds: string[]) => {
await fetchData('ai/provider/sort', {
method: 'PUT',
body: JSON.stringify({
providers: sortedProviderIds
})
})
}
const onNodeDragStop: any = useCallback((_: any, node: Node<any>) => {
if (node.type !== 'modelCard') return
setNodes((nds) => {
const modelNodes = nds.filter((n) => n.type === 'modelCard')
const sortedNodes = [...modelNodes].sort((a, b) => a.position.y - b.position.y)
const sortedProviderIds = sortedNodes.map((node) => node.id)
// Update provider order outside of setNodes callback
updateProviderOrder(sortedProviderIds)
// Update all node positions in a single pass
return nds.map((n) => {
if (n.type === 'modelCard') {
const index = sortedNodes.findIndex((sn) => sn.id === n.id)
return {
...n,
position: {
x: LAYOUT.MODEL_NODE_X,
y: LAYOUT.NODE_START_Y + index * LAYOUT.NODE_GAP
}
}
}
if (n.type === 'keyCard') {
const modelId = n.id.replace('-keys', '')
const modelNode = sortedNodes.find((mn) => mn.id === modelId)
if (modelNode) {
const index = sortedNodes.findIndex((sn) => sn.id === modelId)
return {
...n,
position: {
x: LAYOUT.KEY_NODE_X,
y: LAYOUT.NODE_START_Y + index * LAYOUT.NODE_GAP + 16
}
}
}
}
return n
})
})
}, [])
return (
<div className="w-full h-full">
{loading ? (
<div className="flex justify-center items-center h-full">
<Spin size="large" />
</div>
) : modelData.length === 0 ? (
<Space className="flex flex-col justify-center items-center h-[200px]">
<div>{$t('未配置 AI 模型')}</div>
<Button type="primary" onClick={() => navigate('/aisetting?status=unconfigure')}>
{$t('前往设置')}
</Button>
</Space>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop}
proOptions={{ hideAttribution: true }}
draggable={false}
nodeTypes={nodeTypes}
elementsSelectable={false}
edgeTypes={edgeTypes}
zoomOnScroll={false}
panOnDrag={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
panOnScroll={true}
panOnScrollMode={PanOnScrollMode.Vertical}
defaultEdgeOptions={{
type: 'custom'
}}
translateExtent={calculateExtent()}
/>
)}
</div>
)
}
export default AIFlowChart
@@ -1,136 +0,0 @@
import Icon, { LoadingOutlined } from '@ant-design/icons'
import WithPermission from '@common/components/aoplatform/WithPermission'
import { BasicResponse, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const'
import { useGlobalContext } from '@common/contexts/GlobalStateContext'
import { useFetch } from '@common/hooks/http'
import { $t } from '@common/locales'
import { App, Button, Card, Empty, Spin, Tag } from 'antd'
import { memo, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAiSetting } from './contexts/AiSettingContext'
import { AiSettingListItem } from './types'
const CardBox = memo(({ provider }: { provider: AiSettingListItem }) => {
const { openConfigModal } = useAiSetting()
const navigate = useNavigate()
const handleOpenModal = async (provider: AiSettingListItem) => {
await openConfigModal(provider)
navigate('/aisetting?status=configure')
}
return (
<Card
title={
<div className="flex w-full items-center justify-between gap-[4px]">
<div className="flex flex-1 overflow-hidden items-center gap-[4px]">
<span
className=" flex items-center h-[22px] ai-setting-svg-container"
dangerouslySetInnerHTML={{ __html: provider.logo }}
></span>
<span className="font-normal truncate">{provider.name}</span>
</div>
<Tag
bordered={false}
color={provider.configured ? 'green' : undefined}
className="h-[22px] px-[4px] text-center"
>
{provider.configured ? $t('已配置') : $t('未配置')}
</Tag>
</div>
}
className="shadow-[0_5px_10px_0_rgba(0,0,0,0.05)] rounded-[10px] overflow-visible h-[156px] m-0 flex flex-col "
classNames={{ header: 'border-b-[0px] p-[20px] px-[24px]', body: 'pt-0 flex-1' }}
>
<div className="flex flex-col justify-between h-full gap-btnbase">
<div className="flex items-center w-full h-[32px] flex-1">
{provider.configured && (
<>
<label className="text-nowrap">{$t('默认')}</label>
<span className="overflow-hidden flex-1 truncate">{provider.defaultLlm}</span>
</>
)}
</div>
<WithPermission access="system.settings.ai_provider.view">
<Button
block
icon={<Icon icon="ic:outline-settings" width={18} height={18} />}
onClick={() => handleOpenModal(provider)}
classNames={{ icon: 'h-[18px]' }}
>
{$t('设置')}
</Button>
</WithPermission>
</div>
</Card>
)
})
const ModelCardArea = ({ modelList, className }: { modelList: AiSettingListItem[]; className?: string }) => {
return (
<>
{modelList.length > 0 ? (
<div
className={className}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '20px'
}}
>
{modelList.map((provider: AiSettingListItem) => (
<CardBox key={provider.id} provider={provider} />
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</>
)
}
const AIUnConfigure = () => {
const [modelData, setModelData] = useState<AiSettingListItem[]>([])
const { fetchData } = useFetch()
const [loading, setLoading] = useState<boolean>(false)
const { aiConfigFlushed } = useGlobalContext()
useEffect(() => {
setLoading(true)
fetchData<BasicResponse<{ providers: Omit<AiSettingListItem>[] }>>(`ai/providers/unconfigured`, {
method: 'GET',
eoTransformKeys: ['default_llm', 'default_llm_logo']
})
.then((response) => {
const { code, data, msg } = response
if (code === STATUS_CODE.SUCCESS) {
setModelData(data.providers)
} else {
const { message } = App.useApp()
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
.finally(() => setLoading(false))
}, [aiConfigFlushed])
return (
<Spin
className="h-full"
wrapperClassName="h-full pr-PAGE_INSIDE_X"
indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />}
spinning={loading}
>
{modelData && modelData.length > 0 ? (
<div>
{modelData.filter((item) => !item.configured).length > 0 && (
<>
<ModelCardArea modelList={modelData.filter((item) => !item.configured) || []} />
</>
)}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Spin>
)
}
export default AIUnConfigure
@@ -3,9 +3,8 @@ import { useI18n } from '@common/locales'
import { Tabs } from 'antd'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import AIFlowChart from './AIFlowChart'
import AIUnConfigure from './AIUnconfigure'
import { AiSettingProvider } from './contexts/AiSettingContext'
import OnlineModelList from './OnlineModelList'
const CONTENT_STYLE = { height: 'calc(-300px + 100vh)' } as const
@@ -38,21 +37,17 @@ const AiSettingContent = () => {
items={[
{
key: 'flow',
label: $t('已设置'),
label: $t('在线模型'),
children: (
<div className="overflow-auto" style={CONTENT_STYLE}>
<AIFlowChart />
<OnlineModelList />
</div>
)
},
{
key: 'config',
label: $t('未设置'),
children: (
<div className="overflow-auto" style={CONTENT_STYLE}>
<AIUnConfigure />
</div>
)
label: $t('本地模型'),
children: <div className="overflow-auto" style={CONTENT_STYLE}></div>
}
]}
/>
@@ -1,20 +1,20 @@
import { QuestionCircleOutlined } from '@ant-design/icons'
import { Codebox } from '@common/components/postcat/api/Codebox'
import { BasicResponse, PLACEHOLDER, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const'
import { useFetch } from '@common/hooks/http'
import { $t } from '@common/locales'
import { App, Form, InputNumber, Select, Switch, Tag, Tooltip } from 'antd'
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { AiProviderLlmsItems, ModelDetailData, AiSettingListItem } from './types'
import { AiProviderLlmsItems, ModelDetailData, AiSettingListItem, AISettingEntityItem } from './types'
import { MemberItem, SimpleTeamItem } from '@common/const/type'
import { useGlobalContext } from '@common/contexts/GlobalStateContext'
export type AiSettingModalContentProps = {
entity: ModelDetailData & { defaultLlm: string }
entity?: AISettingEntityItem
readOnly: boolean
modelMode?: 'auto' | 'manual'
originEntity?: string
/** 如果是手动选择 AI 模型,那么需要更新 footer 底部的内容,所以需要这个方法去更新外部的 footer */
updateEntityData: (entity: ModelDetailData & { defaultLlm: string }) => void
updateEntityData: (entity: AISettingEntityItem) => void
}
export type AiSettingModalContentHandle = {
@@ -25,7 +25,7 @@ export type AiSettingModalContentHandle = {
const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingModalContentProps>((props, ref) => {
const [form] = Form.useForm()
const { message } = App.useApp()
const { entity, readOnly, modelMode = 'auto', updateEntityData } = props
const { entity, readOnly, modelMode = 'auto', updateEntityData, originEntity } = props
const { fetchData } = useFetch()
const [llmList, setLlmList] = useState<AiProviderLlmsItems[]>()
const [loading, setLoading] = useState<boolean>(false)
@@ -47,7 +47,7 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
setLoading(true)
fetchData<BasicResponse<{ llms: AiProviderLlmsItems[] }>>(`ai/provider/llms`, {
method: 'GET',
eoParams: { provider: id || localEntity.id }
eoParams: { provider: id || localEntity?.id }
})
.then((response) => {
const { code, data, msg } = response
@@ -123,18 +123,17 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
fetchData<BasicResponse<{ providers: ModelDetailData[] }>>(`ai/provider/config`, {
method: 'GET',
eoParams: { provider: id },
eoTransformKeys: ['get_apikey_url']
eoTransformKeys: ['get_apikey_url', 'default_llm']
})
.then((response) => {
const { code, data, msg } = response
if (code === STATUS_CODE.SUCCESS) {
const modelEntity = {
...data.provider,
defaultLlm: modelProviderListRef.current.find((x) => x.id === id)?.defaultLlm
...data.provider
}
setLocalEntity(modelEntity)
setFormFieldsValue(modelEntity)
updateEntityData(modelEntity)
updateEntityData?.(modelEntity)
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
}
@@ -153,26 +152,24 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
form.setFieldsValue({
defaultLlm: fieldsValue.defaultLlm,
config: fieldsValue!.config ? JSON.stringify(JSON.parse(fieldsValue!.config), null, 2) : '',
priority: fieldsValue.priority || 1,
enable: fieldsValue.status === 'enabled'
})
} catch (e) {
form.setFieldsValue({
defaultLlm: localEntity.defaultLlm,
defaultLlm: localEntity?.defaultLlm,
config: '',
priority: 1,
enable: true
})
}
}
useEffect(() => {
// 如果是直接在 AI 模型配置,则获取默认模型列表和团队列表
if (modelMode === 'auto') {
getLlmList()
if (localEntity?.id) {
getModelConfig(localEntity.id)
setFormFieldsValue(localEntity)
} else {
getModelProviderList()
getTeamOptionList()
originEntity && getTeamOptionList()
}
}, [])
@@ -217,13 +214,13 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
*/
const save: () => Promise<boolean | string> = () => {
return new Promise((resolve, reject) => {
form
.validateFields()
.then((value) => {
const finalValue = {
...value,
priority: Math.max(1, value.priority)
}
try {
form
.validateFields()
.then((value) => {
const finalValue = {
...value
}
fetchData<BasicResponse<null>>('ai/provider/config', {
method: 'PUT',
@@ -241,10 +238,12 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
message.error(msg || $t(RESPONSE_TIPS.error))
reject(msg || $t(RESPONSE_TIPS.error))
}
})
.catch((errorInfo) => reject(errorInfo))
})
.catch((errorInfo) => reject(errorInfo))
}).catch((errorInfo) => reject(errorInfo))
})
.catch((errorInfo) => reject(errorInfo))
} catch (error) {
reject(error)
}
})
}
@@ -307,43 +306,13 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
}))}
></Select>
</Form.Item>
{modelMode === 'auto' && (
<Form.Item<ModelDetailData>
label={
<span className="flex items-center">
{$t('负载优先级')}
<Tooltip
title={$t('负载优先级决定在原供应商异常或停用后,优先使用哪一个供应商。优先级数字越小,优先级越高。')}
>
<QuestionCircleOutlined className="ml-1 text-gray-500" />
</Tooltip>
</span>
}
name="priority"
rules={[
{ required: true },
{
validator: async (_, value) => {
if (value <= 0) {
throw new Error($t('优先级必须大于 0'))
}
return Promise.resolve()
}
}
]}
initialValue={1}
>
<InputNumber className="w-INPUT_NORMAL" min={1} placeholder={$t('请输入优先级')} />
</Form.Item>
)}
{modelMode === 'manual' && (
{originEntity === 'guide' && (
<Form.Item label={$t('所属团队')} name="team" className="mt-[16px]" rules={[{ required: true }]}>
<Select className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)} options={teamList} onChange={(value) => {
form.setFieldValue('team', value)
}}></Select>
</Form.Item>
)}
<Form.Item<ModelDetailData> label={$t('API Key(默认 Key')} name="config">
<Codebox
editorTheme="vs-dark"
@@ -354,15 +323,14 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
enableToolbar={false}
/>
</Form.Item>
{localEntity?.configured && (
{originEntity !== 'guide' && (
<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>
{localEntity.status === 'enabled' && <Tag color="success">{$t('正常')}</Tag>}
{localEntity.status === 'disabled' && <Tag color="warning">{$t('停用')}</Tag>}
{localEntity.status === 'abnormal' && <Tag color="error">{$t('异常')}</Tag>}
{localEntity?.status === 'enabled' && <Tag color="success">{$t('正常')}</Tag>}
{localEntity?.status === 'disabled' && <Tag color="warning">{$t('停用')}</Tag>}
{localEntity?.status === 'abnormal' && <Tag color="error">{$t('异常')}</Tag>}
</div>
<Form.Item name="enable" valuePropName="checked" noStyle>
<Switch
@@ -375,7 +343,7 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
/>
</Form.Item>
</div>
{(localEntity.status === 'enabled' && !enableState) || (localEntity.status !== 'enabled' && enableState) ? (
{(localEntity?.status === 'enabled' && !enableState) || (localEntity?.status !== 'enabled' && enableState) ? (
<div className="mt-2 text-sm text-gray-500">* {getTooltipText(enableState)}</div>
) : null}
</Form.Item>
@@ -0,0 +1,167 @@
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, Space, Typography } from 'antd'
import React, { useRef, useState } from 'react'
import { useAiSetting } from './contexts/AiSettingContext'
import { AiSettingListItem, ModelListData } from './types'
const OnlineModelList: React.FC = () => {
const pageListRef = useRef<ActionType>(null)
const { message } = 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)
}
const handleAdd = () => {
openConfigModal()
}
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/'
})
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) => {
try {
const response = await fetchData<BasicResponse<{ data: ModelListData[] }>>('ai/providers/configured', {
method: 'GET',
eoParams: {
page_size: params.pageSize,
keyword: searchWord,
page: params.current
},
eoTransformKeys: ['default_llm']
// eoApiPrefix: 'http://uat.apikit.com:11204/mockApi/aoplatform/api/v1/'
})
if (response.code === STATUS_CODE.SUCCESS) {
setTotal(response.data.total)
return {
data: response.data.providers,
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 statusEnum = {
enabled: { text: <Typography.Text type="success">{$t('正常')}</Typography.Text> },
disabled: { text: <Typography.Text type="warning">{$t('停用')}</Typography.Text> },
abnormal: { text: <Typography.Text type="danger">{$t('异常')}</Typography.Text> }
}
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
access="system.devops.ai_provider.edit"
key="delete"
btnType="delete"
onClick={() => handleDelete(entity.id as string)}
btnTitle={$t('删除')}
/>
]
}
]
const columns: PageProColumns<ModelListData>[] = [
{
title: $t('名称'),
dataIndex: 'name',
render: (dom: React.ReactNode, entity: ModelListData) => <Space>{entity.name}</Space>
},
{
title: $t('状态'),
dataIndex: 'status',
ellipsis: true,
valueType: 'select',
// filters: true,
// onFilter: true,
valueEnum: statusEnum,
render: (dom: React.ReactNode, entity: ModelListData) => statusEnum[entity.status]?.text || entity.status
},
{
title: $t('默认模型'),
dataIndex: 'defaultLlm'
},
{
title: $t('Apis'),
dataIndex: 'api_count'
},
{
title: $t('Keys'),
dataIndex: 'key_count'
},
...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 OnlineModelList
@@ -1,72 +0,0 @@
import { BaseEdge, EdgeLabelRenderer, EdgeProps, getSmoothStepPath, useStore } from '@xyflow/react'
export default function CustomEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
label,
data,
source,
target
}: EdgeProps) {
// Get all edges to check for duplicates
const edges = useStore((state) => state.edges)
// Find duplicate edges between the same source and target
const duplicateEdges = edges.filter((edge) => edge.source === source && edge.target === target)
const edgeIndex = duplicateEdges.findIndex((edge) => edge.id === id)
// Adjust the path if this is a duplicate edge
const offset = edgeIndex * 20 // 20px offset for each duplicate edge
const [edgePath] = getSmoothStepPath({
sourceX,
sourceY: sourceY,
sourcePosition,
targetX,
targetY: targetY + offset,
targetPosition,
borderRadius: 16
})
const modelId = data?.id
return (
<>
<BaseEdge
path={edgePath}
markerEnd={markerEnd}
style={{
...style,
cursor: 'pointer'
}}
/>
{label && (
<EdgeLabelRenderer>
<a
href={`${label?.toString().includes('apis') ? '/aiApis' : '/keysetting'}?modelId=${modelId}`}
target="_blank"
style={{
position: 'absolute',
transform: `translate(${targetX - 80}px,${targetY - 20 + offset}px)`,
borderRadius: '4px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
pointerEvents: 'all',
textDecoration: 'none'
}}
>
{label}
</a>
</EdgeLabelRenderer>
)}
</>
)
}
@@ -1,53 +0,0 @@
import { Handle, Position } from '@xyflow/react'
import React from 'react'
import { KeyData } from '../types'
interface KeyStatusNodeData {
id: string
title: string
keys: KeyData[]
}
const KEY_SIZE = '1.25rem' // 20px
const KEY_GAP = '0.25rem' // 4px
const MAX_KEYS = 10
export const KeyStatusNode: React.FC<{ data: KeyStatusNodeData }> = ({ data }) => {
const { title, keys = [] } = data
const totalKeys = keys.length
const keyWidth = totalKeys > 5 ? `calc((100% - ${(totalKeys - 1) * 0.25}rem) / ${totalKeys})` : KEY_SIZE
return (
<div
className="relative p-4 bg-white rounded-lg shadow-sm node-card nodrag"
style={{ border: '1px solid var(--border-color)' }}
>
<Handle type="target" position={Position.Left} />
<div className="flex flex-col">
<div className="text-sm text-gray-900">{title}</div>
<div
className="flex gap-1 w-full"
style={{
minWidth: keys.length > 5 ? '118px' : 'auto',
maxWidth: `calc(${MAX_KEYS} * ${KEY_SIZE} + (${MAX_KEYS} - 1) * ${KEY_GAP})`,
minHeight: KEY_SIZE
}}
>
{keys.map((key) => (
<div
key={key.id}
style={{
width: keyWidth,
height: KEY_SIZE
}}
className={`
flex-shrink-0
${key.status === 'normal' ? 'bg-green-500' : 'bg-red-500'}
transition-all duration-200 hover:opacity-80
`}
/>
))}
</div>
</div>
</div>
)
}
@@ -1,76 +0,0 @@
import { $t } from '@common/locales'
import { Icon } from '@iconify/react'
import { Handle, Position } from '@xyflow/react'
import React from 'react'
import { useAiSetting } from '../contexts/AiSettingContext'
import { AiSettingListItem, ModelDetailData, ModelStatus } from '../types'
type ModelCardNodeData = ModelDetailData & {
id: string
position: { x: number; y: number }
alternativeModel?: ModelDetailData
}
export const ModelCardNode: React.FC<{ data: ModelCardNodeData }> = ({ data }) => {
const { name, status, defaultLlm, logo, alternativeModel } = data
const { openConfigModal } = useAiSetting()
const getStatusIcon = (status: ModelStatus) => {
switch (status) {
case 'enabled':
return { icon: 'mdi:check-circle', color: 'text-green-500' }
case 'disabled':
return { icon: 'mdi:pause-circle', color: 'text-gray-400' }
case 'abnormal':
return { icon: 'mdi:alert-circle', color: 'text-red-500' }
}
}
const statusConfig = getStatusIcon(status)
return (
<>
<div
className="node-card bg-white rounded-lg shadow-sm p-4 min-w-[280px] group"
style={{ border: '1px solid var(--border-color)' }}
>
<Handle type="target" position={Position.Left} />
<Handle type="source" position={Position.Right} />
<div>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<div className="flex flex-1 overflow-hidden items-center gap-[4px]">
<span
className="flex items-center h-[22px] ai-setting-svg-container"
dangerouslySetInnerHTML={{ __html: logo }}
></span>
</div>
<span className="text-base text-gray-900 max-w-[180px] truncate">{name}</span>
<Icon icon={statusConfig?.icon} className={`text-xl ${statusConfig?.color}`} />
</div>
{/* Action buttons */}
<div className="flex gap-2 transition-opacity duration-200">
<Icon
icon="mdi:cog"
className="text-xl text-gray-400 cursor-pointer hover:text-[--primary-color]"
onClick={() => {
openConfigModal({ id: data.id, defaultLlm: defaultLlm } as AiSettingListItem)
}}
/>
</div>
</div>
<div className="mt-2 text-sm text-gray-500">
{$t('默认:')}
{defaultLlm}
</div>
</div>
</div>
{status !== 'enabled' && alternativeModel && (
<div className="ml-4 mt-1 text-sm text-gray-500">
{$t('关联 API 已转用')} {alternativeModel.name}/{alternativeModel.defaultLlm}
</div>
)}
</>
)
}
@@ -1,3 +0,0 @@
export { KeyStatusNode } from './KeyStatusNode'
export { ModelCardNode } from './ModelCardNode'
export { ServiceCardNode } from './ServiceCardNode'
@@ -1,18 +0,0 @@
import { Icon } from '@iconify/react'
import { Handle, NodeProps, Position } from '@xyflow/react'
import React from 'react'
export const ServiceCardNode: React.FC<NodeProps> = () => {
return (
<div
className="node-card bg-white rounded-lg shadow-sm p-4 min-w-[150px] nodrag"
style={{ border: '1px solid var(--border-color)' }}
>
<Handle type="source" position={Position.Right} />
<div className="flex flex-col gap-2 items-center">
<Icon icon="mdi:robot" className="text-3xl text-[--primary-color]" />
<span className="text-base text-gray-900">AI Services</span>
</div>
</div>
)
}
@@ -1,45 +1,39 @@
import Icon from '@ant-design/icons'
import { BasicResponse, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const'
import { useGlobalContext } from '@common/contexts/GlobalStateContext'
import { useFetch } from '@common/hooks/http'
import { $t } from '@common/locales'
import { checkAccess } from '@common/utils/permission'
import { App } from 'antd'
import { createContext, useContext, useRef } from 'react'
import AiSettingModalContent, { AiSettingModalContentHandle } from '../AiSettingModal'
import { AiSettingListItem, ModelDetailData } from '../types'
import { AiSettingListItem } from '../types'
interface AiSettingContextType {
openConfigModal: (entity: AiSettingListItem) => Promise<void>
openConfigModal: (entity?: AiSettingListItem) => Promise<void>
}
const AiSettingContext = createContext<AiSettingContextType | undefined>(undefined)
export const AiSettingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { modal, message } = App.useApp()
const { fetchData } = useFetch()
const { modal } = App.useApp()
const { aiConfigFlushed, setAiConfigFlushed, accessData } = useGlobalContext()
const modalRef = useRef<AiSettingModalContentHandle>()
const entityData = useRef<any>(null)
const openConfigModal = async (entity: AiSettingListItem) => {
message.loading($t(RESPONSE_TIPS.loading))
const { code, data, msg } = await fetchData<BasicResponse<{ provider: ModelDetailData }>>('ai/provider/config', {
method: 'GET',
eoParams: { provider: entity!.id },
eoTransformKeys: ['get_apikey_url']
})
message.destroy()
if (code !== STATUS_CODE.SUCCESS) {
message.error(msg || $t(RESPONSE_TIPS.error))
return
const openConfigModal = async (entity?: AiSettingListItem) => {
// 更新弹窗
const updateEntityData = (data: any) => {
entityData.current = data
// 更新弹窗
modalInstance.update({})
}
modal.confirm({
const modalInstance = modal.confirm({
title: $t('模型配置'),
content: (
<AiSettingModalContent
ref={modalRef}
entity={{ ...data.provider, defaultLlm: entity.defaultLlm }}
entity={{ id: entity?.id, defaultLlm: entity?.defaultLlm }}
modelMode={entity ? 'auto' : 'manual'}
updateEntityData={updateEntityData}
readOnly={!checkAccess('system.devops.ai_provider.edit', accessData)}
/>
),
@@ -58,10 +52,10 @@ export const AiSettingProvider: React.FC<{ children: React.ReactNode }> = ({ chi
<a
target="_blank"
rel="noopener noreferrer"
href={data.provider.getApikeyUrl}
href={entityData.current?.getApikeyUrl}
className="flex items-center gap-[8px]"
>
<span>{$t('从 (0) 获取 API KEY', [data.provider.name])}</span>
<span>{$t('从 (0) 获取 API KEY', [entityData.current?.name])}</span>
<Icon icon="ic:baseline-open-in-new" width={16} height={16} />
</a>
<div>
@@ -1,33 +1,37 @@
export type ModelStatus = 'enabled' | 'abnormal'|'disabled'
export type KeyStatus ='normal' | 'abnormal'|'disabled'
export type ModelStatus = 'enabled' | 'abnormal' | 'disabled'
export type KeyStatus = 'normal' | 'abnormal' | 'disabled'
export interface KeyData {
id: string
name: string
status: KeyStatus,
status: KeyStatus
}
export interface ModelListData {
id: string
id: string | undefined
name: string
logo: string
defaultLlm: string
defaultLlm: string | undefined
modelMode?: string
status: ModelStatus
api_count: number
key_count: number
keys: KeyData[]
}
export interface ModelDetailData extends ModelListData{
enable:boolean
config: string,
priority?: number
export interface AISettingEntityItem {
id: string | undefined
status?: ModelStatus | undefined
defaultLlm: string | undefined
}
export interface ModelDetailData extends ModelListData {
enable: boolean
config: string
getApikeyUrl: string
status: ModelStatus
configured: boolean
}
export type AiSettingListItem = {
name: string
id: string
@@ -54,5 +58,3 @@ export type AiProviderDefaultConfig = {
defaultLlm: string
scopes: string[]
}
@@ -68,6 +68,7 @@ export const AIModelGuide = () => {
ref={modalRef}
modelMode="manual"
updateEntityData={updateEntityData}
originEntity="guide"
readOnly={!checkAccess('system.devops.ai_provider.edit', accessData)}
/>
),
@@ -1,7 +1,5 @@
'use client'
import AIFlowChart from '../aiSetting/AIFlowChart'
export default function Playground() {
return <AIFlowChart />
return <iframe src="/playground" />
}
+11 -18
View File
@@ -535,15 +535,15 @@ func (i *imlProviderModule) UpdateProviderConfig(ctx context.Context, id string,
Priority: input.Priority,
Status: &status,
}
_, err = i.aiKeyService.DefaultKey(ctx, id)
_, err = i.aiKeyService.DefaultKey(txCtx, id)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
err = i.aiKeyService.Create(ctx, &ai_key.Create{
err = i.aiKeyService.Create(txCtx, &ai_key.Create{
ID: id,
Name: info.Name,
Config: info.Config,
Config: input.Config,
Provider: id,
Status: 1,
ExpireTime: 0,
@@ -551,28 +551,21 @@ func (i *imlProviderModule) UpdateProviderConfig(ctx context.Context, id string,
Priority: 1,
})
} else {
err = i.aiKeyService.Save(ctx, id, &ai_key.Edit{
Config: &info.Config,
err = i.aiKeyService.Save(txCtx, id, &ai_key.Edit{
Config: &input.Config,
Status: &status,
})
}
if err != nil {
return err
}
//if input.Enable != nil {
// status = 0
// if *input.Enable {
// status = 1
// }
// pInfo.Status = &status
//}
err = i.providerService.Save(ctx, id, pInfo)
err = i.providerService.Save(txCtx, id, pInfo)
if err != nil {
return err
}
if *pInfo.Status == 0 {
return i.syncGateway(ctx, cluster.DefaultClusterID, []*gateway.DynamicRelease{
return i.syncGateway(txCtx, cluster.DefaultClusterID, []*gateway.DynamicRelease{
{
BasicItem: &gateway.BasicItem{
ID: id,
@@ -581,8 +574,8 @@ func (i *imlProviderModule) UpdateProviderConfig(ctx context.Context, id string,
},
}, false)
}
// 获取当前供应商所有Key信息
defaultKey, err := i.aiKeyService.DefaultKey(ctx, id)
// 获取当前供应商默认Key信息
defaultKey, err := i.aiKeyService.DefaultKey(txCtx, id)
if err != nil {
return err
}
@@ -592,7 +585,7 @@ func (i *imlProviderModule) UpdateProviderConfig(ctx context.Context, id string,
cfg["model_config"] = model.DefaultConfig()
cfg["priority"] = info.Priority
cfg["base"] = fmt.Sprintf("%s://%s", p.URI().Scheme(), p.URI().Host())
return i.syncGateway(ctx, cluster.DefaultClusterID, []*gateway.DynamicRelease{
return i.syncGateway(txCtx, cluster.DefaultClusterID, []*gateway.DynamicRelease{
{
BasicItem: &gateway.BasicItem{
ID: id,
+4 -2
View File
@@ -100,7 +100,8 @@ func (i *imlAuthorizationModule) getApplications(ctx context.Context, appIds []s
Config: authCfg,
HideCredential: a.HideCredential,
Label: map[string]string{
"authorization": a.UUID,
"authorization": a.UUID,
"authorization_name": a.Name,
},
}
}),
@@ -157,7 +158,8 @@ func (i *imlAuthorizationModule) online(ctx context.Context, s *service.Service)
Config: authCfg,
HideCredential: a.HideCredential,
Label: map[string]string{
"authorization": a.UUID,
"authorization": a.UUID,
"authorization_name": a.Name,
},
}
}),
+1 -1
View File
@@ -211,7 +211,7 @@ APIParkはApache 2.0ライセンスの下で提供されています。詳細に
エンタープライズ機能や専門的な技術サポートについては、プリセールスの専門家に連絡し、個別デモ、カスタムソリューション、価格情報を入手してください。
- ウェブサイト: https://apipark.com
- メール: dev@apipark.com
- メール: contact@apipark.com
<br>
+1 -1
View File
@@ -215,7 +215,7 @@ APIPark 使用 Apache 2.0 许可证。更多详情请查看 LICENSE 文件。
对于企业级功能和专业技术支持,请联系售前专家进行个性化演示、定制方案和获取报价。
- 网站: https://apipark.com
- 电子邮件: dev@apipark.com
- 电子邮件: contact@apipark.com
<br>
+1 -1
View File
@@ -212,7 +212,7 @@ APIPark 使用 Apache 2.0 授權條款。更多詳情請參閱 LICENSE 文件。
如需企業級功能與專業技術支援,請聯絡我們的售前專家,獲取個性化演示、定制方案和報價。
- 網站: https://apipark.com
- 電子郵件: dev@apipark.com
- 電子郵件: contact@apipark.com
<br>