Merge branch 'feature/1.4' into 'main'

Feature/1.4

See merge request apipark/APIPark!138
This commit is contained in:
秦圆圆
2025-01-03 08:20:23 +08:00
24 changed files with 1878 additions and 1158 deletions
+128 -130
View File
@@ -1,47 +1,45 @@
import { StyleProvider } from '@ant-design/cssinjs'
import { BreadcrumbProvider } from '@common/contexts/BreadcrumbContext.tsx'
import { GlobalProvider } from '@common/contexts/GlobalStateContext'
import { useLocaleContext } from '@common/contexts/LocaleContext'
import { PluginEventHubProvider } from '@common/contexts/PluginEventHubContext'
import { PluginSlotHubProvider } from '@common/contexts/PluginSlotHubContext'
import useInitializeMonaco from '@common/hooks/useInitializeMonaco'
import { $t } from '@common/locales'
import RenderRoutes from '@core/components/aoplatform/RenderRoutes'
import { App as AppAntd, ConfigProvider } from 'antd'
import { useMemo } from 'react'
import './App.css'
import { ConfigProvider, App as AppAntd } from 'antd';
import RenderRoutes from '@core/components/aoplatform/RenderRoutes';
import {BreadcrumbProvider} from "@common/contexts/BreadcrumbContext.tsx";
import useInitializeMonaco from "@common/hooks/useInitializeMonaco";
import { useMemo } from 'react';
import { GlobalProvider } from '@common/contexts/GlobalStateContext';
import { $t } from '@common/locales';
import { PluginEventHubProvider } from '@common/contexts/PluginEventHubContext';
import { PluginSlotHubProvider } from '@common/contexts/PluginSlotHubContext';
import { useLocaleContext } from '@common/contexts/LocaleContext';
import { StyleProvider } from '@ant-design/cssinjs';
const antdComponentThemeToken = {
token: {
// Seed Token,影响范围大
colorPrimary: '#3D46F2',
colorLink:'#3D46F2',
colorBorder:'#ededed',
colorText:'#333',
colorLink: '#3D46F2',
colorBorder: '#ededed',
colorText: '#333',
borderRadius: 4,
// 派生变量,影响范围小
colorBgContainer: '#fff',
colorPrimaryBg:'#EBEEF2',
colorTextQuaternary:'#BBB',
colorTextTertiary:'#999'
colorPrimaryBg: '#EBEEF2',
colorTextQuaternary: '#BBB',
colorTextTertiary: '#999'
},
components:{
components: {
// 派生变量,影响范围小
Input:{
activeShadow:'none'
Input: {
activeShadow: 'none'
},
Select:{
activeShadow:'none'
Select: {
activeShadow: 'none'
},
Checkbox:{
activeShadow:'none'
Checkbox: {
activeShadow: 'none'
},
Cascader:{
activeShadow:'none',
optionSelectedBg:'#EBEEF2',
optionHoverBg:'#EBEEF2'
Cascader: {
activeShadow: 'none',
optionSelectedBg: '#EBEEF2',
optionHoverBg: '#EBEEF2'
},
Layout: {
bodyBg: '#fff',
@@ -50,122 +48,122 @@ const antdComponentThemeToken = {
headerHeight: 50,
headerPadding: '10 20px',
lightSiderBg: '#fff',
siderBg: '#fff',
siderBg: '#fff'
},
Breadcrumb:{
itemColor:'#666',
linkColor:'#666',
lastItemColor:'#333',
Breadcrumb: {
itemColor: '#666',
linkColor: '#666',
lastItemColor: '#333'
},
Table:{
headerBorderRadius:0,
headerSplitColor:'#ededed',
borderColor:'#ededed',
cellPaddingBlockMD:'15px',
cellPaddingInlineMD:'12px',
cellPaddingBlockSM:'8px',
cellPaddingInlineSM:'12px',
headerFilterHoverBg:'#EBEEF2',
headerSortActiveBg:'#F7F8FA',
headerSortHoverBg:'#F7F8FA',
fixedHeaderSortActiveBg:'#F7F8FA',
headerBg:'#FAFAFA',
rowHoverBg:'#EBEEF2'
Table: {
headerBorderRadius: 0,
headerSplitColor: '#ededed',
borderColor: '#ededed',
cellPaddingBlockMD: '15px',
cellPaddingInlineMD: '12px',
cellPaddingBlockSM: '8px',
cellPaddingInlineSM: '12px',
headerFilterHoverBg: '#EBEEF2',
headerSortActiveBg: '#F7F8FA',
headerSortHoverBg: '#F7F8FA',
fixedHeaderSortActiveBg: '#F7F8FA',
headerBg: '#FAFAFA',
rowHoverBg: '#EBEEF2'
},
Segmented:{
itemColor:'#333',
itemSelectedColor:'#333',
trackBg:'#f7f8fa',
trackPadding:0,
// itemHoverColor:'#EBEEF2',
itemActiveBg:'#EBEEF2',
itemHoverBg:'#EBEEF2',
itemSelectedBg:'#EBEEF2',
Segmented: {
itemColor: '#333',
itemSelectedColor: '#333',
trackBg: '#f7f8fa',
trackPadding: 0,
// itemHoverColor:'#EBEEF2',
itemActiveBg: '#EBEEF2',
itemHoverBg: '#EBEEF2',
itemSelectedBg: '#EBEEF2'
},
Tree:{
// titleHeight:30,
// fontSize:12,
directoryNodeSelectedBg:'#EBEEF2',
directoryNodeSelectedColor:'#333',
nodeSelectedBg:'#EBEEF2',
nodeHoverBg:'#EBEEF2'
},
Collapse:{
headerBg:'#f7f8fa',
headerPadding:"12px",
contentPadding:"0 10px 12px 10px"
},
Button:{
// paddingInline:8,
dangerShadow:'none',
defaultShadow:'none',
primaryShadow:'none'
},
Tabs:{
cardBg:'#EBEEF2',
cardHeight:42,
horizontalItemGutter:8,
horizontalItemPaddingSM:'12px 8px 8px 8px',
horizontalItemPadding:'12px 8px 8px 8px',
},
Menu:{
// itemBg:'#F7F8FA',
// subMenuItemBg:'#F7F8FA',
// itemMarginBlock:0,
// activeBarBorderWidth:0,
// itemSelectedColor:'#333',
// itemSelectedBg:'#EBEEF2',
// itemHoverBg:'#EBEEF2'
},
List:{
itemPadding:'8px 0'
},
Form:{
itemMarginBottom:10,
},
Alert:{
defaultPadding:'12px 16px'
},
Tag:{
defaultBg:"#f7f8fa"
},
Tree: {
// titleHeight:30,
// fontSize:12,
directoryNodeSelectedBg: '#EBEEF2',
directoryNodeSelectedColor: '#333',
nodeSelectedBg: '#EBEEF2',
nodeHoverBg: '#EBEEF2'
},
Collapse: {
headerBg: '#f7f8fa',
headerPadding: '12px',
contentPadding: '0 10px 12px 10px'
},
Button: {
// paddingInline:8,
dangerShadow: 'none',
defaultShadow: 'none',
primaryShadow: 'none'
},
Tabs: {
cardBg: '#EBEEF2',
cardHeight: 42,
horizontalItemGutter: 8,
horizontalItemPaddingSM: '12px 8px 8px 8px',
horizontalItemPadding: '12px 8px 8px 8px'
},
Menu: {
// itemBg:'#F7F8FA',
// subMenuItemBg:'#F7F8FA',
// itemMarginBlock:0,
// activeBarBorderWidth:0,
// itemSelectedColor:'#333',
// itemSelectedBg:'#EBEEF2',
// itemHoverBg:'#EBEEF2'
},
List: {
itemPadding: '8px 0'
},
Form: {
itemMarginBottom: 10
},
Alert: {
defaultPadding: '8px 12px'
},
Tag: {
defaultBg: '#f7f8fa'
}
}
}
function App() {
const { locale } = useLocaleContext();
const { locale } = useLocaleContext()
useInitializeMonaco()
const validateMessages = useMemo(()=>({
required: $t('必填项'),
email:$t('不是有效邮箱地址')}
),[locale])
const validateMessages = useMemo(
() => ({
required: $t('必填项'),
email: $t('不是有效邮箱地址')
}),
[locale]
)
return (
<StyleProvider hashPriority={"high"}>
<ConfigProvider
<StyleProvider hashPriority={'high'}>
<ConfigProvider
locale={locale}
wave={{disabled:true}}
wave={{ disabled: true }}
theme={antdComponentThemeToken}
form={{validateMessages }}>
<PluginEventHubProvider>
<AppAntd className="h-full" message={{ maxCount: 1 }}>
<PluginSlotHubProvider>
<GlobalProvider>
<BreadcrumbProvider>
<RenderRoutes />
</BreadcrumbProvider>
</GlobalProvider>
</PluginSlotHubProvider>
form={{ validateMessages }}
>
<PluginEventHubProvider>
<AppAntd className="h-full" message={{ maxCount: 1 }}>
<PluginSlotHubProvider>
<GlobalProvider>
<BreadcrumbProvider>
<RenderRoutes />
</BreadcrumbProvider>
</GlobalProvider>
</PluginSlotHubProvider>
</AppAntd>
</PluginEventHubProvider>
</ConfigProvider>
</StyleProvider>
);
)
}
export default App
@@ -1,17 +1,14 @@
import { STATUS_CODE } from '@common/const/const'
import { useFetch } from '@common/hooks/http'
import { ModelDetailData } from '@core/pages/aiSetting/types'
import { Select, Space, message } from 'antd'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export interface AIProvider {
id: string
name: string
logo: string
configured: boolean
getApikeyUrl: string
status: string
export interface AIProvider extends ModelDetailData {
default_config: string
backupName: string
backupModel: string
}
interface AIProviderResponse {
@@ -41,13 +38,18 @@ const AIProviderSelect: React.FC<AIProviderSelectProps> = ({ value, onChange, st
const fetchProviders = async () => {
if (isMounted) setLoading(true)
try {
const response = await fetchData<AIProviderResponse>('simple/ai/providers/configured', { method: 'GET' })
const endpoint = 'simple/ai/providers/configured'
const response = await fetchData<AIProviderResponse>(endpoint, { method: 'GET' })
const { code, data, msg } = response
if (code === STATUS_CODE.SUCCESS) {
isMounted && setProviders(data.providers)
const providers = data.providers.map((val) => ({
...val,
backupName: data.backup?.name,
backupModel: data.backup?.model?.name
}))
isMounted && setProviders(providers)
if (!data.providers?.length) return
const selectedProvider: AIProvider = value ? providers.find((p) => p.id === value) : data.providers[0]
const selectedProvider: AIProvider = value ? providers.find((p) => p.id === value) : providers[0]
onChange?.(selectedProvider.id, selectedProvider)
} else {
message.error(msg || t('Failed to fetch AI providers'))
@@ -1,127 +1,134 @@
import { AiServiceRouterTableListItem, VariableItems } from "./type";
import { TabsProps } from "antd";
import { frontendTimeSorter } from "@common/utils/dataTransfer";
import { COLUMNS_TITLE, PLACEHOLDER } from "@common/const/const";
import { PageProColumns } from "@common/components/aoplatform/PageList";
import { COLUMNS_TITLE, PLACEHOLDER } from '@common/const/const'
import { frontendTimeSorter } from '@common/utils/dataTransfer'
import { TabsProps } from 'antd'
import { AiServiceRouterTableListItem, VariableItems } from './type'
import { PageProColumns } from '@common/components/aoplatform/PageList'
export const AI_SERVICE_ROUTER_TABLE_COLUMNS: PageProColumns<AiServiceRouterTableListItem>[] = [
{
title:('URL'),
dataIndex: 'requestPath',
ellipsis:true
},
{
title:('名称'),
dataIndex: 'name',
ellipsis:true,
},
{
title:('模型'),
dataIndex: ['model','logo'],
ellipsis:true,
render: (_: React.ReactNode, entity: AiServiceRouterTableListItem) =><div className="flex items-center gap-[2px] " ><span>{entity.model.id}</span></div>
},
{
title:('描述'),
dataIndex: 'description',
ellipsis:true
},
{
title:('创建者'),
dataIndex: ['creator','name'],
ellipsis: true,
filters: true,
onFilter: true,
valueType: 'select',
filterSearch: true,
},
{
title:('更新时间'),
dataIndex: 'updateTime',
ellipsis:true,
hideInSearch: true,
width:182,
sorter: (a,b)=>frontendTimeSorter(a,b,'updateTime')
},
];
{
title: 'URL',
dataIndex: 'requestPath',
ellipsis: true
},
{
title: '名称',
dataIndex: 'name',
ellipsis: true
},
{
title: '模型',
dataIndex: ['model', 'logo'],
ellipsis: true,
render: (_: React.ReactNode, entity: AiServiceRouterTableListItem) => (
<div className="flex items-center gap-[2px] ">
<span>{entity.model.id}</span>
</div>
)
},
{
title: '是否放行',
dataIndex: 'disabled',
ellipsis: true,
filters: true,
onFilter: true,
valueType: 'select'
},
{
title: '描述',
dataIndex: 'description',
ellipsis: true
},
{
title: '创建者',
dataIndex: ['creator', 'name'],
ellipsis: true,
filters: true,
onFilter: true,
valueType: 'select',
filterSearch: true
},
{
title: '更新时间',
dataIndex: 'updateTime',
ellipsis: true,
hideInSearch: true,
width: 182,
sorter: (a, b) => frontendTimeSorter(a, b, 'updateTime')
}
]
export const AI_SERVICE_VARIABLES_TABLE_COLUMNS: PageProColumns<VariableItems & {_id:string}>[] = [
{
title:('Key'),
dataIndex: 'key',
key:'key',
width: '30%',
formItemProps: {
className:'p-0 bg-transparent border-none',
rules: [
{
required: true,
whitespace: true
},
{
pattern:/^[a-zA-Z][a-zA-Z0-9-_]*$/,
message: PLACEHOLDER.onlyAlphabet
}
],
},
ellipsis:true,
fieldProps:{
allowClear:false
}
},
{
title:('描述'),
dataIndex: 'description',
key:'description',
formItemProps: {
className:'p-0 bg-transparent border-none'
export const AI_SERVICE_VARIABLES_TABLE_COLUMNS: PageProColumns<VariableItems & { _id: string }>[] = [
{
title: 'Key',
dataIndex: 'key',
key: 'key',
width: '30%',
formItemProps: {
className: 'p-0 bg-transparent border-none',
rules: [
{
required: true,
whitespace: true
},
fieldProps:{
allowClear:false
{
pattern: /^[a-zA-Z][a-zA-Z0-9-_]*$/,
message: PLACEHOLDER.onlyAlphabet
}
]
},
{
title:('必填'),
dataIndex: 'require',
key:'require',
valueType:'switch',
width:64,
formItemProps: {
className:'p-0 bg-transparent border-none'}
},
{
title: COLUMNS_TITLE.operate,
valueType: 'option',
width:34,
render: ()=>null
},
];
export const AiService_INSIDE_APPROVAL_TAB_ITEMS: TabsProps['items'] = [
{
key: '0',
label:('待审核'),
},
{
key: '1',
label: ('已审核'),
ellipsis: true,
fieldProps: {
allowClear: false
}
];
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
formItemProps: {
className: 'p-0 bg-transparent border-none'
},
fieldProps: {
allowClear: false
}
},
{
title: '必填',
dataIndex: 'require',
key: 'require',
valueType: 'switch',
width: 64,
formItemProps: {
className: 'p-0 bg-transparent border-none'
}
},
{
title: COLUMNS_TITLE.operate,
valueType: 'option',
width: 34,
render: () => null
}
]
export const AiService_INSIDE_APPROVAL_TAB_ITEMS: TabsProps['items'] = [
{
key: '0',
label: '待审核'
},
{
key: '1',
label: '已审核'
}
]
export const AiService_PUBLISH_TAB_ITEMS: TabsProps['items'] = [
{
key: '0',
label: ('发布版本'),
},
{
key: '1',
label: ('发布申请记录'),
}
];
{
key: '0',
label: '发布版本'
},
{
key: '1',
label: '发布申请记录'
}
]
@@ -258,7 +258,7 @@ export const SYSTEM_API_TABLE_COLUMNS: PageProColumns<SystemApiTableListItem>[]
},
{
title: '是否放行',
dataIndex: 'disable',
dataIndex: 'disabled',
ellipsis: true,
filters: true,
onFilter: true,
@@ -0,0 +1,111 @@
import { Codebox } from '@common/components/postcat/api/Codebox'
import { BasicResponse, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const'
import { useFetch } from '@common/hooks/http'
import { $t } from '@common/locales'
import { AIProvider } from '@core/components/AIProviderSelect'
import { App, DatePicker, Form, Input, Switch } from 'antd'
import dayjs from 'dayjs'
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { EditAPIKey } from '../types'
interface ApiKeyContentProps {
provider?: AIProvider
entity: EditAPIKey
}
const ApiKeyContent: React.FC<ApiKeyContentProps> = forwardRef(({ provider, entity }, ref) => {
const [form] = Form.useForm()
const [neverExpire, setNeverExpire] = useState(true)
const { fetchData } = useFetch()
const { message } = App.useApp()
useEffect(() => {
try {
const isNeverExpire = entity.expire_time === 0
setNeverExpire(isNeverExpire)
form.setFieldsValue({
name: entity.name,
expire_time: isNeverExpire ? undefined : dayjs(entity.expire_time),
config: entity.config
})
} catch (e) {
form.setFieldsValue({
name: entity.name,
expire_time: undefined,
config: ''
})
}
}, [])
const handleOk = async () => {
try {
const values = await form.validateFields()
const { expire_time, ...restValues } = values
const expireTime = neverExpire ? 0 : expire_time.valueOf()
const response = await fetchData<BasicResponse<null>>('ai/resource/key', {
method: entity.id ? 'PUT' : 'POST',
eoParams: { provider: provider?.id, id: entity.id },
eoBody: { ...restValues, expire_time: expireTime },
eoTransformKeys: ['config']
})
const { code, msg } = response
if (code === STATUS_CODE.SUCCESS) {
message.success(msg || $t(RESPONSE_TIPS.success))
return true
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
return false
}
} catch (error) {
console.error('Validation failed:', error)
}
}
useImperativeHandle(ref, () => ({
handleOk
}))
const handleNeverExpireChange = (checked: boolean) => {
setNeverExpire(checked)
if (!checked) {
form.setFieldsValue({
expire_time: dayjs().add(7, 'days')
})
}
}
return (
<Form form={form} layout="vertical">
<Form.Item name="name" label={$t('名称')} rules={[{ required: true, message: $t('请输入 APIKey') }]}>
<Input />
</Form.Item>
<Form.Item label={$t('API Key')} name="config" rules={[{ required: true, message: $t('请填写 APIKey') }]}>
<Codebox
editorTheme="vs-dark"
readOnly={false}
width="100%"
height="150px"
language="json"
enableToolbar={false}
/>
</Form.Item>
<Form.Item label={$t('过期时间')} name="neverExpire" valuePropName="checked">
<div className="flex items-center">
<Switch onChange={handleNeverExpireChange} checked={neverExpire} />
<span className="ml-2">{neverExpire ? $t('永不过期') : $t('设置过期时间')}</span>
</div>
</Form.Item>
{!neverExpire && (
<Form.Item
name="expire_time"
label={$t('过期时间')}
rules={[{ required: true, message: $t('请选择过期时间') }]}
>
<DatePicker style={{ width: '100%' }} showTime />
</Form.Item>
)}
</Form>
)
})
export default ApiKeyContent
@@ -0,0 +1,40 @@
import { $t } from '@common/locales'
import { Select, Space, theme } from 'antd'
import React from 'react'
interface StatusFilterProps {
value: string[]
onChange: (value: string[]) => void
}
const StatusFilter: React.FC<StatusFilterProps> = ({ value, onChange }) => {
const { token } = theme.useToken()
const options = [
{ label: $t('Normal'), value: 'normal', color: token.colorSuccess },
{ label: $t('Exceeded'), value: 'exceeded', color: token.colorError },
{ label: $t('Expired'), value: 'expired', color: token.colorWarning },
{ label: $t('Disabled'), value: 'disabled', color: token.colorTextDisabled },
{ label: $t('Error'), value: 'error', color: token.colorError }
]
return (
<Space>
<span>{$t('Status')}:</span>
<Select
mode="multiple"
value={value}
onChange={onChange}
style={{ width: 300 }}
placeholder={$t('Filter by status')}
allowClear
options={options.map((option) => ({
...option,
label: <span style={{ color: option.color }}>{option.label}</span>
}))}
/>
</Space>
)
}
export default StatusFilter
@@ -1,17 +1,258 @@
import { ActionType } from '@ant-design/pro-components'
import InsidePage from '@common/components/aoplatform/InsidePage'
import PageList, { PageProColumns } from '@common/components/aoplatform/PageList'
import TableBtnWithPermission from '@common/components/aoplatform/TableBtnWithPermission'
import TimeRangeSelector, { TimeRangeButton } from '@common/components/aoplatform/TimeRangeSelector'
import { BasicResponse, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const'
import { useFetch } from '@common/hooks/http'
import { $t } from '@common/locales'
import React from 'react'
import AIProviderSelect, { AIProvider } from '@core/components/AIProviderSelect'
import { getTime } from '@dashboard/utils/dashboard'
import { Alert, App, Button, Typography } from 'antd'
import dayjs from 'dayjs'
import React, { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { APIKey } from './types'
const ApiSettings: React.FC = () => {
const pageListRef = useRef<ActionType>(null)
const { modal, message } = App.useApp()
const [searchParams] = useSearchParams()
const [selectedProvider, setSelectedProvider] = useState<string>(searchParams.get('modelId') || '')
const [provider, setProvider] = useState<AIProvider | undefined>()
const { fetchData } = useFetch()
const [searchWord, setSearchWord] = useState<string>('')
const [total, setTotal] = useState<number>(0)
const [timeButton, setTimeButton] = useState<TimeRangeButton>('day')
const navigate = useNavigate()
const [timeRange, setTimeRange] = useState<{ start: number | null; end: number | null }>({
start: null,
end: null
})
const [queryBtnLoading, setQueryBtnLoading] = useState(false)
useEffect(() => {
pageListRef.current?.reload()
}, [selectedProvider])
const requestApis = async (params: any) => {
if (!selectedProvider) return
setQueryBtnLoading(true)
try {
const eoParams = {
provider: selectedProvider,
page_size: params.pageSize,
keyword: searchWord,
page: params.current,
start: timeRange.start,
end: timeRange.end
}
if (!timeRange || !timeRange.start) {
const { startTime, endTime } = getTime(timeButton, [])
eoParams.start = startTime
eoParams.end = endTime
}
const response = await fetchData<BasicResponse<{ data: APIKey[] }>>('ai/apis', {
method: 'GET',
eoParams
})
setQueryBtnLoading(false)
if (response.code === STATUS_CODE.SUCCESS) {
setTotal(response.data.total)
return {
data: response.data.apis,
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<APIKey>[] = [
{
title: '',
key: 'option',
btnNums: 4,
fixed: 'right',
valueType: 'option',
render: (_: React.ReactNode, entity: APIKey) => [
<TableBtnWithPermission
access="system.settings.ai_key_resource.manager"
key="edit"
btnType="edit"
// onClick={() => handleEdit(entity)}
btnTitle={$t('编辑')}
/>
]
}
]
const columns: PageProColumns<APIKey>[] = [
{
title: 'AI 服务',
dataIndex: 'name',
key: 'name',
width: 180
},
{
title: 'API URL',
dataIndex: 'request_path',
key: 'request_path',
width: 200,
ellipsis: true
},
{
title: '模型',
dataIndex: ['model', 'name'],
key: 'model',
width: 150,
filters: true,
onFilter: true,
valueType: 'select',
valueEnum: {}
},
{
title: '已用 Token',
dataIndex: 'use_token',
key: 'use_token',
width: 120,
sorter: true
},
{
title: '是否放行',
dataIndex: 'disabled',
ellipsis: true,
filters: true,
onFilter: true,
valueType: 'select',
valueEnum: {
true: { text: <Typography.Text type="danger">{$t('拦截')}</Typography.Text> },
false: { text: <Typography.Text type="success">{$t('放行')}</Typography.Text> }
}
},
{
title: '编辑时间',
dataIndex: 'update_time',
key: 'update_time',
width: 200,
render: (time: string) => <Typography.Text>{dayjs(time).format('YYYY-MM-DD HH:mm:ss')}</Typography.Text>
},
...operation
]
const resetQuery = () => {
setTimeButton('day')
setTimeRange({ start: null, end: null })
setSearchWord('')
}
const getData = () => {
pageListRef.current?.reload()
}
const renderProviderBanner = () => {
if (!provider) return null
console.log(provider)
if (provider.status === 'disabled' || provider.status === 'abnormal') {
const message =
provider.status === 'disabled'
? $t(`当前供应商异常,以下API均临时调用 ${provider.backupName} 下的 ${provider.backupModel} 模型能力。`)
: $t(`当前供应商异常,以下API均临时调用 ${provider.backupName} 下的 ${provider.backupModel} 模型能力。`)
return (
<Alert
message={message}
type="warning"
className="my-4"
showIcon
action={
<Button size="small" type="link" onClick={() => navigate('/aisetting')}>
{$t('查看详情')}
</Button>
}
/>
)
}
return null
}
const AIApis: React.FC = () => {
return (
<InsidePage
className="overflow-y-auto pb-PAGE_INSIDE_B"
pageTitle={$t('AI API')}
description={$t('配置好 AI 模型后,你可以使用对应的大模型来创建 AI 服务')}
className="overflow-y-auto gap-4 pb-PAGE_INSIDE_B pr-PAGE_INSIDE_X"
pageTitle={$t('AI API 列表')}
description={
<>
<div className="flex gap-2 items-center">
<AIProviderSelect
value={selectedProvider}
onChange={(value, option) => {
setSelectedProvider(value)
setProvider(option)
}}
/>
</div>
{renderProviderBanner()}
</>
}
showBorder={false}
scrollPage={false}
></InsidePage>
>
<div className="h-[calc(100%-1rem-36px)]">
<PageList
ref={pageListRef}
rowKey="id"
afterNewBtn={
<div className="flex items-center flex-wrap pb-[10px] px-btnbase content-before bg-MAIN_BG pr-PAGE_INSIDE_X">
<TimeRangeSelector
labelSize="small"
hideBtns={['hour']}
initialTimeButton={timeButton}
onTimeButtonChange={setTimeButton}
onTimeRangeChange={($event) => {
setTimeRange($event)
}}
/>
<div className="flex flex-nowrap items-center pt-btnybase">
<Button onClick={resetQuery}>{$t('重置')}</Button>
<Button
className="ml-btnybase"
type="primary"
loading={queryBtnLoading}
onClick={() => {
setQueryBtnLoading(true)
getData()
}}
>
{$t('查询')}
</Button>
</div>
</div>
}
request={requestApis}
onSearchWordChange={(e) => {
setSearchWord(e.target.value)
}}
showPagination={true}
searchPlaceholder={$t('请输入 APIURL 搜索')}
columns={columns}
/>
</div>
</InsidePage>
)
}
export default AIApis
export default ApiSettings
@@ -0,0 +1,16 @@
export interface APIKey extends EditAPIKey {
status: 'normal' | 'exceeded' | 'expired' | 'disabled' | 'error'
use_token: number
update_time: string
can_delete: boolean
priority: number
}
export interface EditAPIKey {
id?: string
name: string
config: string
expire_time: number
}
@@ -1,354 +1,422 @@
import {App, Button, Form, Input, InputNumber, Row, Select, Space, Spin, Tag} from "antd";
import { MutableRefObject, useEffect, useMemo, useRef, useState} from "react";
import {BasicResponse, PLACEHOLDER, RESPONSE_TIPS, STATUS_CODE} from "@common/const/const.tsx";
import {useFetch} from "@common/hooks/http.ts";
import { $t } from "@common/locales/index.ts";
import { LoadingOutlined } from "@ant-design/icons";
import InsidePage from "@common/components/aoplatform/InsidePage.tsx";
import { Icon } from "@iconify/react/dist/iconify.js";
import { RouterParams } from "@core/components/aoplatform/RenderRoutes.tsx";
import { useNavigate, useParams } from "react-router-dom";
import { useAiServiceContext } from "@core/contexts/AiServiceContext.tsx";
import EditableTableNotAutoGen from "@common/components/aoplatform/EditableTableNotAutoGen.tsx";
import { AI_SERVICE_VARIABLES_TABLE_COLUMNS } from "@core/const/ai-service/const.tsx";
import { VariableItems } from "@core/const/ai-service/type.ts";
import PromptEditorResizable from '@common/components/aoplatform/prompt-editor/PromptEditorResizable.tsx';
import { DrawerWithFooter } from "@common/components/aoplatform/DrawerWithFooter";
import AiServiceRouterModelConfig, { AiServiceRouterModelConfigHandle } from "./AiServiceInsideRouterModelConfig";
import { AiProviderDefaultConfig, AiProviderLlmsItems } from "@core/pages/aiSetting/AiSettingList";
import { EditableFormInstance } from "@ant-design/pro-components";
import { validateUrlSlash } from "@common/utils/validate";
import { API_PATH_MATCH_RULES } from "@core/const/system/const";
import { useGlobalContext } from "@common/contexts/GlobalStateContext";
import { LoadingOutlined } from '@ant-design/icons'
import { EditableFormInstance } from '@ant-design/pro-components'
import { DrawerWithFooter } from '@common/components/aoplatform/DrawerWithFooter'
import EditableTableNotAutoGen from '@common/components/aoplatform/EditableTableNotAutoGen.tsx'
import InsidePage from '@common/components/aoplatform/InsidePage.tsx'
import PromptEditorResizable from '@common/components/aoplatform/prompt-editor/PromptEditorResizable.tsx'
import { BasicResponse, PLACEHOLDER, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const.tsx'
import { useGlobalContext } from '@common/contexts/GlobalStateContext'
import { useFetch } from '@common/hooks/http.ts'
import { $t } from '@common/locales/index.ts'
import { validateUrlSlash } from '@common/utils/validate'
import { RouterParams } from '@core/components/aoplatform/RenderRoutes.tsx'
import { AI_SERVICE_VARIABLES_TABLE_COLUMNS } from '@core/const/ai-service/const.tsx'
import { VariableItems } from '@core/const/ai-service/type.ts'
import { API_PATH_MATCH_RULES } from '@core/const/system/const'
import { useAiServiceContext } from '@core/contexts/AiServiceContext.tsx'
import { AiProviderDefaultConfig, AiProviderLlmsItems } from '@core/pages/aiSetting/AiSettingList'
import { Icon } from '@iconify/react/dist/iconify.js'
import { App, Button, Form, Input, InputNumber, Row, Select, Space, Spin, Switch, Tag } from 'antd'
import { MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import AiServiceRouterModelConfig, { AiServiceRouterModelConfigHandle } from './AiServiceInsideRouterModelConfig'
type AiServiceRouterField = {
name:string
path:string
prompt:string
variables:Array<{key:string, description:string, require:true}>
description:string
timeout:number
retry:number
name: string
path: string
prompt: string
variables: Array<{ key: string; description: string; require: true }>
description: string
timeout: number
retry: number
disabled: boolean
}
type AiServiceRouterConfig = {
name:string
path:string
aiPrompt:{
prompt:string
variables:Array<{key:string, description:string, require:true}>
}
aiModel:{
id:string
config:string
}
description:string
timeout:number
retry:number
name: string
path: string
aiPrompt: {
prompt: string
variables: Array<{ key: string; description: string; require: true }>
}
aiModel: {
id: string
config: string
}
description: string
timeout: number
retry: number
}
const AiServiceInsideRouterCreate = () => {
const navigator = useNavigate()
const { message } = App.useApp()
const {serviceId, teamId,routeId} = useParams<RouterParams>()
const [form] = Form.useForm();
const {fetchData} = useFetch()
const [loading, setLoading] = useState<boolean>(false)
const {apiPrefix,prefixForce ,aiServiceInfo} = useAiServiceContext()
const [variablesTable,setVariablesTable] = useState<VariableItems[]>([])
const [drawerType,setDrawerType]= useState<'edit'|undefined>()
const [open, setOpen] = useState(false);
const drawerAddFormRef = useRef<AiServiceRouterModelConfigHandle>(null)
const [defaultLlm, setDefaultLlm] = useState<AiProviderDefaultConfig & {config:string}>()
const [llmList, setLlmList] = useState<AiProviderLlmsItems[]>([])
const [variablesTableRef, setVariablesTableRef] = useState<MutableRefObject<EditableFormInstance<T> | undefined>>()
const {state} = useGlobalContext()
const onFinish = ()=>{
return variablesTableRef?.current?.validateFields().then(()=>{
return form.validateFields().then((formValue)=>{
const {name, path, description, variables, prompt, timeout, retry,pathMatch} = formValue
const body = {
name,
path: `${prefixForce ? apiPrefix + '/' : ''}${path.trim()}${pathMatch === 'prefix' ? '/*' : ''}`,
description,timeout, retry,aiPrompt:{variables:variables, prompt:prompt},aiModel:{id:defaultLlm?.id, provider:defaultLlm?.provider, config:defaultLlm?.config}}
return fetchData<BasicResponse<null>>('service/ai-router',{method: routeId ? 'PUT' : 'POST',eoBody:(body), eoParams: {service:serviceId,team:teamId, ...(routeId ? {router:routeId}: {})},eoTransformKeys:['aiPrompt','aiModel']}).then(response=>{
const {code,msg} = response
if(code === STATUS_CODE.SUCCESS){
message.success(msg || $t(RESPONSE_TIPS.success))
navigator(`/service/${teamId}/aiInside/${serviceId}/route`)
return Promise.resolve(true)
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return Promise.reject(msg || $t(RESPONSE_TIPS.error))
}
}).catch(errInfo=>Promise.reject(errInfo))
})
})
.catch(errInfo=>Promise.reject(errInfo))
}
const navigator = useNavigate()
const { message } = App.useApp()
const { serviceId, teamId, routeId } = useParams<RouterParams>()
const [form] = Form.useForm()
const { fetchData } = useFetch()
const [loading, setLoading] = useState<boolean>(false)
const { apiPrefix, prefixForce, aiServiceInfo } = useAiServiceContext()
const [variablesTable, setVariablesTable] = useState<VariableItems[]>([])
const [drawerType, setDrawerType] = useState<'edit' | undefined>()
const [open, setOpen] = useState(false)
const drawerAddFormRef = useRef<AiServiceRouterModelConfigHandle>(null)
const [defaultLlm, setDefaultLlm] = useState<AiProviderDefaultConfig & { config: string }>()
const [llmList, setLlmList] = useState<AiProviderLlmsItems[]>([])
const [variablesTableRef, setVariablesTableRef] = useState<MutableRefObject<EditableFormInstance<T> | undefined>>()
const { state } = useGlobalContext()
const openDrawer = (type:'edit')=>{
setDrawerType(type)
}
useEffect(()=>{drawerType !== undefined ? setOpen(true):setOpen(false)},[drawerType])
const getRouterConfig = ()=>{
setLoading(true)
fetchData<BasicResponse<{api:AiServiceRouterConfig}>>('service/ai-router',{method:'GET',eoParams:{service:serviceId,team:teamId, router:routeId}, eoTransformKeys:['ai_model', 'ai_prompt']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
const {path, aiPrompt,aiModel} = data.api
let newPath = path
let pathMatch = 'full'
if(prefixForce && path?.startsWith(apiPrefix + '/')){
newPath = path.slice((apiPrefix?.length || 0) + 1)
}
if(newPath.endsWith('/*')){
newPath = newPath.slice(0,-2)
pathMatch = 'prefix'
}
form.setFieldsValue({
...data.api,
...aiPrompt,
path:newPath,
pathMatch})
setVariablesTable(aiPrompt.variables as VariableItems[])
setDefaultLlm(prev => ({...prev, provider: aiModel?.provider, id:aiModel?.id, config:aiModel.config}) as (AiProviderDefaultConfig & { config: string; }))
getDefaultModelConfig(aiModel?.provider)
}else{
const onFinish = () => {
return variablesTableRef?.current
?.validateFields()
.then(() => {
return form.validateFields().then((formValue) => {
const { name, path, description, variables, prompt, timeout, retry, pathMatch, disabled } = formValue
const body = {
name,
path: `${prefixForce ? apiPrefix + '/' : ''}${path.trim()}${pathMatch === 'prefix' ? '/*' : ''}`,
description,
timeout,
retry,
aiPrompt: { variables: variables, prompt: prompt },
aiModel: { id: defaultLlm?.id, provider: defaultLlm?.provider, config: defaultLlm?.config },
disabled
}
return fetchData<BasicResponse<null>>('service/ai-router', {
method: routeId ? 'PUT' : 'POST',
eoBody: body,
eoParams: { service: serviceId, team: teamId, ...(routeId ? { router: routeId } : {}) },
eoTransformKeys: ['aiPrompt', 'aiModel']
})
.then((response) => {
const { code, msg } = response
if (code === STATUS_CODE.SUCCESS) {
message.success(msg || $t(RESPONSE_TIPS.success))
navigator(`/service/${teamId}/aiInside/${serviceId}/route`)
return Promise.resolve(true)
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=> console.error(errorInfo))
.finally(()=>setLoading(false))
}
const getDefaultModelConfig = (provider?:string)=>{
fetchData<BasicResponse<{llms:AiProviderLlmsItems[],provider:AiProviderDefaultConfig}>>('ai/provider/llms',{method:'GET',eoParams:{provider:provider ?? aiServiceInfo?.provider?.id}, eoTransformKeys:['default_llm']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setLlmList(data.llms)
setDefaultLlm(prev => {
const llmSetting = data.llms?.find((x:AiProviderLlmsItems)=>x.id ===( prev?.id ?? data.provider.defaultLlm))
return {...prev,
defaultLlm:data.provider.defaultLlm,
provider:data.provider.id,
name:data.provider.name,
config:llmSetting?.config || '',
...(llmSetting ?? {})
} as (AiProviderDefaultConfig & { config: string; })
})
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
}
}).catch((errorInfo)=> console.error(errorInfo))
}
useEffect(()=>{
!routeId && aiServiceInfo?.provider && getDefaultModelConfig()
},[
aiServiceInfo
])
useEffect(() => {
if(routeId){
getRouterConfig()
}else{
form.setFieldsValue({
prefix:apiPrefix,
variables:[{key:'Query',value:'',require:true}],
prompt:'{{Query}}',
retry:0,
timeout:300000,
pathMatch:'prefix'
return Promise.reject(msg || $t(RESPONSE_TIPS.error))
}
})
}
return (form.setFieldsValue({}))
}, []);
const addVariable = ()=>{
form.setFieldsValue({
variables:[...form.getFieldValue('variables'),{key:'',value:'',require:true}]
.catch((errInfo) => Promise.reject(errInfo))
})
}
})
.catch((errInfo) => Promise.reject(errInfo))
}
const handleVariablesChange = (newKeys:string[])=>{
const variables = form.getFieldValue('variables') || []
const variablesKeys = variables?.map(({key}:{key:string})=>(key))
for(const key of newKeys){
if(!variablesKeys ||variablesKeys.indexOf(key) === -1){
variables.push({key, value:'',require:true})
}
const openDrawer = (type: 'edit') => {
setDrawerType(type)
}
useEffect(() => {
drawerType !== undefined ? setOpen(true) : setOpen(false)
}, [drawerType])
const getRouterConfig = () => {
setLoading(true)
fetchData<BasicResponse<{ api: AiServiceRouterConfig }>>('service/ai-router', {
method: 'GET',
eoParams: { service: serviceId, team: teamId, router: routeId },
eoTransformKeys: ['ai_model', 'ai_prompt']
})
.then((response) => {
const { code, data, msg } = response
if (code === STATUS_CODE.SUCCESS) {
const { path, aiPrompt, aiModel } = data.api
let newPath = path
let pathMatch = 'full'
if (prefixForce && path?.startsWith(apiPrefix + '/')) {
newPath = path.slice((apiPrefix?.length || 0) + 1)
}
if (newPath.endsWith('/*')) {
newPath = newPath.slice(0, -2)
pathMatch = 'prefix'
}
form.setFieldsValue({
...data.api,
...aiPrompt,
path: newPath,
pathMatch
})
setVariablesTable(aiPrompt.variables as VariableItems[])
setDefaultLlm(
(prev) =>
({
...prev,
provider: aiModel?.provider,
id: aiModel?.id,
config: aiModel.config
}) as AiProviderDefaultConfig & { config: string }
)
getDefaultModelConfig(aiModel?.provider)
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
}
form.setFieldsValue({
variables:[...variables]
})
setVariablesTable(variables as VariableItems[])
}
})
.catch((errorInfo) => console.error(errorInfo))
.finally(() => setLoading(false))
}
const handleValuesChange = (changedValues:Record<string,unknown>) => {
if(changedValues.variables){
setVariablesTable(changedValues.variables as VariableItems[])
const getDefaultModelConfig = (provider?: string) => {
fetchData<BasicResponse<{ llms: AiProviderLlmsItems[]; provider: AiProviderDefaultConfig }>>('ai/provider/llms', {
method: 'GET',
eoParams: { provider: provider ?? aiServiceInfo?.provider?.id },
eoTransformKeys: ['default_llm']
})
.then((response) => {
const { code, data, msg } = response
if (code === STATUS_CODE.SUCCESS) {
setLlmList(data.llms)
setDefaultLlm((prev) => {
const llmSetting = data.llms?.find(
(x: AiProviderLlmsItems) => x.id === (prev?.id ?? data.provider.defaultLlm)
)
return {
...prev,
defaultLlm: data.provider.defaultLlm,
provider: data.provider.id,
name: data.provider.name,
config: llmSetting?.config || '',
...(llmSetting ?? {})
} as AiProviderDefaultConfig & { config: string }
})
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
}
};
})
.catch((errorInfo) => console.error(errorInfo))
}
const handlerSubmit:() => Promise<boolean>|undefined= ()=>{
return drawerAddFormRef.current?.save()?.then((res:{id:string, config:string})=>{
setDefaultLlm(prev => ({...prev, provider:res.provider, id:res.id, config:res.config, logo:llmList?.find((x:AiProviderLlmsItems)=>x.id === res.id)?.logo}) as (AiProviderDefaultConfig & { config: string; }))
return true})
useEffect(() => {
!routeId && aiServiceInfo?.provider && getDefaultModelConfig()
}, [aiServiceInfo])
useEffect(() => {
if (routeId) {
getRouterConfig()
} else {
form.setFieldsValue({
prefix: apiPrefix,
variables: [{ key: 'Query', value: '', require: true }],
prompt: '{{Query}}',
retry: 0,
timeout: 300000,
pathMatch: 'prefix'
})
}
return form.setFieldsValue({})
}, [])
const onClose = () => {
setDrawerType(undefined);
};
const addVariable = () => {
form.setFieldsValue({
variables: [...form.getFieldValue('variables'), { key: '', value: '', require: true }]
})
}
const apiPathMatchRulesOptions = useMemo(()=>API_PATH_MATCH_RULES.map(
x=>({label:$t(x.label), value:x.value})),[state.language])
const handleVariablesChange = (newKeys: string[]) => {
const variables = form.getFieldValue('variables') || []
const variablesKeys = variables?.map(({ key }: { key: string }) => key)
for (const key of newKeys) {
if (!variablesKeys || variablesKeys.indexOf(key) === -1) {
variables.push({ key, value: '', require: true })
}
}
form.setFieldsValue({
variables: [...variables]
})
setVariablesTable(variables as VariableItems[])
}
return (
<InsidePage pageTitle={ $t('AI 路由设置')|| '-'}
showBorder={false}
scrollPage={false}
className="overflow-y-auto"
backUrl={`/service/${teamId}/aiInside/${serviceId}/route`}
customBtn={
<div className="flex items-center gap-btnbase">
<Button icon={<Icon icon='ic:baseline-tune' height={18} width={18} />} iconPosition='end' onClick={()=>openDrawer('edit')}>
<div className="flex items-center gap-[10px]">
<span className="flex items-center h-[24px] ai-setting-svg-container " dangerouslySetInnerHTML={{__html: defaultLlm?.logo || ''}}></span>
<span>{defaultLlm?.id || defaultLlm?.defaultLlm}</span>
{defaultLlm?.scopes?.map(x=><Tag >{x?.toLocaleUpperCase()}</Tag>)}
</div>
</Button>
<Button type="primary" onClick={onFinish}>
{$t('保存')}
</Button>
const handleValuesChange = (changedValues: Record<string, unknown>) => {
if (changedValues.variables) {
setVariablesTable(changedValues.variables as VariableItems[])
}
}
const handlerSubmit: () => Promise<boolean> | undefined = () => {
return drawerAddFormRef.current?.save()?.then((res: { id: string; config: string }) => {
setDefaultLlm(
(prev) =>
({
...prev,
provider: res.provider,
id: res.id,
config: res.config,
logo: llmList?.find((x: AiProviderLlmsItems) => x.id === res.id)?.logo
}) as AiProviderDefaultConfig & { config: string }
)
return true
})
}
const onClose = () => {
setDrawerType(undefined)
}
const apiPathMatchRulesOptions = useMemo(
() => API_PATH_MATCH_RULES.map((x) => ({ label: $t(x.label), value: x.value })),
[state.language]
)
return (
<InsidePage
pageTitle={$t('AI 路由设置') || '-'}
showBorder={false}
scrollPage={false}
className="overflow-y-auto"
backUrl={`/service/${teamId}/aiInside/${serviceId}/route`}
customBtn={
<div className="flex items-center gap-btnbase">
<Button
icon={<Icon icon="ic:baseline-tune" height={18} width={18} />}
iconPosition="end"
onClick={() => openDrawer('edit')}
>
<div className="flex items-center gap-[10px]">
<span
className="flex items-center h-[24px] ai-setting-svg-container "
dangerouslySetInnerHTML={{ __html: defaultLlm?.logo || '' }}
></span>
<span>{defaultLlm?.id || defaultLlm?.defaultLlm}</span>
{defaultLlm?.scopes?.map((x) => <Tag>{x?.toLocaleUpperCase()}</Tag>)}
</div>
</Button>
<Button type="primary" onClick={onFinish}>
{$t('保存')}
</Button>
</div>
}
>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />}
spinning={loading}
wrapperClassName=" pb-PAGE_INSIDE_B pr-PAGE_INSIDE_X"
>
<Form
layout="vertical"
labelAlign="left"
scrollToFirstError
form={form}
className="flex flex-col mx-auto h-full"
name="AiServiceInsideRouterCreate"
onValuesChange={handleValuesChange}
onFinish={onFinish}
autoComplete="off"
>
<div className="">
<Row className="flex justify-between items-center w-full gap-btnbase">
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t('路由名称')}
name="name"
rules={[{ required: true, whitespace: true }]}
>
<Input className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)} />
</Form.Item>
<Form.Item className="flex-1" label={$t('请求路径')}>
<Space.Compact block>
<Form.Item
name="pathMatch"
rules={[
{ required: true, whitespace: true },
{
validator: validateUrlSlash
}
]}
noStyle
>
<Select
placeholder={$t(PLACEHOLDER.select)}
options={apiPathMatchRulesOptions}
className="w-[30%] min-w-[100px]"
/>
</Form.Item>
<Form.Item<AiServiceRouterField>
name="path"
rules={[
{ required: true, whitespace: true },
{
validator: validateUrlSlash
}
]}
noStyle
>
<Input
prefix={prefixForce ? `${apiPrefix}/` : '/'}
placeholder={$t(PLACEHOLDER.input)}
onChange={(e) => {
if ((e.target.value as string).endsWith('/*')) {
form.setFieldValue('path', e.target.value.slice(0, -2))
form.setFieldValue('pathMatch', 'prefix')
}
}}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
</Row>
<Form.Item<AiServiceRouterField> label={$t('提示词')} name="prompt">
<PromptEditorResizable variablesChange={handleVariablesChange} promptVariables={variablesTable} />
</Form.Item>
<Form.Item<AiServiceRouterField>
label={
<div className="flex justify-between items-center w-full">
<span>{$t('变量')}</span>
<a className="flex items-center gap-[4px]" onClick={addVariable}>
<Icon icon="ic:baseline-add" width={16} height={16} />
New
</a>
</div>
}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} spinning={loading} wrapperClassName=' pb-PAGE_INSIDE_B pr-PAGE_INSIDE_X'>
<Form
layout='vertical'
labelAlign='left'
scrollToFirstError
form={form}
className="flex flex-col h-full mx-auto"
name="AiServiceInsideRouterCreate"
onValuesChange={handleValuesChange}
onFinish={onFinish}
autoComplete="off"
>
<div className="">
<Row className="flex items-center justify-between w-full gap-btnbase">
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t("路由名称")}
name="name"
rules={[{ required: true,whitespace:true }]}
>
<Input className="w-INPUT_NORMAL" placeholder={$t(PLACEHOLDER.input)}/>
</Form.Item>
<Form.Item className="flex-1" label={$t("请求路径")}>
<Space.Compact block>
<Form.Item
name="pathMatch"
rules={[{ required: true,whitespace:true },
{
validator: validateUrlSlash,
}]}
noStyle
>
<Select placeholder={$t(PLACEHOLDER.select)} options={apiPathMatchRulesOptions} className="w-[30%] min-w-[100px]"/>
</Form.Item>
<Form.Item<AiServiceRouterField>
name="path"
rules={[{ required: true,whitespace:true },
{
validator: validateUrlSlash,
}]}
noStyle
>
<Input prefix={(prefixForce ? `${apiPrefix}/` :"/")}
placeholder={$t(PLACEHOLDER.input)} onChange={(e)=>{
if((e.target.value as string).endsWith('/*')){
form.setFieldValue('path',e.target.value.slice(0,-2))
form.setFieldValue('pathMatch','prefix')
}
}}/>
</Form.Item>
</Space.Compact>
</Form.Item>
}
name="variables"
className="[&>.ant-row>.ant-col>label]:w-full"
>
<EditableTableNotAutoGen<VariableItems & { _id: string }>
getFromRef={setVariablesTableRef}
configFields={AI_SERVICE_VARIABLES_TABLE_COLUMNS}
/>
</Form.Item>
</Row>
<Form.Item<AiServiceRouterField> label={$t('描述')} name="description">
<Input.TextArea className="w-INPUT_NORMAL" placeholder={$t('输入这个接口的描述')} />
</Form.Item>
<Form.Item<AiServiceRouterField>
label={$t("提示词")}
name="prompt"
>
<PromptEditorResizable variablesChange={handleVariablesChange} promptVariables={variablesTable}/>
</Form.Item>
<Form.Item<AiServiceRouterField>
label={<div className="flex items-center justify-between w-full"><span>{$t("变量")}</span><a className="flex items-center gap-[4px]" onClick={addVariable}><Icon icon="ic:baseline-add" width={16} height={16} />New</a></div>}
name="variables"
className="[&>.ant-row>.ant-col>label]:w-full"
>
<EditableTableNotAutoGen<VariableItems & {_id:string}>
getFromRef={setVariablesTableRef}
configFields={AI_SERVICE_VARIABLES_TABLE_COLUMNS}
/>
</Form.Item>
<Form.Item<AiServiceRouterField>
label={$t("描述")}
name="description"
>
<Input.TextArea className="w-INPUT_NORMAL" placeholder={$t('输入这个接口的描述')}/>
</Form.Item>
<Row className="flex items-center justify-between w-full gap-btnbase">
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t("请求超时时间")}
name={'timeout'}
rules={[{required: true}]}
>
<InputNumber className="w-INPUT_NORMAL" suffix="ms" min={1} placeholder={$t(PLACEHOLDER.input)} />
</Form.Item>
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t("重试次数")}
name={'retry'}
rules={[{required: true}]}
>
<InputNumber className="w-INPUT_NORMAL" min={0} placeholder={$t(PLACEHOLDER.input)} />
</Form.Item>
</Row>
</div>
</Form>
</Spin>
<DrawerWithFooter
title={ $t("模型配置")}
open={open}
onClose={onClose}
onSubmit={()=>handlerSubmit()}
>
<AiServiceRouterModelConfig ref={drawerAddFormRef} llmList={llmList} entity={defaultLlm!} />
</DrawerWithFooter>
</InsidePage>
)
<Row className="flex justify-between items-center w-full gap-btnbase">
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t('请求超时时间')}
name={'timeout'}
rules={[{ required: true }]}
>
<InputNumber className="w-INPUT_NORMAL" suffix="ms" min={1} placeholder={$t(PLACEHOLDER.input)} />
</Form.Item>
<Form.Item<AiServiceRouterField>
className="flex-1"
label={$t('重试次数')}
name={'retry'}
rules={[{ required: true }]}
>
<InputNumber className="w-INPUT_NORMAL" min={0} placeholder={$t(PLACEHOLDER.input)} />
</Form.Item>
</Row>
<Form.Item<AiServiceRouterField>
label={$t('拦截接口')}
name="disabled"
extra={$t('开启拦截后,网关会拦截所有该路径的请求。')}
>
<Switch />
</Form.Item>
</div>
</Form>
</Spin>
<DrawerWithFooter title={$t('模型配置')} open={open} onClose={onClose} onSubmit={() => handlerSubmit()}>
<AiServiceRouterModelConfig ref={drawerAddFormRef} llmList={llmList} entity={defaultLlm!} />
</DrawerWithFooter>
</InsidePage>
)
}
export default AiServiceInsideRouterCreate
@@ -1,182 +1,224 @@
import PageList, { PageProColumns } from "@common/components/aoplatform/PageList.tsx"
import {ActionType} from "@ant-design/pro-components";
import {FC, useEffect, useMemo, useRef, useState} from "react";
import {Link, useNavigate, useParams} from "react-router-dom";
import {useBreadcrumb} from "@common/contexts/BreadcrumbContext.tsx";
import {App, Divider} from "antd";
import {BasicResponse, COLUMNS_TITLE, DELETE_TIPS, RESPONSE_TIPS, STATUS_CODE} from "@common/const/const.tsx";
import { SimpleMemberItem} from '@common/const/type.ts'
import {useFetch} from "@common/hooks/http.ts";
import {RouterParams} from "@core/components/aoplatform/RenderRoutes.tsx";
import TableBtnWithPermission from "@common/components/aoplatform/TableBtnWithPermission.tsx";
import { useGlobalContext } from "@common/contexts/GlobalStateContext.tsx";
import { checkAccess } from "@common/utils/permission.ts";
import { $t } from "@common/locales/index.ts";
import { AiServiceRouterTableListItem } from "@core/const/ai-service/type.ts";
import { AI_SERVICE_ROUTER_TABLE_COLUMNS } from "@core/const/ai-service/const.tsx";
import { ActionType } from '@ant-design/pro-components'
import PageList, { PageProColumns } from '@common/components/aoplatform/PageList.tsx'
import TableBtnWithPermission from '@common/components/aoplatform/TableBtnWithPermission.tsx'
import { BasicResponse, COLUMNS_TITLE, DELETE_TIPS, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const.tsx'
import { SimpleMemberItem } from '@common/const/type.ts'
import { useBreadcrumb } from '@common/contexts/BreadcrumbContext.tsx'
import { useGlobalContext } from '@common/contexts/GlobalStateContext.tsx'
import { useFetch } from '@common/hooks/http.ts'
import { $t } from '@common/locales/index.ts'
import { checkAccess } from '@common/utils/permission.ts'
import { RouterParams } from '@core/components/aoplatform/RenderRoutes.tsx'
import { AI_SERVICE_ROUTER_TABLE_COLUMNS } from '@core/const/ai-service/const.tsx'
import { AiServiceRouterTableListItem } from '@core/const/ai-service/type.ts'
import { App, Divider, Typography } from 'antd'
import { FC, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
const AiServiceInsideRouterList:FC = ()=>{
const [searchWord, setSearchWord] = useState<string>('')
const { setBreadcrumb } = useBreadcrumb()
const { modal,message } = App.useApp()
const [tableListDataSource, setTableListDataSource] = useState<AiServiceRouterTableListItem[]>([]);
const [tableHttpReload, setTableHttpReload] = useState(true);
const {fetchData} = useFetch()
const pageListRef = useRef<ActionType>(null);
const [memberValueEnum, setMemberValueEnum] = useState<SimpleMemberItem[]>([])
const {accessData,state} = useGlobalContext()
const {serviceId, teamId} = useParams<RouterParams>()
const navigator = useNavigate()
const AiServiceInsideRouterList: FC = () => {
const [searchWord, setSearchWord] = useState<string>('')
const { setBreadcrumb } = useBreadcrumb()
const { modal, message } = App.useApp()
const [tableListDataSource, setTableListDataSource] = useState<AiServiceRouterTableListItem[]>([])
const [tableHttpReload, setTableHttpReload] = useState(true)
const { fetchData } = useFetch()
const pageListRef = useRef<ActionType>(null)
const [memberValueEnum, setMemberValueEnum] = useState<SimpleMemberItem[]>([])
const { accessData, state } = useGlobalContext()
const { serviceId, teamId } = useParams<RouterParams>()
const navigator = useNavigate()
const getRoutesList = (): Promise<{ data: AiServiceRouterTableListItem[], success: boolean }>=> {
if(!tableHttpReload){
setTableHttpReload(true)
return Promise.resolve({
data: tableListDataSource,
success: true,
});
}
return fetchData<BasicResponse<{apis:AiServiceRouterTableListItem}>>('service/ai-routers',{method:'GET',eoParams:{service:serviceId,team:teamId, keyword:searchWord},eoTransformKeys:['request_path','create_time','update_time','disable']}).then(response=>{
const {code,data,msg} = response
if(code === STATUS_CODE.SUCCESS){
setTableListDataSource(data.apis)
setTableHttpReload(false)
return {data:data.apis, success: true}
}else{
message.error(msg || $t(RESPONSE_TIPS.error))
return {data:[], success:false}
}
}).catch(() => {
return {data:[], success:false}
})
const getRoutesList = (): Promise<{ data: AiServiceRouterTableListItem[]; success: boolean }> => {
if (!tableHttpReload) {
setTableHttpReload(true)
return Promise.resolve({
data: tableListDataSource,
success: true
})
}
const deleteRoute = (entity:AiServiceRouterTableListItem)=>{
return new Promise((resolve, reject)=>{
fetchData<BasicResponse<null>>('service/ai-router',{method:'DELETE',eoParams:{service:serviceId,team:teamId, router:entity!.id}}).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))
})
}
const openModal = async (type: 'delete',entity:AiServiceRouterTableListItem) =>{
let title:string = ''
let content:string|React.ReactNode = ''
switch (type){
case 'delete':
title=$t('删除')
content=$t(DELETE_TIPS.default)
break;
return fetchData<BasicResponse<{ apis: AiServiceRouterTableListItem }>>('service/ai-routers', {
method: 'GET',
eoParams: { service: serviceId, team: teamId, keyword: searchWord },
eoTransformKeys: ['request_path', 'create_time', 'update_time', 'disable']
})
.then((response) => {
const { code, data, msg } = response
if (code === STATUS_CODE.SUCCESS) {
setTableListDataSource(data.apis)
setTableHttpReload(false)
return { data: data.apis, success: true }
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
return { data: [], success: false }
}
})
.catch(() => {
return { data: [], success: false }
})
}
modal.confirm({
title,
content,
onOk:()=> {
switch (type){
case 'delete':
return deleteRoute(entity).then((res)=>{if(res === true) manualReloadTable()})
}
},
width:600,
okText:$t('确认'),
okButtonProps:{
disabled : !checkAccess( `team.service.router.${type}`, accessData )
},
cancelText:$t('取消'),
closable:true,
icon:<></>,
})
}
const operation:PageProColumns<AiServiceRouterTableListItem>[] =[
{
title: COLUMNS_TITLE.operate,
key: 'option',
btnNums:2,
fixed:'right',
valueType: 'option',
render: (_: React.ReactNode, entity: AiServiceRouterTableListItem) => [
<TableBtnWithPermission access="team.service.router.edit" key="edit" btnType="edit" onClick={()=>{navigator(`/service/${teamId}/aiInside/${serviceId}/route/${entity.id}`)}} btnTitle="编辑"/>,
<Divider type="vertical" className="mx-0" key="div3"/>,
<TableBtnWithPermission access="team.service.router.delete" key="delete" btnType="delete" onClick={()=>{openModal('delete',entity)}} btnTitle="删除"/>,
],
}
]
const manualReloadTable = () => {
setTableHttpReload(true); // 表格数据需要从后端接口获取
pageListRef.current?.reload()
};
const getMemberList = async ()=>{
setMemberValueEnum([])
const {code,data,msg} = await fetchData<BasicResponse<{ members: SimpleMemberItem[] }>>('simple/member',{method:'GET'})
if(code === STATUS_CODE.SUCCESS){
setMemberValueEnum(data.members)
}else{
const deleteRoute = (entity: AiServiceRouterTableListItem) => {
return new Promise((resolve, reject) => {
fetchData<BasicResponse<null>>('service/ai-router', {
method: 'DELETE',
eoParams: { service: serviceId, team: teamId, router: entity!.id }
})
.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))
})
}
const openModal = async (type: 'delete', entity: AiServiceRouterTableListItem) => {
let title: string = ''
let content: string | React.ReactNode = ''
switch (type) {
case 'delete':
title = $t('删除')
content = $t(DELETE_TIPS.default)
break
}
useEffect(() => {
setBreadcrumb([
{
title:<Link to={`/service/list`}>{$t('服务')}</Link>
},
{
title:$t('路由')
}
])
getMemberList()
manualReloadTable()
}, [serviceId]);
const columns = useMemo(()=>{
return [...AI_SERVICE_ROUTER_TABLE_COLUMNS].map(x=>{
if(x.filters &&((x.dataIndex as string[])?.indexOf('creator') !== -1) ){
const tmpValueEnum:{[k:string]:{text:string}} = {}
memberValueEnum?.forEach((x:SimpleMemberItem)=>{
tmpValueEnum[x.name] = {text:x.name}
})
x.valueEnum = tmpValueEnum
}
return {...x,title:typeof x.title === 'string' ? $t(x.title as string) : x.title}})
},[memberValueEnum,state.language])
modal.confirm({
title,
content,
onOk: () => {
switch (type) {
case 'delete':
return deleteRoute(entity).then((res) => {
if (res === true) manualReloadTable()
})
}
},
width: 600,
okText: $t('确认'),
okButtonProps: {
disabled: !checkAccess(`team.service.router.${type}`, accessData)
},
cancelText: $t('取消'),
closable: true,
icon: <></>
})
}
const operation: PageProColumns<AiServiceRouterTableListItem>[] = [
{
title: COLUMNS_TITLE.operate,
key: 'option',
btnNums: 2,
fixed: 'right',
valueType: 'option',
render: (_: React.ReactNode, entity: AiServiceRouterTableListItem) => [
<TableBtnWithPermission
access="team.service.router.edit"
key="edit"
btnType="edit"
onClick={() => {
navigator(`/service/${teamId}/aiInside/${serviceId}/route/${entity.id}`)
}}
btnTitle="编辑"
/>,
<Divider type="vertical" className="mx-0" key="div3" />,
<TableBtnWithPermission
access="team.service.router.delete"
key="delete"
btnType="delete"
onClick={() => {
openModal('delete', entity)
}}
btnTitle="删除"
/>
]
}
]
return (
<>
<PageList
id="global_system_api"
ref={pageListRef}
columns = {[...columns,...operation]}
request={()=>getRoutesList()}
dataSource={tableListDataSource}
addNewBtnTitle={$t('添加路由')}
searchPlaceholder={$t('输入 URL 查找路由')}
onAddNewBtnClick={()=>{navigator(`/service/${teamId}/aiInside/${serviceId}/route/create`)}}
addNewBtnAccess="team.service.router.add"
tableClickAccess="team.service.router.view"
manualReloadTable={manualReloadTable}
onSearchWordChange={(e)=>{setSearchWord(e.target.value)}}
onChange={() => {
setTableHttpReload(false)
}}
onRowClick={(row:AiServiceRouterTableListItem)=>navigator(`/service/${teamId}/aiInside/${serviceId}/route/${row.id}`)}
tableClass="mr-PAGE_INSIDE_X "
/>
</>
)
const manualReloadTable = () => {
setTableHttpReload(true) // 表格数据需要从后端接口获取
pageListRef.current?.reload()
}
const getMemberList = async () => {
setMemberValueEnum([])
const { code, data, msg } = await fetchData<BasicResponse<{ members: SimpleMemberItem[] }>>('simple/member', {
method: 'GET'
})
if (code === STATUS_CODE.SUCCESS) {
setMemberValueEnum(data.members)
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
}
}
useEffect(() => {
setBreadcrumb([
{
title: <Link to={`/service/list`}>{$t('服务')}</Link>
},
{
title: $t('路由')
}
])
getMemberList()
manualReloadTable()
}, [serviceId])
const columns = useMemo(() => {
return [...AI_SERVICE_ROUTER_TABLE_COLUMNS].map((x) => {
if (x.filters && (x.dataIndex as string[])?.indexOf('creator') !== -1) {
const tmpValueEnum: { [k: string]: { text: string } } = {}
memberValueEnum?.forEach((x: SimpleMemberItem) => {
tmpValueEnum[x.name] = { text: x.name }
})
x.valueEnum = tmpValueEnum
}
if (x.filters && (x.dataIndex as string[])?.indexOf('disabled') !== -1) {
x.valueEnum = {
true: { text: <Typography.Text type="danger">{$t('拦截')}</Typography.Text> },
false: { text: <Typography.Text type="success">{$t('放行')}</Typography.Text> }
}
}
return { ...x, title: typeof x.title === 'string' ? $t(x.title as string) : x.title }
})
}, [memberValueEnum, state.language])
return (
<>
<PageList
id="global_system_api"
ref={pageListRef}
columns={[...columns, ...operation]}
request={() => getRoutesList()}
dataSource={tableListDataSource}
addNewBtnTitle={$t('添加路由')}
searchPlaceholder={$t('输入 URL 查找路由')}
onAddNewBtnClick={() => {
navigator(`/service/${teamId}/aiInside/${serviceId}/route/create`)
}}
addNewBtnAccess="team.service.router.add"
tableClickAccess="team.service.router.view"
manualReloadTable={manualReloadTable}
onSearchWordChange={(e) => {
setSearchWord(e.target.value)
}}
onChange={() => {
setTableHttpReload(false)
}}
onRowClick={(row: AiServiceRouterTableListItem) =>
navigator(`/service/${teamId}/aiInside/${serviceId}/route/${row.id}`)
}
tableClass="mr-PAGE_INSIDE_X "
/>
</>
)
}
export default AiServiceInsideRouterList
export default AiServiceInsideRouterList
@@ -1,6 +1,9 @@
'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,
@@ -13,28 +16,26 @@ import {
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 { ModelData } from './types'
import { ModelListData } from './types'
interface ApiResponse {
data: {
backup: {
id: string
name: string
}
providers: ModelData[]
export type ApiResponse = BasicResponse<{
backup: {
id: string
name: string
}
code: number
success: string
}
providers: ModelListData[]
}>
const calculateNodePositions = (models: ModelData[], startY = LAYOUT.NODE_START_Y, gap = LAYOUT.NODE_GAP) => {
const calculateNodePositions = (models: ModelListData[], startY = LAYOUT.NODE_START_Y, gap = LAYOUT.NODE_GAP) => {
return models.reduce(
(acc, model, index) => {
const y = startY + index * gap
@@ -46,7 +47,7 @@ const calculateNodePositions = (models: ModelData[], startY = LAYOUT.NODE_START_
},
[`${model.id}-keys`]: {
x: LAYOUT.KEY_NODE_X,
y
y: y + 16
}
}
},
@@ -65,21 +66,29 @@ const edgeTypes: EdgeTypes = {
}
const AIFlowChart = () => {
const [modelData, setModelData] = useState<ModelData[]>([])
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(() => {
// Mock API call - replace with actual API call
setLoading(true)
fetchData<ApiResponse>('ai/providers/configured', {
method: 'GET'
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)
})
}, [])
.then((response) => {
const mockApiResponse: ApiResponse = response as ApiResponse
setModelData(mockApiResponse.data.providers)
})
.finally(() => {
setLoading(false)
})
}, [aiConfigFlushed])
useEffect(() => {
if (!modelData.length) return
@@ -87,14 +96,13 @@ const AIFlowChart = () => {
const positions = calculateNodePositions(modelData)
// 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 },
data: {
title: 'API Service',
title: 'API Services',
count: modelData.length
}
},
@@ -103,10 +111,11 @@ const AIFlowChart = () => {
type: 'modelCard',
position: positions[model.id],
data: {
title: model.name,
name: model.name,
status: model.status,
defaultModel: model.default_llm,
logo: model.logo
defaultLlm: model.defaultLlm,
logo: model.logo,
id: model.id
}
})),
...modelData.map((model) => ({
@@ -114,7 +123,7 @@ const AIFlowChart = () => {
type: 'keyCard',
position: positions[`${model.id}-keys`],
data: {
title: 'API Keys',
title: '',
keys: (model.keys || []).map((key, index) => ({
id: key.id,
status: key.status,
@@ -138,17 +147,17 @@ const AIFlowChart = () => {
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 - 100
const right = LAYOUT.KEY_NODE_X + 100
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 [
@@ -157,91 +166,91 @@ const AIFlowChart = () => {
] as CoordinateExtent
}, [modelData.length])
const onNodeDrag: any = useCallback(
(_: MouseEvent, node: Node<any>) => {
if (node.type !== 'modelCard') return
const updateProviderOrder = async (sortedProviderIds: string[]) => {
await fetchData('ai/provider/sort', {
method: 'PUT',
body: JSON.stringify({
providers: sortedProviderIds
})
})
}
setNodes((nds) => {
return nds.map((n) => {
if (n.type === 'keyCard' && n.id === `${node.id}-keys`) {
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: node.position.y
y: LAYOUT.NODE_START_Y + index * LAYOUT.NODE_GAP + 16
}
}
}
return n
})
}
return n
})
},
[setNodes]
)
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)
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
}
}
}
}
return n
})
})
},
[setNodes]
)
})
}, [])
return (
<div className="w-full h-full" style={{ height: 'calc(100vh - 64px)' }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDrag={onNodeDrag}
proOptions={{ hideAttribution: true }}
onNodeDragStop={onNodeDragStop}
draggable={false}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
panOnScroll={true}
panOnScrollMode={PanOnScrollMode.Vertical}
defaultEdgeOptions={{
type: 'custom'
}}
translateExtent={calculateExtent()}
/>
<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}
edgeTypes={edgeTypes}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
panOnScroll={true}
panOnScrollMode={PanOnScrollMode.Vertical}
defaultEdgeOptions={{
type: 'custom'
}}
translateExtent={calculateExtent()}
/>
)}
</div>
)
}
@@ -0,0 +1,136 @@
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
@@ -1,243 +1,71 @@
import { LoadingOutlined } from '@ant-design/icons'
import InsidePage from '@common/components/aoplatform/InsidePage'
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 { checkAccess } from '@common/utils/permission'
import { Icon } from '@iconify/react/dist/iconify.js'
import { App, Button, Card, Divider, Empty, Spin, Tag } from 'antd'
import { memo, useEffect, useRef, useState } from 'react'
import { Tabs } from 'antd'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import AIFlowChart from './AIFlowChart'
import AiSettingModalContent, { AiSettingModalContentHandle } from './AiSettingModal'
import AIUnConfigure from './AIUnconfigure'
import { AiSettingProvider } from './contexts/AiSettingContext'
export type AiSettingListItem = {
name: string
id: string
logo: string
defaultLlm: string
defaultLlmLogo: string
enable: boolean
configured: boolean
}
const CONTENT_STYLE = { height: 'calc(-300px + 100vh)' } as const
export type AiProviderLlmsItems = {
id: string
logo: string
scopes: ('chat' | 'completions')[]
config: string
}
export type AiProviderDefaultConfig = {
id: string
provider: string
name: string
logo: string
defaultLlm: string
scopes: string[]
}
export type AiProviderConfig = {
id: string
name: string
config: string
getApikeyUrl: string
}
const AiSettingList = () => {
const { modal, message } = App.useApp()
const { fetchData } = useFetch()
const [aiSettingList, setAiSettingList] = useState<AiSettingListItem[]>([])
const [loading, setLoading] = useState<boolean>(false)
const modalRef = useRef<AiSettingModalContentHandle>()
const { setAiConfigFlushed, accessData } = useGlobalContext()
const getAiSettingList = () => {
setLoading(true)
return fetchData<BasicResponse<{ providers: Omit<AiSettingListItem, 'availableLlms' | 'llmListStatus'>[] }>>(
`ai/providers/unconfigured`,
{ method: 'GET', eoTransformKeys: ['default_llm', 'default_llm_logo'] }
// eoApiPrefix: 'http://uat.apikit.com:11204/mockApi/aoplatform/api/v1/'
)
.then((response) => {
const { code, data, msg } = response
if (code === STATUS_CODE.SUCCESS) {
setAiSettingList(
data.providers?.map((x: AiSettingListItem) => ({
...x,
name: $t(x.name),
llmListStatus: 'unload',
availableLlms: []
}))
)
} else {
message.error(msg || $t(RESPONSE_TIPS.error))
}
})
.finally(() => setLoading(false))
}
const openModal = async (entity: AiSettingListItem) => {
message.loading($t(RESPONSE_TIPS.loading))
const { code, data, msg } = await fetchData<BasicResponse<{ provider: AiProviderConfig }>>('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
}
modal.confirm({
title: $t('模型配置'),
content: (
<AiSettingModalContent
ref={modalRef}
entity={{ ...data.provider, defaultLlm: entity.defaultLlm }}
readOnly={!checkAccess('system.devops.ai_provider.edit', accessData)}
/>
),
onOk: () => {
return modalRef.current?.save().then((res) => {
if (res === true) setAiConfigFlushed(true)
getAiSettingList()
})
},
width: 600,
okText: $t('确认'),
footer: (_, { OkBtn, CancelBtn }) => {
return (
<div className="flex justify-between items-center">
<a
target="_blank"
rel="noopener noreferrer"
href={data.provider.getApikeyUrl}
className="flex items-center gap-[8px]"
>
<span>{$t('从 (0) 获取 API KEY', [data.provider.name])}</span>
<Icon icon="ic:baseline-open-in-new" width={16} height={16} />
</a>
<div>
<CancelBtn />
{checkAccess('system.devops.ai_provider.edit', accessData) ? <OkBtn /> : null}
</div>
</div>
)
},
cancelText: $t('取消'),
closable: true,
icon: <></>
})
}
const AiSettingContent = () => {
const [searchParams, setSearchParams] = useSearchParams()
const [activeKey, setActiveKey] = useState(searchParams.get('status') === 'unconfigure' ? 'config' : 'flow')
useEffect(() => {
getAiSettingList()
}, [])
const CardBox = memo(({ provider }: { provider: AiSettingListItem }) => {
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={() => openModal(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 newActiveKey = searchParams.get('status') === 'unconfigure' ? 'config' : 'flow'
setActiveKey(newActiveKey)
}, [searchParams])
return (
<>
<InsidePage
className="overflow-y-auto pb-PAGE_INSIDE_B"
pageTitle={$t('AI 模型')}
description={$t('配置好 AI 模型后,你可以使用对应的大模型来创建 AI 服务')}
showBorder={false}
scrollPage={false}
>
<AIFlowChart />
<Spin
className="h-full"
wrapperClassName="h-full pr-PAGE_INSIDE_X"
indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />}
spinning={loading}
>
{aiSettingList && aiSettingList.length > 0 ? (
<div>
{aiSettingList.filter((item) => !item.configured).length > 0 && (
<>
<Divider style={{ margin: '20px 0 !important;' }} />
<p className="text-[14px] text-[#666] mb-[4px] mt-[20px] font-bold">{$t('未配置')}</p>
<ModelCardArea modelList={aiSettingList.filter((item) => !item.configured) || []} />
</>
)}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Spin>
</InsidePage>
</>
<InsidePage
className="h-full pb-PAGE_INSIDE_B"
pageTitle={$t('AI 模型')}
description={$t('配置好 AI 模型后,你可以使用对应的大模型来创建 AI 服务')}
showBorder={false}
scrollPage={false}
>
<div className="flex flex-col h-full">
<Tabs
activeKey={activeKey}
onChange={(key) => {
setActiveKey(key)
setSearchParams({ status: key === 'config' ? 'unconfigure' : 'configure' })
}}
className="sticky top-0 flex-shrink-0"
items={[
{
key: 'flow',
label: $t('已设置'),
children: (
<div className="overflow-auto" style={CONTENT_STYLE}>
<AIFlowChart />
</div>
)
},
{
key: 'config',
label: $t('未设置'),
children: (
<div className="overflow-auto" style={CONTENT_STYLE}>
<AIUnConfigure />
</div>
)
}
]}
/>
</div>
</InsidePage>
)
}
const AiSettingList = () => {
return (
<AiSettingProvider>
<AiSettingContent />
</AiSettingProvider>
)
}
export default AiSettingList
@@ -1,13 +1,14 @@
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, Select, Tag } from 'antd'
import { App, Form, InputNumber, Select, Switch, Tag, Tooltip } from 'antd'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { AiProviderConfig, AiProviderLlmsItems } from './AiSettingList'
import { AiProviderLlmsItems, ModelDetailData } from './types'
export type AiSettingModalContentProps = {
entity: AiProviderConfig & { defaultLlm: string }
entity: ModelDetailData & { defaultLlm: string }
readOnly: boolean
}
@@ -15,11 +16,6 @@ export type AiSettingModalContentHandle = {
save: () => Promise<boolean | string>
}
type AiSettingModalContentField = {
config: string
defaultLlm: string
}
const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingModalContentProps>((props, ref) => {
const [form] = Form.useForm()
const { message } = App.useApp()
@@ -27,7 +23,7 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
const { fetchData } = useFetch()
const [llmList, setLlmList] = useState<AiProviderLlmsItems[]>()
const [loading, setLoading] = useState<boolean>(false)
const [enableState, setEnableState] = useState<boolean>(entity.status === 'enabled')
const getLlmList = () => {
setLoading(true)
fetchData<BasicResponse<{ llms: AiProviderLlmsItems[] }>>(`ai/provider/llms`, {
@@ -52,12 +48,16 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
try {
form.setFieldsValue({
defaultLlm: entity.defaultLlm,
config: entity!.config ? JSON.stringify(JSON.parse(entity!.config), null, 2) : ''
config: entity!.config ? JSON.stringify(JSON.parse(entity!.config), null, 2) : '',
priority: entity.priority || 1,
enable: entity.status === 'enabled'
})
} catch (e) {
form.setFieldsValue({
defaultLlm: entity.defaultLlm,
config: ''
config: '',
priority: 1,
enable: true
})
}
}, [])
@@ -67,11 +67,17 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
form
.validateFields()
.then((value) => {
const finalValue = {
...value,
priority: Math.max(1, value.priority)
}
fetchData<BasicResponse<null>>('ai/provider/config', {
method: 'PUT',
eoParams: { provider: entity?.id },
eoBody: value,
eoBody: finalValue,
eoTransformKeys: ['defaultLlm']
// eoApiPrefix: 'http://uat.apikit.com:11204/mockApi/aoplatform/api/v1/'
})
.then((response) => {
const { code, msg } = response
@@ -89,21 +95,29 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
})
}
const getTooltipText = (isChecked: boolean) => {
if (!isChecked) {
return '保存后供应商状态变为【停用】,使用本供应商的 API 将临时使用负载优先级最高的正常供应商。'
}
return '保存后供应商状态变为【正常】,恢复调用本供应商的 AI 能力。'
}
useImperativeHandle(ref, () => ({
save
}))
return (
<Form
form={form}
layout="vertical"
labelAlign="left"
scrollToFirstError
form={form}
className="flex flex-col mx-auto h-full"
name="aiServiceInsideRouterModalConfig"
autoComplete="off"
disabled={readOnly}
>
<Form.Item<AiSettingModalContentField> label={$t('模型')} name="defaultLlm" rules={[{ required: true }]}>
<Form.Item<ModelDetailData> label={$t('默认模型')} name="defaultLlm" rules={[{ required: true }]}>
<Select
className="w-INPUT_NORMAL"
placeholder={$t(PLACEHOLDER.select)}
@@ -113,23 +127,77 @@ const AiSettingModalContent = forwardRef<AiSettingModalContentHandle, AiSettingM
label: (
<div className="flex items-center gap-[10px]">
<span>{x.id}</span>
{x?.scopes?.map((s) => <Tag>{s?.toLocaleUpperCase()}</Tag>)}
{x?.scopes?.map((s) => <Tag key={s}>{s?.toLocaleUpperCase()}</Tag>)}
</div>
)
}))}
></Select>
</Form.Item>
<Form.Item<AiSettingModalContentField> label={$t('参数')} name="config">
<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>
<Form.Item<ModelDetailData> label={$t('API Key(默认 Key')} name="config">
<Codebox
editorTheme="vs-dark"
readOnly={readOnly}
width="100%"
height="300px"
height="200px"
language="json"
enableToolbar={false}
/>
</Form.Item>
{entity.configured && (
<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"></span>
{entity.status === 'enabled' && <Tag color="success">{$t('正常')}</Tag>}
{entity.status === 'disabled' && <Tag color="warning">{$t('停用')}</Tag>}
{entity.status === 'abnormal' && <Tag color="error">{$t('异常')}</Tag>}
</div>
<Form.Item name="enable" valuePropName="checked" noStyle>
<Switch
checkedChildren={$t('启用')}
unCheckedChildren={$t('停用')}
onChange={(checked) => {
form.setFieldsValue({ enable: checked })
setEnableState(checked)
}}
/>
</Form.Item>
</div>
{(entity.status === 'enabled' && !enableState) || (entity.status !== 'enabled' && enableState) ? (
<div className="mt-2 text-sm text-gray-500">* {getTooltipText(enableState)}</div>
) : null}
</Form.Item>
)}
</Form>
)
})
@@ -31,7 +31,7 @@ export default function CustomEdge({
{label && (
<EdgeLabelRenderer>
<a
href={`/aiSetting/model?modelId=${modelId}`}
href={`${label?.toString().includes('apis') ? '/aiapis' : '/keysetting'}?modelId=${modelId}`}
target="_blank"
style={{
position: 'absolute',
@@ -22,7 +22,7 @@ export const KeyStatusNode: React.FC<{ data: KeyStatusNodeData }> = ({ data }) =
style={{ border: '1px solid var(--border-color)' }}
>
<Handle type="target" position={Position.Left} />
<div className="flex flex-col gap-2">
<div className="flex flex-col">
<div className="text-sm text-gray-900">{title}</div>
<div
className="flex gap-1 w-full"
@@ -2,25 +2,34 @@ import { Icon } from '@iconify/react'
import { Handle, Position } from '@xyflow/react'
import { t } from 'i18next'
import React from 'react'
import { ModelStatus } from '../types'
import { useAiSetting } from '../contexts/AiSettingContext'
import { AiSettingListItem, ModelDetailData, ModelStatus } from '../types'
interface ModelCardData {
title: string
status: ModelStatus
logo: string
defaultModel: string
}
type ModelCardNodeData = ModelCardData & {
type ModelCardNodeData = ModelDetailData & {
id: string
position: { x: number; y: number }
}
export const ModelCardNode: React.FC<{ data: ModelCardNodeData }> = ({ data }) => {
const { title, status, defaultModel, logo } = data
const { name, status, defaultLlm, logo } = 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"
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} />
@@ -28,17 +37,14 @@ export const ModelCardNode: React.FC<{ data: ModelCardNodeData }> = ({ data }) =
<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]">
<div className="flex flex-1 overflow-hidden items-center gap-[4px]">
<span
className=" flex items-center h-[22px] ai-setting-svg-container"
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">{title}</span>
<Icon
icon={status === 'enable' ? 'mdi:check-circle' : 'mdi:close-circle'}
className={`text-xl ${status === 'enable' ? 'text-green-500' : 'text-red-500'}`}
/>
<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 */}
@@ -46,13 +52,15 @@ export const ModelCardNode: React.FC<{ data: ModelCardNodeData }> = ({ data }) =
<Icon
icon="mdi:cog"
className="text-xl text-gray-400 cursor-pointer hover:text-[--primary-color]"
onClick={() => console.log('Default:', data.id)}
onClick={() => {
openConfigModal({ id: data.id, defaultLlm: defaultLlm } as AiSettingListItem)
}}
/>
</div>
</div>
<div className="mt-2 text-sm text-gray-500">
{t('默认:')}
{defaultModel}
{defaultLlm}
</div>
</div>
</div>
@@ -11,7 +11,7 @@ export const ServiceCardNode: React.FC<NodeProps> = () => {
<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 Service</span>
<span className="text-base text-gray-900">AI Services</span>
</div>
</div>
)
@@ -1,5 +1,5 @@
export const LAYOUT = {
SERVICE_NODE_X: 50,
SERVICE_NODE_X: 0,
NODE_START_Y: 20,
NODE_GAP: 120,
MODEL_NODE_X: 500,
@@ -0,0 +1,89 @@
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'
interface AiSettingContextType {
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 { aiConfigFlushed, setAiConfigFlushed, accessData } = useGlobalContext()
const modalRef = useRef<AiSettingModalContentHandle>()
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
}
modal.confirm({
title: $t('模型配置'),
content: (
<AiSettingModalContent
ref={modalRef}
entity={{ ...data.provider, defaultLlm: entity.defaultLlm }}
readOnly={!checkAccess('system.devops.ai_provider.edit', accessData)}
/>
),
onOk: () => {
return modalRef.current?.save().then((res) => {
if (res === true) {
setAiConfigFlushed(!aiConfigFlushed)
}
})
},
width: 600,
okText: $t('确认'),
footer: (_, { OkBtn, CancelBtn }) => {
return (
<div className="flex justify-between items-center">
<a
target="_blank"
rel="noopener noreferrer"
href={data.provider.getApikeyUrl}
className="flex items-center gap-[8px]"
>
<span>{$t('从 (0) 获取 API KEY', [data.provider.name])}</span>
<Icon icon="ic:baseline-open-in-new" width={16} height={16} />
</a>
<div>
<CancelBtn />
{checkAccess('system.devops.ai_provider.edit', accessData) ? <OkBtn /> : null}
</div>
</div>
)
},
cancelText: $t('取消'),
closable: true,
icon: <></>
})
}
return <AiSettingContext.Provider value={{ openConfigModal }}>{children}</AiSettingContext.Provider>
}
export const useAiSetting = () => {
const context = useContext(AiSettingContext)
if (!context) {
throw new Error('useAiSetting must be used within an AiSettingProvider')
}
return context
}
@@ -1,4 +1,20 @@
/* Flow Chart Styles */
.react-flow {
width: 100%;
height: 100%;
min-height: 500px;
}
.react-flow__container {
width: 100%;
height: 100%;
}
.react-flow__renderer {
width: 100%;
height: 100%;
}
.react-flow__node {
padding: 0;
border-radius: 8px;
@@ -1,4 +1,4 @@
export type ModelStatus = 'enable' | 'abnormal'|'disabled'
export type ModelStatus = 'enabled' | 'abnormal'|'disabled'
export type KeyStatus ='normal' | 'abnormal'|'disabled'
export interface KeyData {
@@ -7,14 +7,51 @@ export interface KeyData {
status: KeyStatus,
}
export interface ModelData {
export interface ModelListData {
id: string
name: string
logo: string
default_llm: string
defaultLlm: string
status: ModelStatus
api_count: number
key_count: number
keys: KeyData[]
}
export interface ModelDetailData extends ModelListData{
enable:boolean
config: string,
priority?: number
getApikeyUrl: string
status: ModelStatus
configured: boolean
}
export type AiSettingListItem = {
name: string
id: string
logo: string
defaultLlm: string
defaultLlmLogo: string
enable: boolean
configured: boolean
priority?: number
}
export type AiProviderLlmsItems = {
id: string
logo: string
scopes: ('chat' | 'completions')[]
config: string
}
export type AiProviderDefaultConfig = {
id: string
provider: string
name: string
logo: string
defaultLlm: string
scopes: string[]
}
@@ -12,15 +12,16 @@ import AIProviderSelect, { AIProvider } from '@core/components/AIProviderSelect'
import { App, Divider, Space, Typography } from 'antd'
import dayjs from 'dayjs'
import React, { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import ApiKeyContent from './components/ApiKeyContent'
import { APIKey, EditAPIKey } from './types'
const KeySettings: React.FC = () => {
const pageListRef = useRef<ActionType>(null)
const { modal, message } = App.useApp()
const [selectedProvider, setSelectedProvider] = useState<string>()
const [searchParams] = useSearchParams()
const [selectedProvider, setSelectedProvider] = useState<string>(searchParams.get('modelId') || '')
const [provider, setProvider] = useState<AIProvider | undefined>()
const [apiKeys, setApiKeys] = useState<APIKey[]>([])
const { fetchData } = useFetch()
const [searchWord, setSearchWord] = useState<string>('')
const [total, setTotal] = useState<number>(0)
@@ -186,6 +187,8 @@ const KeySettings: React.FC = () => {
page_size: params.pageSize,
keyword: searchWord,
page: params.current
//TODO API 筛选
// statuses: params.statuses || []
}
// eoApiPrefix: 'http://uat.apikit.com:11204/mockApi/aoplatform/api/v1/'
})
@@ -306,7 +309,7 @@ const KeySettings: React.FC = () => {
render: (dom: React.ReactNode, entity: APIKey) => {
return entity.expire_time === 0
? $t('永不过期')
: dayjs(Number(entity.expire_time)).format('YYYY-MM-DD HH:mm:ss')
: dayjs(Number(entity.expire_time) * 1000).format('YYYY-MM-DD HH:mm:ss')
}
},
...operation
@@ -1,23 +1,23 @@
import { CloseOutlined, ExpandOutlined, SearchOutlined } from '@ant-design/icons'
import { Select, Input, Button, App, Drawer } from 'antd'
import { debounce } from 'lodash-es'
import { useState, useEffect, useRef } from 'react'
import { MonitorApiData, SearchBody } from '@dashboard/const/type'
import { getTime } from '../utils/dashboard'
import ScrollableSection from '@common/components/aoplatform/ScrollableSection'
import TimeRangeSelector, {
RangeValue,
TimeRange,
TimeRangeButton
} from '@common/components/aoplatform/TimeRangeSelector'
import MonitorTable, { MonitorTableHandler } from './MonitorTable'
import { BasicResponse, RESPONSE_TIPS, STATUS_CODE } from '@common/const/const'
import { DefaultOptionType } from 'antd/es/select'
import { useExcelExport } from '@common/hooks/excel'
import { API_TABLE_GLOBAL_COLUMNS_CONFIG } from '@dashboard/const/const'
import { useFetch } from '@common/hooks/http'
import { EntityItem } from '@common/const/type'
import { useExcelExport } from '@common/hooks/excel'
import { useFetch } from '@common/hooks/http'
import { $t } from '@common/locales'
import { API_TABLE_GLOBAL_COLUMNS_CONFIG } from '@dashboard/const/const'
import { MonitorApiData, SearchBody } from '@dashboard/const/type'
import { App, Button, Drawer, Input, Select } from 'antd'
import { DefaultOptionType } from 'antd/es/select'
import { debounce } from 'lodash-es'
import { useEffect, useRef, useState } from 'react'
import { getTime } from '../utils/dashboard'
import MonitorTable, { MonitorTableHandler } from './MonitorTable'
export type MonitorApiPageProps = {
fetchTableData: (body: SearchBody) => Promise<BasicResponse<{ statistics: MonitorApiData[] }>>
detailDrawerContent: React.ReactNode
@@ -100,6 +100,7 @@ export default function MonitorApiPage(props: MonitorApiPageProps) {
const getMonitorData = () => {
let query = queryData
if (!queryData || queryData.start === undefined) {
console.log(timeButton, datePickerValue)
const { startTime, endTime } = getTime(timeButton, datePickerValue || [])
query = { ...query, start: startTime, end: endTime }
}
@@ -186,7 +187,7 @@ export default function MonitorApiPage(props: MonitorApiPageProps) {
}
return (
<div className="h-full overflow-hidden">
<div className="overflow-hidden h-full">
<ScrollableSection>
<div className="pl-btnbase pr-btnrbase pb-btnbase content-before">
<TimeRangeSelector
@@ -196,8 +197,8 @@ export default function MonitorApiPage(props: MonitorApiPageProps) {
initialDatePickerValue={datePickerValue}
onTimeRangeChange={handleTimeRangeChange}
/>
<div className="flex flex-nowrap items-center pt-btnybase mr-btnybase">
<label className=" whitespace-nowrap inline-block">{$t('服务')}</label>
<div className="flex flex-nowrap items-center pt-btnybase mr-btnybase">
<label className="inline-block whitespace-nowrap">{$t('服务')}</label>
<Select
className="w-[346px]"
value={queryData?.services}
@@ -212,7 +213,7 @@ export default function MonitorApiPage(props: MonitorApiPageProps) {
}}
/>
</div>
<div className="flex flex-nowrap items-center pt-btnybase mr-btnybase">
<div className="flex flex-nowrap items-center pt-btnybase mr-btnybase">
<label className=" whitespace-nowrap inline-block w-[42px] text-right">API </label>
<Select
className="w-[346px]"
@@ -226,7 +227,7 @@ export default function MonitorApiPage(props: MonitorApiPageProps) {
setQueryData((prevData) => ({ ...(prevData || {}), apis: value }))
}}
/>
<label className="ml-btnybase whitespace-nowrap">{$t('路径')}</label>
<label className="whitespace-nowrap ml-btnybase">{$t('路径')}</label>
<div className="w-[346px] inline-block">
{/* <SearchInputGroup eoSingle={false} eoInputVal={queryData.path} eoClick={() => setQueryData({ ...queryData, path: '' })} /> */}
<Input