From 5634b3c939ebe74f51851ff040fd5d4406738239 Mon Sep 17 00:00:00 2001 From: lim Date: Thu, 14 Nov 2024 10:25:50 +0000 Subject: [PATCH 01/42] init admin layout --- .../app/[lng]/(admin)/global-configs/page.tsx | 12 +++ .../app/[lng]/(admin)/global-logs/page.tsx | 12 +++ .../aiproxy/app/[lng]/(admin)/layout.tsx | 23 ++--- .../app/[lng]/(admin)/ns-manager/page.tsx | 12 +++ .../aiproxy/app/[lng]/(user)/layout.tsx | 98 ------------------- .../providers/aiproxy/app/[lng]/layout.tsx | 6 +- .../aiproxy/app/i18n/locales/en/common.json | 6 +- .../aiproxy/app/i18n/locales/zh/common.json | 6 +- 8 files changed, 63 insertions(+), 112 deletions(-) create mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx create mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx create mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx new file mode 100644 index 00000000000..2ce3f541607 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx @@ -0,0 +1,12 @@ +'use client' +import { Flex } from '@chakra-ui/react' + +export default function GlobalConfigsPage() { + return ( + <> + + Global Configs + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx new file mode 100644 index 00000000000..69d79758b1a --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx @@ -0,0 +1,12 @@ +'use client' +import { Flex } from '@chakra-ui/react' + +export default function GlobalLogsPage() { + return ( + <> + + Global Logs + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx index 6454303cbdd..458d217f371 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx @@ -1,17 +1,18 @@ -import { Flex } from '@chakra-ui/react' +'use client' +import { Box, Flex } from '@chakra-ui/react' -export default function AdminLayout({ children }: { children: React.ReactNode }) { +import SideBar from '@/components/admin/Sidebar' + +export default function UserLayout({ children }: { children: React.ReactNode }) { return ( - - + + + + + {/* Main Content */} + {children} - + ) } diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx new file mode 100644 index 00000000000..2f6bcf7d4e9 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx @@ -0,0 +1,12 @@ +'use client' +import { Flex } from '@chakra-ui/react' + +export default function NsManagerPage() { + return ( + <> + + Ns Manager + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/layout.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/layout.tsx index 11fcb755d05..ddabc0c9447 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/layout.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/layout.tsx @@ -3,105 +3,7 @@ import { Box, Flex } from '@chakra-ui/react' import SideBar from '@/components/user/Sidebar' -import { EVENT_NAME } from 'sealos-desktop-sdk' -import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app' -import { useCallback, useEffect } from 'react' -import { initAppConfig } from '@/api/platform' -import { useI18n } from '@/providers/i18n/i18nContext' -import { useBackendStore } from '@/store/backend' -import { useTranslationClientSide } from '@/app/i18n/client' -import { usePathname } from 'next/navigation' -import { useRouter } from 'next/navigation' - export default function UserLayout({ children }: { children: React.ReactNode }) { - const router = useRouter() - const pathname = usePathname() - const { lng } = useI18n() - const { i18n } = useTranslationClientSide(lng) - const { setAiproxyBackend } = useBackendStore() - - const handleI18nChange = useCallback( - (data: { currentLanguage: string }) => { - const currentLng = i18n.resolvedLanguage // get the latest resolvedLanguage - const newLng = data.currentLanguage - - if (currentLng !== newLng) { - const currentPath = window.location.pathname - const pathWithoutLang = currentPath.split('/').slice(2).join('/') - router.push(`/${newLng}/${pathWithoutLang}`) - } - }, - [i18n.resolvedLanguage] - ) - - // init session - useEffect(() => { - const cleanup = createSealosApp() - ;(async () => { - try { - const newSession = JSON.stringify(await sealosApp.getSession()) - const oldSession = localStorage.getItem('session') - if (newSession && newSession !== oldSession) { - localStorage.setItem('session', newSession) - window.location.reload() - } - console.log('aiproxy: app init success') - } catch (err) { - console.log('aiproxy: app is not running in desktop') - if (!process.env.NEXT_PUBLIC_MOCK_USER) { - localStorage.removeItem('session') - } - } - })() - return () => { - if (cleanup && typeof cleanup === 'function') { - cleanup() - } - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // init config and language - useEffect(() => { - const initConfig = async () => { - const { aiproxyBackend } = await initAppConfig() - setAiproxyBackend(aiproxyBackend) - } - - initConfig() - - const initLanguage = async () => { - const pathLng = pathname.split('/')[1] - try { - const lang = await sealosApp.getLanguage() - if (pathLng !== lang.lng) { - const pathParts = pathname.split('/') - pathParts[1] = lang.lng - router.push(pathParts.join('/')) - router.refresh() - } - } catch (error) { - if (error instanceof Error) { - console.debug('Language initialization error:', error.message) - } else { - console.debug('Unknown language initialization error:', error) - } - } - } - - initLanguage() - - const cleanup = sealosApp?.addAppEventListen(EVENT_NAME.CHANGE_I18N, handleI18nChange) - - return () => { - if (cleanup && typeof cleanup === 'function') { - cleanup() - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - return ( diff --git a/frontend/providers/aiproxy/app/[lng]/layout.tsx b/frontend/providers/aiproxy/app/[lng]/layout.tsx index 5b861c6a6b4..f04e33fcb2d 100644 --- a/frontend/providers/aiproxy/app/[lng]/layout.tsx +++ b/frontend/providers/aiproxy/app/[lng]/layout.tsx @@ -6,6 +6,7 @@ import { fallbackLng, languages } from '@/app/i18n/settings' import ChakraProviders from '@/providers/chakra/providers' import { I18nProvider } from '@/providers/i18n/i18nContext' import QueryProvider from '@/providers/chakra/QueryProvider' +import InitializeApp from '@/components/InitializeApp' import './globals.css' import 'react-day-picker/dist/style.css' @@ -47,7 +48,10 @@ export default async function RootLayout({ - {children} + + + {children} + diff --git a/frontend/providers/aiproxy/app/i18n/locales/en/common.json b/frontend/providers/aiproxy/app/i18n/locales/en/common.json index e5d2271905a..88c49fc8519 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/en/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/en/common.json @@ -4,7 +4,11 @@ "Sidebar": { "Home": "API Keys", "Logs": "Logs", - "Price": "Pricing" + "Price": "Pricing", + "Dashboard": "Channels", + "GlobalLogs": "Logs", + "GlobalConfigs": "Config", + "NsManager": "NS Manage" }, "keyList": { "title": "API Keys" diff --git a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json index 5a6226556e4..b6ef69b0b62 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json @@ -4,7 +4,11 @@ "Sidebar": { "Home": "API Keys", "Logs": "调用日志", - "Price": "模型价格" + "Price": "模型价格", + "Dashboard": "AI 渠道", + "GlobalLogs": "全局日志", + "GlobalConfigs": "全局配置", + "NsManager": "NS管理" }, "keyList": { "title": "API Keys" From 48224ecb9fc56e5542daa285b14599b6b4459ddf Mon Sep 17 00:00:00 2001 From: lim Date: Thu, 14 Nov 2024 10:28:36 +0000 Subject: [PATCH 02/42] add admin sidebar --- .../aiproxy/components/InitializeApp.tsx | 119 ++++++++++++++++++ .../aiproxy/components/admin/Sidebar.tsx | 119 ++++++++++++++++++ .../aiproxy/components/user/KeyList.tsx | 12 +- 3 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 frontend/providers/aiproxy/components/InitializeApp.tsx create mode 100644 frontend/providers/aiproxy/components/admin/Sidebar.tsx diff --git a/frontend/providers/aiproxy/components/InitializeApp.tsx b/frontend/providers/aiproxy/components/InitializeApp.tsx new file mode 100644 index 00000000000..ebc0e912bf2 --- /dev/null +++ b/frontend/providers/aiproxy/components/InitializeApp.tsx @@ -0,0 +1,119 @@ +'use client' + +import { EVENT_NAME } from 'sealos-desktop-sdk' +import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app' +import { useCallback, useEffect } from 'react' +import { initAppConfig } from '@/api/platform' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useBackendStore } from '@/store/backend' +import { useTranslationClientSide } from '@/app/i18n/client' +import { usePathname } from 'next/navigation' +import { useRouter } from 'next/navigation' + +export default function InitializeApp() { + const router = useRouter() + const pathname = usePathname() + const { lng } = useI18n() + const { i18n } = useTranslationClientSide(lng) + const { setAiproxyBackend } = useBackendStore() + + const handleI18nChange = useCallback( + (data: { currentLanguage: string }) => { + const currentLng = i18n.resolvedLanguage // get the latest resolvedLanguage + const newLng = data.currentLanguage + + if (currentLng !== newLng) { + const currentPath = window.location.pathname + const pathWithoutLang = currentPath.split('/').slice(2).join('/') + router.push(`/${newLng}/${pathWithoutLang}`) + } + }, + [i18n.resolvedLanguage] + ) + + useEffect(() => { + const cleanupApp = createSealosApp() + let cleanupEventListener: (() => void) | undefined + + const initApp = async () => { + try { + await initLanguage() + + await initSession() + + await initConfig() + + cleanupEventListener = sealosApp?.addAppEventListen( + EVENT_NAME.CHANGE_I18N, + handleI18nChange + ) + } catch (error) { + console.error('aiproxy: init app error:', error) + } + } + + initApp() + + return () => { + if (cleanupEventListener && typeof cleanupEventListener === 'function') { + cleanupEventListener() + } + if (cleanupApp && typeof cleanupApp === 'function') { + cleanupApp() + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // init language + const initLanguage = async () => { + const pathLng = pathname.split('/')[1] + try { + const lang = await sealosApp.getLanguage() + if (pathLng !== lang.lng) { + const pathParts = pathname.split('/') + pathParts[1] = lang.lng + router.push(pathParts.join('/')) + router.refresh() + } + console.log('aiproxy: init language success') + } catch (error) { + if (error instanceof Error) { + console.debug('aiproxy: init language error:', error.message) + } else { + console.debug('aiproxy: unknown init language error:', error) + } + } + } + + // init session + const initSession = async () => { + try { + const newSession = JSON.stringify(await sealosApp.getSession()) + const oldSession = localStorage.getItem('session') + if (newSession && newSession !== oldSession) { + localStorage.setItem('session', newSession) + window.location.reload() + } + console.log('aiproxy: init session success') + } catch (err) { + console.log('aiproxy: app is not running in desktop') + if (!process.env.NEXT_PUBLIC_MOCK_USER) { + localStorage.removeItem('session') + } + } + } + + // init config + const initConfig = async () => { + try { + const { aiproxyBackend } = await initAppConfig() + setAiproxyBackend(aiproxyBackend) + console.log('aiproxy: init config success') + } catch (error) { + console.error('aiproxy: init config error:', error) + } + } + + return null +} diff --git a/frontend/providers/aiproxy/components/admin/Sidebar.tsx b/frontend/providers/aiproxy/components/admin/Sidebar.tsx new file mode 100644 index 00000000000..e6de40e6b68 --- /dev/null +++ b/frontend/providers/aiproxy/components/admin/Sidebar.tsx @@ -0,0 +1,119 @@ +'use client' +import { Flex, Text } from '@chakra-ui/react' +import Image, { StaticImageData } from 'next/image' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +import { useTranslationClientSide } from '@/app/i18n/client' +import homeIcon from '@/ui/svg/icons/sidebar/home.svg' +import homeIcon_a from '@/ui/svg/icons/sidebar/home_a.svg' +import logsIcon from '@/ui/svg/icons/sidebar/logs.svg' +import logsIcon_a from '@/ui/svg/icons/sidebar/logs_a.svg' +import priceIcon from '@/ui/svg/icons/sidebar/price.svg' +import priceIcon_a from '@/ui/svg/icons/sidebar/price_a.svg' +import { useI18n } from '@/providers/i18n/i18nContext' + +type Menu = { + id: string + url: string + value: string + icon: StaticImageData + activeIcon: StaticImageData + display: boolean +} + +const SideBar = (): JSX.Element => { + const pathname = usePathname() + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const menus: Menu[] = [ + { + id: 'dashboard', + url: '/dashboard', + value: t('Sidebar.Dashboard'), + icon: homeIcon, + activeIcon: homeIcon_a, + display: true + }, + { + id: 'global-logs', + url: '/global-logs', + value: t('Sidebar.GlobalLogs'), + icon: logsIcon, + activeIcon: logsIcon_a, + display: true + }, + { + id: 'global-configs', + url: '/global-configs', + value: t('Sidebar.GlobalConfigs'), + icon: priceIcon, + activeIcon: priceIcon_a, + display: true + }, + { + id: 'ns-manager', + url: '/ns-manager', + value: t('Sidebar.NsManager'), + icon: priceIcon, + activeIcon: priceIcon_a, + display: true + } + ] + + return ( + + {menus + .filter((menu) => menu.display) + .map((menu) => { + const fullUrl = `/${lng}${menu.url}` + const isActive = pathname === fullUrl + + return ( + + + {menu.value} + + {menu.value} + + + + ) + })} + + ) +} + +export default SideBar diff --git a/frontend/providers/aiproxy/components/user/KeyList.tsx b/frontend/providers/aiproxy/components/user/KeyList.tsx index 09b7b5e9c7d..d7174b37c3c 100644 --- a/frontend/providers/aiproxy/components/user/KeyList.tsx +++ b/frontend/providers/aiproxy/components/user/KeyList.tsx @@ -550,8 +550,8 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { cy="32" r="31.6" stroke="#9CA2A8" - stroke-width="0.8" - stroke-dasharray="3.2 3.2" + strokeWidth="0.8" + strokeDasharray="3.2 3.2" /> @@ -610,8 +610,8 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { viewBox="0 0 16 16" fill="none"> @@ -693,8 +693,8 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { viewBox="0 0 16 16" fill="none"> From 29947046a0c45429eedf80631914bc37319bcc8c Mon Sep 17 00:00:00 2001 From: lim Date: Thu, 14 Nov 2024 10:35:46 +0000 Subject: [PATCH 03/42] change dir name --- .../providers/{chakra => tanstack-query}/QueryProvider.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename frontend/providers/aiproxy/providers/{chakra => tanstack-query}/QueryProvider.tsx (100%) diff --git a/frontend/providers/aiproxy/providers/chakra/QueryProvider.tsx b/frontend/providers/aiproxy/providers/tanstack-query/QueryProvider.tsx similarity index 100% rename from frontend/providers/aiproxy/providers/chakra/QueryProvider.tsx rename to frontend/providers/aiproxy/providers/tanstack-query/QueryProvider.tsx From 9ead06a669a7fec992afaa1e84feb53961b56c26 Mon Sep 17 00:00:00 2001 From: lim Date: Thu, 14 Nov 2024 11:01:35 +0000 Subject: [PATCH 04/42] ok --- frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx | 6 +++--- frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx | 2 +- frontend/providers/aiproxy/app/[lng]/layout.tsx | 2 +- .../{MyTooltip/index.tsx => common/MyTooltip.tsx} | 0 .../index.tsx => common/SelectDateRange.tsx} | 0 .../aiproxy/components/{ => common}/SwitchPage.tsx | 0 .../components/table/{baseTable.tsx => BaseTable.tsx} | 0 frontend/providers/aiproxy/components/user/KeyList.tsx | 4 ++-- frontend/providers/aiproxy/components/user/ModelList.tsx | 2 +- 9 files changed, 8 insertions(+), 8 deletions(-) rename frontend/providers/aiproxy/components/{MyTooltip/index.tsx => common/MyTooltip.tsx} (100%) rename frontend/providers/aiproxy/components/{SelectDateRange/index.tsx => common/SelectDateRange.tsx} (100%) rename frontend/providers/aiproxy/components/{ => common}/SwitchPage.tsx (100%) rename frontend/providers/aiproxy/components/table/{baseTable.tsx => BaseTable.tsx} (100%) diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx index c3ca7c7907b..eac04c24211 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx @@ -6,9 +6,9 @@ import { useMemo, useState } from 'react' import { getKeys, getLogs, getModels } from '@/api/platform' import { useTranslationClientSide } from '@/app/i18n/client' -import SelectDateRange from '@/components/SelectDateRange' -import SwitchPage from '@/components/SwitchPage' -import { BaseTable } from '@/components/table/baseTable' +import SelectDateRange from '@/components/common/SelectDateRange' +import SwitchPage from '@/components/common/SwitchPage' +import { BaseTable } from '@/components/table/BaseTable' import { useI18n } from '@/providers/i18n/i18nContext' import { LogItem } from '@/types/log' import { useQuery } from '@tanstack/react-query' diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx index e8545672698..ec4925f9f24 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx @@ -27,7 +27,7 @@ import { } from '@tanstack/react-table' import { SealosCoin } from '@sealos/ui' import { ModelIdentifier } from '@/types/front' -import { MyTooltip } from '@/components/MyTooltip' +import { MyTooltip } from '@/components/common/MyTooltip' import { useMessage } from '@sealos/ui' // icons import OpenAIIcon from '@/ui/svg/icons/modelist/openai.svg' diff --git a/frontend/providers/aiproxy/app/[lng]/layout.tsx b/frontend/providers/aiproxy/app/[lng]/layout.tsx index f04e33fcb2d..72d516fc5c1 100644 --- a/frontend/providers/aiproxy/app/[lng]/layout.tsx +++ b/frontend/providers/aiproxy/app/[lng]/layout.tsx @@ -5,7 +5,7 @@ import { useTranslationServerSide } from '@/app/i18n/server' import { fallbackLng, languages } from '@/app/i18n/settings' import ChakraProviders from '@/providers/chakra/providers' import { I18nProvider } from '@/providers/i18n/i18nContext' -import QueryProvider from '@/providers/chakra/QueryProvider' +import QueryProvider from '@/providers/tanstack-query/QueryProvider' import InitializeApp from '@/components/InitializeApp' import './globals.css' diff --git a/frontend/providers/aiproxy/components/MyTooltip/index.tsx b/frontend/providers/aiproxy/components/common/MyTooltip.tsx similarity index 100% rename from frontend/providers/aiproxy/components/MyTooltip/index.tsx rename to frontend/providers/aiproxy/components/common/MyTooltip.tsx diff --git a/frontend/providers/aiproxy/components/SelectDateRange/index.tsx b/frontend/providers/aiproxy/components/common/SelectDateRange.tsx similarity index 100% rename from frontend/providers/aiproxy/components/SelectDateRange/index.tsx rename to frontend/providers/aiproxy/components/common/SelectDateRange.tsx diff --git a/frontend/providers/aiproxy/components/SwitchPage.tsx b/frontend/providers/aiproxy/components/common/SwitchPage.tsx similarity index 100% rename from frontend/providers/aiproxy/components/SwitchPage.tsx rename to frontend/providers/aiproxy/components/common/SwitchPage.tsx diff --git a/frontend/providers/aiproxy/components/table/baseTable.tsx b/frontend/providers/aiproxy/components/table/BaseTable.tsx similarity index 100% rename from frontend/providers/aiproxy/components/table/baseTable.tsx rename to frontend/providers/aiproxy/components/table/BaseTable.tsx diff --git a/frontend/providers/aiproxy/components/user/KeyList.tsx b/frontend/providers/aiproxy/components/user/KeyList.tsx index d7174b37c3c..b8d44ca58ca 100644 --- a/frontend/providers/aiproxy/components/user/KeyList.tsx +++ b/frontend/providers/aiproxy/components/user/KeyList.tsx @@ -44,9 +44,9 @@ import { ChainIcon } from '@/ui/icons/home/Icons' import { useMessage } from '@sealos/ui' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' import { TokenInfo } from '@/types/getKeys' -import SwitchPage from '@/components/SwitchPage' +import SwitchPage from '@/components/common/SwitchPage' import { useBackendStore } from '@/store/backend' -import { MyTooltip } from '@/components/MyTooltip' +import { MyTooltip } from '@/components/common/MyTooltip' export function KeyList(): JSX.Element { const { lng } = useI18n() diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx index e58eca70739..6c02af4d57b 100644 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ b/frontend/providers/aiproxy/components/user/ModelList.tsx @@ -18,7 +18,7 @@ import AbabIcon from '@/ui/svg/icons/modelist/minimax.svg' import DoubaoIcon from '@/ui/svg/icons/modelist/doubao.svg' import ErnieIcon from '@/ui/svg/icons/modelist/ernie.svg' import { useMemo } from 'react' -import { MyTooltip } from '@/components/MyTooltip' +import { MyTooltip } from '@/components/common/MyTooltip' import { ModelIdentifier } from '@/types/front' const getIdentifier = (modelName: string): ModelIdentifier => { From 6677043e335981623d99b12f15544d00253da5dc Mon Sep 17 00:00:00 2001 From: lim Date: Thu, 14 Nov 2024 11:13:02 +0000 Subject: [PATCH 05/42] ok --- frontend/providers/aiproxy/api/platform.ts | 2 +- frontend/providers/aiproxy/app/api/create-key/route.ts | 2 +- frontend/providers/aiproxy/app/api/delete-key/[id]/route.ts | 2 +- frontend/providers/aiproxy/app/api/get-keys/route.ts | 2 +- frontend/providers/aiproxy/app/api/get-logs/route.ts | 2 +- frontend/providers/aiproxy/app/api/get-mode-price/route.ts | 2 +- frontend/providers/aiproxy/app/api/get-models/route.ts | 2 +- frontend/providers/aiproxy/app/api/update-key/[id]/route.ts | 2 +- frontend/providers/aiproxy/utils/{ => backend}/auth.ts | 0 frontend/providers/aiproxy/utils/{ => frontend}/request.ts | 0 frontend/providers/aiproxy/utils/{ => frontend}/user.ts | 0 11 files changed, 8 insertions(+), 8 deletions(-) rename frontend/providers/aiproxy/utils/{ => backend}/auth.ts (100%) rename frontend/providers/aiproxy/utils/{ => frontend}/request.ts (100%) rename frontend/providers/aiproxy/utils/{ => frontend}/user.ts (100%) diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index 012096a2f53..8032977b415 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -1,7 +1,7 @@ import { KeysSearchResponse } from '@/app/api/get-keys/route' import { QueryParams, SearchResponse } from '@/app/api/get-logs/route' import { QueryParams as KeysQueryParams } from '@/app/api/get-keys/route' -import { GET, POST, DELETE } from '@/utils/request' +import { GET, POST, DELETE } from '@/utils/frontend/request' import { ModelPrice } from '@/types/backend' export const initAppConfig = () => GET<{ aiproxyBackend: string }>('/api/init-app-config') diff --git a/frontend/providers/aiproxy/app/api/create-key/route.ts b/frontend/providers/aiproxy/app/api/create-key/route.ts index 89161f4457e..e8959227c84 100644 --- a/frontend/providers/aiproxy/app/api/create-key/route.ts +++ b/frontend/providers/aiproxy/app/api/create-key/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/auth' +import { parseJwtToken } from '@/utils/backend/auth' export const dynamic = 'force-dynamic' diff --git a/frontend/providers/aiproxy/app/api/delete-key/[id]/route.ts b/frontend/providers/aiproxy/app/api/delete-key/[id]/route.ts index 26ed34f6ff2..17969ee9c8f 100644 --- a/frontend/providers/aiproxy/app/api/delete-key/[id]/route.ts +++ b/frontend/providers/aiproxy/app/api/delete-key/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/auth' +import { parseJwtToken } from '@/utils/backend/auth' export const dynamic = 'force-dynamic' interface DeleteTokenResponse { diff --git a/frontend/providers/aiproxy/app/api/get-keys/route.ts b/frontend/providers/aiproxy/app/api/get-keys/route.ts index 13d3ff9c136..8a06115014d 100644 --- a/frontend/providers/aiproxy/app/api/get-keys/route.ts +++ b/frontend/providers/aiproxy/app/api/get-keys/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { TokenInfo } from '@/types/getKeys' -import { parseJwtToken } from '@/utils/auth' +import { parseJwtToken } from '@/utils/backend/auth' export const dynamic = 'force-dynamic' export interface KeysSearchResponse { diff --git a/frontend/providers/aiproxy/app/api/get-logs/route.ts b/frontend/providers/aiproxy/app/api/get-logs/route.ts index 5f9fd0a93be..cb15fe851e8 100644 --- a/frontend/providers/aiproxy/app/api/get-logs/route.ts +++ b/frontend/providers/aiproxy/app/api/get-logs/route.ts @@ -1,5 +1,5 @@ import { LogItem } from '@/types/log' -import { parseJwtToken } from '@/utils/auth' +import { parseJwtToken } from '@/utils/backend/auth' import { NextRequest, NextResponse } from 'next/server' export const dynamic = 'force-dynamic' diff --git a/frontend/providers/aiproxy/app/api/get-mode-price/route.ts b/frontend/providers/aiproxy/app/api/get-mode-price/route.ts index f9b6e0680e5..c00d8b9b2c4 100644 --- a/frontend/providers/aiproxy/app/api/get-mode-price/route.ts +++ b/frontend/providers/aiproxy/app/api/get-mode-price/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/auth' +import { parseJwtToken } from '@/utils/backend/auth' import { ModelPrice } from '@/types/backend' export const dynamic = 'force-dynamic' diff --git a/frontend/providers/aiproxy/app/api/get-models/route.ts b/frontend/providers/aiproxy/app/api/get-models/route.ts index 7f2d205fb48..dcdf726e223 100644 --- a/frontend/providers/aiproxy/app/api/get-models/route.ts +++ b/frontend/providers/aiproxy/app/api/get-models/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/auth' +import { parseJwtToken } from '@/utils/backend/auth' export const dynamic = 'force-dynamic' diff --git a/frontend/providers/aiproxy/app/api/update-key/[id]/route.ts b/frontend/providers/aiproxy/app/api/update-key/[id]/route.ts index c0665abd3ff..3958df56721 100644 --- a/frontend/providers/aiproxy/app/api/update-key/[id]/route.ts +++ b/frontend/providers/aiproxy/app/api/update-key/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/auth' +import { parseJwtToken } from '@/utils/backend/auth' export const dynamic = 'force-dynamic' interface UpdateTokenResponse { diff --git a/frontend/providers/aiproxy/utils/auth.ts b/frontend/providers/aiproxy/utils/backend/auth.ts similarity index 100% rename from frontend/providers/aiproxy/utils/auth.ts rename to frontend/providers/aiproxy/utils/backend/auth.ts diff --git a/frontend/providers/aiproxy/utils/request.ts b/frontend/providers/aiproxy/utils/frontend/request.ts similarity index 100% rename from frontend/providers/aiproxy/utils/request.ts rename to frontend/providers/aiproxy/utils/frontend/request.ts diff --git a/frontend/providers/aiproxy/utils/user.ts b/frontend/providers/aiproxy/utils/frontend/user.ts similarity index 100% rename from frontend/providers/aiproxy/utils/user.ts rename to frontend/providers/aiproxy/utils/frontend/user.ts From 4ac5b0df6043f4b285bed340119cb61a12ca73e2 Mon Sep 17 00:00:00 2001 From: lim Date: Sat, 23 Nov 2024 08:44:57 +0000 Subject: [PATCH 06/42] add select combox --- frontend/providers/aiproxy/api/platform.ts | 19 +- .../dashboard/components/ModelMapping.tsx | 1 + .../app/[lng]/(admin)/dashboard/page.tsx | 1217 ++++++++++++++++- .../aiproxy/app/[lng]/(admin)/layout.tsx | 2 +- .../aiproxy/app/[lng]/(user)/home/page.tsx | 1 - .../aiproxy/app/[lng]/(user)/layout.tsx | 3 +- .../aiproxy/app/[lng]/(user)/logs/page.tsx | 40 +- .../aiproxy/app/[lng]/(user)/price/page.tsx | 11 +- .../providers/aiproxy/app/[lng]/globals.css | 41 +- .../aiproxy/app/api/admin/channels/route.ts | 171 +++ .../aiproxy/app/api/init-app-config/route.ts | 13 +- .../app/api/models/enabled/default/route.ts | 64 + .../providers/aiproxy/app/api/models/route.ts | 65 + .../aiproxy/app/i18n/locales/en/common.json | 31 +- .../aiproxy/app/i18n/locales/zh/common.json | 31 +- .../aiproxy/components/admin/Sidebar.tsx | 22 +- .../aiproxy/components/common/MyTooltip.tsx | 21 +- .../aiproxy/components/user/KeyList.tsx | 81 +- .../aiproxy/components/user/ModelList.tsx | 1 + frontend/providers/aiproxy/package.json | 6 +- .../types/admin/channels/channelInfo.d.ts | 37 + frontend/providers/aiproxy/types/api.d.ts | 21 +- .../providers/aiproxy/types/appConfig.d.ts | 1 + .../providers/aiproxy/types/models/model.ts | 45 + .../ui/svg/icons/admin-sidebar/config.svg | 4 + .../ui/svg/icons/admin-sidebar/config_a.svg | 4 + .../ui/svg/icons/admin-sidebar/home.svg | 6 + .../ui/svg/icons/admin-sidebar/home_a.svg | 6 + .../ui/svg/icons/admin-sidebar/logs.svg | 3 + .../ui/svg/icons/admin-sidebar/logs_a.svg | 3 + .../ui/svg/icons/admin-sidebar/nsManager.svg | 3 + .../svg/icons/admin-sidebar/nsManager_a.svg | 3 + .../providers/aiproxy/utils/backend/auth.ts | 10 +- .../aiproxy/utils/backend/isAdmin.ts | 12 + .../aiproxy/utils/frontend/request.ts | 114 +- 35 files changed, 1957 insertions(+), 156 deletions(-) create mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ModelMapping.tsx create mode 100644 frontend/providers/aiproxy/app/api/admin/channels/route.ts create mode 100644 frontend/providers/aiproxy/app/api/models/enabled/default/route.ts create mode 100644 frontend/providers/aiproxy/app/api/models/route.ts create mode 100644 frontend/providers/aiproxy/types/admin/channels/channelInfo.d.ts create mode 100644 frontend/providers/aiproxy/types/models/model.ts create mode 100644 frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/config.svg create mode 100644 frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/config_a.svg create mode 100644 frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/home.svg create mode 100644 frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/home_a.svg create mode 100644 frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/logs.svg create mode 100644 frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/logs_a.svg create mode 100644 frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/nsManager.svg create mode 100644 frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/nsManager_a.svg create mode 100644 frontend/providers/aiproxy/utils/backend/isAdmin.ts diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index 8032977b415..3a94c50f3d0 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -3,7 +3,12 @@ import { QueryParams, SearchResponse } from '@/app/api/get-logs/route' import { QueryParams as KeysQueryParams } from '@/app/api/get-keys/route' import { GET, POST, DELETE } from '@/utils/frontend/request' import { ModelPrice } from '@/types/backend' - +import { ChannelQueryParams, GetChannelsResponse } from '@/app/api/admin/channels/route' +import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' +import { ApiResp } from '@/types/api' +import { GetModelsResponse } from '@/app/api/models/route' +import { GetDefaultEnabledModelsResponse } from '@/app/api/models/enabled/default/route' +// user export const initAppConfig = () => GET<{ aiproxyBackend: string }>('/api/init-app-config') export const getModels = () => GET('/api/get-models') @@ -20,3 +25,15 @@ export const createKey = (name: string) => POST('/api/create-key', { name }) export const deleteKey = (id: number) => DELETE(`/api/delete-key/${id}`) export const updateKey = (id: number, status: number) => POST(`/api/update-key/${id}`, { status }) + +// admin +export const getChannels = (params: ChannelQueryParams) => + GET('/api/admin/channels', params) + +export const createChannel = (params: CreateChannelRequest) => + POST('/api/admin/channels', params) + +export const getBuiltInSupportModels = () => GET('/api/models') + +export const getDefaultEnabledModels = () => + GET('/api/models/enabled/default') diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ModelMapping.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ModelMapping.tsx new file mode 100644 index 00000000000..0519ecba6ea --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ModelMapping.tsx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx index d84c050af4e..093d38afabd 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx @@ -1,17 +1,1218 @@ 'use client' -import { Flex } from '@chakra-ui/react' +import { + Checkbox, + Box, + Button, + Flex, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + Menu, + MenuButton, + MenuList, + MenuItem, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + FormControl, + Input, + FormErrorMessage, + ModalFooter, + useDisclosure, + FormLabel, + HStack, + VStack, + Center, + Select, + ListItem, + List, + InputGroup, + Spinner +} from '@chakra-ui/react' +import { + Column, + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable +} from '@tanstack/react-table' +import { useMessage } from '@sealos/ui' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { ChannelInfo, ChannelStatus } from '@/types/admin/channels/channelInfo.d' +import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' +import { useState, useRef, useMemo, Dispatch, SetStateAction, useEffect } from 'react' +import { getBuiltInSupportModels, getChannels } from '@/api/platform' +import SwitchPage from '@/components/common/SwitchPage' +import { FieldErrors, useForm } from 'react-hook-form' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { useCombobox, UseComboboxReturnValue, useSelect, useMultipleSelection } from 'downshift' +import { ModelType } from '@/types/models/model' +import clsx from 'clsx' + +type ModelTypeKey = keyof typeof ModelType export default function DashboardPage() { + const { isOpen, onOpen, onClose } = useDisclosure() + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') return ( - <> - - Dashboard + + + {/* header */} + + + + + {t('dashboard.title')} + + + + + + + + + + + {/* body */} + {/* table */} + + {/* modal */} + - - + + ) +} + +function ChannelTable() { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [selectedRows, setSelectedRows] = useState>(new Set()) + + const { data, isLoading } = useQuery({ + queryKey: ['getChannels', page, pageSize], + queryFn: () => getChannels({ page, perPage: pageSize }), + refetchOnReconnect: true, + onSuccess(data) { + setTotal(data?.total || 0) + } + }) + + const columnHelper = createColumnHelper() + + const handleHeaderCheckboxChange = (isChecked: boolean) => { + if (isChecked) { + const currentPageIds = data?.channels.map((channel) => channel.id) || [] + setSelectedRows(new Set(currentPageIds)) + } else { + setSelectedRows(new Set()) + } + } + + const handleRowCheckboxChange = (id: number, isChecked: boolean) => { + const newSelected = new Set(selectedRows) + if (isChecked) { + newSelected.add(id) + } else { + newSelected.delete(id) + } + setSelectedRows(newSelected) + } + + const columns = [ + columnHelper.accessor((row) => row.id, { + id: 'id', + header: () => ( + + {/* 0 && + selectedRows.size === data.channels.length + } + onChange={(e) => handleHeaderCheckboxChange(e.target.checked)} + spacing={0} + iconColor="white" + iconSize="12px" + sx={{ + '.chakra-checkbox__control': { + borderRadius: '4px', + borderColor: 'grayModern.300', + background: 'grayModern.100', + transition: 'all 0.2s ease', + _checked: { + background: 'grayModern.500', + borderColor: 'grayModern.500' + } + } + }} + /> */} + 0 && + selectedRows.size === data.channels.length + } + onChange={(e) => handleHeaderCheckboxChange(e.target.checked)} + sx={{ + '.chakra-checkbox__control': { + width: '16px', + height: '16px', + border: '1px solid', + borderColor: 'grayModern.300', + background: 'grayModern.100', + transition: 'all 0.2s ease', + _checked: { + background: 'grayModern.500', + borderColor: 'grayModern.500' + } + } + }} + /> + + ID + + + ), + cell: (info) => ( + + handleRowCheckboxChange(info.getValue(), e.target.checked)} + sx={{ + '.chakra-checkbox__control': { + width: '16px', + height: '16px', + border: '1px solid', + borderColor: 'grayModern.300', + background: 'white', + _checked: { + background: 'grayModern.500', + borderColor: 'grayModern.500' + } + } + }} + /> + + {info.getValue()} + + + ) + }), + columnHelper.accessor((row) => row.name, { + id: 'name', + header: () => ( + + {t('channels.name')} + + ), + cell: (info) => ( + + {info.getValue()} + + ) + }), + columnHelper.accessor((row) => row.type, { + id: 'type', + header: () => ( + + {t('channels.type')} + + ), + cell: (info) => ( + + {new Date(info.getValue()).toLocaleString()} + + ) + }), + columnHelper.accessor((row) => row.request_count, { + id: 'request_count', + header: () => Request Count, + cell: (info) => ( + + {new Date(info.getValue()).toLocaleString()} + + ) + }), + columnHelper.accessor((row) => row.status, { + id: 'status', + header: () => Status, + cell: (info) => { + const status = info.getValue() + let statusText = '' + let statusColor = '' + + switch (status) { + case ChannelStatus.ChannelStatusEnabled: + statusText = t('keystatus.enabled') + statusColor = 'green.600' + break + case ChannelStatus.ChannelStatusDisabled: + statusText = t('keystatus.disabled') + statusColor = 'red.600' + break + case ChannelStatus.ChannelStatusAutoDisabled: + statusText = t('channelStatus.autoDisabled') + statusColor = 'orange.500' + break + default: + statusText = t('keystatus.unknown') + statusColor = 'gray.500' + } + + return ( + + {statusText} + + ) + } + }), + + columnHelper.display({ + id: 'actions', + header: () => ( + + Action + + ), + cell: (info) => ( + + + + + + + + console.log('Export', info.row.original.id)}> + {t('channels.test')} + + console.log('Enable/Disable', info.row.original.id)}> + {info.row.original.status === ChannelStatus.ChannelStatusEnabled + ? t('channels.disable') + : t('channels.enable')} + + console.log('Edit', info.row.original.id)}> + {t('channels.edit')} + + console.log('Export', info.row.original.id)}> + {t('channels.export')} + + + + ) + }) + ] + + const table = useReactTable({ + data: data?.channels || [], + columns, + getCoreRowModel: getCoreRowModel() + }) + + return ( + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header, i) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ setPage(idx)} + /> +
) } -function ChannelList() { - return ChannelList +function SelectTypeCombobox({ + dropdownItems, + setSelectedItem, + errors +}: { + dropdownItems: string[] + setSelectedItem: (item: string) => void + errors: FieldErrors<{ type: number }> +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const [getFilteredDropdownItems, setGetFilteredDropdownItems] = useState(dropdownItems) + + const handleGetFilteredDropdownItems = (inputValue: string) => { + const lowerCasedInputValue = inputValue.toLowerCase() + return function dropdownItemsFilter(item: string) { + return !inputValue || item.toLowerCase().includes(lowerCasedInputValue) + } + } + + const { + isOpen: isComboboxOpen, + getToggleButtonProps, + getLabelProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + selectedItem + }: UseComboboxReturnValue = useCombobox({ + items: getFilteredDropdownItems, + onInputValueChange: ({ inputValue }) => { + setGetFilteredDropdownItems(dropdownItems.filter(handleGetFilteredDropdownItems(inputValue))) + }, + + onSelectedItemChange: ({ selectedItem }) => { + const selectedDropdownItem = dropdownItems.find((item) => item === selectedItem) + if (selectedDropdownItem) { + setSelectedItem(selectedDropdownItem) + } + } + }) + return ( + + + + {t('channelsForm.type')} + + + + + + + + {errors.type && {errors.type.message}} + + + {isComboboxOpen && + getFilteredDropdownItems.map((item, index) => ( + + {item} + + ))} + + + ) +} + +function SelectModelComBox({ + dropdownItems, + selectedItems, + setSelectedItems +}: { + dropdownItems: string[] + selectedItems: string[] + setSelectedItems: Dispatch> +}) { + function getFilteredDropdownItems(selectedItems: string[], inputValue: string) { + const lowerCasedInputValue = inputValue.toLowerCase() + + return dropdownItems.filter( + (item) => !selectedItems.includes(item) && item.toLowerCase().includes(lowerCasedInputValue) + ) + } + + const MultipleComboBox = () => { + const [inputValue, setInputValue] = useState('') + + // Dropdown list excludes already selected options and includes those matching the input. + const items = useMemo(() => getFilteredDropdownItems(selectedItems, inputValue), [inputValue]) + + const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({ + selectedItems, + onStateChange({ selectedItems: newSelectedItems, type }) { + switch (type) { + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: + case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: + setSelectedItems(newSelectedItems ?? []) + break + default: + break + } + } + }) + const { + isOpen, + getToggleButtonProps, + getLabelProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + selectedItem + } = useCombobox({ + items, + defaultHighlightedIndex: 0, // after selection, highlight the first item. + selectedItem: null, + inputValue, + stateReducer(state, actionAndChanges) { + const { changes, type } = actionAndChanges + + switch (type) { + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.ItemClick: + return { + ...changes, + isOpen: true, // keep the menu open after selection. + highlightedIndex: 0 // with the first option highlighted. + } + default: + return changes + } + }, + onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) { + switch (type) { + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.ItemClick: + case useCombobox.stateChangeTypes.InputBlur: + if (newSelectedItem) { + setSelectedItems([...selectedItems, newSelectedItem]) + setInputValue('') + } + break + + case useCombobox.stateChangeTypes.InputChange: + setInputValue(newInputValue ?? '') + + break + default: + break + } + } + }) + + return ( + + + + Pick some books: + + + + + {selectedItems.map((selectedItemForRender, index) => ( + + + {selectedItemForRender} + { + e.stopPropagation() + removeSelectedItem(selectedItemForRender) + }}> + × + + + + ))} + + + + + + + + + + + {isOpen && + items.map((item, index) => ( + + + + {item} + + + + ))} + + + ) + } + return +} + +function UpdateChannelModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const [modelTypes, setModelTypes] = useState([]) + + const [selectedModelType, setSelectedModelType] = useState(null) + const [models, setModels] = useState([]) + const [selectedModels, setSelectedModels] = useState([]) + + const [modelMapping, setModelMapping] = useState>({}) + + console.log(selectedModelType) + console.log(selectedModels) + + const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ + queryKey: ['models'], + queryFn: () => getBuiltInSupportModels(), + onSuccess: (data) => { + if (!data) return + + const types = Object.keys(data) + .map((key) => { + // Find the corresponding enumeration key based on an enumeration value (string). + const enumKey = Object.entries(ModelType).find( + ([_, value]) => value === key + )?.[0] as ModelTypeKey + return enumKey + }) + .filter((key): key is ModelTypeKey => key !== undefined) + + setModelTypes(types) + } + }) + + useEffect(() => { + if (!builtInSupportModels || !selectedModelType) return + const modelTypeValue = selectedModelType ? (ModelType[selectedModelType] as ModelType) : null + const models = modelTypeValue + ? builtInSupportModels?.[modelTypeValue as keyof typeof builtInSupportModels] || [] + : [] + setModels(models) + }, [selectedModelType, builtInSupportModels]) + + console.log(1) + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const schema = z.object({ + type: z.number(), + name: z.string().min(1, { message: t('channels.name_required') }), + key: z.string().min(1, { message: t('channels.key_required') }), + base_url: z.string(), + models: z.array(z.string()).default([]), + model_mapping: z.record(z.string(), z.any()).default({}) + }) + + type FormData = z.infer + + const { + register, + handleSubmit, + reset, + trigger, + getValues, + setValue, + formState: { errors } + } = useForm({ + resolver: zodResolver(schema) + }) + + const onValidate = (data: FormData) => { + console.log(data) + } + + const onInvalid = () => { + const firstErrorMessage = Object.values(errors)[0]?.message + if (firstErrorMessage) { + message({ + title: firstErrorMessage as string, + status: 'error', + position: 'top', + duration: 2000, + isClosable: true, + description: firstErrorMessage as string + }) + } + } + + const onSubmit = handleSubmit(onValidate, onInvalid) + + return ( + + + {isOpen && ( + + {/* header */} + + + + + {t('channels.create')} + + + + + + {/* body */} + {isBuiltInSupportModelsLoading || !builtInSupportModels ? ( +
+ +
+ ) : ( + <> + + + + + + {t('channelsForm.name')} + + + + {errors.name && {errors.name.message}} + + + setSelectedModelType(item as ModelTypeKey)} + errors={errors} + /> + + + + + + + + + )} +
+ )} +
+ ) } diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx index 458d217f371..130e90d6b77 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx @@ -10,7 +10,7 @@ export default function UserLayout({ children }: { children: React.ReactNode }) {/* Main Content */} - + {children}
diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx index e43a476d36a..6bf70c4edb9 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx @@ -8,7 +8,6 @@ export default function Home(): JSX.Element { {/* Main Content */} - + {children} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx index eac04c24211..b1f2ed129cb 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx @@ -133,16 +133,16 @@ export default function Home(): React.JSX.Element { }) return ( - + - + h="0" + bg="white" + borderRadius="12px" + px="32px" + pt="24px"> + {t('logs.call_log')} + + + + ))} + + ) +} + +export default UpdateChannelModal diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx index 093d38afabd..f819f6ad195 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx @@ -1,68 +1,14 @@ 'use client' -import { - Checkbox, - Box, - Button, - Flex, - Table, - TableContainer, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - Menu, - MenuButton, - MenuList, - MenuItem, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - FormControl, - Input, - FormErrorMessage, - ModalFooter, - useDisclosure, - FormLabel, - HStack, - VStack, - Center, - Select, - ListItem, - List, - InputGroup, - Spinner -} from '@chakra-ui/react' -import { - Column, - createColumnHelper, - flexRender, - getCoreRowModel, - useReactTable -} from '@tanstack/react-table' -import { useMessage } from '@sealos/ui' +import { Button, Flex, Text, useDisclosure } from '@chakra-ui/react' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { ChannelInfo, ChannelStatus } from '@/types/admin/channels/channelInfo.d' -import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' -import { useState, useRef, useMemo, Dispatch, SetStateAction, useEffect } from 'react' -import { getBuiltInSupportModels, getChannels } from '@/api/platform' -import SwitchPage from '@/components/common/SwitchPage' -import { FieldErrors, useForm } from 'react-hook-form' -import { z } from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' -import { useCombobox, UseComboboxReturnValue, useSelect, useMultipleSelection } from 'downshift' -import { ModelType } from '@/types/models/model' -import clsx from 'clsx' - -type ModelTypeKey = keyof typeof ModelType +import ChannelTable from './components/ChannelTable' +import UpdateChannelModal from './components/UpdateChannelModal' +import { useState } from 'react' export default function DashboardPage() { const { isOpen, onOpen, onClose } = useDisclosure() + const [operationType, setOperationType] = useState<'create' | 'update'>('create') const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') return ( @@ -113,22 +59,24 @@ export default function DashboardPage() { lineHeight="16px" letterSpacing="0.5px" transition="all 0.2s ease" - onClick={() => onOpen()} _hover={{ - bg: '#2D3648', - transform: 'scale(1.05)' + transform: 'scale(1.05)', + transition: 'transform 0.2s ease' }} _active={{ - bg: '#0A0F17', - transform: 'scale(0.95)', - animation: 'shake 0.3s' + transform: 'scale(0.92)', + animation: 'pulse 0.3s ease' }} sx={{ - '@keyframes shake': { - '0%, 100%': { transform: 'scale(0.95)' }, - '25%': { transform: 'scale(0.95) translateX(-2px)' }, - '75%': { transform: 'scale(0.95) translateX(2px)' } + '@keyframes pulse': { + '0%': { transform: 'scale(0.92)' }, + '50%': { transform: 'scale(0.96)' }, + '100%': { transform: 'scale(0.92)' } } + }} + onClick={() => { + setOperationType('create') + onOpen() }}> + letterSpacing="0.5px" + _hover={{ + transform: 'scale(1.05)', + transition: 'transform 0.2s ease' + }} + _active={{ + transform: 'scale(0.92)', + animation: 'pulse 0.3s ease' + }} + sx={{ + '@keyframes pulse': { + '0%': { transform: 'scale(0.92)' }, + '50%': { transform: 'scale(0.96)' }, + '100%': { transform: 'scale(0.92)' } + } + }}> + letterSpacing="0.5px" + _hover={{ + transform: 'scale(1.05)', + transition: 'transform 0.2s ease' + }} + _active={{ + transform: 'scale(0.92)', + animation: 'pulse 0.3s ease' + }} + sx={{ + '@keyframes pulse': { + '0%': { transform: 'scale(0.92)' }, + '50%': { transform: 'scale(0.96)' }, + '100%': { transform: 'scale(0.92)' } + } + }}> - {/* body */} + {/* header end */} {/* table */} {/* modal */} - + ) } - -function ChannelTable() { - const { lng } = useI18n() - const { t } = useTranslationClientSide(lng, 'common') - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(10) - const [selectedRows, setSelectedRows] = useState>(new Set()) - - const { data, isLoading } = useQuery({ - queryKey: ['getChannels', page, pageSize], - queryFn: () => getChannels({ page, perPage: pageSize }), - refetchOnReconnect: true, - onSuccess(data) { - setTotal(data?.total || 0) - } - }) - - const columnHelper = createColumnHelper() - - const handleHeaderCheckboxChange = (isChecked: boolean) => { - if (isChecked) { - const currentPageIds = data?.channels.map((channel) => channel.id) || [] - setSelectedRows(new Set(currentPageIds)) - } else { - setSelectedRows(new Set()) - } - } - - const handleRowCheckboxChange = (id: number, isChecked: boolean) => { - const newSelected = new Set(selectedRows) - if (isChecked) { - newSelected.add(id) - } else { - newSelected.delete(id) - } - setSelectedRows(newSelected) - } - - const columns = [ - columnHelper.accessor((row) => row.id, { - id: 'id', - header: () => ( - - {/* 0 && - selectedRows.size === data.channels.length - } - onChange={(e) => handleHeaderCheckboxChange(e.target.checked)} - spacing={0} - iconColor="white" - iconSize="12px" - sx={{ - '.chakra-checkbox__control': { - borderRadius: '4px', - borderColor: 'grayModern.300', - background: 'grayModern.100', - transition: 'all 0.2s ease', - _checked: { - background: 'grayModern.500', - borderColor: 'grayModern.500' - } - } - }} - /> */} - 0 && - selectedRows.size === data.channels.length - } - onChange={(e) => handleHeaderCheckboxChange(e.target.checked)} - sx={{ - '.chakra-checkbox__control': { - width: '16px', - height: '16px', - border: '1px solid', - borderColor: 'grayModern.300', - background: 'grayModern.100', - transition: 'all 0.2s ease', - _checked: { - background: 'grayModern.500', - borderColor: 'grayModern.500' - } - } - }} - /> - - ID - - - ), - cell: (info) => ( - - handleRowCheckboxChange(info.getValue(), e.target.checked)} - sx={{ - '.chakra-checkbox__control': { - width: '16px', - height: '16px', - border: '1px solid', - borderColor: 'grayModern.300', - background: 'white', - _checked: { - background: 'grayModern.500', - borderColor: 'grayModern.500' - } - } - }} - /> - - {info.getValue()} - - - ) - }), - columnHelper.accessor((row) => row.name, { - id: 'name', - header: () => ( - - {t('channels.name')} - - ), - cell: (info) => ( - - {info.getValue()} - - ) - }), - columnHelper.accessor((row) => row.type, { - id: 'type', - header: () => ( - - {t('channels.type')} - - ), - cell: (info) => ( - - {new Date(info.getValue()).toLocaleString()} - - ) - }), - columnHelper.accessor((row) => row.request_count, { - id: 'request_count', - header: () => Request Count, - cell: (info) => ( - - {new Date(info.getValue()).toLocaleString()} - - ) - }), - columnHelper.accessor((row) => row.status, { - id: 'status', - header: () => Status, - cell: (info) => { - const status = info.getValue() - let statusText = '' - let statusColor = '' - - switch (status) { - case ChannelStatus.ChannelStatusEnabled: - statusText = t('keystatus.enabled') - statusColor = 'green.600' - break - case ChannelStatus.ChannelStatusDisabled: - statusText = t('keystatus.disabled') - statusColor = 'red.600' - break - case ChannelStatus.ChannelStatusAutoDisabled: - statusText = t('channelStatus.autoDisabled') - statusColor = 'orange.500' - break - default: - statusText = t('keystatus.unknown') - statusColor = 'gray.500' - } - - return ( - - {statusText} - - ) - } - }), - - columnHelper.display({ - id: 'actions', - header: () => ( - - Action - - ), - cell: (info) => ( - - - - - - - - console.log('Export', info.row.original.id)}> - {t('channels.test')} - - console.log('Enable/Disable', info.row.original.id)}> - {info.row.original.status === ChannelStatus.ChannelStatusEnabled - ? t('channels.disable') - : t('channels.enable')} - - console.log('Edit', info.row.original.id)}> - {t('channels.edit')} - - console.log('Export', info.row.original.id)}> - {t('channels.export')} - - - - ) - }) - ] - - const table = useReactTable({ - data: data?.channels || [], - columns, - getCoreRowModel: getCoreRowModel() - }) - - return ( - - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header, i) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {flexRender(header.column.columnDef.header, header.getContext())} -
{flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- setPage(idx)} - /> -
- ) -} - -function SelectTypeCombobox({ - dropdownItems, - setSelectedItem, - errors -}: { - dropdownItems: string[] - setSelectedItem: (item: string) => void - errors: FieldErrors<{ type: number }> -}) { - const { lng } = useI18n() - const { t } = useTranslationClientSide(lng, 'common') - const [getFilteredDropdownItems, setGetFilteredDropdownItems] = useState(dropdownItems) - - const handleGetFilteredDropdownItems = (inputValue: string) => { - const lowerCasedInputValue = inputValue.toLowerCase() - return function dropdownItemsFilter(item: string) { - return !inputValue || item.toLowerCase().includes(lowerCasedInputValue) - } - } - - const { - isOpen: isComboboxOpen, - getToggleButtonProps, - getLabelProps, - getMenuProps, - getInputProps, - highlightedIndex, - getItemProps, - selectedItem - }: UseComboboxReturnValue = useCombobox({ - items: getFilteredDropdownItems, - onInputValueChange: ({ inputValue }) => { - setGetFilteredDropdownItems(dropdownItems.filter(handleGetFilteredDropdownItems(inputValue))) - }, - - onSelectedItemChange: ({ selectedItem }) => { - const selectedDropdownItem = dropdownItems.find((item) => item === selectedItem) - if (selectedDropdownItem) { - setSelectedItem(selectedDropdownItem) - } - } - }) - return ( - - - - {t('channelsForm.type')} - - - - - - - - {errors.type && {errors.type.message}} - - - {isComboboxOpen && - getFilteredDropdownItems.map((item, index) => ( - - {item} - - ))} - - - ) -} - -function SelectModelComBox({ - dropdownItems, - selectedItems, - setSelectedItems -}: { - dropdownItems: string[] - selectedItems: string[] - setSelectedItems: Dispatch> -}) { - function getFilteredDropdownItems(selectedItems: string[], inputValue: string) { - const lowerCasedInputValue = inputValue.toLowerCase() - - return dropdownItems.filter( - (item) => !selectedItems.includes(item) && item.toLowerCase().includes(lowerCasedInputValue) - ) - } - - const MultipleComboBox = () => { - const [inputValue, setInputValue] = useState('') - - // Dropdown list excludes already selected options and includes those matching the input. - const items = useMemo(() => getFilteredDropdownItems(selectedItems, inputValue), [inputValue]) - - const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({ - selectedItems, - onStateChange({ selectedItems: newSelectedItems, type }) { - switch (type) { - case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace: - case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: - case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: - case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: - setSelectedItems(newSelectedItems ?? []) - break - default: - break - } - } - }) - const { - isOpen, - getToggleButtonProps, - getLabelProps, - getMenuProps, - getInputProps, - highlightedIndex, - getItemProps, - selectedItem - } = useCombobox({ - items, - defaultHighlightedIndex: 0, // after selection, highlight the first item. - selectedItem: null, - inputValue, - stateReducer(state, actionAndChanges) { - const { changes, type } = actionAndChanges - - switch (type) { - case useCombobox.stateChangeTypes.InputKeyDownEnter: - case useCombobox.stateChangeTypes.ItemClick: - return { - ...changes, - isOpen: true, // keep the menu open after selection. - highlightedIndex: 0 // with the first option highlighted. - } - default: - return changes - } - }, - onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) { - switch (type) { - case useCombobox.stateChangeTypes.InputKeyDownEnter: - case useCombobox.stateChangeTypes.ItemClick: - case useCombobox.stateChangeTypes.InputBlur: - if (newSelectedItem) { - setSelectedItems([...selectedItems, newSelectedItem]) - setInputValue('') - } - break - - case useCombobox.stateChangeTypes.InputChange: - setInputValue(newInputValue ?? '') - - break - default: - break - } - } - }) - - return ( - - - - Pick some books: - - - - - {selectedItems.map((selectedItemForRender, index) => ( - - - {selectedItemForRender} - { - e.stopPropagation() - removeSelectedItem(selectedItemForRender) - }}> - × - - - - ))} - - - - - - - - - - - {isOpen && - items.map((item, index) => ( - - - - {item} - - - - ))} - - - ) - } - return -} - -function UpdateChannelModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { - const { lng } = useI18n() - const { t } = useTranslationClientSide(lng, 'common') - - const [modelTypes, setModelTypes] = useState([]) - - const [selectedModelType, setSelectedModelType] = useState(null) - const [models, setModels] = useState([]) - const [selectedModels, setSelectedModels] = useState([]) - - const [modelMapping, setModelMapping] = useState>({}) - - console.log(selectedModelType) - console.log(selectedModels) - - const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ - queryKey: ['models'], - queryFn: () => getBuiltInSupportModels(), - onSuccess: (data) => { - if (!data) return - - const types = Object.keys(data) - .map((key) => { - // Find the corresponding enumeration key based on an enumeration value (string). - const enumKey = Object.entries(ModelType).find( - ([_, value]) => value === key - )?.[0] as ModelTypeKey - return enumKey - }) - .filter((key): key is ModelTypeKey => key !== undefined) - - setModelTypes(types) - } - }) - - useEffect(() => { - if (!builtInSupportModels || !selectedModelType) return - const modelTypeValue = selectedModelType ? (ModelType[selectedModelType] as ModelType) : null - const models = modelTypeValue - ? builtInSupportModels?.[modelTypeValue as keyof typeof builtInSupportModels] || [] - : [] - setModels(models) - }, [selectedModelType, builtInSupportModels]) - - console.log(1) - - const { message } = useMessage({ - warningBoxBg: 'var(--Yellow-50, #FFFAEB)', - warningIconBg: 'var(--Yellow-500, #F79009)', - warningIconFill: 'white', - - successBoxBg: 'var(--Green-50, #EDFBF3)', - successIconBg: 'var(--Green-600, #039855)', - successIconFill: 'white' - }) - - const schema = z.object({ - type: z.number(), - name: z.string().min(1, { message: t('channels.name_required') }), - key: z.string().min(1, { message: t('channels.key_required') }), - base_url: z.string(), - models: z.array(z.string()).default([]), - model_mapping: z.record(z.string(), z.any()).default({}) - }) - - type FormData = z.infer - - const { - register, - handleSubmit, - reset, - trigger, - getValues, - setValue, - formState: { errors } - } = useForm({ - resolver: zodResolver(schema) - }) - - const onValidate = (data: FormData) => { - console.log(data) - } - - const onInvalid = () => { - const firstErrorMessage = Object.values(errors)[0]?.message - if (firstErrorMessage) { - message({ - title: firstErrorMessage as string, - status: 'error', - position: 'top', - duration: 2000, - isClosable: true, - description: firstErrorMessage as string - }) - } - } - - const onSubmit = handleSubmit(onValidate, onInvalid) - - return ( - - - {isOpen && ( - - {/* header */} - - - - - {t('channels.create')} - - - - - - {/* body */} - {isBuiltInSupportModelsLoading || !builtInSupportModels ? ( -
- -
- ) : ( - <> - - - - - - {t('channelsForm.name')} - - - - {errors.name && {errors.name.message}} - - - setSelectedModelType(item as ModelTypeKey)} - errors={errors} - /> - - - - - - - - - )} -
- )} -
- ) -} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx index 2ce3f541607..d5f485057ab 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx @@ -1,12 +1,197 @@ 'use client' -import { Flex } from '@chakra-ui/react' +import { Button, Flex, Text, useDisclosure } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useState } from 'react' -export default function GlobalConfigsPage() { +export default function GlobalConfigPage() { + const { isOpen, onOpen, onClose } = useDisclosure() + const [operationType, setOperationType] = useState<'create' | 'update'>('create') + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') return ( - <> - - Global Configs + + + {/* header */} + + + + + {t('dashboard.title')} + + + + + + + + + + + {/* header end */} + {/* table */} + {/* modal */} - + ) } diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page1.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page1.tsx new file mode 100644 index 00000000000..2ce3f541607 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page1.tsx @@ -0,0 +1,12 @@ +'use client' +import { Flex } from '@chakra-ui/react' + +export default function GlobalConfigsPage() { + return ( + <> + + Global Configs + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page2.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page2.tsx new file mode 100644 index 00000000000..2ca8c617d60 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page2.tsx @@ -0,0 +1,348 @@ +'use client' +import React, { useState, useEffect } from 'react' +import { + Box, + Button, + VStack, + FormControl, + IconButton, + Divider, + Flex, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + ModalCloseButton, + Text, + Spinner, + Center, + useDisclosure +} from '@chakra-ui/react' +import { useForm, useFieldArray, Controller } from 'react-hook-form' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { Trash2 } from 'lucide-react' +import { SingleSelectCombobox } from '@/components/common/SingleSelectCombobox' +import { MultiSelectCombobox } from '@/components/common/MultiSelectCombobox' +import { ConstructModeMappingComponent } from '@/components/common/ConstructModeMappingComponent' +import { ModelType } from '@/types/models/model' +import { getEnumKeyByValue } from '@/utils/common' +import { useMessage } from '@sealos/ui' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useQuery } from '@tanstack/react-query' +import { getBuiltInSupportModels, getDefaultEnabledModels } from '@/api/platform' + +// 类型定义 +type ModelTypeKey = keyof typeof ModelType + +type Model = { + name: string + isDefault: boolean +} + +// 每个配置项的数据结构 +type ConfigItem = { + type: ModelTypeKey | null + selectedModels: Model[] + modelMapping: Record +} + +// 表单验证schema +const schema = z.object({ + configs: z.array( + z.object({ + type: z.number(), + models: z.array(z.string()).default([]), + model_mapping: z.record(z.string(), z.any()).default({}) + }) + ) +}) + +type FormData = z.infer + +function UpdateMultiChannelModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { message } = useMessage() + + // 配置列表状态 + const [configs, setConfigs] = useState([ + { + type: null, + selectedModels: [], + modelMapping: {} + } + ]) + + // react-hook-form 配置 + const { + control, + handleSubmit, + setValue, + formState: { errors }, + watch + } = useForm>({ + resolver: zodResolver(schema), + defaultValues: { + configs: [ + { + type: undefined, + models: [], + model_mapping: {} + } + ] + } + }) + + // 使用 useFieldArray 管理多个表单项 + const { fields, append, remove } = useFieldArray({ + control, + name: 'configs' + }) + + // 获取支持的模型数据 + const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ + queryKey: ['models'], + queryFn: () => getBuiltInSupportModels() + }) + + const { isLoading: isDefaultEnabledModelsLoading, data: defaultEnabledModels } = useQuery({ + queryKey: ['defaultEnabledModels'], + queryFn: () => getDefaultEnabledModels() + }) + + // 处理模型类型变更 + const handleModelTypeChange = (index: number, value: ModelTypeKey | null) => { + const newConfigs = [...configs] + newConfigs[index].type = value + newConfigs[index].selectedModels = [] + newConfigs[index].modelMapping = {} + setConfigs(newConfigs) + + if (value) { + setValue(`configs.${index}.type`, Number(ModelType[value])) + } + } + + // 处理选中模型变更 + const handleSelectedModelsChange = (index: number, models: Model[]) => { + const newConfigs = [...configs] + newConfigs[index].selectedModels = models + newConfigs[index].modelMapping = {} + setConfigs(newConfigs) + + setValue( + `configs.${index}.models`, + models.map((m) => m.name) + ) + } + + // 处理模型映射变更 + const handleModelMappingChange = (index: number, mapping: Record) => { + const newConfigs = [...configs] + newConfigs[index].modelMapping = mapping + setConfigs(newConfigs) + + setValue(`configs.${index}.model_mapping`, mapping) + } + + // 添加新配置 + const handleAddConfig = () => { + setConfigs([ + ...configs, + { + type: null, + selectedModels: [], + modelMapping: {} + } + ]) + append({ + type: undefined, + models: [], + model_mapping: {} + }) + } + + // 移除配置 + const handleRemoveConfig = (index: number) => { + const newConfigs = configs.filter((_, i) => i !== index) + setConfigs(newConfigs) + remove(index) + } + + // 表单提交 + const onSubmit = async (data: z.infer) => { + try { + // 处理提交的数据 + console.log('提交的数据:', data.configs) + // ... 其他提交逻辑 + } catch (error) { + // ... 错误处理 + } + } + + return ( + + {isOpen && + (isBuiltInSupportModelsLoading || isDefaultEnabledModelsLoading ? ( +
+ +
+ ) : ( + <> + + + + {t('channels.create')} + + + + + + {fields.map((field, index) => ( + + } + position="absolute" + right={2} + top={2} + size="sm" + onClick={() => remove(index)} + isDisabled={fields.length === 1} + /> + + + + ( + + dropdownItems={Object.keys(ModelType) as ModelTypeKey[]} + setSelectedItem={(type) => { + if (type) { + field.onChange( + Number(ModelType[type as keyof typeof ModelType]) + ) + // 清空当前配置的模型选择 + setValue(`configs.${index}.models`, []) + setValue(`configs.${index}.model_mapping`, {}) + } + }} + handleDropdownItemFilter={(items, input) => { + return items.filter( + (item) => + !input || item.toLowerCase().includes(input.toLowerCase()) + ) + }} + handleDropdownItemDisplay={(item) => item} + /> + )} + /> + + + + ( + + dropdownItems={[]} // 需要根据选中的类型填充可选模型列表 + selectedItems={field.value.map((name) => ({ + name, + isDefault: false + }))} + setSelectedItems={(models) => { + field.onChange(models.map((m) => m.name)) + // 清空当前配置的映射 + setValue(`configs.${index}.model_mapping`, {}) + }} + handleFilteredDropdownItems={(items, selected, input) => { + return items.filter( + (item) => + !selected.includes(item) && + (!input || + item.name.toLowerCase().includes(input.toLowerCase())) + ) + }} + handleDropdownItemDisplay={(item) => item.name} + handleSelectedItemDisplay={(item) => item.name} + handleSetCustomSelectedItem={(item) => ({ + name: item, + isDefault: false + })} + /> + )} + /> + + + + ( + ({ + name, + isDefault: false + }))} + mapData={field.value} + setMapData={field.onChange} + /> + )} + /> + + + + ))} + + + + + + + + + + + + + + ))} +
+ ) +} + +export default function GlobalConfigsPage() { + const { isOpen, onOpen, onClose } = useDisclosure() + return ( + <> + + + onClose()} /> + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx index 69d79758b1a..a219636702c 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx @@ -1,12 +1,328 @@ 'use client' -import { Flex } from '@chakra-ui/react' -export default function GlobalLogsPage() { +import { Box, Flex, Text, Button, Icon } from '@chakra-ui/react' +import { MySelect, MyTooltip, SealosCoin } from '@sealos/ui' +import { useMemo, useState } from 'react' + +import { getKeys, getLogs, getModels } from '@/api/platform' +import { useTranslationClientSide } from '@/app/i18n/client' +import SelectDateRange from '@/components/common/SelectDateRange' +import SwitchPage from '@/components/common/SwitchPage' +import { BaseTable } from '@/components/table/BaseTable' +import { useI18n } from '@/providers/i18n/i18nContext' +import { LogItem } from '@/types/log' +import { useQuery } from '@tanstack/react-query' +import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' + +const mockStatus = ['all', 'success', 'failed'] + +export default function Home(): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const [startTime, setStartTime] = useState(() => { + const currentDate = new Date() + currentDate.setMonth(currentDate.getMonth() - 1) + return currentDate + }) + const [endTime, setEndTime] = useState(new Date()) + const [name, setName] = useState('') + const [modelName, setModelName] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [logData, setLogData] = useState([]) + const [total, setTotal] = useState(0) + + const { data: models = [] } = useQuery(['getModels'], () => getModels()) + const { data: tokenData } = useQuery(['getKeys'], () => getKeys({ page: 1, perPage: 100 })) + + const { isLoading } = useQuery( + ['getLogs', page, pageSize, name, modelName, startTime, endTime], + () => + getLogs({ + page, + perPage: pageSize, + token_name: name, + model_name: modelName, + start_timestamp: startTime.getTime().toString(), + end_timestamp: endTime.getTime().toString() + }), + { + onSuccess: (data) => { + if (!data.logs) { + setLogData([]) + setTotal(0) + return + } + setLogData(data.logs) + setTotal(data.total) + } + } + ) + + const columns = useMemo[]>(() => { + return [ + { + header: t('logs.name'), + accessorKey: 'token_name' + }, + { + header: t('logs.model'), + accessorKey: 'model' + }, + { + header: t('logs.prompt_tokens'), + accessorKey: 'prompt_tokens' + }, + { + header: t('logs.completion_tokens'), + accessorKey: 'completion_tokens' + }, + + { + header: t('logs.status'), + accessorFn: (row) => (row.code === 200 ? t('logs.success') : t('logs.failed')), + cell: ({ getValue }) => { + const value = getValue() as string + return ( + + {value} + + ) + }, + id: 'status' + }, + { + header: t('logs.time'), + accessorFn: (row) => new Date(row.created_at).toLocaleString(), + id: 'created_at' + }, + { + accessorKey: 'used_amount', + id: 'used_amount', + header: () => { + return ( + + + + {t('logs.total_price')} + + + + + ) + } + } + ] + }, []) + + const table = useReactTable({ + data: logData, + columns, + getCoreRowModel: getCoreRowModel() + }) + return ( - <> - - Global Logs + + + {/* -- header */} + + + + {t('logs.call_log')} + + + + + {/* -- the first row */} + + + + {t('logs.name')} + + ({ + value: item.name, + label: item.name + })) || []) + ]} + onchange={(val: string) => { + if (val === 'all') { + setName('') + } else { + setName(val) + } + }} + /> + + + + + {t('logs.modal')} + + ({ + value: item, + label: item + })) || [] + } + onchange={(val: string) => { + if (val === 'all') { + setModelName('') + } else { + setModelName(val) + } + }} + /> + + + + {/* -- the first row end */} + + {/* -- the second row */} + + + + {t('logs.time')} + + + + + + {/* -- the second row end */} + + + {/* -- header end */} + + {/* -- table */} + + + setPage(idx)} + /> + + {/* -- table end */} - + ) } diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx index 130e90d6b77..747b8b6c26b 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx @@ -1,4 +1,3 @@ -'use client' import { Box, Flex } from '@chakra-ui/react' import SideBar from '@/components/admin/Sidebar' diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx index 2f6bcf7d4e9..a219636702c 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx @@ -1,12 +1,328 @@ 'use client' -import { Flex } from '@chakra-ui/react' -export default function NsManagerPage() { +import { Box, Flex, Text, Button, Icon } from '@chakra-ui/react' +import { MySelect, MyTooltip, SealosCoin } from '@sealos/ui' +import { useMemo, useState } from 'react' + +import { getKeys, getLogs, getModels } from '@/api/platform' +import { useTranslationClientSide } from '@/app/i18n/client' +import SelectDateRange from '@/components/common/SelectDateRange' +import SwitchPage from '@/components/common/SwitchPage' +import { BaseTable } from '@/components/table/BaseTable' +import { useI18n } from '@/providers/i18n/i18nContext' +import { LogItem } from '@/types/log' +import { useQuery } from '@tanstack/react-query' +import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' + +const mockStatus = ['all', 'success', 'failed'] + +export default function Home(): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const [startTime, setStartTime] = useState(() => { + const currentDate = new Date() + currentDate.setMonth(currentDate.getMonth() - 1) + return currentDate + }) + const [endTime, setEndTime] = useState(new Date()) + const [name, setName] = useState('') + const [modelName, setModelName] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [logData, setLogData] = useState([]) + const [total, setTotal] = useState(0) + + const { data: models = [] } = useQuery(['getModels'], () => getModels()) + const { data: tokenData } = useQuery(['getKeys'], () => getKeys({ page: 1, perPage: 100 })) + + const { isLoading } = useQuery( + ['getLogs', page, pageSize, name, modelName, startTime, endTime], + () => + getLogs({ + page, + perPage: pageSize, + token_name: name, + model_name: modelName, + start_timestamp: startTime.getTime().toString(), + end_timestamp: endTime.getTime().toString() + }), + { + onSuccess: (data) => { + if (!data.logs) { + setLogData([]) + setTotal(0) + return + } + setLogData(data.logs) + setTotal(data.total) + } + } + ) + + const columns = useMemo[]>(() => { + return [ + { + header: t('logs.name'), + accessorKey: 'token_name' + }, + { + header: t('logs.model'), + accessorKey: 'model' + }, + { + header: t('logs.prompt_tokens'), + accessorKey: 'prompt_tokens' + }, + { + header: t('logs.completion_tokens'), + accessorKey: 'completion_tokens' + }, + + { + header: t('logs.status'), + accessorFn: (row) => (row.code === 200 ? t('logs.success') : t('logs.failed')), + cell: ({ getValue }) => { + const value = getValue() as string + return ( + + {value} + + ) + }, + id: 'status' + }, + { + header: t('logs.time'), + accessorFn: (row) => new Date(row.created_at).toLocaleString(), + id: 'created_at' + }, + { + accessorKey: 'used_amount', + id: 'used_amount', + header: () => { + return ( + + + + {t('logs.total_price')} + + + + + ) + } + } + ] + }, []) + + const table = useReactTable({ + data: logData, + columns, + getCoreRowModel: getCoreRowModel() + }) + return ( - <> - - Ns Manager + + + {/* -- header */} + + + + {t('logs.call_log')} + + + + + {/* -- the first row */} + + + + {t('logs.name')} + + ({ + value: item.name, + label: item.name + })) || []) + ]} + onchange={(val: string) => { + if (val === 'all') { + setName('') + } else { + setName(val) + } + }} + /> + + + + + {t('logs.modal')} + + ({ + value: item, + label: item + })) || [] + } + onchange={(val: string) => { + if (val === 'all') { + setModelName('') + } else { + setModelName(val) + } + }} + /> + + + + {/* -- the first row end */} + + {/* -- the second row */} + + + + {t('logs.time')} + + + + + + {/* -- the second row end */} + + + {/* -- header end */} + + {/* -- table */} + + + setPage(idx)} + /> + + {/* -- table end */} - + ) } diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx index b1f2ed129cb..f9f053cb3d4 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx @@ -133,125 +133,193 @@ export default function Home(): React.JSX.Element { }) return ( - + - - {t('logs.call_log')} - - + pt="24px" + pb="8px" + gap="24px" + borderRadius="12px" + flexDirection="column" + h="full" + w="full" + flex="1"> + {/* -- header */} + + + + {t('logs.call_log')} + + + + + {/* -- the first row */} + + + + {t('logs.name')} + + ({ + value: item.name, + label: item.name + })) || []) + ]} + onchange={(val: string) => { + if (val === 'all') { + setName('') + } else { + setName(val) + } + }} + /> + - - - - - {t('logs.name')} - - ({ - value: item.name, - label: item.name - })) || []) - ]} - onchange={(val: string) => { - if (val === 'all') { - setName('') - } else { - setName(val) + + + {t('logs.modal')} + + ({ + value: item, + label: item + })) || [] } - }} - /> - + onchange={(val: string) => { + if (val === 'all') { + setModelName('') + } else { + setModelName(val) + } + }} + /> + - - - {t('logs.modal')} - - ({ - value: item, - label: item - })) || [] - } - onchange={(val: string) => { - if (val === 'all') { - setModelName('') - } else { - setModelName(val) - } - }} - /> + + + {t('logs.time')} + + + - - - - - {t('logs.time')} - - + {/* -- the first row end */} + {/* -- header end */} - - setPage(idx)} - /> + {/* -- table */} + + + setPage(idx)} + /> + + {/* -- table end */} ) diff --git a/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts b/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts new file mode 100644 index 00000000000..83d1d09ad75 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ChannelInfo } from '@/types/admin/channels/channelInfo' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { CreateChannelRequest } from '@/types/admin/channels/channelInfo.d' + +export const dynamic = 'force-dynamic' + +export type GetChannelsResponse = ApiResp<{ + channels: ChannelInfo[] + total: number +}> + +async function updateChannel(channelData: CreateChannelRequest, id: string): Promise { + try { + const url = new URL( + `/api/channel/${id}`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify(channelData), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to create channel') + } + } catch (error) { + console.error('admin channels api: create channel error:## ', error) + throw error + } +} + +// update channel +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + if (!params.id) { + return NextResponse.json( + { + code: 400, + message: 'Channel id is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + const channelData: CreateChannelRequest = await request.json() + await updateChannel(channelData, params.id) + + return NextResponse.json({ + code: 200, + message: 'Channel created successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin channels api: create channel error:## ', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/channels/route.ts b/frontend/providers/aiproxy/app/api/admin/channels/route.ts index d2ebbde55cf..4cee4ccb630 100644 --- a/frontend/providers/aiproxy/app/api/admin/channels/route.ts +++ b/frontend/providers/aiproxy/app/api/admin/channels/route.ts @@ -7,7 +7,7 @@ import { CreateChannelRequest } from '@/types/admin/channels/channelInfo.d' export const dynamic = 'force-dynamic' -type ChannelsSearchResponse = { +type ApiProxyBackendChannelsSearchResponse = { data: { channels: ChannelInfo[] total: number @@ -26,27 +26,26 @@ export type GetChannelsResponse = ApiResp<{ total: number }> -function validateParams(page: number, perPage: number): string | null { - if (page < 1) { +function validateParams(queryParams: ChannelQueryParams): string | null { + if (queryParams.page < 1) { return 'Page number must be greater than 0' } - if (perPage < 1 || perPage > 100) { + if (queryParams.perPage < 1 || queryParams.perPage > 100) { return 'Per page must be between 1 and 100' } return null } async function fetchChannels( - page: number, - perPage: number + queryParams: ChannelQueryParams ): Promise<{ channels: ChannelInfo[]; total: number }> { try { const url = new URL( `/api/channels/search`, global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy ) - url.searchParams.append('p', page.toString()) - url.searchParams.append('per_page', perPage.toString()) + url.searchParams.append('p', queryParams.page.toString()) + url.searchParams.append('per_page', queryParams.perPage.toString()) const token = global.AppConfig?.auth.aiProxyBackendKey const response = await fetch(url.toString(), { method: 'GET', @@ -59,12 +58,12 @@ async function fetchChannels( if (!response.ok) { throw new Error(`HTTP error, status code: ${response.status}`) } - const result: ChannelsSearchResponse = await response.json() + const result: ApiProxyBackendChannelsSearchResponse = await response.json() if (!result.success) { throw new Error(result.message || 'admin channels api:ai proxy backend error') } return { - channels: result.data.channels.sort((a, b) => a.name.localeCompare(b.name)), + channels: result.data.channels, total: result.data.total } } catch (error) { @@ -104,15 +103,19 @@ async function createChannel(channelData: CreateChannelRequest): Promise { } } -export async function GET(request: NextRequest): Promise { +// get channels +export async function GET(request: NextRequest): Promise> { try { const namespace = await parseJwtToken(request.headers) await isAdmin(namespace) const searchParams = request.nextUrl.searchParams - const page = parseInt(searchParams.get('page') || '1', 10) - const perPage = parseInt(searchParams.get('perPage') || '10', 10) - const validationError = validateParams(page, perPage) + const queryParams: ChannelQueryParams = { + page: parseInt(searchParams.get('page') || '1', 10), + perPage: parseInt(searchParams.get('perPage') || '10', 10) + } + + const validationError = validateParams(queryParams) if (validationError) { return NextResponse.json( { @@ -124,14 +127,14 @@ export async function GET(request: NextRequest): Promise { ) } - const { channels, total } = await fetchChannels(page, perPage) + const { channels, total } = await fetchChannels(queryParams) return NextResponse.json({ code: 200, data: { channels: channels, total: total } - }) + } satisfies GetChannelsResponse) } catch (error) { console.error('admin channels api: get channels error:', error) return NextResponse.json( @@ -139,13 +142,14 @@ export async function GET(request: NextRequest): Promise { code: 500, message: error instanceof Error ? error.message : 'server error', error: error instanceof Error ? error.message : 'server error' - }, + } satisfies GetChannelsResponse, { status: 500 } ) } } -export async function POST(request: NextRequest): Promise { +// create channel +export async function POST(request: NextRequest): Promise> { try { const namespace = await parseJwtToken(request.headers) await isAdmin(namespace) @@ -156,7 +160,7 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ code: 200, message: 'Channel created successfully' - }) + } satisfies ApiResp) } catch (error) { console.error('admin channels api: create channel error:', error) return NextResponse.json( @@ -164,7 +168,7 @@ export async function POST(request: NextRequest): Promise { code: 500, message: error instanceof Error ? error.message : 'server error', error: error instanceof Error ? error.message : 'server error' - }, + } satisfies ApiResp, { status: 500 } ) } diff --git a/frontend/providers/aiproxy/app/api/models/enabled/default/route.ts b/frontend/providers/aiproxy/app/api/models/enabled/default/route.ts index e6fd2bae0b4..48ff3f41f64 100644 --- a/frontend/providers/aiproxy/app/api/models/enabled/default/route.ts +++ b/frontend/providers/aiproxy/app/api/models/enabled/default/route.ts @@ -4,7 +4,9 @@ import { ApiProxyBackendResp, ApiResp } from '@/types/api' import { ModelType } from '@/types/models/model' import { isAdmin } from '@/utils/backend/isAdmin' -type ModelMap = Partial> +export const dynamic = 'force-dynamic' + +type ModelMap = { [K in ModelType]?: string[] } type ApiProxyBackendDefaultEnabledModelsResponse = ApiProxyBackendResp export type GetDefaultEnabledModelsResponse = ApiResp diff --git a/frontend/providers/aiproxy/app/api/models/route.ts b/frontend/providers/aiproxy/app/api/models/route.ts index c4788335c1a..dd425bed26a 100644 --- a/frontend/providers/aiproxy/app/api/models/route.ts +++ b/frontend/providers/aiproxy/app/api/models/route.ts @@ -4,7 +4,9 @@ import { isAdmin } from '@/utils/backend/isAdmin' import { ApiProxyBackendResp, ApiResp } from '@/types/api' import { ModelType } from '@/types/models/model' -type ModelMap = Partial> +export const dynamic = 'force-dynamic' + +type ModelMap = { [K in ModelType]?: string[] } type ModelsResponse = ApiProxyBackendResp diff --git a/frontend/providers/aiproxy/app/i18n/locales/en/common.json b/frontend/providers/aiproxy/app/i18n/locales/en/common.json index 9cd7c97c46d..3b638d5c01d 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/en/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/en/common.json @@ -111,14 +111,36 @@ "export": "Export", "create": "New", "name_required": "The name is illegal", - "key_required": "Key is illegal" + "key_required": "Key is illegal", + "modelDefault": "Channel default model", + "createFailed": "Failed to create channel", + "createSuccess": "Channel created successfully", + "updateSuccess": "Update channel successful", + "updateFailed": "Update channel failed", + "id": "ID", + "requestCount": "Request Times", + "status": "Status" }, "channelsForm": { "name": "Custom name", - "type": "Manufacturer" + "type": "Manufacturer", + "models": "Model", + "model_mapping": "Input Mode & Output Mode", + "add": "Add", + "key": "Key", + "base_url": "Proxy Address" }, "channelsFormPlaceholder": { "name": "Custom name", - "type": "Open AI" + "type": "Open AI", + "model": "Enter selection", + "modelInput": "Enter custom model name", + "modelMappingInput": "Input model", + "modelMappingOutput": "Output model", + "key": "Please enter the channel key", + "base_url": "Please enter agent" + }, + "common": { + "add": "Add" } } diff --git a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json index 7fa4672a709..1f6faf74af6 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json @@ -111,14 +111,36 @@ "export": "导出", "create": "新建", "name_required": "名称不合法", - "key_required": "Key 不合法" + "key_required": "Key 不合法", + "modelDefault": "渠道默认模型", + "createFailed": "创建渠道失败", + "createSuccess": "成功创建渠道", + "updateSuccess": "更新渠道成功", + "updateFailed": "更新渠道失败", + "id": "ID", + "requestCount": "调用次数", + "status": "状态" }, "channelsForm": { "name": "自定义名称", - "type": "厂商" + "type": "厂商", + "models": "模型", + "model_mapping": "输入 & 输出模型", + "add": "添加", + "key": "密钥", + "base_url": "代理" }, "channelsFormPlaceholder": { "name": "自定义名称", - "type": "Open AI" + "type": "Open AI", + "model": "输入选择", + "modelInput": "输入自定义模型名称", + "modelMappingInput": "输入模型", + "modelMappingOutput": "输出模型", + "key": "请输入渠道对应的鉴权密钥", + "base_url": "请输入代理" + }, + "common": { + "add": "填入" } } diff --git a/frontend/providers/aiproxy/components/InitializeApp.tsx b/frontend/providers/aiproxy/components/InitializeApp.tsx index ebc0e912bf2..24ec9e99fed 100644 --- a/frontend/providers/aiproxy/components/InitializeApp.tsx +++ b/frontend/providers/aiproxy/components/InitializeApp.tsx @@ -9,6 +9,7 @@ import { useBackendStore } from '@/store/backend' import { useTranslationClientSide } from '@/app/i18n/client' import { usePathname } from 'next/navigation' import { useRouter } from 'next/navigation' +import { useSessionStore } from '@/store/session' export default function InitializeApp() { const router = useRouter() @@ -88,18 +89,20 @@ export default function InitializeApp() { // init session const initSession = async () => { + const { setSession } = useSessionStore.getState() + try { - const newSession = JSON.stringify(await sealosApp.getSession()) - const oldSession = localStorage.getItem('session') - if (newSession && newSession !== oldSession) { - localStorage.setItem('session', newSession) + const newSession = await sealosApp.getSession() + // 只要有新 session 就更新 + if (newSession) { + setSession(newSession) window.location.reload() } console.log('aiproxy: init session success') } catch (err) { console.log('aiproxy: app is not running in desktop') if (!process.env.NEXT_PUBLIC_MOCK_USER) { - localStorage.removeItem('session') + setSession(null) } } } diff --git a/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx new file mode 100644 index 00000000000..55937153ba0 --- /dev/null +++ b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx @@ -0,0 +1,255 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { VStack, Flex, FormLabel, Input, Button, Text } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { CustomSelect } from './Select' +type MapKeyValuePair = { key: string; value: string } + +export const ConstructMappingComponent = function ({ + mapKeys, + mapData, + setMapData +}: { + mapKeys: string[] + mapData: Record + setMapData: (mapping: Record) => void +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const [mapKeyValuePairs, setMapkeyValuePairs] = useState>([ + { key: '', value: '' } + ]) + + const handleDropdownItemDisplay = (dropdownItem: string) => { + return dropdownItem + } + + const handleSeletedItemDisplay = (selectedItem: string) => { + return selectedItem + } + + // Handling removed mappings when map keys change. + useEffect(() => { + // Find the keys that were removed + // The key of mapData must exist in mapKeys + const removedKeys = Object.keys(mapData).filter((key) => !mapKeys.includes(key)) + + if (removedKeys.length > 0) { + // If there are mappings with removed keys, delete them + const newMapData = { ...mapData } + removedKeys.forEach((key) => { + delete newMapData[key] + }) + setMapData(newMapData) + + // If there are rows with removed keys, delete them + const newMapKeyValuePairs = mapKeyValuePairs.filter( + (mapKeyValuePair) => !removedKeys.includes(mapKeyValuePair.key) + ) + if (newMapKeyValuePairs.length === 0) { + // If all rows are deleted, add an empty row + setMapkeyValuePairs([{ key: '', value: '' }]) + } else { + setMapkeyValuePairs(newMapKeyValuePairs) + } + } + }, [mapKeys]) + + // Get the keys that have been selected + const getSelectedMapKeys = (currentIndex: number) => { + const selected = new Set() + mapKeyValuePairs.forEach((mapKeyValuePair, idx) => { + if (idx !== currentIndex && mapKeyValuePair.key) { + selected.add(mapKeyValuePair.key) + } + }) + return selected + } + + // Handling adding a new row + const handleAddNewMapKeyPair = () => { + setMapkeyValuePairs([...mapKeyValuePairs, { key: '', value: '' }]) + } + + // Handling deleting a row + const handleRemoveMapKeyPair = (index: number) => { + const mapKeyValuePair = mapKeyValuePairs[index] + const newMapData = { ...mapData } + if (mapKeyValuePair.key) { + delete newMapData[mapKeyValuePair.key] + } + setMapData(newMapData) + + const newMapKeyValuePairs = mapKeyValuePairs.filter((_, idx) => idx !== index) + if (newMapKeyValuePairs.length === 0) { + setMapkeyValuePairs([{ key: '', value: '' }]) + } else { + setMapkeyValuePairs(newMapKeyValuePairs) + } + } + + // Handling selection/input changes + const handleInputChange = (index: number, field: 'key' | 'value', value: string) => { + const newMapKeyValuePairs = [...mapKeyValuePairs] + const oldValue = newMapKeyValuePairs[index][field] + newMapKeyValuePairs[index][field] = value + + // Update the mapping relationship + const newMapData = { ...mapData } + if (field === 'key') { + if (oldValue) delete newMapData[oldValue] + + if (!value) { + newMapKeyValuePairs[index].value = '' + } + + if (value && newMapKeyValuePairs[index].value) { + newMapData[value] = newMapKeyValuePairs[index].value + } + } else { + if (newMapKeyValuePairs[index].key) { + newMapData[newMapKeyValuePairs[index].key] = value + } + } + + setMapkeyValuePairs(newMapKeyValuePairs) + setMapData(newMapData) + } + + // Check if there are still keys that can be selected + const hasAvailableKeys = useMemo(() => { + const usedKeys = new Set( + mapKeyValuePairs.map((mapKeyValuePair) => mapKeyValuePair.key).filter(Boolean) + ) + return mapKeys.some((mapKey) => !usedKeys.has(mapKey)) + }, [mapKeys, mapKeyValuePairs]) + + return ( + + + {t('channelsForm.model_mapping')} + + + {mapKeyValuePairs.map((row, index) => ( + + + listItems={mapKeys.filter((key) => !getSelectedMapKeys(index).has(key))} + handleSelectedItemChange={(newSelectedItem) => + handleInputChange(index, 'key', newSelectedItem) + } + handleDropdownItemDisplay={handleDropdownItemDisplay} + handleSelectedItemDisplay={handleSeletedItemDisplay} + placeholder={t('channelsFormPlaceholder.modelMappingInput')} + /> + + handleInputChange(index, 'value', e.target.value)} + placeholder={t('channelsFormPlaceholder.modelMappingOutput')} + py="8px" + px="12px" + borderRadius="6px" + border="1px solid var(--Gray-Modern-200, #E8EBF0)" + bgColor="white" + sx={{ + '&::placeholder': { + color: 'grayModern.500', + fontFamily: '"PingFang SC"', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: '16px', + letterSpacing: '0.048px' + } + }} + /> + + {mapKeyValuePairs.length > 1 && ( + + )} + + ))} + + {hasAvailableKeys && ( + + )} + + ) +} +export default ConstructMappingComponent diff --git a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx new file mode 100644 index 00000000000..1aa9b4fdaa3 --- /dev/null +++ b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx @@ -0,0 +1,455 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { Flex, FormLabel, Input, Button, Text, Box, Badge, VStack } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { CustomSelect } from './Select' +type MapKeyValuePair = { key: string; value: string } + +type Model = { + name: string + isDefault: boolean +} + +export const ConstructModeMappingComponent = function ({ + mapKeys, + mapData, + setMapData +}: { + mapKeys: Model[] + mapData: Record + setMapData: (mapping: Record) => void +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const [mapKeyValuePairs, setMapkeyValuePairs] = useState>([ + { key: '', value: '' } + ]) + + const handleDropdownItemDisplay = (dropdownItem: Model | string) => { + if (dropdownItem === t('channelsFormPlaceholder.modelMappingInput')) { + return ( + + {t('channelsFormPlaceholder.modelMappingInput')} + + ) + } + + if ((dropdownItem as Model).isDefault) { + return ( + + + {(dropdownItem as Model).name} + + + + + + + + + + + ) + } + return ( + + {(dropdownItem as Model).name} + + ) + } + + const handleSeletedItemDisplay = (selectedItem: Model | string) => { + if (selectedItem === t('channelsFormPlaceholder.modelMappingInput')) { + return ( + + {t('channelsFormPlaceholder.modelMappingInput')} + + ) + } + + if ((selectedItem as Model).isDefault) { + return ( + + ) + } + return ( + + + {(selectedItem as Model).name} + + + ) + } + + // Handling removed mappings when map keys change. + useEffect(() => { + // Find the keys that were removed + // The key of mapData must exist in mapKeys + const removedKeys = Object.keys(mapData).filter( + (key) => !mapKeys.some((model) => model.name === key) + ) + + if (removedKeys.length > 0) { + // If there are mappings with removed keys, delete them + const newMapData = { ...mapData } + removedKeys.forEach((key) => { + delete newMapData[key] + }) + setMapData(newMapData) + + // If there are rows with removed keys, delete them + const newMapKeyValuePairs = mapKeyValuePairs.filter( + (mapKeyValuePair) => !removedKeys.includes(mapKeyValuePair.key) + ) + if (newMapKeyValuePairs.length === 0) { + // If all rows are deleted, add an empty row + setMapkeyValuePairs([{ key: '', value: '' }]) + } else { + setMapkeyValuePairs(newMapKeyValuePairs) + } + } + }, [mapKeys]) + + // Get the keys that have been selected + const getSelectedMapKeys = (currentIndex: number) => { + const selected = new Set() + mapKeyValuePairs.forEach((mapKeyValuePair, idx) => { + if (idx !== currentIndex && mapKeyValuePair.key) { + selected.add(mapKeyValuePair.key) + } + }) + return selected + } + + // Handling adding a new row + const handleAddNewMapKeyPair = () => { + setMapkeyValuePairs([...mapKeyValuePairs, { key: '', value: '' }]) + } + + // Handling deleting a row + const handleRemoveMapKeyPair = (index: number) => { + const mapKeyValuePair = mapKeyValuePairs[index] + const newMapData = { ...mapData } + if (mapKeyValuePair.key) { + delete newMapData[mapKeyValuePair.key] + } + setMapData(newMapData) + + const newMapKeyValuePairs = mapKeyValuePairs.filter((_, idx) => idx !== index) + if (newMapKeyValuePairs.length === 0) { + setMapkeyValuePairs([{ key: '', value: '' }]) + } else { + setMapkeyValuePairs(newMapKeyValuePairs) + } + } + + // Handling selection/input changes + const handleInputChange = (index: number, field: 'key' | 'value', value: string) => { + const newMapKeyValuePairs = [...mapKeyValuePairs] + const oldValue = newMapKeyValuePairs[index][field] + newMapKeyValuePairs[index][field] = value + + // Update the mapping relationship + const newMapData = { ...mapData } + if (field === 'key') { + if (oldValue) delete newMapData[oldValue] + + if (!value) { + newMapKeyValuePairs[index].value = '' + } + + if (value && newMapKeyValuePairs[index].value) { + newMapData[value] = newMapKeyValuePairs[index].value + } + } else { + if (newMapKeyValuePairs[index].key) { + newMapData[newMapKeyValuePairs[index].key] = value + } + } + + setMapkeyValuePairs(newMapKeyValuePairs) + setMapData(newMapData) + } + + // Check if there are still keys that can be selected + const hasAvailableKeys = useMemo(() => { + const usedKeys = new Set( + mapKeyValuePairs.map((mapKeyValuePair) => mapKeyValuePair.key).filter(Boolean) + ) + return mapKeys.some((mapKey) => !usedKeys.has(mapKey.name)) + }, [mapKeys, mapKeyValuePairs]) + + return ( + + + {t('channelsForm.model_mapping')} + + + {mapKeyValuePairs.map((row, index) => ( + + + + listItems={mapKeys.filter((model) => !getSelectedMapKeys(index).has(model.name))} + handleSelectedItemChange={(newSelectedItem) => { + if (newSelectedItem) { + handleInputChange(index, 'key', newSelectedItem.name) + } else { + handleInputChange(index, 'key', '') + } + }} + handleDropdownItemDisplay={handleDropdownItemDisplay} + handleSelectedItemDisplay={handleSeletedItemDisplay} + placeholder={t('channelsFormPlaceholder.modelMappingInput')} + /> + + + + handleInputChange(index, 'value', e.target.value)} + placeholder={t('channelsFormPlaceholder.modelMappingOutput')} + py="8px" + px="12px" + borderRadius="6px" + border="1px solid var(--Gray-Modern-200, #E8EBF0)" + bgColor="white" + _hover={{ borderColor: 'grayModern.300' }} + _focus={{ borderColor: 'grayModern.300' }} + _focusVisible={{ borderColor: 'grayModern.300' }} + _active={{ borderColor: 'grayModern.300' }} + sx={{ + '&::placeholder': { + color: 'grayModern.500', + fontFamily: '"PingFang SC"', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: '16px', + letterSpacing: '0.048px' + } + }} + /> + + + {mapKeyValuePairs.length > 1 && ( + + )} + + ))} + + {hasAvailableKeys && ( + + )} + + ) +} +export default ConstructModeMappingComponent diff --git a/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx b/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx new file mode 100644 index 00000000000..efb26d3b3f9 --- /dev/null +++ b/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx @@ -0,0 +1,401 @@ +'use client' +import { + Box, + Button, + Flex, + Text, + InputGroup, + Input, + FormLabel, + VStack, + ListItem, + List +} from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useState, useMemo, Dispatch, SetStateAction, ReactNode } from 'react' +import { useCombobox, useMultipleSelection } from 'downshift' + +export const MultiSelectCombobox = function ({ + dropdownItems, + selectedItems, + setSelectedItems, + handleFilteredDropdownItems, + handleDropdownItemDisplay, + handleSelectedItemDisplay, + handleSetCustomSelectedItem +}: { + dropdownItems: T[] + selectedItems: T[] + setSelectedItems: Dispatch> + handleFilteredDropdownItems: (dropdownItems: T[], selectedItems: T[], inputValue: string) => T[] + handleDropdownItemDisplay: (dropdownItem: T) => ReactNode + handleSelectedItemDisplay: (selectedItem: T) => ReactNode + handleSetCustomSelectedItem?: ( + selectedItems: T[], + setSelectedItems: Dispatch>, + customSelectedItemName: string, + setCustomSelectedItemName: Dispatch> + ) => void +}): JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const [inputValue, setInputValue] = useState('') + const [customSelectedItemName, setCustomSelectedItemName] = useState('') + + // Dropdown list excludes already selected options and includes those matching the input. + const items = useMemo( + () => handleFilteredDropdownItems(dropdownItems, selectedItems, inputValue), + [inputValue, selectedItems, dropdownItems, handleFilteredDropdownItems] + ) + + const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({ + selectedItems, + onStateChange({ selectedItems: newSelectedItems, type }) { + switch (type) { + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: + case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: + if (newSelectedItems) { + setSelectedItems(newSelectedItems) + } + break + default: + break + } + } + }) + const { + isOpen, + getToggleButtonProps, + getLabelProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + selectedItem + } = useCombobox({ + items, + defaultHighlightedIndex: 0, // after selection, highlight the first item. + selectedItem: null, + inputValue, + stateReducer(state, actionAndChanges) { + const { changes, type } = actionAndChanges + + switch (type) { + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.ItemClick: + return { + ...changes, + isOpen: true, // keep the menu open after selection. + highlightedIndex: 0 // with the first option highlighted. + } + default: + return changes + } + }, + onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) { + switch (type) { + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.ItemClick: + case useCombobox.stateChangeTypes.InputBlur: + if (newSelectedItem) { + setSelectedItems([...selectedItems, newSelectedItem]) + setInputValue('') + } + break + + case useCombobox.stateChangeTypes.InputChange: + setInputValue(newInputValue ?? '') + + break + default: + break + } + } + }) + + return ( + + + + + + {t('channelsForm.models')} + + + * + + + {handleSetCustomSelectedItem && ( + + setCustomSelectedItemName(e.target.value)} + /> + + + )} + + + + + {selectedItems.map((selectedItemForRender, index) => ( + + + {handleSelectedItemDisplay(selectedItemForRender)} + { + e.stopPropagation() + removeSelectedItem(selectedItemForRender) + }}> + + + + + + + ))} + + + + + + + + + + + + {isOpen && + items.map((item, index) => ( + + {handleDropdownItemDisplay(item)} + + ))} + + + ) +} diff --git a/frontend/providers/aiproxy/components/common/Select.tsx b/frontend/providers/aiproxy/components/common/Select.tsx new file mode 100644 index 00000000000..4d25f60574d --- /dev/null +++ b/frontend/providers/aiproxy/components/common/Select.tsx @@ -0,0 +1,138 @@ +'use client' +import { Box, Text, ListItem, List } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { ReactNode } from 'react' +import { useSelect } from 'downshift' + +export const CustomSelect = function ({ + listItems, + handleSelectedItemChange, + handleDropdownItemDisplay, + handleSelectedItemDisplay, + placeholder +}: { + listItems: T[] + handleSelectedItemChange: (selectedItem: T) => void + handleDropdownItemDisplay: (dropdownItem: T) => ReactNode + handleSelectedItemDisplay: (selectedItem: T) => ReactNode + placeholder?: string +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const items = [placeholder, ...listItems] + + const { + isOpen, + selectedItem, + getToggleButtonProps, + getMenuProps, + getItemProps, + highlightedIndex + } = useSelect({ + items: items, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { + if (newSelectedItem === placeholder) { + handleSelectedItemChange(null as T) + } else { + handleSelectedItemChange(newSelectedItem as T) + } + } + }) + + return ( + + + {selectedItem ? ( + handleSelectedItemDisplay(selectedItem as T) + ) : placeholder ? ( + + {placeholder} + + ) : ( + + Select + + )} + + + + + + + + + {isOpen && + items.map((item, index) => ( + + {handleDropdownItemDisplay(item as T)} + + ))} + + + ) +} diff --git a/frontend/providers/aiproxy/components/common/SelectDateRange.tsx b/frontend/providers/aiproxy/components/common/SelectDateRange.tsx index 67862c57cf2..05d6e862941 100644 --- a/frontend/providers/aiproxy/components/common/SelectDateRange.tsx +++ b/frontend/providers/aiproxy/components/common/SelectDateRange.tsx @@ -4,6 +4,7 @@ import { Box, Button, Flex, + FlexProps, Icon, Input, Popover, @@ -25,8 +26,9 @@ export default function SelectDateRange({ startTime, setStartTime, endTime, - setEndTime -}: SelectDateRangeProps): JSX.Element { + setEndTime, + ...props +}: SelectDateRangeProps & FlexProps): JSX.Element { const initState = { from: startTime, to: endTime } const [selectedRange, setSelectedRange] = useState(initState) @@ -180,7 +182,8 @@ export default function SelectDateRange({ boxSizing={'border-box'} justify={'space-between'} border={'1px solid #DEE0E2'} - borderRadius="6px"> + borderRadius="6px" + {...props}> + + + + {isComboboxOpen && + getFilteredDropdownItems.map((item, index) => ( + + {handleDropdownItemDisplay(item)} + + ))} + + + ) +} diff --git a/frontend/providers/aiproxy/components/user/KeyList.tsx b/frontend/providers/aiproxy/components/user/KeyList.tsx index 15b6039cebe..bc50e2ff9b6 100644 --- a/frontend/providers/aiproxy/components/user/KeyList.tsx +++ b/frontend/providers/aiproxy/components/user/KeyList.tsx @@ -356,11 +356,13 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { setOpenPopoverId(info.row.original.id)}> void }) => { display="flex" padding="6px 4px" alignItems="center" - gap="2px" + gap="8px" alignSelf="stretch" borderRadius="4px" background="transparent" @@ -408,26 +410,32 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { background: 'rgba(17, 24, 36, 0.05)', color: 'brightBlue.600' }} - leftIcon={ - - - - } onClick={() => { handleStatusUpdate(info.row.original.id, info.row.original.status) setOpenPopoverId(null) }}> - {t('enable')} + + + + + {t('enable')} + ) : ( )} diff --git a/frontend/providers/aiproxy/package.json b/frontend/providers/aiproxy/package.json index 118d09ba5fc..defc40aa79a 100644 --- a/frontend/providers/aiproxy/package.json +++ b/frontend/providers/aiproxy/package.json @@ -24,6 +24,7 @@ "i18next-resources-to-backend": "^1.2.1", "immer": "^10.1.1", "jsonwebtoken": "^9.0.2", + "lucide-react": "^0.461.0", "next": "14.2.5", "react": "^18", "react-day-picker": "^8.8.2", diff --git a/frontend/providers/aiproxy/store/session.ts b/frontend/providers/aiproxy/store/session.ts new file mode 100644 index 00000000000..434b829b06f --- /dev/null +++ b/frontend/providers/aiproxy/store/session.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { immer } from 'zustand/middleware/immer' +import { SessionV1 } from 'sealos-desktop-sdk' + +interface SessionState { + session: SessionV1 | null + setSession: (session: SessionV1 | null) => void +} + +export const useSessionStore = create()( + persist( + immer((set) => ({ + session: null, + setSession: (session) => + set((state) => { + state.session = session + }) + })), + { + name: 'session' + } + ) +) diff --git a/frontend/providers/aiproxy/utils/backend/auth.ts b/frontend/providers/aiproxy/utils/backend/auth.ts index 1984d2eb8b0..e07eab2ff06 100644 --- a/frontend/providers/aiproxy/utils/backend/auth.ts +++ b/frontend/providers/aiproxy/utils/backend/auth.ts @@ -1,6 +1,6 @@ import jwt from 'jsonwebtoken' -// Token payload 类型定义 +// Token payload interface AppTokenPayload { workspaceUid: string workspaceId: string diff --git a/frontend/providers/aiproxy/utils/common.ts b/frontend/providers/aiproxy/utils/common.ts new file mode 100644 index 00000000000..22ed116d904 --- /dev/null +++ b/frontend/providers/aiproxy/utils/common.ts @@ -0,0 +1,8 @@ +// 根据枚举值获取枚举键 +export const getEnumKeyByValue = ( + enumObj: T, + value: string +): keyof T | undefined => { + const keys = Object.keys(enumObj) as Array + return keys.find((key) => enumObj[key] === value) +} diff --git a/frontend/providers/aiproxy/utils/frontend/request.ts b/frontend/providers/aiproxy/utils/frontend/request.ts index 75913f7916a..6ccab93abef 100644 --- a/frontend/providers/aiproxy/utils/frontend/request.ts +++ b/frontend/providers/aiproxy/utils/frontend/request.ts @@ -1,6 +1,6 @@ import { ApiResp } from '@/types/api' import axios, { InternalAxiosRequestConfig, AxiosResponse, AxiosRequestConfig } from 'axios' -import { getUserSession } from './user' +import { getAppToken } from './user' const request = axios.create({ baseURL: '/', @@ -20,9 +20,9 @@ request.interceptors.request.use( config.headers = config.headers || {} // append user session to Authorization header - const userSession = getUserSession() - if (userSession) { - config.headers['Authorization'] = userSession + const appToken = getAppToken() + if (appToken) { + config.headers['Authorization'] = appToken } // set default Content-Type diff --git a/frontend/providers/aiproxy/utils/frontend/user.ts b/frontend/providers/aiproxy/utils/frontend/user.ts index c60c02883dc..d95f55d156e 100644 --- a/frontend/providers/aiproxy/utils/frontend/user.ts +++ b/frontend/providers/aiproxy/utils/frontend/user.ts @@ -1,14 +1,15 @@ -export const getUserSession = () => { - let token: string = - process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_MOCK_USER || '' : '' +import { useSessionStore } from '@/store/session' - try { - const store = localStorage.getItem('session') - if (!token && store) { - token = JSON.parse(store)?.token +export const getAppToken = () => { + let token = process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_MOCK_USER || '' : '' + + if (!token) { + // 从 store 获取 token + const { session } = useSessionStore.getState() + if (session?.token) { + token = session.token } - } catch (err) { - err } + return token } From 7216a5121f24f6290cface1b1abad6e8814ded35 Mon Sep 17 00:00:00 2001 From: lim Date: Sun, 1 Dec 2024 04:49:22 +0000 Subject: [PATCH 08/42] add config --- .../dashboard/components/ChannelTable.tsx | 1 + .../components/UpdateChannelModal.tsx | 4 - .../app/[lng]/(admin)/dashboard/page.tsx | 319 ++++++++------- .../components/CommonConfig.tsx | 69 ++++ .../components/EditableText.tsx | 86 ++++ .../global-configs/components/ModelConfig.tsx | 370 ++++++++++++++++++ .../app/[lng]/(admin)/global-configs/page.tsx | 317 +++++++-------- .../aiproxy/app/[lng]/(user)/home/page.tsx | 3 +- .../providers/aiproxy/app/[lng]/globals.css | 4 +- .../app/api/admin/channels/[id]/route.ts | 9 +- .../aiproxy/app/i18n/locales/en/common.json | 14 + .../aiproxy/app/i18n/locales/zh/common.json | 14 + .../aiproxy/components/InitializeApp.tsx | 5 +- .../aiproxy/components/admin/Sidebar.tsx | 2 +- .../common/ConstructModeMappingComponent.tsx | 1 + .../components/common/MultiSelectCombobox.tsx | 3 + .../common/SingleSelectCombobox.tsx | 1 + .../aiproxy/components/user/ModelList.tsx | 16 +- .../aiproxy/components/user/Sidebar.tsx | 2 +- frontend/providers/aiproxy/store/session.ts | 2 +- 20 files changed, 893 insertions(+), 349 deletions(-) create mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx create mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx create mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx index 21362802b79..d82c6363fe7 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx @@ -589,6 +589,7 @@ export default function ChannelTable() { ({ @@ -403,9 +402,6 @@ export const UpdateChannelModal = function ({ } }, [channelInfo]) - console.log('id:', id) - console.log('watch:', watch()) - const createChannelMutation = useMutation({ mutationFn: createChannel, onSuccess: () => { diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx index f819f6ad195..9df4dea5d52 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx @@ -26,168 +26,165 @@ export default function DashboardPage() { w="full" flex="1"> {/* header */} - - - - - {t('dashboard.title')} - - + + + {t('dashboard.title')} + - - - - - + + + + {/* header end */} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx new file mode 100644 index 00000000000..aeaed48db42 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx @@ -0,0 +1,69 @@ +'use client' +import { Button, Flex, Text, useDisclosure } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { EditIcon } from '@chakra-ui/icons' +import { Switch } from '@chakra-ui/react' +import { EditableText } from './EditableText' + +const CommonConfig = () => { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + return ( + + {/* title */} + + + {t('globalonfigs.common_config')} + + + {/* -- title end */} + + {/* config */} + + + {/* QPM Limit */} + {}} + flexProps={{ h: '24px' }} + /> + + {/* Pause Service */} + + {t('global_configs.pause_service')} + console.log(e)} /> + + + {/* Retry Count */} + {}} + flexProps={{ h: '24px' }} + /> + + {/* Max Token */} + {}} + flexProps={{ h: '24px' }} + /> + + + {/* -- config end */} + + ) +} + +export default CommonConfig diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx new file mode 100644 index 00000000000..6447a70b1b9 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx @@ -0,0 +1,86 @@ +'use client' +import React, { useState } from 'react' +import { + Flex, + Text, + Button, + Input, + IconButton, + useDisclosure, + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + HStack, + FlexProps +} from '@chakra-ui/react' +import { EditIcon, CheckIcon, CloseIcon } from '@chakra-ui/icons' + +interface EditableTextProps { + value: string | number + label: string + onSubmit: (value: string) => void + flexProps?: FlexProps +} + +export const EditableText = ({ value, label, onSubmit, flexProps }: EditableTextProps) => { + const [editValue, setEditValue] = useState(value.toString()) + const { isOpen, onOpen, onClose } = useDisclosure() + + const handleSubmit = () => { + onSubmit(editValue) + onClose() + } + + const handleCancel = () => { + setEditValue(value.toString()) + onClose() + } + + return ( + + {label} + + + + {value} + } + variant="ghost" + size="sm" + onClick={onOpen} + /> + + + + + + setEditValue(e.target.value)} + size="sm" + autoFocus + /> + + } + size="sm" + onClick={handleCancel} + /> + } + size="sm" + colorScheme="blue" + onClick={handleSubmit} + /> + + + + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx new file mode 100644 index 00000000000..488fdbf79d0 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx @@ -0,0 +1,370 @@ +'use client' +import { + Checkbox, + Box, + Button, + Flex, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + Menu, + MenuButton, + MenuList, + MenuItem, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + FormControl, + Input, + FormErrorMessage, + ModalFooter, + useDisclosure, + FormLabel, + HStack, + VStack, + Center, + Select, + ListItem, + List, + InputGroup, + Spinner, + Badge, + IconButton +} from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { EditIcon } from '@chakra-ui/icons' +import { Switch } from '@chakra-ui/react' +import { EditableText } from './EditableText' +import { MultiSelectCombobox } from '@/components/common/MultiSelectCombobox' +import { SingleSelectCombobox } from '@/components/common/SingleSelectCombobox' +import ConstructModeMappingComponent from '@/components/common/ConstructModeMappingComponent' +import { FieldError, FieldErrors, useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +const ModelConfig = () => { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + // form schema + const schema = z.object({ + id: z.number().optional(), + type: z.number(), + name: z.string().min(1, { message: t('channels.name_required') }), + key: z.string().min(1, { message: t('channels.key_required') }), + base_url: z.string(), + models: z.array(z.string()).default([]), + model_mapping: z.record(z.string(), z.any()).default({}) + }) + + const { + register, + handleSubmit, + reset, + setValue, + formState: { errors }, + control + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + id: undefined, + type: undefined, + name: '', + key: '', + base_url: '', + models: [], + model_mapping: {} + }, + mode: 'onChange', + reValidateMode: 'onChange' + }) + + return ( + + {/* title */} + + + {t('globalonfigs.model_config')} + + + {/* -- title end */} + + {/* config */} + + {/* add default model */} + + + {t('globalConfigs.defaultModel')} + + + + + + + + {/* default model */} + + + + {/* */} + + ( + + dropdownItems={[]} + setSelectedItem={(type) => { + if (type) { + field.onChange(null) + } + }} + handleDropdownItemFilter={() => {}} + handleDropdownItemDisplay={() => {}} + /> + )} + /> + {/* {errors.type && {errors.type.message}} */} + + + {/* */} + + ( + + dropdownItems={[]} + selectedItems={[]} + setSelectedItems={(models) => { + field.onChange(models) + }} + handleFilteredDropdownItems={() => []} + handleDropdownItemDisplay={() => <>} + handleSelectedItemDisplay={() => <>} + handleSetCustomSelectedItem={() => {}} + /> + )} + /> + {/* {errors.models && {errors.models.message}} */} + + + {/* */} + + ( + { + field.onChange(mapping) + }} + /> + )} + /> + {/* {errors.model_mapping?.message && ( + {errors.model_mapping.message.toString()} + )} */} + + + + + + + {/* */} + + ( + + dropdownItems={[]} + setSelectedItem={(type) => { + if (type) { + field.onChange(null) + } + }} + handleDropdownItemFilter={() => {}} + handleDropdownItemDisplay={() => {}} + /> + )} + /> + {/* {errors.type && {errors.type.message}} */} + + + {/* */} + + ( + + dropdownItems={[]} + selectedItems={[]} + setSelectedItems={(models) => { + field.onChange(models) + }} + handleFilteredDropdownItems={() => []} + handleDropdownItemDisplay={() => <>} + handleSelectedItemDisplay={() => <>} + handleSetCustomSelectedItem={() => {}} + /> + )} + /> + {/* {errors.models && {errors.models.message}} */} + + + {/* */} + + ( + { + field.onChange(mapping) + }} + /> + )} + /> + {/* {errors.model_mapping?.message && ( + {errors.model_mapping.message.toString()} + )} */} + + + + + + {/* -- config end */} + + ) +} + +export default ModelConfig diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx index d5f485057ab..061b5763dc6 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx @@ -1,196 +1,169 @@ 'use client' -import { Button, Flex, Text, useDisclosure } from '@chakra-ui/react' +import { Button, Divider, Flex, Text, useDisclosure } from '@chakra-ui/react' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { useState } from 'react' +import CommonConfig from './components/CommonConfig' +import ModelConfig from './components/ModelConfig' export default function GlobalConfigPage() { - const { isOpen, onOpen, onClose } = useDisclosure() - const [operationType, setOperationType] = useState<'create' | 'update'>('create') const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') return ( - + {/* header */} - - - - - {t('dashboard.title')} - - + + + {t('global_configs.title')} + - - - - - + + + {/* header end */} - {/* table */} - {/* modal */} + {/* config */} + + + + + + {/* -- config end */} ) diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx index 6bf70c4edb9..19c7fd2f1a3 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx @@ -32,8 +32,7 @@ export default function Home(): JSX.Element { gap="22px" minW="260px" w="full" - h="full" - overflow="auto"> + h="full"> diff --git a/frontend/providers/aiproxy/app/[lng]/globals.css b/frontend/providers/aiproxy/app/[lng]/globals.css index 0714afd1dce..c356d55399f 100644 --- a/frontend/providers/aiproxy/app/[lng]/globals.css +++ b/frontend/providers/aiproxy/app/[lng]/globals.css @@ -107,7 +107,7 @@ textarea::placeholder { } ::-webkit-scrollbar-track { - margin: 12px 0; + margin: 24px 0; background: transparent !important; border-radius: 6px; } @@ -127,7 +127,7 @@ div { } &::-webkit-scrollbar-track { - margin: 12px 0; + margin: 24px 0; } &:hover { diff --git a/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts b/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts index 83d1d09ad75..e11ed1ef1d6 100644 --- a/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts +++ b/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts @@ -15,9 +15,14 @@ export type GetChannelsResponse = ApiResp<{ async function updateChannel(channelData: CreateChannelRequest, id: string): Promise { try { const url = new URL( - `/api/channel/${id}`, + `/api/channel`, global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy ) + + const updateChannelData = { + id: id, + ...channelData + } const token = global.AppConfig?.auth.aiProxyBackendKey const response = await fetch(url.toString(), { method: 'PUT', @@ -25,7 +30,7 @@ async function updateChannel(channelData: CreateChannelRequest, id: string): Pro 'Content-Type': 'application/json', Authorization: `${token}` }, - body: JSON.stringify(channelData), + body: JSON.stringify(updateChannelData), cache: 'no-store' }) diff --git a/frontend/providers/aiproxy/app/i18n/locales/en/common.json b/frontend/providers/aiproxy/app/i18n/locales/en/common.json index 3b638d5c01d..2cb78413129 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/en/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/en/common.json @@ -142,5 +142,19 @@ }, "common": { "add": "Add" + }, + "global_configs": { + "title": "Global configuration", + "export": "Export", + "import": "import" + }, + "globalonfigs": { + "common_config": "Common configuration", + "model_config": "Model settings" + }, + "globalConfigs": { + "defaultModel": "Default model", + "addDefaultModel": "New", + "saveDefaultModel": "Save" } } diff --git a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json index 1f6faf74af6..98ef7af6e57 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json @@ -142,5 +142,19 @@ }, "common": { "add": "填入" + }, + "global_configs": { + "title": "全局配置", + "export": "导出", + "import": "导入" + }, + "globalonfigs": { + "common_config": "通用配置", + "model_config": "模型设置" + }, + "globalConfigs": { + "defaultModel": "默认模型", + "addDefaultModel": "新增模型", + "saveDefaultModel": "保存" } } diff --git a/frontend/providers/aiproxy/components/InitializeApp.tsx b/frontend/providers/aiproxy/components/InitializeApp.tsx index 24ec9e99fed..74b2447b8e5 100644 --- a/frontend/providers/aiproxy/components/InitializeApp.tsx +++ b/frontend/providers/aiproxy/components/InitializeApp.tsx @@ -93,8 +93,9 @@ export default function InitializeApp() { try { const newSession = await sealosApp.getSession() - // 只要有新 session 就更新 - if (newSession) { + const currentSession = useSessionStore.getState().session + // Compare token from persisted session with new session token + if (newSession?.token !== currentSession?.token) { setSession(newSession) window.location.reload() } diff --git a/frontend/providers/aiproxy/components/admin/Sidebar.tsx b/frontend/providers/aiproxy/components/admin/Sidebar.tsx index b084fc4b36e..40af5a5d297 100644 --- a/frontend/providers/aiproxy/components/admin/Sidebar.tsx +++ b/frontend/providers/aiproxy/components/admin/Sidebar.tsx @@ -71,7 +71,7 @@ const SideBar = (): JSX.Element => { px="12px" gap="var(--md, 8px)" alignContent="center" - flexShrink={0}> + flex="1"> {menus .filter((menu) => menu.display) .map((menu) => { diff --git a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx index 1aa9b4fdaa3..62cba78b036 100644 --- a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx +++ b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx @@ -316,6 +316,7 @@ export const ConstructModeMappingComponent = function ({ return ( ({ {...getLabelProps()}> ({ {handleSetCustomSelectedItem && ( ({ } }}> (props: { alignItems="center" h="20px" justifyContent="flex-start" + whiteSpace="nowrap" m={0} {...getLabelProps()}> {t('channelsForm.type')} diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx index 0fd81dc2e7d..4888bc6015a 100644 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ b/frontend/providers/aiproxy/components/user/ModelList.tsx @@ -191,7 +191,21 @@ const ModelList: React.FC = () => { - + {isLoading ? (
diff --git a/frontend/providers/aiproxy/components/user/Sidebar.tsx b/frontend/providers/aiproxy/components/user/Sidebar.tsx index 8b2b211db30..51e36bccb14 100644 --- a/frontend/providers/aiproxy/components/user/Sidebar.tsx +++ b/frontend/providers/aiproxy/components/user/Sidebar.tsx @@ -61,7 +61,7 @@ const SideBar = (): JSX.Element => { px="12px" gap="var(--md, 8px)" alignContent="center" - flexShrink={0}> + flex="1"> {menus .filter((menu) => menu.display) .map((menu) => { diff --git a/frontend/providers/aiproxy/store/session.ts b/frontend/providers/aiproxy/store/session.ts index 434b829b06f..90f7d521461 100644 --- a/frontend/providers/aiproxy/store/session.ts +++ b/frontend/providers/aiproxy/store/session.ts @@ -11,7 +11,7 @@ interface SessionState { export const useSessionStore = create()( persist( immer((set) => ({ - session: null, + session: null as SessionV1 | null, setSession: (session) => set((state) => { state.session = session From 4372302d59f42170ca90e47cde55553ca630c1da Mon Sep 17 00:00:00 2001 From: lim Date: Mon, 2 Dec 2024 08:18:05 +0000 Subject: [PATCH 09/42] v1 --- frontend/providers/aiproxy/api/platform.ts | 11 + .../components/UpdateChannelModal.tsx | 26 +- .../components/CommonConfig.tsx | 6 + .../global-configs/components/ModelConfig.tsx | 446 +++++++++++------- .../app/[lng]/(admin)/global-configs/page.tsx | 23 +- .../app/api/admin/channels/[id]/route.ts | 2 +- .../app/api/admin/option/batch/route.ts | 87 ++++ .../aiproxy/app/api/admin/option/route.ts | 138 ++++++ .../app/api/models/enabled/default/route.ts | 4 +- .../providers/aiproxy/app/api/models/route.ts | 4 +- .../aiproxy/app/i18n/locales/en/common.json | 6 +- .../aiproxy/app/i18n/locales/zh/common.json | 6 +- .../common/ConstructMappingComponent.tsx | 46 +- .../common/ConstructModeMappingComponent.tsx | 41 +- .../aiproxy/components/common/Select.tsx | 5 +- .../common/SingleSelectCombobox.tsx | 13 +- .../providers/aiproxy/types/admin/option.ts | 22 + frontend/providers/aiproxy/types/api.d.ts | 2 +- .../providers/aiproxy/types/models/model.ts | 4 + 19 files changed, 656 insertions(+), 236 deletions(-) create mode 100644 frontend/providers/aiproxy/app/api/admin/option/batch/route.ts create mode 100644 frontend/providers/aiproxy/app/api/admin/option/route.ts create mode 100644 frontend/providers/aiproxy/types/admin/option.ts diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index 7136beff22c..301d34cafb8 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -8,6 +8,8 @@ import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' import { ApiResp } from '@/types/api' import { GetModelsResponse } from '@/app/api/models/route' import { GetDefaultEnabledModelsResponse } from '@/app/api/models/enabled/default/route' +import { GetOptionResponse } from '@/app/api/admin/option/route' +import { BatchOptionData } from '@/types/admin/option' // user export const initAppConfig = () => GET<{ aiproxyBackend: string }>('/api/init-app-config') @@ -43,3 +45,12 @@ export const getBuiltInSupportModels = () => GET('/ap export const getDefaultEnabledModels = () => GET('/api/models/enabled/default') + +// option +export const getOption = () => GET('/api/admin/option') + +export const updateOption = (params: { key: string; value: string }) => + PUT(`/api/admin/option/`, params) + +export const batchOption = (params: BatchOptionData) => + PUT(`/api/admin/option/batch`, params) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx index 816a3941ee3..40da24fb382 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx @@ -105,7 +105,7 @@ export const UpdateChannelModal = function ({ successIconFill: 'white' }) - const [modelTypes, setModelTypes] = useState([]) + const [allSupportModelTypes, setAllSupportModelTypes] = useState([]) const [selectedModelType, setSelectedModelType] = useState(null) const [models, setModels] = useState([]) @@ -121,9 +121,10 @@ export const UpdateChannelModal = function ({ const types = Object.keys(data) .map((key) => getEnumKeyByValue(ModelType, key)) + // Remove values that are not in ModelType .filter((key): key is ModelTypeKey => key !== undefined) - setModelTypes(types) + setAllSupportModelTypes(types) } }) @@ -160,6 +161,7 @@ export const UpdateChannelModal = function ({ setModels(convertedModels) setSelectedModels([]) + setModelMapping({}) }, [selectedModelType, builtInSupportModels, defaultEnabledModels]) // model type select combobox @@ -398,10 +400,20 @@ export const UpdateChannelModal = function ({ useEffect(() => { if (channelInfo) { - setValue('id', channelInfo.id) + const { id, type, name, key, base_url, models, model_mapping } = channelInfo + reset({ id, type, name, key, base_url, models, model_mapping }) } }, [channelInfo]) + const resetModalState = () => { + reset() + setAllSupportModelTypes([]) + setSelectedModelType(null) + setModels([]) + setSelectedModels([]) + setModelMapping({}) + } + const createChannelMutation = useMutation({ mutationFn: createChannel, onSuccess: () => { @@ -409,7 +421,6 @@ export const UpdateChannelModal = function ({ title: t('channels.createSuccess'), status: 'success' }) - onClose() } }) @@ -452,7 +463,8 @@ export const UpdateChannelModal = function ({ break } queryClient.invalidateQueries({ queryKey: ['getChannels'] }) - reset() + resetModalState() + onClose() } catch (error) { switch (operationType) { case 'create': @@ -474,7 +486,9 @@ export const UpdateChannelModal = function ({ isClosable: true, description: error instanceof Error ? error.message : t('channels.updateFailed') }) + break } + resetModalState() } } @@ -618,7 +632,7 @@ export const UpdateChannelModal = function ({ control={control} render={({ field }) => ( - dropdownItems={modelTypes} + dropdownItems={allSupportModelTypes} setSelectedItem={(type) => { if (type) { field.onChange(Number(ModelType[type as keyof typeof ModelType])) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx index aeaed48db42..983119f36ab 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx @@ -10,6 +10,12 @@ const CommonConfig = () => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') return ( + /* + h = 72px + 20px + 60px = 152px + EditableText (24px × 3) = 72px + Switch container (20px) = 20px + gap (20px × 3) = 60px + */ {/* title */} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx index 488fdbf79d0..a86bd55325f 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx @@ -49,46 +49,136 @@ import { SingleSelectCombobox } from '@/components/common/SingleSelectCombobox' import ConstructModeMappingComponent from '@/components/common/ConstructModeMappingComponent' import { FieldError, FieldErrors, useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' +import { object, z } from 'zod' +import { getBuiltInSupportModels, getOption } from '@/api/platform' +import { ModelMap, ModelMappingMap, ModelType } from '@/types/models/model' +import { SetStateAction, Dispatch, useEffect, useState } from 'react' +import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' +import { getEnumKeyByValue } from '@/utils/common' +import ConstructMappingComponent from '@/components/common/ConstructMappingComponent' + const ModelConfig = () => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') + type ModelTypeKey = keyof typeof ModelType + + const [allSupportModelTypes, setAllSupportModelTypes] = useState([]) + const [allSupportModel, setAllSupportModel] = useState({}) + + const [defaultModel, setDefaultModel] = useState>>({}) + const [defaultModelMapping, setDefaultModelMapping] = useState>>({}) + + const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ + queryKey: ['models'], + queryFn: () => getBuiltInSupportModels(), + onSuccess: (data) => { + if (!data) return + + const types = Object.keys(data) + .map((key) => getEnumKeyByValue(ModelType, key)) + .filter((key): key is ModelTypeKey => key !== undefined) + + setAllSupportModelTypes(types) + setAllSupportModel(data) + } + }) + + const { isLoading: isOptionLoading, data: optionData } = useQuery({ + queryKey: ['option'], + queryFn: () => getOption(), + onSuccess: (data) => { + if (!data) return + + const defaultModels: ModelMap = JSON.parse(data.DefaultChannelModels) + const defaultModelMappings: ModelMappingMap = JSON.parse(data.DefaultChannelModelMapping) + + setDefaultModel(defaultModels) + setDefaultModelMapping(defaultModelMappings) + } + }) + // form schema - const schema = z.object({ - id: z.number().optional(), + const itemSchema = z.object({ type: z.number(), - name: z.string().min(1, { message: t('channels.name_required') }), - key: z.string().min(1, { message: t('channels.key_required') }), - base_url: z.string(), - models: z.array(z.string()).default([]), - model_mapping: z.record(z.string(), z.any()).default({}) + defaultMode: z.array(z.string()), + defaultModeMapping: z.record(z.string(), z.any()).default({}) }) + const schema = z.array(itemSchema) + + type ConfigItem = z.infer + type FormData = ConfigItem[] + const { register, handleSubmit, reset, setValue, + watch, formState: { errors }, control } = useForm({ resolver: zodResolver(schema), - defaultValues: { - id: undefined, - type: undefined, - name: '', - key: '', - base_url: '', - models: [], - model_mapping: {} - }, + defaultValues: [], mode: 'onChange', reValidateMode: 'onChange' }) + useEffect(() => { + // Only proceed if both defaultModel and defaultModelMapping are available + if (Object.keys(defaultModel).length === 0) return + + // Transform the data into form format + const formData: FormData = Object.entries(defaultModel).map(([typeKey, modes]) => { + const modelType = Number(typeKey) + return { + type: modelType, + defaultMode: modes || [], // Using first mode as default + defaultModeMapping: defaultModelMapping[typeKey as ModelType] || {} + } + }) + + console.log('formData', formData) + + // Reset form with the new values + reset(formData) + }, [defaultModel, defaultModelMapping, reset]) + + const handleAddDefaultModel = () => { + const newItem = { + type: undefined, // Default type value + defaultMode: [], + defaultModeMapping: {} + } + + // Get current form values + const currentValues = watch() + // Create new array with new item at the beginning + const newValues = [newItem, ...Object.values(currentValues)] + console.log('newValues', newValues) + // Reset form with new values + reset(newValues) + } + + // console.log('watch', watch()) + + const formValues = watch() + + const formValuesArray: FormData = Array.isArray(formValues) + ? formValues + : Object.values(formValues) + + console.log('formValuesArray', formValuesArray) + return ( - + /* + 顶级 Flex 容器的高度: calc(100vh - 16px - 24px - 12px - 32px - 36px) + ModelConfig 的高度: calc(100vh - 16px - 24px - 12px - 32px - 36px)- CommonConfig 的高度(152px) -两个 gap 的高度(36px × 2 = 72px) + = calc(100vh - 16px - 24px - 12px - 32px - 36px - 152px - 72px) + = calc(100vh - 344px) + */ + {/* title */} { {/* -- title end */} {/* config */} - + {/* add default model */} { + + + + {isComboboxOpen && + getFilteredDropdownItems.map((item, index) => ( + + {handleDropdownItemDisplay(item)} + + ))} + + + ) +} diff --git a/frontend/providers/aiproxy/components/table/BaseTable.tsx b/frontend/providers/aiproxy/components/table/BaseTable.tsx index 21f569a6d95..70e14059848 100644 --- a/frontend/providers/aiproxy/components/table/BaseTable.tsx +++ b/frontend/providers/aiproxy/components/table/BaseTable.tsx @@ -16,7 +16,7 @@ export function BaseTable({ isLoading }: { table: ReactTable; isLoading: boolean } & TableContainerProps) { return ( - + {table.getHeaderGroups().map((headers) => { diff --git a/frontend/providers/aiproxy/components/user/KeyList.tsx b/frontend/providers/aiproxy/components/user/KeyList.tsx index bc50e2ff9b6..2a56213dc37 100644 --- a/frontend/providers/aiproxy/components/user/KeyList.tsx +++ b/frontend/providers/aiproxy/components/user/KeyList.tsx @@ -26,7 +26,8 @@ import { Input, FormErrorMessage, useDisclosure, - Center + Center, + Spinner } from '@chakra-ui/react' import { Column, @@ -36,7 +37,7 @@ import { useReactTable } from '@tanstack/react-table' import { TFunction } from 'i18next' -import { createKey, deleteKey, getKeys, updateKey } from '@/api/platform' +import { createToken, deleteToken, getTokens, updateToken } from '@/api/platform' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' @@ -47,7 +48,7 @@ import { TokenInfo } from '@/types/getKeys' import SwitchPage from '@/components/common/SwitchPage' import { useBackendStore } from '@/store/backend' import { MyTooltip } from '@/components/common/MyTooltip' -import { ApiResp } from '@/types/api' +import { QueryKey } from '@/types/queryKey' export function KeyList(): JSX.Element { const { lng } = useI18n() @@ -56,6 +57,7 @@ export function KeyList(): JSX.Element { return ( <> + {/* gap is 13px */} - - {/* table */} - - {/* modal */} - - + {/* table */} + + {/* modal */} + ) } @@ -126,9 +126,18 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { }) const queryClient = useQueryClient() - const deleteKeyMutation = useMutation((id: number) => deleteKey(id), { + const { data, isLoading } = useQuery({ + queryKey: [QueryKey.GetTokens, page, pageSize], + queryFn: () => getTokens({ page, perPage: pageSize }), + refetchOnReconnect: true, + onSuccess(data) { + setTotal(data?.total || 0) + } + }) + + const deleteKeyMutation = useMutation((id: number) => deleteToken(id), { onSuccess() { - queryClient.invalidateQueries(['getKeys']) + queryClient.invalidateQueries([QueryKey.GetTokens]) message({ status: 'success', title: t('key.deleteSuccess'), @@ -149,10 +158,10 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { }) const updateKeyMutation = useMutation( - ({ id, status }: { id: number; status: number }) => updateKey(id, status), + ({ id, status }: { id: number; status: number }) => updateToken(id, status), { onSuccess() { - queryClient.invalidateQueries(['getKeys']) + queryClient.invalidateQueries([QueryKey.GetTokens]) message({ status: 'success', title: t('key.updateSuccess'), @@ -183,15 +192,6 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { deleteKeyMutation.mutate(id) } - const { data, isLoading } = useQuery({ - queryKey: ['getKeys', page, pageSize], - queryFn: () => getKeys({ page, perPage: pageSize }), - refetchOnReconnect: true, - onSuccess(data) { - setTotal(data.total) - } - }) - const columnHelper = createColumnHelper() const columns = [ @@ -547,106 +547,109 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { return ( <> - {isLoading || data?.tokens.length === 0 ? ( - - -
- - - - - - - - - - - - - - - - - {t('noData')} - - - - + + + + {t('noData')} + -
-
+ + + +
) : ( - <> + + {/* header */} @@ -704,75 +707,90 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { padding="8px 14px" justifyContent="center" alignItems="center" - gap="1px" + gap="6px" borderRadius="6px" bg="grayModern.900" color="white" boxShadow="0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)" _hover={{ bg: 'grayModern.800' }} - onClick={onOpen} - leftIcon={ - - - - }> + onClick={onOpen}> + + + {t('createKey')} + {/* header end */} - - -
+ {/* table */} + + +
- {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header, i) => ( - - ))} - - ))} + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header, i) => { + return ( + + ) + })} + + ) + })} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ) + })} + + ) + })}
- {flexRender(header.column.columnDef.header, header.getContext())} -
+ {flexRender(header.column.columnDef.header, header.getContext())} +
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
void }) => { setCurrentPage={(idx: number) => setPage(idx)} /> - + {/* table end */} +
)} ) @@ -810,11 +829,11 @@ function CreateKeyModal({ successIconFill: 'white' }) - const createKeyMutation = useMutation((name: string) => createKey(name), { + const createKeyMutation = useMutation((name: string) => createToken(name), { onSuccess(data) { createKeyMutation.reset() setName('') - queryClient.invalidateQueries(['getKeys']) + queryClient.invalidateQueries([QueryKey.GetTokens]) message({ status: 'success', title: t('key.createSuccess'), diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx index 4888bc6015a..cfe748ecc13 100644 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ b/frontend/providers/aiproxy/components/user/ModelList.tsx @@ -5,7 +5,7 @@ import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import Image, { StaticImageData } from 'next/image' import { useQuery } from '@tanstack/react-query' -import { getModels } from '@/api/platform' +import { getModelConfig } from '@/api/platform' import { useMessage } from '@sealos/ui' // icons import OpenAIIcon from '@/ui/svg/icons/modelist/openai.svg' @@ -17,41 +17,17 @@ import SparkdeskIcon from '@/ui/svg/icons/modelist/sparkdesk.svg' import AbabIcon from '@/ui/svg/icons/modelist/minimax.svg' import DoubaoIcon from '@/ui/svg/icons/modelist/doubao.svg' import ErnieIcon from '@/ui/svg/icons/modelist/ernie.svg' -import { useMemo } from 'react' +import BaaiIcon from '@/ui/svg/icons/modelist/baai.svg' +import HunyuanIcon from '@/ui/svg/icons/modelist/hunyuan.svg' import { MyTooltip } from '@/components/common/MyTooltip' import { ModelIdentifier } from '@/types/front' - +import { QueryKey } from '@/types/queryKey' const getIdentifier = (modelName: string): ModelIdentifier => { return modelName.toLowerCase().split(/[-._\d]/)[0] as ModelIdentifier } -const sortModels = (models: string[]): string[] => { - // group by identifier - const groupMap = new Map() - - // group by identifier - models.forEach((model) => { - const identifier = getIdentifier(model) - // special handle gpt and o1, group them as 'openai' - const groupKey = identifier === 'gpt' || identifier === 'o' ? 'openai' : identifier - if (!groupMap.has(groupKey)) { - groupMap.set(groupKey, []) - } - groupMap.get(groupKey)?.push(model) - }) - - // sort by identifier and flatten the result - return Array.from(groupMap.entries()) - .sort((a, b) => a[0].localeCompare(b[0])) // sort by identifier - .flatMap(([_, models]) => models.sort()) // flatten and keep the order in each group -} - const ModelComponent = ({ modelName }: { modelName: string }) => { const modelGroups = { - openai: { - icon: OpenAIIcon, - identifiers: ['gpt', 'o1'] - }, ernie: { icon: ErnieIcon, identifiers: ['ernie'] @@ -83,9 +59,20 @@ const ModelComponent = ({ modelName }: { modelName: string }) => { doubao: { icon: DoubaoIcon, identifiers: ['doubao'] + }, + baai: { + icon: BaaiIcon, + identifiers: ['bge'] + }, + hunyuan: { + icon: HunyuanIcon, + identifiers: ['hunyuan'] + }, + openai: { + icon: OpenAIIcon, + identifiers: ['gpt,o1'] } } - // get model icon const getModelIcon = (modelName: string): StaticImageData => { const identifier = getIdentifier(modelName) @@ -152,9 +139,7 @@ const ModelComponent = ({ modelName }: { modelName: string }) => { const ModelList: React.FC = () => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') - const { isLoading, data } = useQuery(['getModels'], () => getModels()) - - const sortedData = useMemo(() => sortModels(data || []), [data]) + const { isLoading, data } = useQuery([QueryKey.GetModelConfig], () => getModelConfig()) return ( <> @@ -211,7 +196,9 @@ const ModelList: React.FC = () => {
) : ( - sortedData.map((model) => ) + data?.map((modelConfig) => ( + + )) )}
diff --git a/frontend/providers/aiproxy/types/models/model.ts b/frontend/providers/aiproxy/types/models/model.ts index 7dcf92859dc..0e8ed3efdd4 100644 --- a/frontend/providers/aiproxy/types/models/model.ts +++ b/frontend/providers/aiproxy/types/models/model.ts @@ -47,3 +47,15 @@ export enum ModelType { export type ModelMap = { [K in ModelType]?: string[] } export type ModelMappingMap = { [K in ModelType]?: {} } + +export interface ModelConfig { + image_prices: null + model: string + owner: string + image_batch_size: number + type: number + input_price: number + output_price: number + created_at: number + updated_at: number +} diff --git a/frontend/providers/aiproxy/types/queryKey.ts b/frontend/providers/aiproxy/types/queryKey.ts new file mode 100644 index 00000000000..35f3f82d42e --- /dev/null +++ b/frontend/providers/aiproxy/types/queryKey.ts @@ -0,0 +1,6 @@ +export enum QueryKey { + GetTokens = 'getTokens', + GetUserLogs = 'getUserLogs', + GetModelConfig = 'getModelConfig', + GetChannels = 'getChannels' +} diff --git a/frontend/providers/aiproxy/types/log.d.ts b/frontend/providers/aiproxy/types/user/logs.ts similarity index 71% rename from frontend/providers/aiproxy/types/log.d.ts rename to frontend/providers/aiproxy/types/user/logs.ts index 35e53134b77..673f1b29ab7 100644 --- a/frontend/providers/aiproxy/types/log.d.ts +++ b/frontend/providers/aiproxy/types/user/logs.ts @@ -14,12 +14,3 @@ export interface LogItem { endpoint: string created_at: number } - -export interface LogResponse { - data: { - logs: LogItem[] - total: number - } - message: string - success: boolean -} diff --git a/frontend/providers/aiproxy/types/user/token.ts b/frontend/providers/aiproxy/types/user/token.ts new file mode 100644 index 00000000000..de84e756d30 --- /dev/null +++ b/frontend/providers/aiproxy/types/user/token.ts @@ -0,0 +1,15 @@ +export type TokenInfo = { + key: string + name: string + group: string + subnet: string + models: string[] | null + status: number + id: number + quota: number + used_amount: number + request_count: number + created_at: number + accessed_at: number + expired_at: number +} diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/baai.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/baai.svg new file mode 100644 index 00000000000..ec85f45d150 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/baai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/hunyuan.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/hunyuan.svg new file mode 100644 index 00000000000..d7e6fc65521 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/hunyuan.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/providers/aiproxy/utils/backend/isAdmin.ts b/frontend/providers/aiproxy/utils/backend/isAdmin.ts index 0b2a2a0d3db..0a3a054fd7f 100644 --- a/frontend/providers/aiproxy/utils/backend/isAdmin.ts +++ b/frontend/providers/aiproxy/utils/backend/isAdmin.ts @@ -1,5 +1,7 @@ export async function isAdmin(namespace: string): Promise { - return namespace + if (!namespace) { + return Promise.reject('Admin: Invalid namespace') + } try { if (global.AppConfig?.adminNameSpace.includes(namespace)) { return namespace From b2ebc5f77fb9b9b51f593c4fda42972065d4c47c Mon Sep 17 00:00:00 2001 From: lim Date: Tue, 3 Dec 2024 07:21:20 +0000 Subject: [PATCH 11/42] ok --- frontend/providers/aiproxy/api/platform.ts | 2 +- .../dashboard/components/ChannelTable.tsx | 17 +- .../components/UpdateChannelModal.tsx | 12 +- .../components/EditableText.tsx | 155 ++++++-- .../[lng]/(admin)/global-configs/page1.tsx | 12 - .../[lng]/(admin)/global-configs/page2.tsx | 348 ------------------ .../aiproxy/app/[lng]/(user)/logs/page.tsx | 2 +- .../aiproxy/app/[lng]/(user)/price/page.tsx | 32 +- .../app/api/admin/channels/[id]/route.ts | 2 +- .../aiproxy/app/api/admin/channels/route.ts | 2 +- .../aiproxy/app/api/init-app-config/route.ts | 2 +- .../app/api/models/builtin/channel/route.ts | 0 .../aiproxy/app/i18n/locales/en/common.json | 16 +- .../aiproxy/app/i18n/locales/zh/common.json | 16 +- .../common/ConstructMappingComponent.tsx | 8 +- .../common/ConstructModeMappingComponent.tsx | 12 +- .../components/common/MultiSelectCombobox.tsx | 12 +- .../common/SingleSelectCombobox.tsx | 8 +- .../common/SingleSelectComboboxUnStyle.tsx | 8 +- .../aiproxy/components/user/KeyList.tsx | 6 +- .../aiproxy/components/user/ModelList.tsx | 4 +- .../{channelInfo.d.ts => channelInfo.ts} | 0 .../types/{appConfig.d.ts => app-config.d.ts} | 0 frontend/providers/aiproxy/types/backend.d.ts | 5 - frontend/providers/aiproxy/types/form.d.ts | 8 - frontend/providers/aiproxy/types/getKeys.d.ts | 15 - .../types/{queryKey.ts => query-key.ts} | 0 .../providers/aiproxy/ui/icons/home/Icons.tsx | 44 --- frontend/providers/aiproxy/ui/icons/index.tsx | 40 ++ .../aiproxy/ui/icons/sidebar/HomeIcon.tsx | 28 -- .../aiproxy/utils/backend/isAdmin.ts | 1 + frontend/providers/aiproxy/utils/common.ts | 15 + 32 files changed, 293 insertions(+), 539 deletions(-) delete mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page1.tsx delete mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page2.tsx create mode 100644 frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts rename frontend/providers/aiproxy/types/admin/channels/{channelInfo.d.ts => channelInfo.ts} (100%) rename frontend/providers/aiproxy/types/{appConfig.d.ts => app-config.d.ts} (100%) delete mode 100644 frontend/providers/aiproxy/types/backend.d.ts delete mode 100644 frontend/providers/aiproxy/types/form.d.ts delete mode 100644 frontend/providers/aiproxy/types/getKeys.d.ts rename frontend/providers/aiproxy/types/{queryKey.ts => query-key.ts} (100%) delete mode 100644 frontend/providers/aiproxy/ui/icons/home/Icons.tsx delete mode 100644 frontend/providers/aiproxy/ui/icons/sidebar/HomeIcon.tsx diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index f461ed10150..a00da229dcf 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -8,7 +8,7 @@ import { GetOptionResponse } from '@/app/api/admin/option/route' import { BatchOptionData } from '@/types/admin/option' import { GetEnabledModelsResponse } from '@/app/api/models/enabled/route' import { GetTokensQueryParams, GetTokensResponse } from '@/app/api/user/token/route' -import { TokenInfo } from '@/types/getKeys' +import { TokenInfo } from '@/types/user/token' import { UserLogSearchResponse } from '@/app/api/user/log/route' import { UserLogQueryParams } from '@/app/api/user/log/route' // user diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx index 2309d0e68a5..616a6efd435 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx @@ -27,7 +27,7 @@ import { import { useMessage } from '@sealos/ui' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { ChannelInfo, ChannelStatus } from '@/types/admin/channels/channelInfo.d' +import { ChannelInfo, ChannelStatus } from '@/types/admin/channels/channelInfo' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' import { useState, useMemo } from 'react' import { getChannels } from '@/api/platform' @@ -35,7 +35,7 @@ import SwitchPage from '@/components/common/SwitchPage' import UpdateChannelModal from './UpdateChannelModal' import { ModelType } from '@/types/models/model' import { getEnumKeyByValue } from '@/utils/common' -import { QueryKey } from '@/types/queryKey' +import { QueryKey } from '@/types/query-key' export default function ChannelTable() { const { isOpen, onOpen, onClose } = useDisclosure() @@ -405,8 +405,8 @@ export default function ChannelTable() { viewBox="0 0 16 16" fill="none"> @@ -430,8 +430,8 @@ export default function ChannelTable() { viewBox="0 0 16 16" fill="none"> @@ -550,9 +550,10 @@ export default function ChannelTable() { h="full" display="flex" flexDirection="column" - gap="8px" + gap="24px" + overflow="hidden" id="channel-table-container"> - + {table.getHeaderGroups().map((headerGroup) => ( diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx index 8197e8847fb..b79ac28ad1c 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx @@ -49,7 +49,7 @@ import { import { useMessage } from '@sealos/ui' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { ChannelInfo, ChannelStatus } from '@/types/admin/channels/channelInfo.d' +import { ChannelInfo, ChannelStatus } from '@/types/admin/channels/channelInfo' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' import { useCallback, @@ -72,7 +72,7 @@ import { MultiSelectCombobox } from '@/components/common/MultiSelectCombobox' import { SingleSelectCombobox } from '@/components/common/SingleSelectCombobox' import ConstructModeMappingComponent from '@/components/common/ConstructModeMappingComponent' import { createChannel, updateChannel } from '@/api/platform' -import { QueryKey } from '@/types/queryKey' +import { QueryKey } from '@/types/query-key' type ModelTypeKey = keyof typeof ModelType @@ -231,8 +231,8 @@ export const UpdateChannelModal = function ({ viewBox="0 0 12 12" fill="none"> @@ -306,8 +306,8 @@ export const UpdateChannelModal = function ({ viewBox="0 0 12 12" fill="none"> diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx index 6447a70b1b9..49ad397d7b6 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx @@ -5,16 +5,16 @@ import { Text, Button, Input, - IconButton, useDisclosure, Popover, PopoverTrigger, PopoverContent, PopoverBody, HStack, - FlexProps + FlexProps, + Box } from '@chakra-ui/react' -import { EditIcon, CheckIcon, CloseIcon } from '@chakra-ui/icons' +import { CheckIcon, CloseIcon } from '@chakra-ui/icons' interface EditableTextProps { value: string | number @@ -39,43 +39,142 @@ export const EditableText = ({ value, label, onSubmit, flexProps }: EditableText return ( - {label} + + {label} + - - {value} - } + + + {value} + + - + - + setEditValue(e.target.value)} - size="sm" + minW="0" + w="full" + h="28px" + borderRadius="6px" + border="1px solid var(--Gray-Modern-200, #E8EBF0)" + bgColor="white" + _hover={{ borderColor: 'grayModern.300' }} + _focus={{ borderColor: 'grayModern.300' }} + _focusVisible={{ borderColor: 'grayModern.300' }} + _active={{ borderColor: 'grayModern.300' }} autoFocus /> - - } - size="sm" - onClick={handleCancel} - /> - } - size="sm" - colorScheme="blue" - onClick={handleSubmit} - /> + + + + diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page1.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page1.tsx deleted file mode 100644 index 2ce3f541607..00000000000 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page1.tsx +++ /dev/null @@ -1,12 +0,0 @@ -'use client' -import { Flex } from '@chakra-ui/react' - -export default function GlobalConfigsPage() { - return ( - <> - - Global Configs - - - ) -} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page2.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page2.tsx deleted file mode 100644 index 2ca8c617d60..00000000000 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page2.tsx +++ /dev/null @@ -1,348 +0,0 @@ -'use client' -import React, { useState, useEffect } from 'react' -import { - Box, - Button, - VStack, - FormControl, - IconButton, - Divider, - Flex, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalFooter, - ModalCloseButton, - Text, - Spinner, - Center, - useDisclosure -} from '@chakra-ui/react' -import { useForm, useFieldArray, Controller } from 'react-hook-form' -import { z } from 'zod' -import { zodResolver } from '@hookform/resolvers/zod' -import { Trash2 } from 'lucide-react' -import { SingleSelectCombobox } from '@/components/common/SingleSelectCombobox' -import { MultiSelectCombobox } from '@/components/common/MultiSelectCombobox' -import { ConstructModeMappingComponent } from '@/components/common/ConstructModeMappingComponent' -import { ModelType } from '@/types/models/model' -import { getEnumKeyByValue } from '@/utils/common' -import { useMessage } from '@sealos/ui' -import { useTranslationClientSide } from '@/app/i18n/client' -import { useI18n } from '@/providers/i18n/i18nContext' -import { useQuery } from '@tanstack/react-query' -import { getBuiltInSupportModels, getDefaultEnabledModels } from '@/api/platform' - -// 类型定义 -type ModelTypeKey = keyof typeof ModelType - -type Model = { - name: string - isDefault: boolean -} - -// 每个配置项的数据结构 -type ConfigItem = { - type: ModelTypeKey | null - selectedModels: Model[] - modelMapping: Record -} - -// 表单验证schema -const schema = z.object({ - configs: z.array( - z.object({ - type: z.number(), - models: z.array(z.string()).default([]), - model_mapping: z.record(z.string(), z.any()).default({}) - }) - ) -}) - -type FormData = z.infer - -function UpdateMultiChannelModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { - const { lng } = useI18n() - const { t } = useTranslationClientSide(lng, 'common') - const { message } = useMessage() - - // 配置列表状态 - const [configs, setConfigs] = useState([ - { - type: null, - selectedModels: [], - modelMapping: {} - } - ]) - - // react-hook-form 配置 - const { - control, - handleSubmit, - setValue, - formState: { errors }, - watch - } = useForm>({ - resolver: zodResolver(schema), - defaultValues: { - configs: [ - { - type: undefined, - models: [], - model_mapping: {} - } - ] - } - }) - - // 使用 useFieldArray 管理多个表单项 - const { fields, append, remove } = useFieldArray({ - control, - name: 'configs' - }) - - // 获取支持的模型数据 - const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ - queryKey: ['models'], - queryFn: () => getBuiltInSupportModels() - }) - - const { isLoading: isDefaultEnabledModelsLoading, data: defaultEnabledModels } = useQuery({ - queryKey: ['defaultEnabledModels'], - queryFn: () => getDefaultEnabledModels() - }) - - // 处理模型类型变更 - const handleModelTypeChange = (index: number, value: ModelTypeKey | null) => { - const newConfigs = [...configs] - newConfigs[index].type = value - newConfigs[index].selectedModels = [] - newConfigs[index].modelMapping = {} - setConfigs(newConfigs) - - if (value) { - setValue(`configs.${index}.type`, Number(ModelType[value])) - } - } - - // 处理选中模型变更 - const handleSelectedModelsChange = (index: number, models: Model[]) => { - const newConfigs = [...configs] - newConfigs[index].selectedModels = models - newConfigs[index].modelMapping = {} - setConfigs(newConfigs) - - setValue( - `configs.${index}.models`, - models.map((m) => m.name) - ) - } - - // 处理模型映射变更 - const handleModelMappingChange = (index: number, mapping: Record) => { - const newConfigs = [...configs] - newConfigs[index].modelMapping = mapping - setConfigs(newConfigs) - - setValue(`configs.${index}.model_mapping`, mapping) - } - - // 添加新配置 - const handleAddConfig = () => { - setConfigs([ - ...configs, - { - type: null, - selectedModels: [], - modelMapping: {} - } - ]) - append({ - type: undefined, - models: [], - model_mapping: {} - }) - } - - // 移除配置 - const handleRemoveConfig = (index: number) => { - const newConfigs = configs.filter((_, i) => i !== index) - setConfigs(newConfigs) - remove(index) - } - - // 表单提交 - const onSubmit = async (data: z.infer) => { - try { - // 处理提交的数据 - console.log('提交的数据:', data.configs) - // ... 其他提交逻辑 - } catch (error) { - // ... 错误处理 - } - } - - return ( - - {isOpen && - (isBuiltInSupportModelsLoading || isDefaultEnabledModelsLoading ? ( -
- -
- ) : ( - <> - - - - {t('channels.create')} - - - - - - {fields.map((field, index) => ( - - } - position="absolute" - right={2} - top={2} - size="sm" - onClick={() => remove(index)} - isDisabled={fields.length === 1} - /> - - - - ( - - dropdownItems={Object.keys(ModelType) as ModelTypeKey[]} - setSelectedItem={(type) => { - if (type) { - field.onChange( - Number(ModelType[type as keyof typeof ModelType]) - ) - // 清空当前配置的模型选择 - setValue(`configs.${index}.models`, []) - setValue(`configs.${index}.model_mapping`, {}) - } - }} - handleDropdownItemFilter={(items, input) => { - return items.filter( - (item) => - !input || item.toLowerCase().includes(input.toLowerCase()) - ) - }} - handleDropdownItemDisplay={(item) => item} - /> - )} - /> - - - - ( - - dropdownItems={[]} // 需要根据选中的类型填充可选模型列表 - selectedItems={field.value.map((name) => ({ - name, - isDefault: false - }))} - setSelectedItems={(models) => { - field.onChange(models.map((m) => m.name)) - // 清空当前配置的映射 - setValue(`configs.${index}.model_mapping`, {}) - }} - handleFilteredDropdownItems={(items, selected, input) => { - return items.filter( - (item) => - !selected.includes(item) && - (!input || - item.name.toLowerCase().includes(input.toLowerCase())) - ) - }} - handleDropdownItemDisplay={(item) => item.name} - handleSelectedItemDisplay={(item) => item.name} - handleSetCustomSelectedItem={(item) => ({ - name: item, - isDefault: false - })} - /> - )} - /> - - - - ( - ({ - name, - isDefault: false - }))} - mapData={field.value} - setMapData={field.onChange} - /> - )} - /> - - - - ))} - - - - - - - - - - - - - - ))} -
- ) -} - -export default function GlobalConfigsPage() { - const { isOpen, onOpen, onClose } = useDisclosure() - return ( - <> - - - onClose()} /> - - - ) -} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx index 77197195d3a..8ea9d221648 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx @@ -13,7 +13,7 @@ import { useI18n } from '@/providers/i18n/i18nContext' import { LogItem } from '@/types/user/logs' import { useQuery } from '@tanstack/react-query' import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { QueryKey } from '@/types/queryKey' +import { QueryKey } from '@/types/query-key' export default function Home(): React.JSX.Element { const { lng } = useI18n() diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx index 6e9d2989922..e2063c9747b 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx @@ -30,7 +30,7 @@ import { MyTooltip } from '@/components/common/MyTooltip' import { useMessage } from '@sealos/ui' import { ModelConfig } from '@/types/models/model' import Image, { StaticImageData } from 'next/image' -import { QueryKey } from '@/types/queryKey' +import { QueryKey } from '@/types/query-key' // icons import OpenAIIcon from '@/ui/svg/icons/modelist/openai.svg' import QwenIcon from '@/ui/svg/icons/modelist/qianwen.svg' @@ -43,6 +43,7 @@ import DoubaoIcon from '@/ui/svg/icons/modelist/doubao.svg' import ErnieIcon from '@/ui/svg/icons/modelist/ernie.svg' import BaaiIcon from '@/ui/svg/icons/modelist/baai.svg' import HunyuanIcon from '@/ui/svg/icons/modelist/hunyuan.svg' +import { getTranslationWithFallback } from '@/utils/common' function Price() { const { lng } = useI18n() @@ -204,6 +205,35 @@ function PriceTable() { ), cell: (info) => }), + columnHelper.accessor((row) => row.type, { + id: 'type', + header: () => ( + + {t('key.modelType')} + + ), + cell: (info) => ( + + {getTranslationWithFallback( + `modeType.${String(info.getValue())}`, + 'modeType.0', + t as any + )} + + ) + }), columnHelper.accessor((row) => row.input_price, { id: 'inputPrice', header: () => { diff --git a/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts b/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts index 9330f1a3708..4cd5cbe7d90 100644 --- a/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts +++ b/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts @@ -3,7 +3,7 @@ import { ChannelInfo } from '@/types/admin/channels/channelInfo' import { parseJwtToken } from '@/utils/backend/auth' import { ApiProxyBackendResp, ApiResp } from '@/types/api' import { isAdmin } from '@/utils/backend/isAdmin' -import { CreateChannelRequest } from '@/types/admin/channels/channelInfo.d' +import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' export const dynamic = 'force-dynamic' diff --git a/frontend/providers/aiproxy/app/api/admin/channels/route.ts b/frontend/providers/aiproxy/app/api/admin/channels/route.ts index 63c12463bb0..58c73c63843 100644 --- a/frontend/providers/aiproxy/app/api/admin/channels/route.ts +++ b/frontend/providers/aiproxy/app/api/admin/channels/route.ts @@ -3,7 +3,7 @@ import { ChannelInfo } from '@/types/admin/channels/channelInfo' import { parseJwtToken } from '@/utils/backend/auth' import { ApiProxyBackendResp, ApiResp } from '@/types/api' import { isAdmin } from '@/utils/backend/isAdmin' -import { CreateChannelRequest } from '@/types/admin/channels/channelInfo.d' +import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' export const dynamic = 'force-dynamic' diff --git a/frontend/providers/aiproxy/app/api/init-app-config/route.ts b/frontend/providers/aiproxy/app/api/init-app-config/route.ts index c87c64bc973..31ee62f2a42 100644 --- a/frontend/providers/aiproxy/app/api/init-app-config/route.ts +++ b/frontend/providers/aiproxy/app/api/init-app-config/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' -import type { AppConfigType } from '@/types/appConfig' +import type { AppConfigType } from '@/types/app-config' export const dynamic = 'force-dynamic' diff --git a/frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts b/frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frontend/providers/aiproxy/app/i18n/locales/en/common.json b/frontend/providers/aiproxy/app/i18n/locales/en/common.json index 3b901164f7b..0183da71a91 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/en/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/en/common.json @@ -33,7 +33,8 @@ "unused": "Not use", "inputPrice": "Input price", "outputPrice": "Output price", - "createName": "Name" + "createName": "Name", + "modelType": "Type" }, "logs": { "call_log": "Logs", @@ -162,5 +163,18 @@ "defaultModel": "Default model", "addDefaultModel": "New", "saveDefaultModel": "Save" + }, + "modeType": { + "0": "Unknown", + "1": "Chat", + "2": "Text", + "3": "Embed", + "4": "Moderate", + "5": "Image", + "6": "Edit", + "7": "TTS", + "8": "STT", + "9": "Audio", + "10": "Rerank" } } diff --git a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json index 00123a0241c..ace6471f09f 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json @@ -33,7 +33,8 @@ "unused": "未使用", "inputPrice": "输入单价", "outputPrice": "输出单价", - "createName": "名称" + "createName": "名称", + "modelType": "模型类型" }, "logs": { "call_log": "调用日志", @@ -162,5 +163,18 @@ "defaultModel": "默认模型", "addDefaultModel": "新增模型", "saveDefaultModel": "保存" + }, + "modeType": { + "0": "未知", + "1": "聊天补全", + "2": "文本补全", + "3": "文本嵌入", + "4": "内容审核", + "5": "图像生成", + "6": "文本编辑", + "7": "语音合成", + "8": "语音转录", + "9": "音频翻译", + "10": "重排序" } } diff --git a/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx index e7ce8da5e3e..3f9267812b8 100644 --- a/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx +++ b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx @@ -201,8 +201,8 @@ export const ConstructMappingComponent = function ({ viewBox="0 0 19 18" fill="none"> @@ -235,8 +235,8 @@ export const ConstructMappingComponent = function ({ viewBox="0 0 17 16" fill="none"> diff --git a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx index 5d75801515d..c914c372da7 100644 --- a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx +++ b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx @@ -75,8 +75,8 @@ export const ConstructModeMappingComponent = function ({ viewBox="0 0 12 12" fill="none"> @@ -405,8 +405,8 @@ export const ConstructModeMappingComponent = function ({ viewBox="0 0 19 18" fill="none"> @@ -439,8 +439,8 @@ export const ConstructModeMappingComponent = function ({ viewBox="0 0 17 16" fill="none"> diff --git a/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx b/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx index 89f28359ab9..17b046a289f 100644 --- a/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx +++ b/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx @@ -282,8 +282,8 @@ export const MultiSelectCombobox = function ({ viewBox="0 0 16 16" fill="none"> @@ -331,8 +331,8 @@ export const MultiSelectCombobox = function ({ viewBox="0 0 17 16" fill="none"> @@ -345,8 +345,8 @@ export const MultiSelectCombobox = function ({ viewBox="0 0 17 16" fill="none"> diff --git a/frontend/providers/aiproxy/components/common/SingleSelectCombobox.tsx b/frontend/providers/aiproxy/components/common/SingleSelectCombobox.tsx index d573ee0c08a..ab13bf7bc9b 100644 --- a/frontend/providers/aiproxy/components/common/SingleSelectCombobox.tsx +++ b/frontend/providers/aiproxy/components/common/SingleSelectCombobox.tsx @@ -124,8 +124,8 @@ export const SingleSelectCombobox: (props: { viewBox="0 0 17 16" fill="none"> @@ -138,8 +138,8 @@ export const SingleSelectCombobox: (props: { viewBox="0 0 17 16" fill="none"> diff --git a/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx b/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx index d573ee0c08a..ab13bf7bc9b 100644 --- a/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx +++ b/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx @@ -124,8 +124,8 @@ export const SingleSelectCombobox: (props: { viewBox="0 0 17 16" fill="none"> @@ -138,8 +138,8 @@ export const SingleSelectCombobox: (props: { viewBox="0 0 17 16" fill="none"> diff --git a/frontend/providers/aiproxy/components/user/KeyList.tsx b/frontend/providers/aiproxy/components/user/KeyList.tsx index 2a56213dc37..156e27f541e 100644 --- a/frontend/providers/aiproxy/components/user/KeyList.tsx +++ b/frontend/providers/aiproxy/components/user/KeyList.tsx @@ -41,14 +41,14 @@ import { createToken, deleteToken, getTokens, updateToken } from '@/api/platform import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { ChainIcon } from '@/ui/icons/home/Icons' +import { ChainIcon } from '@/ui/icons/index' import { useMessage } from '@sealos/ui' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' -import { TokenInfo } from '@/types/getKeys' +import { TokenInfo } from '@/types/user/token' import SwitchPage from '@/components/common/SwitchPage' import { useBackendStore } from '@/store/backend' import { MyTooltip } from '@/components/common/MyTooltip' -import { QueryKey } from '@/types/queryKey' +import { QueryKey } from '@/types/query-key' export function KeyList(): JSX.Element { const { lng } = useI18n() diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx index cfe748ecc13..d069d418b7b 100644 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ b/frontend/providers/aiproxy/components/user/ModelList.tsx @@ -1,6 +1,6 @@ 'use client' import { Badge, Center, Flex, Spinner, Text } from '@chakra-ui/react' -import { ListIcon } from '@/ui/icons/home/Icons' +import { ListIcon } from '@/ui/icons/index' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import Image, { StaticImageData } from 'next/image' @@ -21,7 +21,7 @@ import BaaiIcon from '@/ui/svg/icons/modelist/baai.svg' import HunyuanIcon from '@/ui/svg/icons/modelist/hunyuan.svg' import { MyTooltip } from '@/components/common/MyTooltip' import { ModelIdentifier } from '@/types/front' -import { QueryKey } from '@/types/queryKey' +import { QueryKey } from '@/types/query-key' const getIdentifier = (modelName: string): ModelIdentifier => { return modelName.toLowerCase().split(/[-._\d]/)[0] as ModelIdentifier } diff --git a/frontend/providers/aiproxy/types/admin/channels/channelInfo.d.ts b/frontend/providers/aiproxy/types/admin/channels/channelInfo.ts similarity index 100% rename from frontend/providers/aiproxy/types/admin/channels/channelInfo.d.ts rename to frontend/providers/aiproxy/types/admin/channels/channelInfo.ts diff --git a/frontend/providers/aiproxy/types/appConfig.d.ts b/frontend/providers/aiproxy/types/app-config.d.ts similarity index 100% rename from frontend/providers/aiproxy/types/appConfig.d.ts rename to frontend/providers/aiproxy/types/app-config.d.ts diff --git a/frontend/providers/aiproxy/types/backend.d.ts b/frontend/providers/aiproxy/types/backend.d.ts deleted file mode 100644 index d572c684e7c..00000000000 --- a/frontend/providers/aiproxy/types/backend.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ModelPrice { - name: string - prompt: number - completion: number -} diff --git a/frontend/providers/aiproxy/types/form.d.ts b/frontend/providers/aiproxy/types/form.d.ts deleted file mode 100644 index 3f5d605bbb5..00000000000 --- a/frontend/providers/aiproxy/types/form.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface LogForm { - name: string - modelName: string - createdAt: Date - endedAt: Date - page: number - pageSize: number -} diff --git a/frontend/providers/aiproxy/types/getKeys.d.ts b/frontend/providers/aiproxy/types/getKeys.d.ts deleted file mode 100644 index de84e756d30..00000000000 --- a/frontend/providers/aiproxy/types/getKeys.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type TokenInfo = { - key: string - name: string - group: string - subnet: string - models: string[] | null - status: number - id: number - quota: number - used_amount: number - request_count: number - created_at: number - accessed_at: number - expired_at: number -} diff --git a/frontend/providers/aiproxy/types/queryKey.ts b/frontend/providers/aiproxy/types/query-key.ts similarity index 100% rename from frontend/providers/aiproxy/types/queryKey.ts rename to frontend/providers/aiproxy/types/query-key.ts diff --git a/frontend/providers/aiproxy/ui/icons/home/Icons.tsx b/frontend/providers/aiproxy/ui/icons/home/Icons.tsx deleted file mode 100644 index 54c78739c00..00000000000 --- a/frontend/providers/aiproxy/ui/icons/home/Icons.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Icon, IconProps } from '@chakra-ui/react' - -export const ChainIcon = (props: IconProps) => ( - - - -) - -export const ListIcon = (props: IconProps) => ( - - - - - - - - -) - -// 使用示例: -// diff --git a/frontend/providers/aiproxy/ui/icons/index.tsx b/frontend/providers/aiproxy/ui/icons/index.tsx index 14c182dd619..7c01da583b2 100644 --- a/frontend/providers/aiproxy/ui/icons/index.tsx +++ b/frontend/providers/aiproxy/ui/icons/index.tsx @@ -33,3 +33,43 @@ export function RightFirstIcon(props: IconProps) { ) } + +export const ChainIcon = (props: IconProps) => ( + + + +) + +export const ListIcon = (props: IconProps) => ( + + + + + + + + +) diff --git a/frontend/providers/aiproxy/ui/icons/sidebar/HomeIcon.tsx b/frontend/providers/aiproxy/ui/icons/sidebar/HomeIcon.tsx deleted file mode 100644 index a2ddb485ae1..00000000000 --- a/frontend/providers/aiproxy/ui/icons/sidebar/HomeIcon.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { createIcon } from '@chakra-ui/react'; - -export const ConsoleIcon = createIcon({ - displayName: 'ConsoleIcon', - viewBox: '0 0 24 24', - path: ( - <> - - - - - - ) -}); diff --git a/frontend/providers/aiproxy/utils/backend/isAdmin.ts b/frontend/providers/aiproxy/utils/backend/isAdmin.ts index 0a3a054fd7f..3a372c7144b 100644 --- a/frontend/providers/aiproxy/utils/backend/isAdmin.ts +++ b/frontend/providers/aiproxy/utils/backend/isAdmin.ts @@ -1,4 +1,5 @@ export async function isAdmin(namespace: string): Promise { + return true if (!namespace) { return Promise.reject('Admin: Invalid namespace') } diff --git a/frontend/providers/aiproxy/utils/common.ts b/frontend/providers/aiproxy/utils/common.ts index 22ed116d904..14f254adfab 100644 --- a/frontend/providers/aiproxy/utils/common.ts +++ b/frontend/providers/aiproxy/utils/common.ts @@ -6,3 +6,18 @@ export const getEnumKeyByValue = ( const keys = Object.keys(enumObj) as Array return keys.find((key) => enumObj[key] === value) } + +/** + * 获取翻译,如果翻译不存在则返回指定的默认翻译 + * @param key - 翻译键 + * @param defaultKey - 默认翻译键 + * @param t - i18n 翻译函数 + */ +export const getTranslationWithFallback = ( + key: string, + defaultKey: string, + t: (key: string) => string +): string => { + const translated = t(key) + return translated === key ? t(defaultKey) : translated +} From b4721086d171aba902feaad273cd79d54bb06992 Mon Sep 17 00:00:00 2001 From: lim Date: Tue, 3 Dec 2024 07:27:32 +0000 Subject: [PATCH 12/42] ok --- frontend/providers/aiproxy/package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/providers/aiproxy/package.json b/frontend/providers/aiproxy/package.json index defc40aa79a..8e41cde7514 100644 --- a/frontend/providers/aiproxy/package.json +++ b/frontend/providers/aiproxy/package.json @@ -15,7 +15,6 @@ "@tanstack/react-table": "^8.10.7", "accept-language": "^3.0.20", "axios": "^1.7.7", - "clsx": "^1.2.1", "date-fns": "^2.30.0", "downshift": "^9.0.8", "i18next": "^23.11.5", @@ -24,7 +23,6 @@ "i18next-resources-to-backend": "^1.2.1", "immer": "^10.1.1", "jsonwebtoken": "^9.0.2", - "lucide-react": "^0.461.0", "next": "14.2.5", "react": "^18", "react-day-picker": "^8.8.2", From 5aa9fdc7ec318363f37f56317cd5768b8363b833 Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 4 Dec 2024 08:37:58 +0000 Subject: [PATCH 13/42] add global-config api --- frontend/providers/aiproxy/api/platform.ts | 25 +- .../dashboard/components/ChannelTable.tsx | 16 +- .../components/UpdateChannelModal.tsx | 301 ++++++------- .../components/EditableText.tsx | 9 +- .../global-configs/components/ModelConfig.tsx | 402 ++++++++++++------ .../app/[lng]/(admin)/global-logs/page.tsx | 173 +++++--- .../aiproxy/app/[lng]/(user)/logs/page.tsx | 6 +- .../aiproxy/app/[lng]/(user)/price/page.tsx | 8 +- .../app/api/admin/channels/[id]/route.ts | 8 +- .../channels/type-names}/route.ts | 44 +- .../aiproxy/app/api/admin/log/route.ts | 153 +++++++ .../app/api/models/builtin/channel/route.ts | 64 +++ .../api/models/{enabled => }/default/route.ts | 22 +- .../aiproxy/app/api/models/enabled/route.ts | 1 - .../aiproxy/app/i18n/locales/en/common.json | 12 +- .../aiproxy/app/i18n/locales/zh/common.json | 12 +- .../common/ConstructMappingComponent.tsx | 82 +++- .../common/ConstructModeMappingComponent.tsx | 23 +- .../components/common/MultiSelectCombobox.tsx | 1 + .../aiproxy/components/common/Select.tsx | 2 +- .../common/SingleSelectCombobox.tsx | 6 +- .../common/SingleSelectComboboxUnStyle.tsx | 184 ++++---- .../aiproxy/components/user/ModelList.tsx | 4 +- .../types/admin/channels/channelInfo.ts | 6 + .../providers/aiproxy/types/admin/option.ts | 12 + .../providers/aiproxy/types/models/model.ts | 83 ++-- frontend/providers/aiproxy/types/query-key.ts | 11 +- frontend/providers/aiproxy/types/user/logs.ts | 6 + 28 files changed, 1121 insertions(+), 555 deletions(-) rename frontend/providers/aiproxy/app/api/{models => admin/channels/type-names}/route.ts (50%) create mode 100644 frontend/providers/aiproxy/app/api/admin/log/route.ts rename frontend/providers/aiproxy/app/api/models/{enabled => }/default/route.ts (68%) diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index 3fccfdc95cf..32882d0a9fd 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -2,8 +2,6 @@ import { GET, POST, DELETE, PUT } from '@/utils/frontend/request' import { ChannelQueryParams, GetChannelsResponse } from '@/app/api/admin/channels/route' import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' import { ApiResp } from '@/types/api' -import { GetModelsResponse } from '@/app/api/models/route' -import { GetDefaultEnabledModelsResponse } from '@/app/api/models/enabled/default/route' import { GetOptionResponse } from '@/app/api/admin/option/route' import { BatchOptionData } from '@/types/admin/option' import { GetEnabledModelsResponse } from '@/app/api/models/enabled/route' @@ -11,6 +9,10 @@ import { GetTokensQueryParams, GetTokensResponse } from '@/app/api/user/token/ro import { TokenInfo } from '@/types/user/token' import { UserLogSearchResponse } from '@/app/api/user/log/route' import { UserLogQueryParams } from '@/app/api/user/log/route' +import { GlobalLogQueryParams, GlobalLogSearchResponse } from '@/app/api/admin/log/route' +import { GetAllChannelEnabledModelsResponse } from '@/app/api/models/builtin/channel/route' +import { GetDefaultModelAndModeMappingResponse } from '@/app/api/models/default/route' +import { GetChannelTypeNamesResponse } from '@/app/api/admin/channels/type-names/route' export const initAppConfig = () => GET<{ aiproxyBackend: string; currencySymbol: 'shellCoin' | 'cny' | 'usd' }>( @@ -18,11 +20,13 @@ export const initAppConfig = () => ) // user -export const getModelConfig = () => GET('/api/models/enabled') +export const getEnabledMode = () => GET('/api/models/enabled') +// log export const getUserLogs = (params: UserLogQueryParams) => GET('/api/user/log', params) +// token export const getTokens = (params: GetTokensQueryParams) => GET('/api/user/token', params) @@ -47,10 +51,15 @@ export const createChannel = (params: CreateChannelRequest) => export const updateChannel = (params: CreateChannelRequest, id: string) => PUT(`/api/admin/channels/${id}`, params) -export const getBuiltInSupportModels = () => GET('/api/models') +export const getChannelTypeNames = () => + GET('/api/admin/channels/type-names') -export const getDefaultEnabledModels = () => - GET('/api/models/enabled/default') +// channel built-in support models and default model default mode mapping +export const getChannelBuiltInSupportModels = () => + GET('/api/models/builtin/channel') + +export const getChannelDefaultModelAndDefaultModeMapping = () => + GET('/api/models/default') // option export const getOption = () => GET('/api/admin/option') @@ -60,3 +69,7 @@ export const updateOption = (params: { key: string; value: string }) => export const batchOption = (params: BatchOptionData) => PUT(`/api/admin/option/batch`, params) + +// log +export const getGlobalLogs = (params: GlobalLogQueryParams) => + GET('/api/admin/log', params) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx index 616a6efd435..ed124e079fa 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx @@ -27,13 +27,12 @@ import { import { useMessage } from '@sealos/ui' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { ChannelInfo, ChannelStatus } from '@/types/admin/channels/channelInfo' +import { ChannelInfo, ChannelStatus, ChannelType } from '@/types/admin/channels/channelInfo' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' import { useState, useMemo } from 'react' -import { getChannels } from '@/api/platform' +import { getChannels, getChannelTypeNames } from '@/api/platform' import SwitchPage from '@/components/common/SwitchPage' import UpdateChannelModal from './UpdateChannelModal' -import { ModelType } from '@/types/models/model' import { getEnumKeyByValue } from '@/utils/common' import { QueryKey } from '@/types/query-key' @@ -49,6 +48,11 @@ export default function ChannelTable() { const [pageSize, setPageSize] = useState(10) const [selectedRows, setSelectedRows] = useState>(new Set()) + const { isLoading: isChannelTypeNamesLoading, data: channelTypeNames } = useQuery({ + queryKey: [QueryKey.GetChannelTypeNames], + queryFn: () => getChannelTypeNames() + }) + const { data, isLoading } = useQuery({ queryKey: [QueryKey.GetChannels, page, pageSize], queryFn: () => getChannels({ page, perPage: pageSize }), @@ -205,7 +209,7 @@ export default function ChannelTable() { fontWeight={500} lineHeight="16px" letterSpacing="0.5px"> - {getEnumKeyByValue(ModelType, info.getValue().toString())} + {channelTypeNames?.[String(info.getValue()) as ChannelType]} ) }), @@ -514,8 +518,8 @@ export default function ChannelTable() { viewBox="0 0 16 16" fill="none"> diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx index b79ac28ad1c..2702b233eb4 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx @@ -1,21 +1,8 @@ 'use client' import { - Checkbox, - Box, Button, Flex, - Table, - TableContainer, - Tbody, - Td, Text, - Th, - Thead, - Tr, - Menu, - MenuButton, - MenuList, - MenuItem, Modal, ModalOverlay, ModalContent, @@ -26,56 +13,32 @@ import { Input, FormErrorMessage, ModalFooter, - useDisclosure, FormLabel, - HStack, VStack, Center, - Select, - ListItem, - List, - InputGroup, Spinner, - Badge, - IconButton + Badge } from '@chakra-ui/react' -import { - Column, - createColumnHelper, - flexRender, - getCoreRowModel, - useReactTable -} from '@tanstack/react-table' import { useMessage } from '@sealos/ui' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { ChannelInfo, ChannelStatus } from '@/types/admin/channels/channelInfo' +import { ChannelInfo, ChannelStatus, ChannelType } from '@/types/admin/channels/channelInfo' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' +import { Dispatch, SetStateAction, useEffect } from 'react' import { - useCallback, - memo, - useState, - useRef, - useMemo, - Dispatch, - SetStateAction, - useEffect, - ReactNode -} from 'react' -import { getBuiltInSupportModels, getDefaultEnabledModels } from '@/api/platform' -import { FieldError, FieldErrors, useForm, Controller } from 'react-hook-form' + getChannelBuiltInSupportModels, + getChannelDefaultModelAndDefaultModeMapping, + getChannelTypeNames +} from '@/api/platform' +import { FieldErrors, useForm, Controller } from 'react-hook-form' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' -import { ModelType } from '@/types/models/model' -import { getEnumKeyByValue } from '@/utils/common' import { MultiSelectCombobox } from '@/components/common/MultiSelectCombobox' import { SingleSelectCombobox } from '@/components/common/SingleSelectCombobox' import ConstructModeMappingComponent from '@/components/common/ConstructModeMappingComponent' import { createChannel, updateChannel } from '@/api/platform' import { QueryKey } from '@/types/query-key' -type ModelTypeKey = keyof typeof ModelType - type Model = { name: string isDefault: boolean @@ -106,67 +69,23 @@ export const UpdateChannelModal = function ({ successIconFill: 'white' }) - const [allSupportModelTypes, setAllSupportModelTypes] = useState([]) - const [selectedModelType, setSelectedModelType] = useState(null) - - const [models, setModels] = useState([]) - const [selectedModels, setSelectedModels] = useState([]) - - const [modelMapping, setModelMapping] = useState>({}) + const { isLoading: isChannelTypeNamesLoading, data: channelTypeNames } = useQuery({ + queryKey: [QueryKey.GetChannelTypeNames], + queryFn: () => getChannelTypeNames() + }) const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ - queryKey: ['models'], - queryFn: () => getBuiltInSupportModels(), - onSuccess: (data) => { - if (!data) return - - const types = Object.keys(data) - .map((key) => getEnumKeyByValue(ModelType, key)) - // Remove values that are not in ModelType - .filter((key): key is ModelTypeKey => key !== undefined) - - setAllSupportModelTypes(types) - } + queryKey: [QueryKey.GetAllChannelModes], + queryFn: () => getChannelBuiltInSupportModels() }) const { isLoading: isDefaultEnabledModelsLoading, data: defaultEnabledModels } = useQuery({ - queryKey: ['defaultEnabledModels'], - queryFn: () => getDefaultEnabledModels() + queryKey: [QueryKey.GetDefaultModelAndModeMapping], + queryFn: () => getChannelDefaultModelAndDefaultModeMapping() }) - useEffect(() => { - if (!builtInSupportModels || !defaultEnabledModels || !selectedModelType) return - - // Retrieve the enum value corresponding to the selected type. - const modelTypeValue = ModelType[selectedModelType] - - // Retrieve all supported models. - const supportModels = builtInSupportModels[modelTypeValue] || [] - // Retrieve the default enabled models. - const enabledModels = defaultEnabledModels[modelTypeValue] || [] - - // Convert to Model[] type and sort them. - const convertedModels = supportModels - .map((modelName) => ({ - name: modelName, - isDefault: enabledModels.includes(modelName) - })) - .sort((a, b) => { - // If isDefault is different, put the one with isDefault true in front. - if (a.isDefault !== b.isDefault) { - return a.isDefault ? -1 : 1 - } - // If isDefault is the same, sort them alphabetically. - return a.name.localeCompare(b.name) - }) - - setModels(convertedModels) - setSelectedModels([]) - setModelMapping({}) - }, [selectedModelType, builtInSupportModels, defaultEnabledModels]) - // model type select combobox - const handleModelTypeDropdownItemFilter = (dropdownItems: ModelTypeKey[], inputValue: string) => { + const handleModelTypeDropdownItemFilter = (dropdownItems: string[], inputValue: string) => { const lowerCasedInput = inputValue.toLowerCase() return dropdownItems.filter( (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) @@ -382,6 +301,7 @@ export const UpdateChannelModal = function ({ handleSubmit, reset, setValue, + watch, formState: { errors }, control } = useForm({ @@ -408,11 +328,6 @@ export const UpdateChannelModal = function ({ const resetModalState = () => { reset() - setAllSupportModelTypes([]) - setSelectedModelType(null) - setModels([]) - setSelectedModels([]) - setModelMapping({}) } const createChannelMutation = useMutation({ @@ -464,6 +379,9 @@ export const UpdateChannelModal = function ({ break } queryClient.invalidateQueries({ queryKey: [QueryKey.GetChannels] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetDefaultModelAndModeMapping] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetChannelTypeNames] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetAllChannelModes] }) resetModalState() onClose() } catch (error) { @@ -489,7 +407,6 @@ export const UpdateChannelModal = function ({ }) break } - resetModalState() } } @@ -513,11 +430,55 @@ export const UpdateChannelModal = function ({ {isOpen && (isBuiltInSupportModelsLoading || isDefaultEnabledModelsLoading || + isChannelTypeNamesLoading || !builtInSupportModels || - !defaultEnabledModels ? ( -
- -
+ !defaultEnabledModels || + !channelTypeNames ? ( + <> + + + + + + + {operationType === 'create' ? t('channels.create') : t('channels.edit')} + + + + + + +
+ +
+
+
+ ) : ( <> @@ -631,19 +592,38 @@ export const UpdateChannelModal = function ({ ( - - dropdownItems={allSupportModelTypes} - setSelectedItem={(type) => { - if (type) { - field.onChange(Number(ModelType[type as keyof typeof ModelType])) - setSelectedModelType(type) - } - }} - handleDropdownItemFilter={handleModelTypeDropdownItemFilter} - handleDropdownItemDisplay={handleModelTypeDropdownItemDisplay} - /> - )} + render={({ field }) => { + const availableChannels = Object.entries(channelTypeNames) + .filter(([channel]) => channel in builtInSupportModels) + .map(([_, name]) => name) + + const initSelectedItem = field.value + ? channelTypeNames[String(field.value) as ChannelType] + : undefined + + return ( + + dropdownItems={availableChannels} + initSelectedItem={initSelectedItem} + setSelectedItem={(channelName: string) => { + if (channelName) { + const channelType = Object.entries(channelTypeNames).find( + ([_, name]) => name === channelName + )?.[0] + + if (channelType) { + const numericChannel = Number(channelType) + field.onChange(numericChannel) + setValue('models', []) + setValue('model_mapping', {}) + } + } + }} + handleDropdownItemFilter={handleModelTypeDropdownItemFilter} + handleDropdownItemDisplay={handleModelTypeDropdownItemDisplay} + /> + ) + }} /> {errors.type && {errors.type.message}} @@ -652,20 +632,37 @@ export const UpdateChannelModal = function ({ ( - - dropdownItems={models} - selectedItems={selectedModels} - setSelectedItems={(models) => { - setSelectedModels(models) - field.onChange((models as Model[]).map((m) => m.name)) - }} - handleFilteredDropdownItems={handleModelFilteredDropdownItems} - handleDropdownItemDisplay={handleModelDropdownItemDisplay} - handleSelectedItemDisplay={handleModelSelectedItemDisplay} - handleSetCustomSelectedItem={handleSetCustomModel} - /> - )} + render={({ field }) => { + const channelType = String(watch('type')) as ChannelType + + const builtInModes = + builtInSupportModels[channelType]?.map((mode) => mode.model) || [] + const defaultModes = defaultEnabledModels.models[channelType] || [] + + const allModes: Model[] = builtInModes.map((modeName) => ({ + name: modeName, + isDefault: defaultModes.includes(modeName) + })) + + const selectedModels: Model[] = field.value.map((modeName) => ({ + name: modeName, + isDefault: defaultModes.includes(modeName) + })) + + return ( + + dropdownItems={allModes} + selectedItems={selectedModels} + setSelectedItems={(models) => { + field.onChange((models as Model[]).map((m) => m.name)) + }} + handleFilteredDropdownItems={handleModelFilteredDropdownItems} + handleDropdownItemDisplay={handleModelDropdownItemDisplay} + handleSelectedItemDisplay={handleModelSelectedItemDisplay} + handleSetCustomSelectedItem={handleSetCustomModel} + /> + ) + }} /> {errors.models && {errors.models.message}} @@ -674,16 +671,26 @@ export const UpdateChannelModal = function ({ ( - { - field.onChange(mapping) - setModelMapping(mapping) - }} - /> - )} + render={({ field }) => { + const channelType = String(watch('type')) as ChannelType + + const selectedModels = watch('models') + const defaultModes = defaultEnabledModels.models[channelType] || [] + + const covertedSelectedModels: Model[] = selectedModels.map((modeName) => ({ + name: modeName, + isDefault: defaultModes.includes(modeName) + })) + return ( + { + field.onChange(mapping) + }} + /> + ) + }} /> {errors.model_mapping?.message && ( {errors.model_mapping.message.toString()} @@ -813,8 +820,8 @@ export const UpdateChannelModal = function ({ boxShadow="0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)" _hover={{ background: 'var(--Gray-Modern-800, #1F2937)' }} onClick={onSubmit} - isDisabled={createChannelMutation.isLoading} - isLoading={createChannelMutation.isLoading}> + isDisabled={createChannelMutation.isLoading || updateChannelMutation.isLoading} + isLoading={createChannelMutation.isLoading || updateChannelMutation.isLoading}> {t('confirm')} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx index 49ad397d7b6..3e42cbf4735 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx @@ -143,10 +143,9 @@ export const EditableText = ({ value, label, onSubmit, flexProps }: EditableText '100%': { transform: 'scale(0.92)' } } }} - onClick={handleSubmit}> - + onClick={handleCancel}> + -
diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx index a86bd55325f..d3460dfb6b0 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx @@ -1,108 +1,113 @@ 'use client' -import { - Checkbox, - Box, - Button, - Flex, - Table, - TableContainer, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - Menu, - MenuButton, - MenuList, - MenuItem, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - FormControl, - Input, - FormErrorMessage, - ModalFooter, - useDisclosure, - FormLabel, - HStack, - VStack, - Center, - Select, - ListItem, - List, - InputGroup, - Spinner, - Badge, - IconButton -} from '@chakra-ui/react' +import { Button, Flex, Text, FormControl, VStack, Skeleton } from '@chakra-ui/react' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { EditIcon } from '@chakra-ui/icons' -import { Switch } from '@chakra-ui/react' -import { EditableText } from './EditableText' import { MultiSelectCombobox } from '@/components/common/MultiSelectCombobox' import { SingleSelectCombobox } from '@/components/common/SingleSelectCombobox' -import ConstructModeMappingComponent from '@/components/common/ConstructModeMappingComponent' -import { FieldError, FieldErrors, useForm, Controller } from 'react-hook-form' +import { useForm, Controller, FieldErrors, FieldErrorsImpl, FieldError } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { object, z } from 'zod' -import { getBuiltInSupportModels, getOption } from '@/api/platform' -import { ModelMap, ModelMappingMap, ModelType } from '@/types/models/model' +import { z } from 'zod' +import { + batchOption, + getChannelBuiltInSupportModels, + getChannelTypeNames, + getOption +} from '@/api/platform' import { SetStateAction, Dispatch, useEffect, useState } from 'react' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' -import { getEnumKeyByValue } from '@/utils/common' import ConstructMappingComponent from '@/components/common/ConstructMappingComponent' +import { DefaultChannelModel, DefaultChannelModelMapping } from '@/types/admin/option' +import { ChannelType } from '@/types/admin/channels/channelInfo' +import { QueryKey } from '@/types/query-key' +import { useMessage } from '@sealos/ui' +import { BatchOptionData } from '@/types/admin/option' const ModelConfig = () => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') + const queryClient = useQueryClient() - type ModelTypeKey = keyof typeof ModelType + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', - const [allSupportModelTypes, setAllSupportModelTypes] = useState([]) - const [allSupportModel, setAllSupportModel] = useState({}) + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) - const [defaultModel, setDefaultModel] = useState>>({}) - const [defaultModelMapping, setDefaultModelMapping] = useState>>({}) + const [allSupportChannel, setAllSupportChannel] = useState([]) + const [allSupportChannelWithMode, setAllSupportChannelWithMode] = useState<{ + [key in ChannelType]: string[] + }>({}) - const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ - queryKey: ['models'], - queryFn: () => getBuiltInSupportModels(), - onSuccess: (data) => { - if (!data) return + const [defaultModel, setDefaultModel] = useState({}) + const [defaultModelMapping, setDefaultModelMapping] = useState({}) - const types = Object.keys(data) - .map((key) => getEnumKeyByValue(ModelType, key)) - .filter((key): key is ModelTypeKey => key !== undefined) + const { isLoading: isChannelTypeNamesLoading, data: channelTypeNames } = useQuery({ + queryKey: [QueryKey.GetChannelTypeNames], + queryFn: () => getChannelTypeNames() + }) - setAllSupportModelTypes(types) - setAllSupportModel(data) - } + const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ + queryKey: [QueryKey.GetAllChannelModes], + queryFn: () => getChannelBuiltInSupportModels() }) const { isLoading: isOptionLoading, data: optionData } = useQuery({ - queryKey: ['option'], + queryKey: [QueryKey.GetOption], queryFn: () => getOption(), onSuccess: (data) => { if (!data) return - const defaultModels: ModelMap = JSON.parse(data.DefaultChannelModels) - const defaultModelMappings: ModelMappingMap = JSON.parse(data.DefaultChannelModelMapping) + const defaultModels: DefaultChannelModel = JSON.parse(data.DefaultChannelModels) + const defaultModelMappings: DefaultChannelModelMapping = JSON.parse( + data.DefaultChannelModelMapping + ) setDefaultModel(defaultModels) setDefaultModelMapping(defaultModelMappings) } }) + useEffect(() => { + if (!channelTypeNames || !builtInSupportModels) return + + // 1. 处理 allSupportChannel + const supportedChannels = Object.entries(channelTypeNames) + .filter(([channel]) => channel in builtInSupportModels) + .map(([_, name]) => name) + + setAllSupportChannel(supportedChannels) + + // 2. 处理 allSupportChannelWithMode + const channelWithModes = Object.entries(channelTypeNames) + .filter(([channel]) => channel in builtInSupportModels) + .reduce((acc, [channel, name]) => { + const modelInfos = builtInSupportModels[channel as ChannelType] || [] + const models = [...new Set(modelInfos.map((info) => info.model))] + + return { + ...acc, + [name]: models + } + }, {} as { [key in ChannelType]: string[] }) + + setAllSupportChannelWithMode(channelWithModes) + }, [channelTypeNames, builtInSupportModels]) + // form schema const itemSchema = z.object({ type: z.number(), defaultMode: z.array(z.string()), - defaultModeMapping: z.record(z.string(), z.any()).default({}) + defaultModeMapping: z + .record(z.string(), z.string()) + .refine((mapping) => { + // 检查所有值不能为空字符串 + return Object.values(mapping).every((value) => value.trim() !== '') + }) + .default({}) }) const schema = z.array(itemSchema) @@ -126,21 +131,19 @@ const ModelConfig = () => { }) useEffect(() => { + if (!defaultModel || !defaultModelMapping) return // Only proceed if both defaultModel and defaultModelMapping are available if (Object.keys(defaultModel).length === 0) return // Transform the data into form format - const formData: FormData = Object.entries(defaultModel).map(([typeKey, modes]) => { - const modelType = Number(typeKey) + const formData: FormData = Object.entries(defaultModel).map(([channelType, modes]) => { return { - type: modelType, + type: Number(channelType), defaultMode: modes || [], // Using first mode as default - defaultModeMapping: defaultModelMapping[typeKey as ModelType] || {} + defaultModeMapping: defaultModelMapping[channelType as ChannelType] || {} } }) - console.log('formData', formData) - // Reset form with the new values reset(formData) }, [defaultModel, defaultModelMapping, reset]) @@ -161,8 +164,6 @@ const ModelConfig = () => { reset(newValues) } - // console.log('watch', watch()) - const formValues = watch() const formValuesArray: FormData = Array.isArray(formValues) @@ -171,6 +172,126 @@ const ModelConfig = () => { console.log('formValuesArray', formValuesArray) + const batchOptionMutation = useMutation({ + mutationFn: batchOption, + onSuccess: () => { + message({ + title: t('channels.createSuccess'), + status: 'success' + }) + } + }) + + const transformFormDataToConfig = (formData: FormData): BatchOptionData => { + // 初始化两个对象 + const defaultChannelModelMapping: Record> = {} + const defaultChannelModels: Record = {} + + // 遍历 FormData + formData.forEach((item) => { + const type = item.type.toString() + + // 处理 DefaultChannelModelMapping + if (Object.keys(item.defaultModeMapping).length > 0) { + defaultChannelModelMapping[type] = item.defaultModeMapping + } + + // 处理 DefaultChannelModels + if (item.defaultMode.length > 0) { + defaultChannelModels[type] = item.defaultMode + } + }) + + return { + // 转换为 JSON 字符串 + DefaultChannelModelMapping: JSON.stringify(defaultChannelModelMapping), + DefaultChannelModels: JSON.stringify(defaultChannelModels) + } + } + + const resetForm = () => { + reset() + } + + type FieldErrorType = + | FieldError + | FieldErrorsImpl<{ + type: number + defaultMode: string[] + defaultModeMapping: Record + }> + + const getFirstErrorMessage = (errors: FieldErrors): string => { + // Iterate through top-level errors + for (const index in errors) { + const fieldError = errors[index] as FieldErrorType + if (!fieldError) continue + + // Check if error is an object + if (typeof fieldError === 'object') { + // If it has a direct message property + if ('message' in fieldError && fieldError.message) { + return `Item ${Number(index) + 1}: ${fieldError.message}` + } + + // Iterate through nested field errors + const errorKeys = Object.keys(fieldError) as Array + for (const fieldName of errorKeys) { + const nestedError = fieldError[fieldName] + if (nestedError && typeof nestedError === 'object' && 'message' in nestedError) { + // Map field names to their display labels + const fieldLabel = + { + type: 'Type', + defaultMode: 'Default Mode', + defaultModeMapping: 'Model Mapping' + }[fieldName as string] || fieldName + + return `Item ${Number(index) + 1} ${fieldLabel}: ${nestedError.message}` + } + } + } + } + return 'Form validation failed' + } + + const onValidate = async (data: FormData) => { + try { + const batchOptionData: BatchOptionData = transformFormDataToConfig(data) + await batchOptionMutation.mutateAsync(batchOptionData) + + queryClient.invalidateQueries({ queryKey: [QueryKey.GetOption] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetChannelTypeNames] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetAllChannelModes] }) + resetForm() + } catch (error) { + message({ + title: t('globalConfigs.saveDefaultModelFailed'), + status: 'error', + position: 'top', + duration: 2000, + isClosable: true, + description: error instanceof Error ? error.message : t('channels.createFailed') + }) + console.error(error) + } + } + + const onInvalid = (errors: FieldErrors): void => { + console.log('errors', errors) + + const errorMessage = getFirstErrorMessage(errors) + + message({ + title: errorMessage, + status: 'error', + position: 'top', + duration: 2000, + isClosable: true + }) + } + + const onSubmit = handleSubmit(onValidate, onInvalid) return ( /* 顶级 Flex 容器的高度: calc(100vh - 16px - 24px - 12px - 32px - 36px) @@ -250,6 +371,9 @@ const ModelConfig = () => {
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts b/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts index 4cd5cbe7d90..7d07ad86f62 100644 --- a/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts +++ b/frontend/providers/aiproxy/app/api/admin/channels/[id]/route.ts @@ -15,14 +15,10 @@ export type GetChannelsResponse = ApiResp<{ async function updateChannel(channelData: CreateChannelRequest, id: string): Promise { try { const url = new URL( - `/api/channel`, + `/api/channel/${id}`, global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy ) - const updateChannelData = { - id: Number(id), - ...channelData - } const token = global.AppConfig?.auth.aiProxyBackendKey const response = await fetch(url.toString(), { method: 'PUT', @@ -30,7 +26,7 @@ async function updateChannel(channelData: CreateChannelRequest, id: string): Pro 'Content-Type': 'application/json', Authorization: `${token}` }, - body: JSON.stringify(updateChannelData), + body: JSON.stringify(channelData), cache: 'no-store' }) diff --git a/frontend/providers/aiproxy/app/api/models/route.ts b/frontend/providers/aiproxy/app/api/admin/channels/type-names/route.ts similarity index 50% rename from frontend/providers/aiproxy/app/api/models/route.ts rename to frontend/providers/aiproxy/app/api/admin/channels/type-names/route.ts index a7794c686f0..605ec2fb285 100644 --- a/frontend/providers/aiproxy/app/api/models/route.ts +++ b/frontend/providers/aiproxy/app/api/admin/channels/type-names/route.ts @@ -1,64 +1,68 @@ import { NextRequest, NextResponse } from 'next/server' +import { ChannelTypeMapName } from '@/types/admin/channels/channelInfo' import { parseJwtToken } from '@/utils/backend/auth' -import { isAdmin } from '@/utils/backend/isAdmin' import { ApiProxyBackendResp, ApiResp } from '@/types/api' -import { ModelMap } from '@/types/models/model' +import { isAdmin } from '@/utils/backend/isAdmin' export const dynamic = 'force-dynamic' -type ModelsResponse = ApiProxyBackendResp +type ApiProxyBackendChannelTypeMapNameResponse = ApiProxyBackendResp -export type GetModelsResponse = ApiResp +export type GetChannelTypeNamesResponse = ApiResp -async function fetchModels(): Promise { +async function fetchChannelTypeNames(): Promise { try { const url = new URL( - '/api/models', + `/api/channels/type_names`, global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy ) - + const token = global.AppConfig?.auth.aiProxyBackendKey const response = await fetch(url.toString(), { method: 'GET', headers: { 'Content-Type': 'application/json', - Authorization: `${global.AppConfig?.auth.aiProxyBackendKey}` + Authorization: `${token}` }, cache: 'no-store' }) - if (!response.ok) { throw new Error(`HTTP error, status code: ${response.status}`) } - - const result: ModelsResponse = await response.json() + const result: ApiProxyBackendChannelTypeMapNameResponse = await response.json() if (!result.success) { - throw new Error(result.message || 'models api: ai proxy backend error') + throw new Error(result.message || 'admin channels api:ai proxy backend error') } - - return result.data || {} + return result.data } catch (error) { - console.error('models api: fetch models from ai proxy backend error:', error) + console.error( + 'admin channels api: fetch channel type names from ai proxy backend error:', + error + ) throw error } } -export async function GET(request: NextRequest): Promise> { +export async function GET( + request: NextRequest +): Promise> { try { const namespace = await parseJwtToken(request.headers) await isAdmin(namespace) + const channelTypeNames = await fetchChannelTypeNames() + return NextResponse.json({ code: 200, - data: await fetchModels() - }) + data: channelTypeNames + } satisfies GetChannelTypeNamesResponse) } catch (error) { - console.error('admin models api: get models error:', error) + console.error('admin channels api: get channel type names error:', error) return NextResponse.json( { code: 500, message: error instanceof Error ? error.message : 'server error', error: error instanceof Error ? error.message : 'server error' - }, + } satisfies GetChannelTypeNamesResponse, { status: 500 } ) } diff --git a/frontend/providers/aiproxy/app/api/admin/log/route.ts b/frontend/providers/aiproxy/app/api/admin/log/route.ts new file mode 100644 index 00000000000..da52fe7275f --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/log/route.ts @@ -0,0 +1,153 @@ +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { GlobalLogItem } from '@/types/user/logs' +import { parseJwtToken } from '@/utils/backend/auth' +import { isAdmin } from '@/utils/backend/isAdmin' +import { NextRequest, NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' +export type ApiProxyBackendGlobalLogSearchResponse = ApiProxyBackendResp<{ + logs: GlobalLogItem[] + total: number +}> + +export type GlobalLogSearchResponse = ApiResp<{ + logs: GlobalLogItem[] + total: number +}> + +export interface GlobalLogQueryParams { + token_name?: string + model_name?: string + code?: string + start_timestamp?: string + end_timestamp?: string + group_id?: string + page: number + perPage: number +} + +function validateParams(params: GlobalLogQueryParams): string | null { + if (params.page < 1) { + return 'Page number must be greater than 0' + } + if (params.perPage < 1 || params.perPage > 100) { + return 'Per page must be between 1 and 100' + } + if (params.start_timestamp && params.end_timestamp) { + if (parseInt(params.start_timestamp) > parseInt(params.end_timestamp)) { + return 'Start timestamp cannot be greater than end timestamp' + } + } + return null +} + +async function fetchLogs( + params: GlobalLogQueryParams +): Promise<{ logs: GlobalLogItem[]; total: number }> { + try { + const url = new URL( + `/api/logs/search`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + url.searchParams.append('p', params.page.toString()) + url.searchParams.append('per_page', params.perPage.toString()) + + if (params.token_name) { + url.searchParams.append('token_name', params.token_name) + } + if (params.model_name) { + url.searchParams.append('model_name', params.model_name) + } + if (params.code) { + url.searchParams.append('code', params.code) + } + if (params.group_id) { + url.searchParams.append('group_id', params.group_id) + } + if (params.start_timestamp) { + url.searchParams.append('start_timestamp', params.start_timestamp) + } + if (params.end_timestamp) { + url.searchParams.append('end_timestamp', params.end_timestamp) + } + + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendGlobalLogSearchResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'API request failed') + } + + return { + logs: result.data?.logs || [], + total: result.data?.total || 0 + } + } catch (error) { + console.error('Error fetching logs:', error) + throw error + } +} + +export async function GET(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + const searchParams = request.nextUrl.searchParams + + const queryParams: GlobalLogQueryParams = { + page: parseInt(searchParams.get('page') || '1', 10), + perPage: parseInt(searchParams.get('perPage') || '10', 10), + token_name: searchParams.get('token_name') || undefined, + model_name: searchParams.get('model_name') || undefined, + code: searchParams.get('code') || undefined, + start_timestamp: searchParams.get('start_timestamp') || undefined, + end_timestamp: searchParams.get('end_timestamp') || undefined + } + + const validationError = validateParams(queryParams) + if (validationError) { + return NextResponse.json( + { + code: 400, + message: validationError, + error: validationError + }, + { status: 400 } + ) + } + + const { logs, total } = await fetchLogs(queryParams) + + return NextResponse.json({ + code: 200, + data: { + logs, + total + } + } satisfies GlobalLogSearchResponse) + } catch (error) { + console.error('Logs search error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts b/frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts index e69de29bb2d..b425fe6b76b 100644 --- a/frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts +++ b/frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { ChannelWithMode } from '@/types/models/model' + +export const dynamic = 'force-dynamic' + +type ApiProxyBackendAllChannelEnabledModeResponse = ApiProxyBackendResp +export type GetAllChannelEnabledModelsResponse = ApiResp + +async function fetchAllChannelEnabledModels(): Promise { + try { + const url = new URL( + '/api/models/builtin/channel', + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${global.AppConfig?.auth.aiProxyBackendKey}` + }, + cache: 'no-store' + }) + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + const result: ApiProxyBackendAllChannelEnabledModeResponse = await response.json() + + if (!result.success) { + throw new Error(result.message || 'builtin channel api: ai proxy backend error') + } + + return result.data || {} + } catch (error) { + console.error('builtin channel api: fetch enabled models error:', error) + throw error + } +} + +export async function GET( + request: NextRequest +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + return NextResponse.json({ + code: 200, + data: await fetchAllChannelEnabledModels() + } satisfies GetAllChannelEnabledModelsResponse) + } catch (error) { + console.error('builtin channel api: get enabled models error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/models/enabled/default/route.ts b/frontend/providers/aiproxy/app/api/models/default/route.ts similarity index 68% rename from frontend/providers/aiproxy/app/api/models/enabled/default/route.ts rename to frontend/providers/aiproxy/app/api/models/default/route.ts index a5193580935..02eccc49e7f 100644 --- a/frontend/providers/aiproxy/app/api/models/enabled/default/route.ts +++ b/frontend/providers/aiproxy/app/api/models/default/route.ts @@ -2,17 +2,21 @@ import { NextRequest, NextResponse } from 'next/server' import { parseJwtToken } from '@/utils/backend/auth' import { ApiProxyBackendResp, ApiResp } from '@/types/api' import { isAdmin } from '@/utils/backend/isAdmin' -import { ModelMap } from '@/types/models/model' +import { ChannelWithDefaultModelAndDefaultModeMapping } from '@/types/models/model' export const dynamic = 'force-dynamic' -type ApiProxyBackendDefaultEnabledModelsResponse = ApiProxyBackendResp -export type GetDefaultEnabledModelsResponse = ApiResp +type ApiProxyBackendDefaultModelAndModeMappingResponse = + ApiProxyBackendResp +export type GetDefaultModelAndModeMappingResponse = + ApiResp -async function fetchDefaultEnabledModels(): Promise { +async function fetchDefaultModeAndModeMapping(): Promise< + ChannelWithDefaultModelAndDefaultModeMapping | undefined +> { try { const url = new URL( - '/api/models/enabled/default', + '/api/models/default', global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy ) const response = await fetch(url.toString(), { @@ -26,13 +30,13 @@ async function fetchDefaultEnabledModels(): Promise { if (!response.ok) { throw new Error(`HTTP error, status code: ${response.status}`) } - const result: ApiProxyBackendDefaultEnabledModelsResponse = await response.json() + const result: ApiProxyBackendDefaultModelAndModeMappingResponse = await response.json() if (!result.success) { throw new Error(result.message || 'default enabled models api: ai proxy backend error') } - return result.data || {} + return result?.data } catch (error) { console.error('default enabled models api: fetch enabled models error:', error) throw error @@ -41,14 +45,14 @@ async function fetchDefaultEnabledModels(): Promise { export async function GET( request: NextRequest -): Promise> { +): Promise> { try { const namespace = await parseJwtToken(request.headers) await isAdmin(namespace) return NextResponse.json({ code: 200, - data: await fetchDefaultEnabledModels() + data: await fetchDefaultModeAndModeMapping() }) } catch (error) { console.error('default enabled models api: get enabled models error:', error) diff --git a/frontend/providers/aiproxy/app/api/models/enabled/route.ts b/frontend/providers/aiproxy/app/api/models/enabled/route.ts index c3f8b3c3317..0db3d4a2c36 100644 --- a/frontend/providers/aiproxy/app/api/models/enabled/route.ts +++ b/frontend/providers/aiproxy/app/api/models/enabled/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { parseJwtToken } from '@/utils/backend/auth' import { ApiProxyBackendResp, ApiResp } from '@/types/api' -import { isAdmin } from '@/utils/backend/isAdmin' import { ModelConfig } from '@/types/models/model' type ApiProxyBackendEnabledModelsResponse = ApiProxyBackendResp diff --git a/frontend/providers/aiproxy/app/i18n/locales/en/common.json b/frontend/providers/aiproxy/app/i18n/locales/en/common.json index 0183da71a91..c3af6f44749 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/en/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/en/common.json @@ -162,7 +162,9 @@ "globalConfigs": { "defaultModel": "Default model", "addDefaultModel": "New", - "saveDefaultModel": "Save" + "saveDefaultModel": "Save", + "saveDefaultModelFailed": "Failed To Save", + "saveDefaultModelSuccess": "Saved successfully" }, "modeType": { "0": "Unknown", @@ -176,5 +178,13 @@ "8": "STT", "9": "Audio", "10": "Rerank" + }, + "GlobalLogs": { + "selectModel": "Model Name", + "select_token_name": "Token name", + "selectGroupId": "Workspace", + "groupId": "Workspace", + "tokenName": "Token Name", + "channel": "Channel ID" } } diff --git a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json index ace6471f09f..4f239585eab 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json @@ -162,7 +162,9 @@ "globalConfigs": { "defaultModel": "默认模型", "addDefaultModel": "新增模型", - "saveDefaultModel": "保存" + "saveDefaultModel": "保存", + "saveDefaultModelFailed": "保存失败", + "saveDefaultModelSuccess": "保存成功" }, "modeType": { "0": "未知", @@ -176,5 +178,13 @@ "8": "语音转录", "9": "音频翻译", "10": "重排序" + }, + "GlobalLogs": { + "selectModel": "模型名称", + "select_token_name": "token 名字", + "selectGroupId": "工作空间", + "groupId": "工作空间", + "tokenName": "Token 名称", + "channel": "渠道 ID" } } diff --git a/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx index 3f9267812b8..c4a1e822100 100644 --- a/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx +++ b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect, useMemo } from 'react' -import { VStack, Flex, FormLabel, Input, Button, Text } from '@chakra-ui/react' +import { VStack, Flex, FormLabel, Input, Button, Text, Box } from '@chakra-ui/react' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import { CustomSelect } from './Select' type MapKeyValuePair = { key: string; value: string } +// mapKeys determines the available selection options export const ConstructMappingComponent = function ({ mapKeys, mapData, @@ -17,20 +18,83 @@ export const ConstructMappingComponent = function ({ const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') - const [mapKeyValuePairs, setMapkeyValuePairs] = useState>(() => { + const [mapKeyValuePairs, setMapkeyValuePairs] = useState>([ + { key: '', value: '' } + ]) + + useEffect(() => { const entries = Object.entries(mapData) - if (entries.length === 0) { - return [{ key: '', value: '' }] + if (entries.length > 0) { + setMapkeyValuePairs(entries.map(([key, value]) => ({ key, value }))) } - return entries.map(([key, value]) => ({ key, value })) - }) + }, [mapData]) const handleDropdownItemDisplay = (dropdownItem: string) => { - return dropdownItem + if (dropdownItem === t('channelsFormPlaceholder.modelMappingInput')) { + return ( + + {t('channelsFormPlaceholder.modelMappingInput')} + + ) + } + return ( + + {dropdownItem} + + ) } const handleSeletedItemDisplay = (selectedItem: string) => { - return selectedItem + if (selectedItem === t('channelsFormPlaceholder.modelMappingInput')) { + return ( + + {t('channelsFormPlaceholder.modelMappingInput')} + + ) + } + return ( + + + {selectedItem} + + + ) } // Handling mapData and mapKeyValuePairs cleanup when map keys change. @@ -149,8 +213,8 @@ export const ConstructMappingComponent = function ({ listItems={mapKeys.filter((key) => !getSelectedMapKeys(index).has(key))} - // when select placeholder, the row.key is null initSelectedItem={row.key !== '' && row.key ? row.key : undefined} + // when select placeholder, the newSelectedItem is null handleSelectedItemChange={(newSelectedItem) => handleInputChange(index, 'key', newSelectedItem) } diff --git a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx index c914c372da7..ba734d17e29 100644 --- a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx +++ b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx @@ -10,6 +10,7 @@ type Model = { isDefault: boolean } +// mapKeys determines the available selection options export const ConstructModeMappingComponent = function ({ mapKeys, mapData, @@ -19,16 +20,20 @@ export const ConstructModeMappingComponent = function ({ mapData: Record setMapData: (mapping: Record) => void }) { + console.log(mapData) const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') - const [mapKeyValuePairs, setMapkeyValuePairs] = useState>(() => { + const [mapKeyValuePairs, setMapkeyValuePairs] = useState>([ + { key: '', value: '' } + ]) + + useEffect(() => { const entries = Object.entries(mapData) - if (entries.length === 0) { - return [{ key: '', value: '' }] + if (entries.length > 0) { + setMapkeyValuePairs(entries.map(([key, value]) => ({ key, value }))) } - return entries.map(([key, value]) => ({ key, value })) - }) + }, [mapData]) const handleDropdownItemDisplay = (dropdownItem: Model | string) => { if (dropdownItem === t('channelsFormPlaceholder.modelMappingInput')) { @@ -173,8 +178,8 @@ export const ConstructModeMappingComponent = function ({ viewBox="0 0 12 12" fill="none"> @@ -342,8 +347,8 @@ export const ConstructModeMappingComponent = function ({ listItems={mapKeys.filter((model) => !getSelectedMapKeys(index).has(model.name))} - // when select placeholder, the row.key is null - // initSelectedItem={row.key !== '' && row.key ? row.key : undefined} + initSelectedItem={row.key ? mapKeys.find((item) => item.name === row.key) : undefined} + // when select placeholder, the newSelectedItem is null handleSelectedItemChange={(newSelectedItem) => { if (newSelectedItem) { handleInputChange(index, 'key', newSelectedItem.name) diff --git a/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx b/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx index 17b046a289f..c0b912ae73a 100644 --- a/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx +++ b/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx @@ -50,6 +50,7 @@ export const MultiSelectCombobox = function ({ [inputValue, selectedItems, dropdownItems, handleFilteredDropdownItems] ) + // 对已经选中的项目 添加处理事件 const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({ selectedItems, onStateChange({ selectedItems: newSelectedItems, type }) { diff --git a/frontend/providers/aiproxy/components/common/Select.tsx b/frontend/providers/aiproxy/components/common/Select.tsx index d6859947223..9f17d6d6c5e 100644 --- a/frontend/providers/aiproxy/components/common/Select.tsx +++ b/frontend/providers/aiproxy/components/common/Select.tsx @@ -36,7 +36,7 @@ export const CustomSelect = function ({ initialSelectedItem: initSelectedItem, onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { if (newSelectedItem === placeholder) { - handleSelectedItemChange(null as T) + handleSelectedItemChange(undefined as T) } else { handleSelectedItemChange(newSelectedItem as T) } diff --git a/frontend/providers/aiproxy/components/common/SingleSelectCombobox.tsx b/frontend/providers/aiproxy/components/common/SingleSelectCombobox.tsx index ab13bf7bc9b..1397bacc780 100644 --- a/frontend/providers/aiproxy/components/common/SingleSelectCombobox.tsx +++ b/frontend/providers/aiproxy/components/common/SingleSelectCombobox.tsx @@ -2,12 +2,12 @@ import { Box, Button, InputGroup, Input, FormLabel, VStack, ListItem, List } from '@chakra-ui/react' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { useState, Dispatch, SetStateAction, ReactNode, useEffect } from 'react' +import { useState, ReactNode, useEffect } from 'react' import { useCombobox, UseComboboxReturnValue } from 'downshift' export const SingleSelectCombobox: (props: { dropdownItems: T[] - setSelectedItem: Dispatch> + setSelectedItem: (value: T) => void handleDropdownItemFilter: (dropdownItems: T[], inputValue: string) => T[] handleDropdownItemDisplay: (dropdownItem: T) => ReactNode initSelectedItem?: T @@ -19,7 +19,7 @@ export const SingleSelectCombobox: (props: { initSelectedItem }: { dropdownItems: T[] - setSelectedItem: Dispatch> + setSelectedItem: (value: T) => void handleDropdownItemFilter: (dropdownItems: T[], inputValue: string) => T[] handleDropdownItemDisplay: (dropdownItem: T) => ReactNode initSelectedItem?: T diff --git a/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx b/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx index ab13bf7bc9b..e438d5edac4 100644 --- a/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx +++ b/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx @@ -1,28 +1,34 @@ 'use client' -import { Box, Button, InputGroup, Input, FormLabel, VStack, ListItem, List } from '@chakra-ui/react' +import { Button, InputGroup, Input, ListItem, List, Flex, FlexProps, Box } from '@chakra-ui/react' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { useState, Dispatch, SetStateAction, ReactNode, useEffect } from 'react' +import { useState, ReactNode, useEffect } from 'react' import { useCombobox, UseComboboxReturnValue } from 'downshift' -export const SingleSelectCombobox: (props: { +export const SingleSelectComboboxUnstyle: (props: { dropdownItems: T[] - setSelectedItem: Dispatch> + setSelectedItem: (value: T) => void handleDropdownItemFilter: (dropdownItems: T[], inputValue: string) => T[] handleDropdownItemDisplay: (dropdownItem: T) => ReactNode initSelectedItem?: T + flexProps?: FlexProps + placeholder?: string }) => JSX.Element = function ({ dropdownItems, setSelectedItem, handleDropdownItemFilter, handleDropdownItemDisplay, - initSelectedItem + initSelectedItem, + flexProps, + placeholder }: { dropdownItems: T[] - setSelectedItem: Dispatch> + setSelectedItem: (value: T) => void handleDropdownItemFilter: (dropdownItems: T[], inputValue: string) => T[] handleDropdownItemDisplay: (dropdownItem: T) => ReactNode initSelectedItem?: T + flexProps?: FlexProps + placeholder?: string }) { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') @@ -34,7 +40,6 @@ export const SingleSelectCombobox: (props: { const { isOpen: isComboboxOpen, getToggleButtonProps, - getLabelProps, getMenuProps, getInputProps, highlightedIndex, @@ -55,110 +60,97 @@ export const SingleSelectCombobox: (props: { } } }) + return ( - - - + + + - - + justifyContent="center" + {...getToggleButtonProps()}> + {isComboboxOpen ? ( + + + + ) : ( + + + + )} + + (props: { ))} - + ) } diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx index d069d418b7b..53a70b63a7f 100644 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ b/frontend/providers/aiproxy/components/user/ModelList.tsx @@ -5,7 +5,7 @@ import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import Image, { StaticImageData } from 'next/image' import { useQuery } from '@tanstack/react-query' -import { getModelConfig } from '@/api/platform' +import { getEnabledMode } from '@/api/platform' import { useMessage } from '@sealos/ui' // icons import OpenAIIcon from '@/ui/svg/icons/modelist/openai.svg' @@ -139,7 +139,7 @@ const ModelComponent = ({ modelName }: { modelName: string }) => { const ModelList: React.FC = () => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') - const { isLoading, data } = useQuery([QueryKey.GetModelConfig], () => getModelConfig()) + const { isLoading, data } = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) return ( <> diff --git a/frontend/providers/aiproxy/types/admin/channels/channelInfo.ts b/frontend/providers/aiproxy/types/admin/channels/channelInfo.ts index ee96326233e..80c08193fba 100644 --- a/frontend/providers/aiproxy/types/admin/channels/channelInfo.ts +++ b/frontend/providers/aiproxy/types/admin/channels/channelInfo.ts @@ -35,3 +35,9 @@ export enum ChannelStatus { ChannelStatusDisabled = 2, ChannelStatusAutoDisabled = 3 } + +export type ChannelType = `${number}` + +export type ChannelTypeMapName = { + [key in ChannelType]: string +} diff --git a/frontend/providers/aiproxy/types/admin/option.ts b/frontend/providers/aiproxy/types/admin/option.ts index dc2898d78a5..bd7ed0de666 100644 --- a/frontend/providers/aiproxy/types/admin/option.ts +++ b/frontend/providers/aiproxy/types/admin/option.ts @@ -1,3 +1,5 @@ +import { ChannelType } from './channels/channelInfo' + export interface BatchOptionData { DefaultChannelModelMapping: string DefaultChannelModels: string @@ -20,3 +22,13 @@ export interface OptionData { ModelPrice: string RetryTimes: string } + +export type DefaultChannelModel = { + [key in ChannelType]: string[] +} + +export type DefaultChannelModelMapping = { + [key in ChannelType]: { + [modelKey: string]: string + } +} diff --git a/frontend/providers/aiproxy/types/models/model.ts b/frontend/providers/aiproxy/types/models/model.ts index 0e8ed3efdd4..9e59951ad72 100644 --- a/frontend/providers/aiproxy/types/models/model.ts +++ b/frontend/providers/aiproxy/types/models/model.ts @@ -1,61 +1,42 @@ -export enum ModelType { - OpenAI = '1', - API2D = '2', - Azure = '3', - CloseAI = '4', - OpenAISB = '5', - OpenAIMax = '6', - OhMyGPT = '7', - Custom = '8', - Ails = '9', - AIProxy = '10', - PaLM = '11', - API2GPT = '12', - AIGC2D = '13', - Anthropic = '14', - Baidu = '15', - Zhipu = '16', - Ali = '17', - Xunfei = '18', - AI360 = '19', - OpenRouter = '20', - AIProxyLibrary = '21', - FastGPT = '22', - Tencent = '23', - Gemini = '24', - Moonshot = '25', - Baichuan = '26', - Minimax = '27', - Mistral = '28', - Groq = '29', - Ollama = '30', - LingYiWanWu = '31', - StepFun = '32', - AwsClaude = '33', - Coze = '34', - Cohere = '35', - DeepSeek = '36', - Cloudflare = '37', - DeepL = '38', - TogetherAI = '39', - Doubao = '40', - Novita = '41', - VertextAI = '42', - SiliconFlow = '43' -} - -export type ModelMap = { [K in ModelType]?: string[] } +import { ChannelType } from '@/types/admin/channels/channelInfo' -export type ModelMappingMap = { [K in ModelType]?: {} } - -export interface ModelConfig { - image_prices: null +export interface ModelInfo { + image_prices: number[] | null model: string owner: string image_batch_size: number type: number input_price: number output_price: number +} + +export type ChannelWithMode = { + [K in ChannelType]?: ModelInfo[] +} + +export type ChannelDefaultModeMapping = { + [K in ChannelType]?: { + [modelKey: string]: string + } +} + +export type ChannelDefaultModel = { + [K in ChannelType]?: string[] +} + +export type ChannelWithDefaultModelAndDefaultModeMapping = { + mapping: ChannelDefaultModeMapping + models: ChannelDefaultModel +} + +export interface TokenConfig { + max_input_tokens: number + max_output_tokens: number + max_context_tokens: number +} + +export interface ModelConfig extends ModelInfo { + config: TokenConfig created_at: number updated_at: number } diff --git a/frontend/providers/aiproxy/types/query-key.ts b/frontend/providers/aiproxy/types/query-key.ts index 35f3f82d42e..bbf76d2745a 100644 --- a/frontend/providers/aiproxy/types/query-key.ts +++ b/frontend/providers/aiproxy/types/query-key.ts @@ -1,6 +1,13 @@ export enum QueryKey { + // common GetTokens = 'getTokens', GetUserLogs = 'getUserLogs', - GetModelConfig = 'getModelConfig', - GetChannels = 'getChannels' + GetEnabledModels = 'getEnabledModels', + // admin + GetChannels = 'getChannels', + GetGlobalLogs = 'getGlobalLogs', + GetChannelTypeNames = 'getChannelTypeNames', + GetAllChannelModes = 'getAllChannelModes', + GetDefaultModelAndModeMapping = 'getDefaultModelAndModeMapping', + GetOption = 'getOption' } diff --git a/frontend/providers/aiproxy/types/user/logs.ts b/frontend/providers/aiproxy/types/user/logs.ts index 673f1b29ab7..f56dd9fc5de 100644 --- a/frontend/providers/aiproxy/types/user/logs.ts +++ b/frontend/providers/aiproxy/types/user/logs.ts @@ -1,4 +1,5 @@ export interface LogItem { + id: number code: number content: string group: string @@ -14,3 +15,8 @@ export interface LogItem { endpoint: string created_at: number } + +export interface GlobalLogItem extends LogItem { + request_id: string + request_at: number +} From 146afb723e7668ddb3efa5e8ff47ca65a3ffcd25 Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 4 Dec 2024 09:48:41 +0000 Subject: [PATCH 14/42] ok --- .../components/CommonConfig.tsx | 108 ++++- .../global-configs/components/ModelConfig.tsx | 383 +++++++++--------- .../aiproxy/app/api/admin/groups/route.ts | 153 +++++++ .../aiproxy/app/api/admin/option/route.ts | 17 +- .../aiproxy/app/i18n/locales/en/common.json | 4 +- .../aiproxy/app/i18n/locales/zh/common.json | 4 +- .../common/ConstructMappingComponent.tsx | 23 +- 7 files changed, 481 insertions(+), 211 deletions(-) create mode 100644 frontend/providers/aiproxy/app/api/admin/groups/route.ts diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx index 983119f36ab..a95d6e97db2 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx @@ -2,13 +2,101 @@ import { Button, Flex, Text, useDisclosure } from '@chakra-ui/react' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { EditIcon } from '@chakra-ui/icons' +import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' import { Switch } from '@chakra-ui/react' import { EditableText } from './EditableText' +import { getOption, updateOption } from '@/api/platform' +import { useMessage } from '@sealos/ui' +import { QueryKey } from '@/types/query-key' +import { useState } from 'react' +import { produce } from 'immer' + +export enum CommonConfigKey { + GlobalApiRateLimitNum = 'GlobalApiRateLimitNum', + DisableServe = 'DisableServe', + RetryTimes = 'RetryTimes', + GroupMaxTokenNum = 'GroupMaxTokenNum' +} + +type CommonConfig = { + [CommonConfigKey.GlobalApiRateLimitNum]: string + [CommonConfigKey.DisableServe]: string + [CommonConfigKey.RetryTimes]: string + [CommonConfigKey.GroupMaxTokenNum]: string +} const CommonConfig = () => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') + const queryClient = useQueryClient() + + const [commonConfig, setCommonConfig] = useState(() => + produce({} as CommonConfig, (draft) => { + draft.GlobalApiRateLimitNum = '' + draft.DisableServe = '' + draft.RetryTimes = '' + draft.GroupMaxTokenNum = '' + }) + ) + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const { isLoading: isOptionLoading, data: optionData } = useQuery({ + queryKey: [QueryKey.GetOption], + queryFn: () => getOption(), + onSuccess: (data) => { + if (!data) return + console.log('data', data) + + setCommonConfig( + produce(commonConfig, (draft) => { + draft.GlobalApiRateLimitNum = data.GlobalApiRateLimitNum || '' + draft.DisableServe = data.DisableServe || '' + draft.RetryTimes = data.RetryTimes || '' + draft.GroupMaxTokenNum = data.GroupMaxTokenNum || '' + }) + ) + } + }) + + const updateOptionMutation = useMutation({ + mutationFn: (params: { key: string; value: string }) => updateOption(params), + onSuccess: () => { + message({ + title: t('globalConfigs.saveCommonConfigSuccess'), + status: 'success' + }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetOption] }) + }, + onError: () => { + message({ + title: t('globalConfigs.saveCommonConfigFailed'), + status: 'error' + }) + } + }) + + const updateConfigField = (field: CommonConfigKey, value: string) => { + setCommonConfig( + produce((draft) => { + draft[field] = value + }) + ) + updateOptionMutation.mutate({ key: field, value }) + } + + const handleDisableServeChange = (checked: boolean) => { + const value = checked ? 'true' : 'false' + updateConfigField(CommonConfigKey.DisableServe, value) + } + return ( /* h = 72px + 20px + 60px = 152px @@ -39,30 +127,34 @@ const CommonConfig = () => { {/* QPM Limit */} {}} + value={commonConfig.GlobalApiRateLimitNum} + onSubmit={(value) => updateConfigField(CommonConfigKey.GlobalApiRateLimitNum, value)} flexProps={{ h: '24px' }} /> {/* Pause Service */} {t('global_configs.pause_service')} - console.log(e)} /> + handleDisableServeChange(e.target.checked)} + /> {/* Retry Count */} {}} + value={commonConfig.RetryTimes} + onSubmit={(value) => updateConfigField(CommonConfigKey.RetryTimes, value)} flexProps={{ h: '24px' }} /> {/* Max Token */} {}} + value={commonConfig.GroupMaxTokenNum} + onSubmit={(value) => updateConfigField(CommonConfigKey.GroupMaxTokenNum, value)} flexProps={{ h: '24px' }} /> diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx index d3460dfb6b0..a7b436d873a 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx @@ -278,7 +278,7 @@ const ModelConfig = () => { } const onInvalid = (errors: FieldErrors): void => { - console.log('errors', errors) + console.error('errors', errors) const errorMessage = getFirstErrorMessage(errors) @@ -433,204 +433,209 @@ const ModelConfig = () => { formValuesArray && formValuesArray.length > 0 && channelTypeNames && - formValuesArray.map((value, index) => ( - - - - { - // Get current form types - const currentFormTypes = Object.values(formValues) - .map((item) => String(item.type)) - .filter( - (type): type is ChannelType => - type !== undefined && type in channelTypeNames - ) - .map((type) => channelTypeNames[type]) - - // Filter available types - const availableTypes = allSupportChannel.filter( - (channelType) => - !currentFormTypes.includes(channelType) || - // 避免编辑时当前选中值"消失"的问题,即当前选择项也包含 - (field.value && - channelTypeNames[String(field.value) as ChannelType] === channelType) - ) - - const initSelectedItem = field.value - ? channelTypeNames[String(field.value) as ChannelType] - : undefined - - return ( - - dropdownItems={availableTypes} - initSelectedItem={initSelectedItem} - setSelectedItem={(channelName: string) => { - if (channelName) { - const channelType = Object.entries(channelTypeNames).find( - ([_, name]) => name === channelName - )?.[0] - - if (channelType) { - const defaultModelField = defaultModel[channelType as ChannelType] - const defaultModelMappingField = - defaultModelMapping[channelType as ChannelType] - - field.onChange(Number(channelType)) - setValue(`${index}.defaultMode`, defaultModelField || []) - setValue( - `${index}.defaultModeMapping`, - defaultModelMappingField || {} + formValuesArray.map((value, index) => { + if (true) { + return ( + + + + { + // Get current form types + const currentFormTypes = Object.values(formValues) + .map((item) => String(item.type)) + .filter( + (type): type is ChannelType => + type !== undefined && type in channelTypeNames + ) + .map((type) => channelTypeNames[type]) + + // Filter available types + const availableTypes = allSupportChannel.filter( + (channelType) => + !currentFormTypes.includes(channelType) || + // 避免编辑时当前选中值"消失"的问题,即当前选择项也包含 + (field.value && + channelTypeNames[String(field.value) as ChannelType] === + channelType) + ) + + const initSelectedItem = field.value + ? channelTypeNames[String(field.value) as ChannelType] + : undefined + + return ( + + dropdownItems={availableTypes} + initSelectedItem={initSelectedItem} + setSelectedItem={(channelName: string) => { + if (channelName) { + const channelType = Object.entries(channelTypeNames).find( + ([_, name]) => name === channelName + )?.[0] + + if (channelType) { + const defaultModelField = + defaultModel[channelType as ChannelType] + const defaultModelMappingField = + defaultModelMapping[channelType as ChannelType] + + field.onChange(Number(channelType)) + setValue(`${index}.defaultMode`, defaultModelField || []) + setValue( + `${index}.defaultModeMapping`, + defaultModelMappingField || {} + ) + } + } + }} + handleDropdownItemFilter={( + dropdownItems: string[], + inputValue: string + ) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => + !inputValue || item.toLowerCase().includes(lowerCasedInput) ) + }} + handleDropdownItemDisplay={(item: string) => ( + + {item} + + )} + /> + ) + }} + /> + + + + { + // Get the current type value from the form + const currentType = watch(`${index}.type`) + const dropdownItems = currentType + ? allSupportChannelWithMode[String(currentType) as ChannelType] || [] + : [] + + const handleSetCustomModel = ( + selectedItems: string[], + setSelectedItems: Dispatch>, + customModeName: string, + setCustomModeName: Dispatch> + ) => { + if (customModeName.trim()) { + const exists = field.value.some( + (item) => item === customModeName.trim() + ) + + if (!exists) { + field.onChange([...field.value, customModeName.trim()]) + setCustomModeName('') } } - }} - handleDropdownItemFilter={( + } + + const handleModelFilteredDropdownItems = ( dropdownItems: string[], + selectedItems: string[], inputValue: string ) => { - const lowerCasedInput = inputValue.toLowerCase() + const lowerCasedInputValue = inputValue.toLowerCase() + return dropdownItems.filter( (item) => - !inputValue || item.toLowerCase().includes(lowerCasedInput) + !selectedItems.includes(item) && + item.toLowerCase().includes(lowerCasedInputValue) ) - }} - handleDropdownItemDisplay={(item: string) => ( - - {item} - - )} - /> - ) - }} - /> - - - - { - // Get the current type value from the form - const currentType = watch(`${index}.type`) - const dropdownItems = currentType - ? allSupportChannelWithMode[String(currentType) as ChannelType] || [] - : [] - - const handleSetCustomModel = ( - selectedItems: string[], - setSelectedItems: Dispatch>, - customModeName: string, - setCustomModeName: Dispatch> - ) => { - if (customModeName.trim()) { - const exists = field.value.some( - (item) => item === customModeName.trim() - ) - - if (!exists) { - field.onChange([...field.value, customModeName.trim()]) - setCustomModeName('') } - } - } - - const handleModelFilteredDropdownItems = ( - dropdownItems: string[], - selectedItems: string[], - inputValue: string - ) => { - const lowerCasedInputValue = inputValue.toLowerCase() - - return dropdownItems.filter( - (item) => - !selectedItems.includes(item) && - item.toLowerCase().includes(lowerCasedInputValue) - ) - } - - return ( - - dropdownItems={dropdownItems || []} - selectedItems={field.value || []} // Use field.value for selected items - setSelectedItems={(models) => { - field.onChange(models) - }} - handleFilteredDropdownItems={handleModelFilteredDropdownItems} - handleDropdownItemDisplay={(item) => ( - - {item} - - )} - handleSelectedItemDisplay={(item) => ( - - {item} - - )} - handleSetCustomSelectedItem={handleSetCustomModel} - /> - ) - }} - /> - - - {/* todo: 表单提交时空值检查 */} - - { - const defaultMode = watch(`${index}.defaultMode`) - const defaultModeMapping = watch(`${index}.defaultModeMapping`) - - return ( - { - field.onChange(mapping) - }} - /> - ) - }} - /> - - - - )) + + return ( + + dropdownItems={dropdownItems || []} + selectedItems={field.value || []} // Use field.value for selected items + setSelectedItems={(models) => { + field.onChange(models) + }} + handleFilteredDropdownItems={handleModelFilteredDropdownItems} + handleDropdownItemDisplay={(item) => ( + + {item} + + )} + handleSelectedItemDisplay={(item) => ( + + {item} + + )} + handleSetCustomSelectedItem={handleSetCustomModel} + /> + ) + }} + /> + + + + { + const defaultMode = watch(`${index}.defaultMode`) + const defaultModeMapping = watch(`${index}.defaultModeMapping`) + + return ( + { + field.onChange(mapping) + }} + /> + ) + }} + /> + + + + ) + } + }) )} diff --git a/frontend/providers/aiproxy/app/api/admin/groups/route.ts b/frontend/providers/aiproxy/app/api/admin/groups/route.ts new file mode 100644 index 00000000000..da52fe7275f --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/groups/route.ts @@ -0,0 +1,153 @@ +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { GlobalLogItem } from '@/types/user/logs' +import { parseJwtToken } from '@/utils/backend/auth' +import { isAdmin } from '@/utils/backend/isAdmin' +import { NextRequest, NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' +export type ApiProxyBackendGlobalLogSearchResponse = ApiProxyBackendResp<{ + logs: GlobalLogItem[] + total: number +}> + +export type GlobalLogSearchResponse = ApiResp<{ + logs: GlobalLogItem[] + total: number +}> + +export interface GlobalLogQueryParams { + token_name?: string + model_name?: string + code?: string + start_timestamp?: string + end_timestamp?: string + group_id?: string + page: number + perPage: number +} + +function validateParams(params: GlobalLogQueryParams): string | null { + if (params.page < 1) { + return 'Page number must be greater than 0' + } + if (params.perPage < 1 || params.perPage > 100) { + return 'Per page must be between 1 and 100' + } + if (params.start_timestamp && params.end_timestamp) { + if (parseInt(params.start_timestamp) > parseInt(params.end_timestamp)) { + return 'Start timestamp cannot be greater than end timestamp' + } + } + return null +} + +async function fetchLogs( + params: GlobalLogQueryParams +): Promise<{ logs: GlobalLogItem[]; total: number }> { + try { + const url = new URL( + `/api/logs/search`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + url.searchParams.append('p', params.page.toString()) + url.searchParams.append('per_page', params.perPage.toString()) + + if (params.token_name) { + url.searchParams.append('token_name', params.token_name) + } + if (params.model_name) { + url.searchParams.append('model_name', params.model_name) + } + if (params.code) { + url.searchParams.append('code', params.code) + } + if (params.group_id) { + url.searchParams.append('group_id', params.group_id) + } + if (params.start_timestamp) { + url.searchParams.append('start_timestamp', params.start_timestamp) + } + if (params.end_timestamp) { + url.searchParams.append('end_timestamp', params.end_timestamp) + } + + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendGlobalLogSearchResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'API request failed') + } + + return { + logs: result.data?.logs || [], + total: result.data?.total || 0 + } + } catch (error) { + console.error('Error fetching logs:', error) + throw error + } +} + +export async function GET(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + const searchParams = request.nextUrl.searchParams + + const queryParams: GlobalLogQueryParams = { + page: parseInt(searchParams.get('page') || '1', 10), + perPage: parseInt(searchParams.get('perPage') || '10', 10), + token_name: searchParams.get('token_name') || undefined, + model_name: searchParams.get('model_name') || undefined, + code: searchParams.get('code') || undefined, + start_timestamp: searchParams.get('start_timestamp') || undefined, + end_timestamp: searchParams.get('end_timestamp') || undefined + } + + const validationError = validateParams(queryParams) + if (validationError) { + return NextResponse.json( + { + code: 400, + message: validationError, + error: validationError + }, + { status: 400 } + ) + } + + const { logs, total } = await fetchLogs(queryParams) + + return NextResponse.json({ + code: 200, + data: { + logs, + total + } + } satisfies GlobalLogSearchResponse) + } catch (error) { + console.error('Logs search error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/option/route.ts b/frontend/providers/aiproxy/app/api/admin/option/route.ts index fde526f02c7..564ce5e7226 100644 --- a/frontend/providers/aiproxy/app/api/admin/option/route.ts +++ b/frontend/providers/aiproxy/app/api/admin/option/route.ts @@ -67,7 +67,7 @@ export async function GET(request: NextRequest): Promise { +async function updateOption(key: string, value: string): Promise { try { const url = new URL( `/api/option`, @@ -81,7 +81,7 @@ async function updateOption(key: string): Promise { 'Content-Type': 'application/json', Authorization: `${token}` }, - body: JSON.stringify({ key: key }), + body: JSON.stringify({ key, value }), cache: 'no-store' }) @@ -118,7 +118,18 @@ export async function PUT(request: NextRequest): Promise>([ - { key: '', value: '' } - ]) + // const [mapKeyValuePairs, setMapkeyValuePairs] = useState>([ + // { key: '', value: '' } + // ]) + const [mapKeyValuePairs, setMapkeyValuePairs] = useState>( + Object.entries(mapData).length > 0 + ? Object.entries(mapData).map(([key, value]) => ({ key, value })) + : [{ key: '', value: '' }] + ) - useEffect(() => { - const entries = Object.entries(mapData) - if (entries.length > 0) { - setMapkeyValuePairs(entries.map(([key, value]) => ({ key, value }))) - } - }, [mapData]) + // useEffect(() => { + // const entries = Object.entries(mapData) + // if (entries.length > 0) { + // setMapkeyValuePairs(entries.map(([key, value]) => ({ key, value }))) + // } + // }, [mapData]) const handleDropdownItemDisplay = (dropdownItem: string) => { if (dropdownItem === t('channelsFormPlaceholder.modelMappingInput')) { From 17458fab4cff509aef563d4b74194410472e490f Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 4 Dec 2024 09:51:04 +0000 Subject: [PATCH 15/42] ok --- frontend/providers/aiproxy/utils/backend/isAdmin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/providers/aiproxy/utils/backend/isAdmin.ts b/frontend/providers/aiproxy/utils/backend/isAdmin.ts index 3a372c7144b..1d27cf5fd6c 100644 --- a/frontend/providers/aiproxy/utils/backend/isAdmin.ts +++ b/frontend/providers/aiproxy/utils/backend/isAdmin.ts @@ -1,5 +1,5 @@ export async function isAdmin(namespace: string): Promise { - return true + // return true if (!namespace) { return Promise.reject('Admin: Invalid namespace') } From e0fdc6bd54971e106da9c8acabf60b8f3be9a33c Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 4 Dec 2024 10:03:42 +0000 Subject: [PATCH 16/42] ok --- .../app/[lng]/(admin)/ns-manager/page.tsx | 173 ++++++++++++------ 1 file changed, 121 insertions(+), 52 deletions(-) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx index e935aa93cf3..ec35161af93 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx @@ -1,24 +1,26 @@ 'use client' -import { Box, Flex, Text, Button, Icon } from '@chakra-ui/react' -import { MySelect, MyTooltip, SealosCoin } from '@sealos/ui' +import { Box, Flex, Text, Button, Icon, Input } from '@chakra-ui/react' +import { CurrencySymbol, MyTooltip } from '@sealos/ui' import { useMemo, useState } from 'react' -import { getKeys, getLogs, getModels } from '@/api/platform' +import { getGlobalLogs, getEnabledMode } from '@/api/platform' import { useTranslationClientSide } from '@/app/i18n/client' import SelectDateRange from '@/components/common/SelectDateRange' import SwitchPage from '@/components/common/SwitchPage' import { BaseTable } from '@/components/table/BaseTable' import { useI18n } from '@/providers/i18n/i18nContext' -import { LogItem } from '@/types/log' +import { GlobalLogItem } from '@/types/user/logs' import { useQuery } from '@tanstack/react-query' import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' - -const mockStatus = ['all', 'success', 'failed'] +import { QueryKey } from '@/types/query-key' +import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' +import { useBackendStore } from '@/store/backend' export default function Home(): React.JSX.Element { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() const [startTime, setStartTime] = useState(() => { const currentDate = new Date() @@ -26,50 +28,61 @@ export default function Home(): React.JSX.Element { return currentDate }) const [endTime, setEndTime] = useState(new Date()) + const [groupId, setGroupId] = useState('') const [name, setName] = useState('') - const [modelName, setModelName] = useState('') + const [modelName, setModelName] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) - const [logData, setLogData] = useState([]) + const [logData, setLogData] = useState([]) const [total, setTotal] = useState(0) - const { data: models = [] } = useQuery(['getModels'], () => getModels()) - const { data: tokenData } = useQuery(['getKeys'], () => getKeys({ page: 1, perPage: 100 })) + const { data: models = [] } = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) + + console.log(modelName) const { isLoading } = useQuery( - ['getLogs', page, pageSize, name, modelName, startTime, endTime], + [QueryKey.GetGlobalLogs, page, pageSize, name, modelName, startTime, endTime, groupId], () => - getLogs({ + getGlobalLogs({ page, perPage: pageSize, token_name: name, model_name: modelName, start_timestamp: startTime.getTime().toString(), - end_timestamp: endTime.getTime().toString() + end_timestamp: endTime.getTime().toString(), + group_id: groupId }), { onSuccess: (data) => { - if (!data.logs) { + if (!data?.logs) { setLogData([]) setTotal(0) return } - setLogData(data.logs) - setTotal(data.total) + setLogData(data?.logs || []) + setTotal(data?.total || 0) } } ) - const columns = useMemo[]>(() => { + const columns = useMemo[]>(() => { return [ { - header: t('logs.name'), + header: t('GlobalLogs.groupId'), + accessorKey: 'group' + }, + { + header: t('GlobalLogs.tokenName'), accessorKey: 'token_name' }, { header: t('logs.model'), accessorKey: 'model' }, + { + header: t('GlobalLogs.channel'), + accessorKey: 'channel' + }, { header: t('logs.prompt_tokens'), accessorKey: 'prompt_tokens' @@ -116,7 +129,7 @@ export default function Home(): React.JSX.Element { {t('logs.total_price')} - + @@ -216,27 +229,30 @@ export default function Home(): React.JSX.Element { letterSpacing="0.5px"> {t('logs.name')} - ({ - value: item.name, - label: item.name - })) || []) - ]} - onchange={(val: string) => { - if (val === 'all') { - setName('') - } else { - setName(val) - } + py="6px" + px="12px" + alignItems="center" + borderRadius="4px" + border="1px solid" + borderColor="grayModern.200" + bgColor="grayModern.50" + _hover={{ borderColor: 'grayModern.300' }} + _focus={{ borderColor: 'grayModern.300' }} + _focusVisible={{ borderColor: 'grayModern.300' }} + _active={{ borderColor: 'grayModern.300' }} + placeholder={t('GlobalLogs.select_token_name')} + _placeholder={{ + color: 'grayModern.500', + fontFamily: 'PingFang SC', + fontSize: '12px', + fontWeight: 400, + lineHeight: '16px', + letterSpacing: '0.048px' }} + value={name} + onChange={(e) => setName(e.target.value)} /> @@ -252,23 +268,38 @@ export default function Home(): React.JSX.Element { letterSpacing="0.5px"> {t('logs.modal')} - ({ - value: item, - label: item - })) || [] - } - onchange={(val: string) => { - if (val === 'all') { + + + dropdownItems={['all', ...models.map((item) => item.model)]} + setSelectedItem={(value) => { + if (value === 'all') { setModelName('') } else { - setModelName(val) + setModelName(value) } }} + handleDropdownItemFilter={(dropdownItems, inputValue) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) + ) + }} + handleDropdownItemDisplay={(dropdownItem) => { + return ( + + {dropdownItem} + + ) + }} + flexProps={{ w: '500px' }} + placeholder={t('GlobalLogs.selectModel')} /> @@ -281,6 +312,45 @@ export default function Home(): React.JSX.Element { justifyContent="space-between" gap="160px" alignSelf="stretch"> + + + {t('logs.name')} + + setGroupId(e.target.value)} + /> + + - {/* -- the second row end */} From 12940b7e7623d13c933b999ea148383f1786fdb4 Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 4 Dec 2024 10:04:27 +0000 Subject: [PATCH 17/42] ok --- frontend/pnpm-lock.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2406f6e0b95..533db6190cc 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -482,6 +482,9 @@ importers: providers/aiproxy: dependencies: + '@hookform/resolvers': + specifier: ^3.9.0 + version: 3.9.0(react-hook-form@7.48.2) '@sealos/ui': specifier: workspace:* version: link:../../packages/ui @@ -500,6 +503,9 @@ importers: date-fns: specifier: ^2.30.0 version: 2.30.0 + downshift: + specifier: ^9.0.8 + version: 9.0.8(react@18.2.0) i18next: specifier: ^23.11.5 version: 23.12.1 @@ -536,6 +542,9 @@ importers: sealos-desktop-sdk: specifier: workspace:* version: link:../../packages/client-sdk + zod: + specifier: ^3.23.8 + version: 3.23.8 zustand: specifier: ^4.5.4 version: 4.5.4(@types/react@18.2.37)(immer@10.1.1)(react@18.2.0) @@ -5517,6 +5526,13 @@ packages: dependencies: regenerator-runtime: 0.14.0 + /@babel/runtime@7.26.0: + resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.0 + dev: false + /@babel/template@7.22.15: resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} engines: {node: '>=6.9.0'} @@ -11041,6 +11057,7 @@ packages: /acorn-import-assertions@1.9.0(acorn@8.11.2): resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + deprecated: package has been renamed to acorn-import-attributes peerDependencies: acorn: ^8 dependencies: @@ -12412,6 +12429,10 @@ packages: resolution: {integrity: sha512-nadqwNxghAGTamwIqQSG433W6OADZx2vCo3UXHNrzTRHK/htu+7+L0zhjEoaeaQVNAi3YgqWDv8+tzf0hRfR+A==} dev: false + /compute-scroll-into-view@3.1.0: + resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} + dev: false + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -13263,6 +13284,19 @@ packages: minimatch: 3.1.2 dev: false + /downshift@9.0.8(react@18.2.0): + resolution: {integrity: sha512-59BWD7+hSUQIM1DeNPLirNNnZIO9qMdIK5GQ/Uo8q34gT4B78RBlb9dhzgnh0HfQTJj4T/JKYD8KoLAlMWnTsA==} + peerDependencies: + react: '>=16.12.0' + dependencies: + '@babel/runtime': 7.26.0 + compute-scroll-into-view: 3.1.0 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 18.2.0 + tslib: 2.6.2 + dev: false + /drange@1.1.1: resolution: {integrity: sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==} engines: {node: '>=4'} From f6c54f28ef7c2b8c581876cbfffd3507cabc6293 Mon Sep 17 00:00:00 2001 From: lim Date: Thu, 5 Dec 2024 07:48:57 +0000 Subject: [PATCH 18/42] ok --- frontend/providers/aiproxy/api/platform.ts | 36 +- .../dashboard/components/ChannelTable.tsx | 214 +++++-- .../app/[lng]/(admin)/dashboard/page.tsx | 37 +- .../components/CommonConfig.tsx | 7 +- .../components/EditableText.tsx | 5 +- .../components/EditableTextNoLable.tsx | 172 ++++++ .../global-configs/components/ModelConfig.tsx | 433 +++++++------- .../app/[lng]/(admin)/global-logs/page.tsx | 63 ++- .../aiproxy/app/[lng]/(admin)/layout.tsx | 6 +- .../app/[lng]/(admin)/ns-manager/page.tsx | 533 ++++++++++++------ .../aiproxy/app/[lng]/(user)/home/page.tsx | 13 +- .../aiproxy/app/[lng]/(user)/layout.tsx | 6 +- .../aiproxy/app/[lng]/(user)/logs/page.tsx | 141 +++-- .../app/api/admin/channel/[id]/route.ts | 151 +++++ .../[id] => channel/[id]/status}/route.ts | 28 +- .../app/api/admin/channel/all/route.ts | 64 +++ .../api/admin/{channels => channel}/route.ts | 0 .../type-names => channel/type-name}/route.ts | 0 .../app/api/admin/channel/upload/route.ts | 65 +++ .../app/api/admin/group/[id]/qpm/route.ts | 78 +++ .../aiproxy/app/api/admin/group/[id]/route.ts | 76 +++ .../app/api/admin/group/[id]/status/route.ts | 80 +++ .../app/api/admin/{groups => group}/route.ts | 78 +-- .../aiproxy/app/i18n/locales/en/common.json | 57 +- .../aiproxy/app/i18n/locales/zh/common.json | 41 +- .../common/ConstructMappingComponent.tsx | 67 +-- .../common/ConstructModeMappingComponent.tsx | 1 - .../aiproxy/components/user/ModelList.tsx | 57 +- .../providers/aiproxy/types/admin/group.ts | 20 + frontend/providers/aiproxy/types/query-key.ts | 8 +- .../aiproxy/utils/backend/isAdmin.ts | 2 +- frontend/providers/aiproxy/utils/common.ts | 13 + 32 files changed, 1882 insertions(+), 670 deletions(-) create mode 100644 frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableTextNoLable.tsx create mode 100644 frontend/providers/aiproxy/app/api/admin/channel/[id]/route.ts rename frontend/providers/aiproxy/app/api/admin/{channels/[id] => channel/[id]/status}/route.ts (71%) create mode 100644 frontend/providers/aiproxy/app/api/admin/channel/all/route.ts rename frontend/providers/aiproxy/app/api/admin/{channels => channel}/route.ts (100%) rename frontend/providers/aiproxy/app/api/admin/{channels/type-names => channel/type-name}/route.ts (100%) create mode 100644 frontend/providers/aiproxy/app/api/admin/channel/upload/route.ts create mode 100644 frontend/providers/aiproxy/app/api/admin/group/[id]/qpm/route.ts create mode 100644 frontend/providers/aiproxy/app/api/admin/group/[id]/route.ts create mode 100644 frontend/providers/aiproxy/app/api/admin/group/[id]/status/route.ts rename frontend/providers/aiproxy/app/api/admin/{groups => group}/route.ts (52%) create mode 100644 frontend/providers/aiproxy/types/admin/group.ts diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index 32882d0a9fd..a8740a0c125 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -1,6 +1,6 @@ import { GET, POST, DELETE, PUT } from '@/utils/frontend/request' -import { ChannelQueryParams, GetChannelsResponse } from '@/app/api/admin/channels/route' -import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' +import { ChannelQueryParams, GetChannelsResponse } from '@/app/api/admin/channel/route' +import { ChannelStatus, CreateChannelRequest } from '@/types/admin/channels/channelInfo' import { ApiResp } from '@/types/api' import { GetOptionResponse } from '@/app/api/admin/option/route' import { BatchOptionData } from '@/types/admin/option' @@ -12,7 +12,10 @@ import { UserLogQueryParams } from '@/app/api/user/log/route' import { GlobalLogQueryParams, GlobalLogSearchResponse } from '@/app/api/admin/log/route' import { GetAllChannelEnabledModelsResponse } from '@/app/api/models/builtin/channel/route' import { GetDefaultModelAndModeMappingResponse } from '@/app/api/models/default/route' -import { GetChannelTypeNamesResponse } from '@/app/api/admin/channels/type-names/route' +import { GetChannelTypeNamesResponse } from '@/app/api/admin/channel/type-name/route' +import { GroupQueryParams, GroupStatus } from '@/types/admin/group' +import { GroupSearchResponse } from '@/app/api/admin/group/route' +import { GetAllChannelResponse } from '@/app/api/admin/channel/all/route' export const initAppConfig = () => GET<{ aiproxyBackend: string; currencySymbol: 'shellCoin' | 'cny' | 'usd' }>( @@ -42,17 +45,24 @@ export const updateToken = (id: number, status: number) => // admin export const getChannels = (params: ChannelQueryParams) => - GET('/api/admin/channels', params) + GET('/api/admin/channel', params) // channel export const createChannel = (params: CreateChannelRequest) => - POST('/api/admin/channels', params) + POST('/api/admin/channel', params) export const updateChannel = (params: CreateChannelRequest, id: string) => - PUT(`/api/admin/channels/${id}`, params) + PUT(`/api/admin/channel/${id}`, params) + +export const updateChannelStatus = (id: string, status: ChannelStatus) => + POST(`/api/admin/channel/${id}/status`, { status }) export const getChannelTypeNames = () => - GET('/api/admin/channels/type-names') + GET('/api/admin/channel/type-name') + +export const getAllChannels = () => GET('/api/admin/channel/all') + +export const deleteChannel = (id: string) => DELETE(`/api/admin/channel/${id}`) // channel built-in support models and default model default mode mapping export const getChannelBuiltInSupportModels = () => @@ -73,3 +83,15 @@ export const batchOption = (params: BatchOptionData) => // log export const getGlobalLogs = (params: GlobalLogQueryParams) => GET('/api/admin/log', params) + +// group +export const getGroups = (params: GroupQueryParams) => + GET('/api/admin/group', params) + +export const updateGroupStatus = (id: string, status: GroupStatus) => + POST(`/api/admin/group/${id}/status`, { status }) + +export const updateGroupQpm = (id: string, qpm: number) => + POST(`/api/admin/group/${id}/qpm`, { qpm }) + +export const deleteGroup = (id: string) => DELETE(`/api/admin/group/${id}`) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx index ed124e079fa..db14c632bb8 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx @@ -15,7 +15,8 @@ import { MenuButton, MenuList, MenuItem, - useDisclosure + useDisclosure, + Spinner } from '@chakra-ui/react' import { Column, @@ -24,66 +25,147 @@ import { getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useMessage } from '@sealos/ui' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import { ChannelInfo, ChannelStatus, ChannelType } from '@/types/admin/channels/channelInfo' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' -import { useState, useMemo } from 'react' -import { getChannels, getChannelTypeNames } from '@/api/platform' +import { useState, useMemo, useEffect } from 'react' +import { + deleteChannel, + getChannels, + getChannelTypeNames, + updateChannelStatus +} from '@/api/platform' import SwitchPage from '@/components/common/SwitchPage' import UpdateChannelModal from './UpdateChannelModal' -import { getEnumKeyByValue } from '@/utils/common' +import { useMessage } from '@sealos/ui' import { QueryKey } from '@/types/query-key' +import { downloadJson } from '@/utils/common' -export default function ChannelTable() { +export default function ChannelTable({ + exportData +}: { + exportData: (data: ChannelInfo[]) => void +}) { const { isOpen, onOpen, onClose } = useDisclosure() const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') const [operationType, setOperationType] = useState<'create' | 'update'>('update') const [channelInfo, setChannelInfo] = useState(undefined) - console.log('channelInfo:', channelInfo) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) - const [selectedRows, setSelectedRows] = useState>(new Set()) + + const [selectedChannels, setSelectedChannels] = useState([]) + + useEffect(() => { + exportData(selectedChannels) + }, [selectedChannels]) + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const queryClient = useQueryClient() const { isLoading: isChannelTypeNamesLoading, data: channelTypeNames } = useQuery({ queryKey: [QueryKey.GetChannelTypeNames], queryFn: () => getChannelTypeNames() }) - const { data, isLoading } = useQuery({ + const { data, isLoading: isChannelsLoading } = useQuery({ queryKey: [QueryKey.GetChannels, page, pageSize], queryFn: () => getChannels({ page, perPage: pageSize }), refetchOnReconnect: true, onSuccess(data) { setTotal(data?.total || 0) - console.log('data:', data) } }) - console.log('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx') + const updateChannelStatusMutation = useMutation( + ({ id, status }: { id: string; status: number }) => updateChannelStatus(id, status), + { + onSuccess() { + message({ + status: 'success', + title: t('channel.updateSuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + queryClient.invalidateQueries([QueryKey.GetChannels]) + queryClient.invalidateQueries([QueryKey.GetChannelTypeNames]) + }, + onError(err: any) { + message({ + status: 'warning', + title: t('channel.updateFailed'), + description: err?.message || t('channel.updateFailed'), + isClosable: true, + position: 'top' + }) + } + } + ) + const deleteChannelMutation = useMutation(({ id }: { id: string }) => deleteChannel(id), { + onSuccess() { + message({ + status: 'success', + title: t('channel.deleteSuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + queryClient.invalidateQueries([QueryKey.GetChannels]) + queryClient.invalidateQueries([QueryKey.GetChannelTypeNames]) + }, + onError(err: any) { + message({ + status: 'warning', + title: t('channel.deleteFailed'), + description: err?.message || t('channel.deleteFailed'), + isClosable: true, + position: 'top' + }) + } + }) + + // Update the button click handlers in the table actions column: + const handleStatusUpdate = (id: string, currentStatus: number) => { + const newStatus = + currentStatus === ChannelStatus.ChannelStatusDisabled + ? ChannelStatus.ChannelStatusEnabled + : ChannelStatus.ChannelStatusDisabled + updateChannelStatusMutation.mutate({ id, status: newStatus }) + } const columnHelper = createColumnHelper() const handleHeaderCheckboxChange = (isChecked: boolean) => { if (isChecked) { - const currentPageIds = data?.channels.map((channel) => channel.id) || [] - setSelectedRows(new Set(currentPageIds)) + setSelectedChannels(data?.channels || []) } else { - setSelectedRows(new Set()) + setSelectedChannels([]) } } - const handleRowCheckboxChange = (id: number, isChecked: boolean) => { - const newSelected = new Set(selectedRows) + const handleRowCheckboxChange = (channel: ChannelInfo, isChecked: boolean) => { if (isChecked) { - newSelected.add(id) + setSelectedChannels([...selectedChannels, channel]) } else { - newSelected.delete(id) + setSelectedChannels(selectedChannels.filter((c) => c.id !== channel.id)) } - setSelectedRows(newSelected) + } + + const handleExportRow = (channel: ChannelInfo) => { + const channels = [channel] + const filename = `channel_${channels[0].id}_${new Date().toISOString()}.json` + downloadJson(channels, filename) } const columns = [ @@ -99,7 +181,7 @@ export default function ChannelTable() { isChecked={ data?.channels && data.channels.length > 0 && - selectedRows.size === data.channels.length + selectedChannels.length === data.channels.length } onChange={(e) => handleHeaderCheckboxChange(e.target.checked)} sx={{ @@ -135,8 +217,8 @@ export default function ChannelTable() { height="16px" borderRadius="4px" colorScheme="grayModern" - isChecked={selectedRows.has(info.getValue())} - onChange={(e) => handleRowCheckboxChange(info.getValue(), e.target.checked)} + isChecked={selectedChannels.some((c) => c.id === info.getValue())} + onChange={(e) => handleRowCheckboxChange(info.row.original, e.target.checked)} sx={{ '.chakra-checkbox__control': { width: '16px', @@ -298,7 +380,7 @@ export default function ChannelTable() { fontWeight={500} lineHeight="16px" letterSpacing="0.5px"> - Action + {t('channels.action')} ), cell: (info) => ( @@ -334,7 +416,7 @@ export default function ChannelTable() { bg="white" borderRadius="6px" boxShadow="0px 4px 10px 0px rgba(19, 51, 107, 0.10), 0px 0px 1px 0px rgba(19, 51, 107, 0.10)"> - {t('channels.test')} - + */} console.log('Enable/Disable', info.row.original.id)}> + onClick={() => + handleStatusUpdate(String(info.row.original.id), info.row.original.status) + }> {info.row.original.status === ChannelStatus.ChannelStatusEnabled ? ( <> console.log('Export', info.row.original.id)}> + onClick={() => handleExportRow(info.row.original)}> + + deleteChannelMutation.mutate({ id: String(info.row.original.id) })}> + + + + + {t('channels.delete')} + + ) @@ -579,18 +700,33 @@ export default function ChannelTable() { ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} + {isChannelTypeNamesLoading || isChannelsLoading ? ( + + - ))} + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx index 9df4dea5d52..3c76742a0b4 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx @@ -1,18 +1,46 @@ 'use client' import { Button, Flex, Text, useDisclosure } from '@chakra-ui/react' +import { useQuery } from '@tanstack/react-query' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import ChannelTable from './components/ChannelTable' import UpdateChannelModal from './components/UpdateChannelModal' import { useState } from 'react' +import { ChannelInfo } from '@/types/admin/channels/channelInfo' +import { getAllChannels, getChannels } from '@/api/platform' +import { QueryKey } from '@/types/query-key' +import { downloadJson } from '@/utils/common' export default function DashboardPage() { const { isOpen, onOpen, onClose } = useDisclosure() const [operationType, setOperationType] = useState<'create' | 'update'>('create') const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') + const [exportData, setExportData] = useState([]) + + const { + data: allChannels, + isFetching: isAllChannelsFetching, + refetch + } = useQuery({ + queryKey: [QueryKey.GetAllChannels], + queryFn: getAllChannels, + refetchOnReconnect: false, + enabled: false + }) + + const handleExport = async () => { + if (exportData.length === 0) { + const result = await refetch() + const dataToExport = result.data || [] + downloadJson(dataToExport, 'channels') + } else { + downloadJson(exportData, 'channels') + } + } + return ( - + {/* header */} @@ -140,6 +167,8 @@ export default function DashboardPage() { {t('dashboard.import')} {/* header end */} {/* table */} - + {/* modal */} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx index a95d6e97db2..64fadc32907 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx @@ -49,11 +49,10 @@ const CommonConfig = () => { }) const { isLoading: isOptionLoading, data: optionData } = useQuery({ - queryKey: [QueryKey.GetOption], + queryKey: [QueryKey.GetCommonConfig], queryFn: () => getOption(), onSuccess: (data) => { if (!data) return - console.log('data', data) setCommonConfig( produce(commonConfig, (draft) => { @@ -73,7 +72,7 @@ const CommonConfig = () => { title: t('globalConfigs.saveCommonConfigSuccess'), status: 'success' }) - queryClient.invalidateQueries({ queryKey: [QueryKey.GetOption] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetCommonConfig] }) }, onError: () => { message({ @@ -116,7 +115,7 @@ const CommonConfig = () => { fontWeight="500" lineHeight="24px" letterSpacing="0.15px"> - {t('globalonfigs.common_config')} + {t('globalConfigs.common_config')}
{/* -- title end */} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx index 3e42cbf4735..2ce5660629f 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx @@ -24,7 +24,7 @@ interface EditableTextProps { } export const EditableText = ({ value, label, onSubmit, flexProps }: EditableTextProps) => { - const [editValue, setEditValue] = useState(value.toString()) + const [editValue, setEditValue] = useState(String(value)) const { isOpen, onOpen, onClose } = useDisclosure() const handleSubmit = () => { @@ -33,7 +33,8 @@ export const EditableText = ({ value, label, onSubmit, flexProps }: EditableText } const handleCancel = () => { - setEditValue(value.toString()) + // 关闭时 恢复到传递来的初始值 + setEditValue(String(value)) onClose() } diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableTextNoLable.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableTextNoLable.tsx new file mode 100644 index 00000000000..0f9df6ff525 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableTextNoLable.tsx @@ -0,0 +1,172 @@ +'use client' +import React, { useState } from 'react' +import { + Flex, + Text, + Button, + Input, + useDisclosure, + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + HStack, + FlexProps, + Box +} from '@chakra-ui/react' +import { CheckIcon, CloseIcon } from '@chakra-ui/icons' + +interface EditableTextProps { + value: string | number + onSubmit: (value: string) => void + flexProps?: FlexProps +} + +export const EditableTextNoLable = ({ value, onSubmit, flexProps }: EditableTextProps) => { + const [editValue, setEditValue] = useState(String(value)) + const { isOpen, onOpen, onClose } = useDisclosure() + + const handleSubmit = () => { + onSubmit(editValue) + onClose() + } + + const handleCancel = () => { + // 关闭时 恢复到传递来的初始值 + setEditValue(String(value)) + onClose() + } + + return ( + + + + + {value} + + + + + + + + setEditValue(e.target.value)} + minW="0" + w="full" + h="28px" + borderRadius="6px" + border="1px solid var(--Gray-Modern-200, #E8EBF0)" + bgColor="white" + _hover={{ borderColor: 'grayModern.300' }} + _focus={{ borderColor: 'grayModern.300' }} + _focusVisible={{ borderColor: 'grayModern.300' }} + _active={{ borderColor: 'grayModern.300' }} + autoFocus + /> + + + + + + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx index a7b436d873a..3499f262fbe 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx @@ -159,7 +159,6 @@ const ModelConfig = () => { const currentValues = watch() // Create new array with new item at the beginning const newValues = [newItem, ...Object.values(currentValues)] - console.log('newValues', newValues) // Reset form with new values reset(newValues) } @@ -170,13 +169,11 @@ const ModelConfig = () => { ? formValues : Object.values(formValues) - console.log('formValuesArray', formValuesArray) - const batchOptionMutation = useMutation({ mutationFn: batchOption, onSuccess: () => { message({ - title: t('channels.createSuccess'), + title: t('globalConfigs.saveDefaultModelSuccess'), status: 'success' }) } @@ -271,7 +268,8 @@ const ModelConfig = () => { position: 'top', duration: 2000, isClosable: true, - description: error instanceof Error ? error.message : t('channels.createFailed') + description: + error instanceof Error ? error.message : t('globalConfigs.saveDefaultModelFailed') }) console.error(error) } @@ -311,7 +309,7 @@ const ModelConfig = () => { fontWeight="500" lineHeight="24px" letterSpacing="0.15px"> - {t('globalonfigs.model_config')} + {t('globalConfigs.model_config')}
{/* -- title end */} @@ -427,214 +425,249 @@ const ModelConfig = () => { borderRadius="6px" overflow="hidden" overflowY="auto"> - {isChannelTypeNamesLoading || isBuiltInSupportModelsLoading || isOptionLoading ? ( + {isChannelTypeNamesLoading || + isBuiltInSupportModelsLoading || + isOptionLoading || + formValuesArray?.length === 0 ? ( ) : ( formValuesArray && formValuesArray.length > 0 && channelTypeNames && formValuesArray.map((value, index) => { - if (true) { - return ( - + + + + + { + // Get current form types + const currentFormTypes = Object.values(formValues) + .map((item) => String(item.type)) + .filter( + (type): type is ChannelType => + type !== undefined && type in channelTypeNames ) - - const initSelectedItem = field.value - ? channelTypeNames[String(field.value) as ChannelType] - : undefined - - return ( - - dropdownItems={availableTypes} - initSelectedItem={initSelectedItem} - setSelectedItem={(channelName: string) => { - if (channelName) { - const channelType = Object.entries(channelTypeNames).find( - ([_, name]) => name === channelName - )?.[0] - - if (channelType) { - const defaultModelField = - defaultModel[channelType as ChannelType] - const defaultModelMappingField = - defaultModelMapping[channelType as ChannelType] - - field.onChange(Number(channelType)) - setValue(`${index}.defaultMode`, defaultModelField || []) - setValue( - `${index}.defaultModeMapping`, - defaultModelMappingField || {} - ) - } + .map((type) => channelTypeNames[type]) + + // Filter available types + const availableTypes = allSupportChannel.filter( + (channelType) => + !currentFormTypes.includes(channelType) || + // 避免编辑时当前选中值"消失"的问题,即当前选择项也包含 + (field.value && + channelTypeNames[String(field.value) as ChannelType] === + channelType) + ) + + const initSelectedItem = field.value + ? channelTypeNames[String(field.value) as ChannelType] + : undefined + + return ( + + dropdownItems={availableTypes} + initSelectedItem={initSelectedItem} + setSelectedItem={(channelName: string) => { + if (channelName) { + const channelType = Object.entries(channelTypeNames).find( + ([_, name]) => name === channelName + )?.[0] + + if (channelType) { + const defaultModelField = + defaultModel[channelType as ChannelType] + const defaultModelMappingField = + defaultModelMapping[channelType as ChannelType] + + field.onChange(Number(channelType)) + setValue(`${index}.defaultMode`, defaultModelField || []) + setValue( + `${index}.defaultModeMapping`, + defaultModelMappingField || {} + ) } - }} - handleDropdownItemFilter={( - dropdownItems: string[], - inputValue: string - ) => { - const lowerCasedInput = inputValue.toLowerCase() - return dropdownItems.filter( - (item) => - !inputValue || item.toLowerCase().includes(lowerCasedInput) - ) - }} - handleDropdownItemDisplay={(item: string) => ( - - {item} - - )} - /> - ) - }} - /> - - - - { - // Get the current type value from the form - const currentType = watch(`${index}.type`) - const dropdownItems = currentType - ? allSupportChannelWithMode[String(currentType) as ChannelType] || [] - : [] - - const handleSetCustomModel = ( - selectedItems: string[], - setSelectedItems: Dispatch>, - customModeName: string, - setCustomModeName: Dispatch> - ) => { - if (customModeName.trim()) { - const exists = field.value.some( - (item) => item === customModeName.trim() + } + }} + handleDropdownItemFilter={( + dropdownItems: string[], + inputValue: string + ) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => + !inputValue || item.toLowerCase().includes(lowerCasedInput) ) + }} + handleDropdownItemDisplay={(item: string) => ( + + {item} + + )} + /> + ) + }} + /> + + + + { + // Get the current type value from the form + const currentType = watch(`${index}.type`) + const dropdownItems = currentType + ? allSupportChannelWithMode[String(currentType) as ChannelType] || [] + : [] + + const handleSetCustomModel = ( + selectedItems: string[], + setSelectedItems: Dispatch>, + customModeName: string, + setCustomModeName: Dispatch> + ) => { + if (customModeName.trim()) { + const exists = field.value.some( + (item) => item === customModeName.trim() + ) - if (!exists) { - field.onChange([...field.value, customModeName.trim()]) - setCustomModeName('') - } + if (!exists) { + field.onChange([...field.value, customModeName.trim()]) + setCustomModeName('') } } - - const handleModelFilteredDropdownItems = ( - dropdownItems: string[], - selectedItems: string[], - inputValue: string - ) => { - const lowerCasedInputValue = inputValue.toLowerCase() - - return dropdownItems.filter( - (item) => - !selectedItems.includes(item) && - item.toLowerCase().includes(lowerCasedInputValue) - ) - } - - return ( - - dropdownItems={dropdownItems || []} - selectedItems={field.value || []} // Use field.value for selected items - setSelectedItems={(models) => { - field.onChange(models) - }} - handleFilteredDropdownItems={handleModelFilteredDropdownItems} - handleDropdownItemDisplay={(item) => ( - - {item} - - )} - handleSelectedItemDisplay={(item) => ( - - {item} - - )} - handleSetCustomSelectedItem={handleSetCustomModel} - /> - ) - }} - /> - - - - { - const defaultMode = watch(`${index}.defaultMode`) - const defaultModeMapping = watch(`${index}.defaultModeMapping`) - - return ( - { - field.onChange(mapping) - }} - /> + } + + const handleModelFilteredDropdownItems = ( + dropdownItems: string[], + selectedItems: string[], + inputValue: string + ) => { + const lowerCasedInputValue = inputValue.toLowerCase() + + return dropdownItems.filter( + (item) => + !selectedItems.includes(item) && + item.toLowerCase().includes(lowerCasedInputValue) ) - }} - /> - - - - ) - } + } + + return ( + + dropdownItems={dropdownItems || []} + selectedItems={field.value || []} // Use field.value for selected items + setSelectedItems={(models) => { + field.onChange(models) + }} + handleFilteredDropdownItems={handleModelFilteredDropdownItems} + handleDropdownItemDisplay={(item) => ( + + {item} + + )} + handleSelectedItemDisplay={(item) => ( + + {item} + + )} + handleSetCustomSelectedItem={handleSetCustomModel} + /> + ) + }} + /> + + + + { + const defaultMode = watch(`${index}.defaultMode`) + const defaultModeMapping = watch(`${index}.defaultModeMapping`) + + return ( + { + field.onChange(mapping) + }} + /> + ) + }} + /> + + +
+ ) }) )}
diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx index ec35161af93..f259f1ccd1d 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx @@ -38,8 +38,6 @@ export default function Home(): React.JSX.Element { const { data: models = [] } = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) - console.log(modelName) - const { isLoading } = useQuery( [QueryKey.GetGlobalLogs, page, pageSize, name, modelName, startTime, endTime, groupId], () => @@ -128,7 +126,16 @@ export default function Home(): React.JSX.Element { - {t('logs.total_price')} + + {t('logs.total_price')} + @@ -184,6 +191,22 @@ export default function Home(): React.JSX.Element { {t('logs.call_log')} - )} +
))} diff --git a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx index ba734d17e29..5fc03918993 100644 --- a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx +++ b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx @@ -20,7 +20,6 @@ export const ConstructModeMappingComponent = function ({ mapData: Record setMapData: (mapping: Record) => void }) { - console.log(mapData) const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx index 53a70b63a7f..0fcdab7ffa2 100644 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ b/frontend/providers/aiproxy/components/user/ModelList.tsx @@ -176,31 +176,40 @@ const ModelList: React.FC = () => {
- - {isLoading ? ( -
- -
- ) : ( - data?.map((modelConfig) => ( + + {isLoading ? ( +
+ +
+ ) : ( + + {data?.map((modelConfig) => ( - )) - )} - + ))} +
+ )} ) } diff --git a/frontend/providers/aiproxy/types/admin/group.ts b/frontend/providers/aiproxy/types/admin/group.ts new file mode 100644 index 00000000000..9eb7845fd8c --- /dev/null +++ b/frontend/providers/aiproxy/types/admin/group.ts @@ -0,0 +1,20 @@ +export interface GroupInfo { + id: string + status: number + used_amount: number + qpm: number + request_count: number + created_at: number + accessed_at: number +} + +export interface GroupQueryParams { + keyword?: string + page: number + perPage: number +} + +export enum GroupStatus { + ENABLED = 1, + DISABLED = 2 +} diff --git a/frontend/providers/aiproxy/types/query-key.ts b/frontend/providers/aiproxy/types/query-key.ts index bbf76d2745a..4834b0826a9 100644 --- a/frontend/providers/aiproxy/types/query-key.ts +++ b/frontend/providers/aiproxy/types/query-key.ts @@ -1,13 +1,19 @@ export enum QueryKey { + // 共用 // common GetTokens = 'getTokens', GetUserLogs = 'getUserLogs', GetEnabledModels = 'getEnabledModels', // admin GetChannels = 'getChannels', + GetAllChannels = 'getAllChannels', GetGlobalLogs = 'getGlobalLogs', + GetGroups = 'getGroups', GetChannelTypeNames = 'getChannelTypeNames', GetAllChannelModes = 'getAllChannelModes', GetDefaultModelAndModeMapping = 'getDefaultModelAndModeMapping', - GetOption = 'getOption' + GetOption = 'getOption', + + // 组件自己管理 + GetCommonConfig = 'getCommonConfig' } diff --git a/frontend/providers/aiproxy/utils/backend/isAdmin.ts b/frontend/providers/aiproxy/utils/backend/isAdmin.ts index 1d27cf5fd6c..3a372c7144b 100644 --- a/frontend/providers/aiproxy/utils/backend/isAdmin.ts +++ b/frontend/providers/aiproxy/utils/backend/isAdmin.ts @@ -1,5 +1,5 @@ export async function isAdmin(namespace: string): Promise { - // return true + return true if (!namespace) { return Promise.reject('Admin: Invalid namespace') } diff --git a/frontend/providers/aiproxy/utils/common.ts b/frontend/providers/aiproxy/utils/common.ts index 14f254adfab..25743fc8566 100644 --- a/frontend/providers/aiproxy/utils/common.ts +++ b/frontend/providers/aiproxy/utils/common.ts @@ -21,3 +21,16 @@ export const getTranslationWithFallback = ( const translated = t(key) return translated === key ? t(defaultKey) : translated } + +// 下载 JSON 文件 +export const downloadJson = (data: T, filename: string): void => { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${filename}_${new Date().toISOString()}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} From f7d535f75a197677f60411979a35c9c342e3ad34 Mon Sep 17 00:00:00 2001 From: lim Date: Thu, 5 Dec 2024 07:49:50 +0000 Subject: [PATCH 19/42] ok --- frontend/providers/aiproxy/utils/backend/isAdmin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/providers/aiproxy/utils/backend/isAdmin.ts b/frontend/providers/aiproxy/utils/backend/isAdmin.ts index 3a372c7144b..1d27cf5fd6c 100644 --- a/frontend/providers/aiproxy/utils/backend/isAdmin.ts +++ b/frontend/providers/aiproxy/utils/backend/isAdmin.ts @@ -1,5 +1,5 @@ export async function isAdmin(namespace: string): Promise { - return true + // return true if (!namespace) { return Promise.reject('Admin: Invalid namespace') } From 445b2ebc713cc5ccf89aa6df75088470c1742efc Mon Sep 17 00:00:00 2001 From: lim Date: Thu, 5 Dec 2024 13:32:32 +0000 Subject: [PATCH 20/42] ok --- frontend/providers/aiproxy/api/platform.ts | 14 ++ .../app/[lng]/(admin)/dashboard/page.tsx | 169 +++++++++++----- .../components/CommonConfig.tsx | 4 +- .../global-configs/components/ModelConfig.tsx | 10 +- .../app/[lng]/(admin)/global-configs/page.tsx | 190 +++++++++++++----- .../app/[lng]/(admin)/ns-manager/page.tsx | 2 +- .../app/api/admin/channel/upload/route.ts | 72 ++++--- .../app/api/admin/group/[id]/status/route.ts | 1 - .../app/api/admin/option/upload/route.ts | 89 ++++++++ .../aiproxy/app/i18n/locales/en/common.json | 8 +- .../aiproxy/app/i18n/locales/zh/common.json | 4 +- .../common/ConstructMappingComponent.tsx | 32 +-- .../common}/EditableTextNoLable.tsx | 0 frontend/providers/aiproxy/package.json | 2 + .../aiproxy/utils/backend/isAdmin.ts | 2 +- .../aiproxy/utils/frontend/request.ts | 5 + 16 files changed, 450 insertions(+), 154 deletions(-) create mode 100644 frontend/providers/aiproxy/app/api/admin/option/upload/route.ts rename frontend/providers/aiproxy/{app/[lng]/(admin)/global-configs/components => components/common}/EditableTextNoLable.tsx (100%) diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index a8740a0c125..b3066fe2d59 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -64,6 +64,13 @@ export const getAllChannels = () => GET('/api/adm export const deleteChannel = (id: string) => DELETE(`/api/admin/channel/${id}`) +export const uploadChannels = (formData: FormData) => + POST('/api/admin/channel/upload', formData, { + headers: { + // Don't set Content-Type header here, it will be automatically set with the correct boundary + } + }) + // channel built-in support models and default model default mode mapping export const getChannelBuiltInSupportModels = () => GET('/api/models/builtin/channel') @@ -80,6 +87,13 @@ export const updateOption = (params: { key: string; value: string }) => export const batchOption = (params: BatchOptionData) => PUT(`/api/admin/option/batch`, params) +export const uploadOptions = (formData: FormData) => + POST('/api/admin/option/upload', formData, { + headers: { + // Don't set Content-Type header here, it will be automatically set with the correct boundary + } + }) + // log export const getGlobalLogs = (params: GlobalLogQueryParams) => GET('/api/admin/log', params) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx index 3c76742a0b4..c09c4b2ca10 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx @@ -1,15 +1,16 @@ 'use client' -import { Button, Flex, Text, useDisclosure } from '@chakra-ui/react' -import { useQuery } from '@tanstack/react-query' +import { Button, Flex, Text, useDisclosure, useToast } from '@chakra-ui/react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import ChannelTable from './components/ChannelTable' import UpdateChannelModal from './components/UpdateChannelModal' -import { useState } from 'react' +import { useState, useRef } from 'react' import { ChannelInfo } from '@/types/admin/channels/channelInfo' -import { getAllChannels, getChannels } from '@/api/platform' +import { getAllChannels, uploadChannels } from '@/api/platform' import { QueryKey } from '@/types/query-key' import { downloadJson } from '@/utils/common' +import { useMessage } from '@sealos/ui' export default function DashboardPage() { const { isOpen, onOpen, onClose } = useDisclosure() @@ -17,6 +18,19 @@ export default function DashboardPage() { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') const [exportData, setExportData] = useState([]) + const fileInputRef = useRef(null) + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const queryClient = useQueryClient() const { data: allChannels, @@ -29,6 +43,10 @@ export default function DashboardPage() { enabled: false }) + const uploadMutation = useMutation({ + mutationFn: uploadChannels + }) + const handleExport = async () => { if (exportData.length === 0) { const result = await refetch() @@ -39,6 +57,42 @@ export default function DashboardPage() { } } + const handleImport = () => { + fileInputRef.current?.click() + } + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + const formData = new FormData() + formData.append('file', file) + + try { + await uploadMutation.mutateAsync(formData) + message({ + title: t('dashboard.importSuccess'), + status: 'success', + duration: 3000, + isClosable: true + }) + queryClient.invalidateQueries([QueryKey.GetChannels]) + queryClient.invalidateQueries([QueryKey.GetChannelTypeNames]) + } catch (error) { + console.error('Import error:', error) + message({ + title: t('dashboard.importError'), + status: 'error', + duration: 3000, + isClosable: true + }) + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + return ( {t('dashboard.create')} - + <> + + + + - diff --git a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx index 5fc03918993..d1a4f63d21e 100644 --- a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx +++ b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx @@ -27,11 +27,18 @@ export const ConstructModeMappingComponent = function ({ { key: '', value: '' } ]) + const [isInternalUpdate, setIsInternalUpdate] = useState(false) + useEffect(() => { - const entries = Object.entries(mapData) - if (entries.length > 0) { - setMapkeyValuePairs(entries.map(([key, value]) => ({ key, value }))) + if (!isInternalUpdate) { + const entries = Object.entries(mapData) + setMapkeyValuePairs( + entries.length > 0 + ? entries.map(([key, value]) => ({ key, value })) + : [{ key: '', value: '' }] + ) } + setIsInternalUpdate(false) }, [mapData]) const handleDropdownItemDisplay = (dropdownItem: Model | string) => { @@ -235,6 +242,7 @@ export const ConstructModeMappingComponent = function ({ removedKeys.forEach((key) => { delete newMapData[key] }) + setIsInternalUpdate(true) setMapData(newMapData) } @@ -273,14 +281,11 @@ export const ConstructModeMappingComponent = function ({ if (mapKeyValuePair.key) { delete newMapData[mapKeyValuePair.key] } + setIsInternalUpdate(true) setMapData(newMapData) const newMapKeyValuePairs = mapKeyValuePairs.filter((_, idx) => idx !== index) - if (newMapKeyValuePairs.length === 0) { - setMapkeyValuePairs([{ key: '', value: '' }]) - } else { - setMapkeyValuePairs(newMapKeyValuePairs) - } + setMapkeyValuePairs(newMapKeyValuePairs) } // Handling selection/input changes @@ -308,6 +313,7 @@ export const ConstructModeMappingComponent = function ({ } setMapkeyValuePairs(newMapKeyValuePairs) + setIsInternalUpdate(true) setMapData(newMapData) } @@ -391,32 +397,34 @@ export const ConstructModeMappingComponent = function ({ /> - {mapKeyValuePairs.length > 1 && ( - - )} + ))} From 71c3b57e67690bad46e55e538e6d5263e5203fe5 Mon Sep 17 00:00:00 2001 From: lim Date: Fri, 20 Dec 2024 04:02:21 +0000 Subject: [PATCH 26/42] refact user logs --- frontend/providers/aiproxy/api/platform.ts | 4 +- .../components/UpdateChannelModal.tsx | 52 +- .../app/[lng]/(admin)/global-logs/page.tsx | 3 + .../aiproxy/app/[lng]/(user)/logs/page.tsx | 868 ++++++++++++++++-- .../aiproxy/app/api/user/log/route.ts | 9 +- .../aiproxy/app/i18n/locales/en/common.json | 16 +- .../aiproxy/app/i18n/locales/zh/common.json | 16 +- .../aiproxy/components/InitializeApp.tsx | 8 +- frontend/providers/aiproxy/package.json | 1 + frontend/providers/aiproxy/types/user/logs.ts | 16 +- 10 files changed, 892 insertions(+), 101 deletions(-) diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index b3066fe2d59..3b679c6479b 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -22,7 +22,7 @@ export const initAppConfig = () => '/api/init-app-config' ) -// user +// export const getEnabledMode = () => GET('/api/models/enabled') // log @@ -42,7 +42,7 @@ export const updateToken = (id: number, status: number) => POST(`/api/user/token/${id}`, { status: status }) // ------------------------------------------------------------ -// admin +// export const getChannels = (params: ChannelQueryParams) => GET('/api/admin/channel', params) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx index 2702b233eb4..7130c07e89d 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx @@ -446,19 +446,17 @@ export const UpdateChannelModal = function ({ borderBottom="1px solid grayModern.100" background="grayModern.25" w="full"> - - - - {operationType === 'create' ? t('channels.create') : t('channels.edit')} - - + + + {operationType === 'create' ? t('channels.create') : t('channels.edit')} + @@ -502,19 +499,17 @@ export const UpdateChannelModal = function ({ borderBottom="1px solid grayModern.100" background="grayModern.25" w="full"> - - - - {operationType === 'create' ? t('channels.create') : t('channels.edit')} - - + + + {operationType === 'create' ? t('channels.create') : t('channels.edit')} + diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx index b02196a8255..2d5a687a2ca 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx @@ -239,7 +239,9 @@ export default function Home(): React.JSX.Element { alignItems="flex-start" justifyContent="space-between" sx={{ + transition: 'gap 0.3s ease', '@media screen and (min-width: 1300px)': { + gap: '160px', flexWrap: 'nowrap' } }} @@ -340,6 +342,7 @@ export default function Home(): React.JSX.Element { alignItems="flex-start" justifyContent="space-between" sx={{ + transition: 'gap 0.3s ease', '@media screen and (min-width: 1300px)': { gap: '160px', flexWrap: 'nowrap' diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx index f5ca74e022a..d91a8c5485a 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx @@ -1,6 +1,20 @@ 'use client' -import { Box, Flex, Text, Button, Icon } from '@chakra-ui/react' +import { + Box, + Flex, + Text, + Button, + Icon, + Modal, + useDisclosure, + ModalOverlay, + ModalHeader, + ModalCloseButton, + ModalBody, + ModalContent, + Grid +} from '@chakra-ui/react' import { CurrencySymbol, MySelect, MyTooltip } from '@sealos/ui' import { useMemo, useState } from 'react' @@ -12,15 +26,24 @@ import { BaseTable } from '@/components/table/BaseTable' import { useI18n } from '@/providers/i18n/i18nContext' import { LogItem } from '@/types/user/logs' import { useQuery } from '@tanstack/react-query' -import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { getCoreRowModel, useReactTable, createColumnHelper } from '@tanstack/react-table' import { QueryKey } from '@/types/query-key' import { useBackendStore } from '@/store/backend' import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' +import { getTranslationWithFallback } from '@/utils/common' +import ReactJson, { OnCopyProps } from 'react-json-view' -export default function Home(): React.JSX.Element { +const getTimeDiff = (createdAt: number, requestAt: number) => { + const diff = Number(((createdAt - requestAt) / 1000).toFixed(4)).toString() + return `${diff}s` +} + +export default function Logs(): React.JSX.Element { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') const { currencySymbol } = useBackendStore() + const { isOpen, onOpen, onClose } = useDisclosure() + const [selectedRow, setSelectedRow] = useState(null) const [startTime, setStartTime] = useState(() => { const currentDate = new Date() @@ -29,6 +52,7 @@ export default function Home(): React.JSX.Element { }) const [endTime, setEndTime] = useState(new Date()) const [name, setName] = useState('') + const [codeType, setCodeType] = useState('all') const [modelName, setModelName] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) @@ -41,13 +65,14 @@ export default function Home(): React.JSX.Element { ) const { isLoading } = useQuery( - [QueryKey.GetUserLogs, page, pageSize, name, modelName, startTime, endTime], + [QueryKey.GetUserLogs, page, pageSize, name, modelName, startTime, endTime, codeType], () => getUserLogs({ page, perPage: pageSize, token_name: name, model_name: modelName, + code_type: codeType as 'all' | 'success' | 'error', start_timestamp: startTime.getTime().toString(), end_timestamp: endTime.getTime().toString() }), @@ -64,56 +89,151 @@ export default function Home(): React.JSX.Element { } ) - const columns = useMemo[]>(() => { - return [ - { - header: t('logs.name'), - accessorKey: 'token_name' - }, - { - header: t('logs.model'), - accessorKey: 'model' - }, - { - header: t('logs.prompt_tokens'), - accessorKey: 'prompt_tokens' - }, - { - header: t('logs.completion_tokens'), - accessorKey: 'completion_tokens' - }, - - { - header: t('logs.status'), - accessorFn: (row) => (row.code === 200 ? t('logs.success') : t('logs.failed')), - cell: ({ getValue }) => { - const value = getValue() as string + const columnHelper = createColumnHelper() + + const columns = useMemo( + () => [ + columnHelper.accessor('token_name', { + header: () => ( + + {t('logs.name')} + + ), + id: 'token_name' + }), + columnHelper.accessor('model', { + header: () => ( + + {t('logs.model')} + + ), + id: 'model' + }), + columnHelper.accessor('prompt_tokens', { + header: () => ( + + {t('logs.prompt_tokens')} + + ), + id: 'prompt_tokens' + }), + columnHelper.accessor('completion_tokens', { + header: () => ( + + {t('logs.completion_tokens')} + + ), + id: 'completion_tokens' + }), + + columnHelper.display({ + header: () => ( + + {t('logs.totalTime')} + + ), + cell: ({ row }) => ( + + {getTimeDiff(row.original.created_at, row.original.request_at)} + + ), + id: 'total_time' + }), + + columnHelper.accessor('code', { + header: () => ( + + {t('logs.status')} + + ), + cell: ({ getValue, row }) => { + const code = getValue() return ( - - {value} - + + + {code !== 200 ? `${t('logs.failed')} (${row.original.code})` : code} + + {code !== 200 && ( + + + + + + )} + ) }, id: 'status' - }, - { - header: t('logs.time'), - accessorFn: (row) => new Date(row.created_at).toLocaleString(), + }), + + columnHelper.accessor('created_at', { + header: () => ( + + {t('logs.time')} + + ), + cell: ({ row }) => new Date(row.original.created_at).toLocaleString(), id: 'created_at' - }, - { - accessorKey: 'used_amount', - id: 'used_amount', + }), + columnHelper.accessor('used_amount', { header: () => { return ( @@ -134,10 +254,88 @@ export default function Home(): React.JSX.Element { ) - } - } - ] - }, []) + }, + id: 'used_amount' + }), + + columnHelper.display({ + header: () => ( + + {t('logs.actions')} + + ), + cell: ({ row }) => ( + + ), + id: 'detail' + }) + ], + [t, currencySymbol] + ) const table = useReactTable({ data: logData, @@ -234,6 +432,7 @@ export default function Home(): React.JSX.Element { alignSelf="stretch" flexWrap="wrap" sx={{ + transition: 'gap 0.3s ease', gap: '16px', '@media screen and (min-width: 1318px)': { gap: '24px' @@ -280,7 +479,7 @@ export default function Home(): React.JSX.Element { ) }} - flexProps={{ w: '320px' }} + flexProps={{ w: '280px' }} placeholder={t('logs.select_token_name')} /> @@ -326,11 +525,51 @@ export default function Home(): React.JSX.Element { ) }} - flexProps={{ w: '320px' }} + flexProps={{ w: '280px' }} placeholder={t('logs.select_modal')} /> + + + {t('logs.status')} + + { + setCodeType(val) + }} + /> + + {/* -- table end */} + ) } + +const LogDetailModal = ({ + isOpen, + onClose, + rowData +}: { + isOpen: boolean + onClose: () => void + rowData: LogItem | null +}) => { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + + // 定义默认的网格配置 + const gridConfig = { + labelWidth: '153px', + rowHeight: '48px', + jsonContentHeight: '122px' + } + + const renderDetailRow = ( + leftLabel: string | React.ReactNode, + leftValue: string | number | React.ReactNode | undefined, + rightLabel?: string | React.ReactNode, + rightValue?: string | number | React.ReactNode | undefined, + options?: { + labelWidth?: string + rowHeight?: string + isFirst?: boolean + isLast?: boolean + } + ) => { + // 辅助函数:渲染标签 + const renderLabel = (label: string | React.ReactNode) => { + if (typeof label === 'string') { + return ( + + {label} + + ) + } + return label + } + + // 辅助函数:渲染值 + const renderValue = (value: string | number | React.ReactNode | undefined) => { + if (typeof value === 'string' || typeof value === 'number') { + return ( + + {value} + + ) + } + return value + } + return ( + + + + {renderLabel(leftLabel)} + + + {renderValue(leftValue)} + + + {rightLabel && ( + + + {renderLabel(rightLabel)} + + + {renderValue(rightValue)} + + + )} + + ) + } + + const renderJsonContent = ( + label: string, + content: string | undefined, + options?: { + labelWidth?: string + contentHeight?: string + isFirst?: boolean + isLast?: boolean + } + ) => { + const handleCopy = (copy: OnCopyProps) => { + const copyText = + typeof copy.src === 'object' ? JSON.stringify(copy.src, null, 2) : String(copy.src) + navigator.clipboard.writeText(copyText) + } + + if (!content) return null + + let parsed = null + + try { + parsed = JSON.parse(content) + } catch { + parsed = content + } + return ( + + + + {label} + + + + {typeof parsed === 'object' ? ( + + ) : ( + + {parsed} + + )} + + + ) + } + + return ( + + + + + + + {t('logs.logDetail')} + + + + + + + {renderDetailRow( + t('logs.requestId'), + rowData?.request_id, + t('logs.status'), + + {rowData?.code === 200 + ? t('logs.success') + : `${t('logs.failed')} (${rowData?.code})`} + , + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: true + } + )} + {renderDetailRow( + 'Endpoint', + rowData?.endpoint, + t('logs.mode'), + getTranslationWithFallback( + `modeType.${String(rowData?.mode)}`, + 'modeType.0', + t as any + ), + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow( + t('logs.requestTime'), + new Date(rowData?.created_at || 0).toLocaleString(), + t('logs.totalTime'), + getTimeDiff(rowData?.created_at || 0, rowData?.request_at || 0), + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow( + t('logs.tokenName'), + rowData?.token_name, + t('logs.tokenId'), + rowData?.token_id, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow(t('logs.model'), rowData?.model, undefined, undefined, { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + })} + + {rowData?.content && + renderDetailRow( + t('logs.info'), + + + {rowData.content} + + , + undefined, + undefined, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + + {rowData?.request_detail?.request_body && + renderJsonContent(t('logs.requestBody'), rowData.request_detail.request_body, { + labelWidth: gridConfig.labelWidth, + contentHeight: gridConfig.jsonContentHeight, + isFirst: false + })} + {rowData?.request_detail?.response_body && + renderJsonContent(t('logs.responseBody'), rowData.request_detail.response_body, { + labelWidth: gridConfig.labelWidth, + contentHeight: gridConfig.jsonContentHeight, + isLast: false + })} + + {renderDetailRow( + + + {t('key.inputPrice')} + + + + /{t('price.per1kTokens').toLowerCase()} + + , + rowData?.price, + + + {t('key.outputPrice')} + + + + /{t('price.per1kTokens').toLowerCase()} + + , + rowData?.completion_price, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow( + t('logs.inputTokens'), + rowData?.prompt_tokens, + t('logs.outputTokens'), + rowData?.completion_tokens, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + + {renderDetailRow( + + + + {t('logs.total_price')} + + + + , + rowData?.used_amount || 0, + undefined, + undefined, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isLast: true + } + )} + + + + + ) +} diff --git a/frontend/providers/aiproxy/app/api/user/log/route.ts b/frontend/providers/aiproxy/app/api/user/log/route.ts index 83b74b4311d..ba7c9c897c5 100644 --- a/frontend/providers/aiproxy/app/api/user/log/route.ts +++ b/frontend/providers/aiproxy/app/api/user/log/route.ts @@ -17,9 +17,9 @@ export type UserLogSearchResponse = ApiResp<{ export interface UserLogQueryParams { token_name?: string model_name?: string - code?: string start_timestamp?: string end_timestamp?: string + code_type?: 'all' | 'success' | 'error' | undefined page: number perPage: number } @@ -58,8 +58,9 @@ async function fetchLogs( if (params.model_name) { url.searchParams.append('model_name', params.model_name) } - if (params.code) { - url.searchParams.append('code', params.code) + + if (params.code_type) { + url.searchParams.append('code_type', params.code_type) } if (params.start_timestamp) { url.searchParams.append('start_timestamp', params.start_timestamp) @@ -108,7 +109,7 @@ export async function GET(request: NextRequest): Promise Date: Fri, 20 Dec 2024 04:04:15 +0000 Subject: [PATCH 27/42] frontend(aiproxy): refact user logs --- frontend/pnpm-lock.yaml | 217 +++++++++++++++++++++++++++++----------- 1 file changed, 157 insertions(+), 60 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 4e20570575b..30070915136 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -526,7 +526,7 @@ importers: version: 9.0.2 next: specifier: 14.2.5 - version: 14.2.5(react-dom@18.2.0)(react@18.2.0) + version: 14.2.5(@babel/core@7.23.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.5) react: specifier: ^18 version: 18.2.0 @@ -539,6 +539,9 @@ importers: react-hook-form: specifier: ^7.46.2 version: 7.48.2(react@18.2.0) + react-json-view: + specifier: ^1.21.3 + version: 1.21.3(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) sealos-desktop-sdk: specifier: workspace:* version: link:../../packages/client-sdk @@ -11783,6 +11786,10 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /base16@1.0.0: + resolution: {integrity: sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==} + dev: false + /base64-arraybuffer@0.1.2: resolution: {integrity: sha512-ewBKKVVPIl78B26mYQHYlaxR7NydMiD/GxwLNIwTAfLIE4xhN2Gxcy30//azq5UrejXjzGpWjcBu3NUJxzMMzg==} engines: {node: '>= 0.6.0'} @@ -12598,6 +12605,14 @@ packages: hasBin: true dev: false + /cross-fetch@3.1.8: + resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dev: false + /cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} dependencies: @@ -15460,6 +15475,32 @@ packages: bser: 2.1.1 dev: true + /fbemitter@3.0.0: + resolution: {integrity: sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==} + dependencies: + fbjs: 3.0.5 + transitivePeerDependencies: + - encoding + dev: false + + /fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + dev: false + + /fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + dependencies: + cross-fetch: 3.1.8 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.39 + transitivePeerDependencies: + - encoding + dev: false + /fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} dev: false @@ -15562,6 +15603,18 @@ packages: /flatted@3.2.9: resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} + /flux@4.0.4(react@18.2.0): + resolution: {integrity: sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==} + peerDependencies: + react: ^15.0.2 || ^16.0.0 || ^17.0.0 + dependencies: + fbemitter: 3.0.0 + fbjs: 3.0.5 + react: 18.2.0 + transitivePeerDependencies: + - encoding + dev: false + /fmin@0.0.2: resolution: {integrity: sha512-sSi6DzInhl9d8yqssDfGZejChO8d2bAGIpysPsvYsxFe898z89XhCZg6CPNV3nhUhFefeC/AXZK2bAJxlBjN6A==} dependencies: @@ -18023,9 +18076,17 @@ packages: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} dev: false + /lodash.curry@4.1.1: + resolution: {integrity: sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==} + dev: false + /lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + /lodash.flow@3.5.0: + resolution: {integrity: sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==} + dev: false + /lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} dev: false @@ -19539,48 +19600,6 @@ packages: - babel-plugin-macros dev: false - /next@14.2.5(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==} - engines: {node: '>=18.17.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 - react: ^18.2.0 - react-dom: ^18.2.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - sass: - optional: true - dependencies: - '@next/env': 14.2.5 - '@swc/helpers': 0.5.5 - busboy: 1.6.0 - caniuse-lite: 1.0.30001594 - graceful-fs: 4.2.11 - postcss: 8.4.31 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(react@18.2.0) - optionalDependencies: - '@next/swc-darwin-arm64': 14.2.5 - '@next/swc-darwin-x64': 14.2.5 - '@next/swc-linux-arm64-gnu': 14.2.5 - '@next/swc-linux-arm64-musl': 14.2.5 - '@next/swc-linux-x64-gnu': 14.2.5 - '@next/swc-linux-x64-musl': 14.2.5 - '@next/swc-win32-arm64-msvc': 14.2.5 - '@next/swc-win32-ia32-msvc': 14.2.5 - '@next/swc-win32-x64-msvc': 14.2.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - dev: false - /node-abi@3.54.0: resolution: {integrity: sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA==} engines: {node: '>=10'} @@ -20318,6 +20337,12 @@ packages: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} dev: false + /promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + dependencies: + asap: 2.0.6 + dev: false + /prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -20372,6 +20397,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + /pure-color@1.3.0: + resolution: {integrity: sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==} + dev: false + /pure-rand@6.0.4: resolution: {integrity: sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==} dev: true @@ -21037,6 +21066,15 @@ packages: - prop-types dev: false + /react-base16-styling@0.6.0: + resolution: {integrity: sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==} + dependencies: + base16: 1.0.0 + lodash.curry: 4.1.1 + lodash.flow: 3.5.0 + pure-color: 1.3.0 + dev: false + /react-clientside-effect@1.2.6(react@18.2.0): resolution: {integrity: sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==} peerDependencies: @@ -21211,6 +21249,23 @@ packages: /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + /react-json-view@1.21.3(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==} + peerDependencies: + react: ^17.0.0 || ^16.3.0 || ^15.5.4 + react-dom: ^17.0.0 || ^16.3.0 || ^15.5.4 + dependencies: + flux: 4.0.4(react@18.2.0) + react: 18.2.0 + react-base16-styling: 0.6.0 + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + react-textarea-autosize: 8.5.6(@types/react@18.2.37)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - encoding + dev: false + /react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} dev: false @@ -21328,6 +21383,20 @@ packages: refractor: 3.6.0 dev: false + /react-textarea-autosize@8.5.6(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + '@babel/runtime': 7.26.0 + react: 18.2.0 + use-composed-ref: 1.4.0(@types/react@18.2.37)(react@18.2.0) + use-latest: 1.3.0(@types/react@18.2.37)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + /react-universal-interface@0.6.2(react@18.2.0)(tslib@2.6.2): resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==} peerDependencies: @@ -22749,23 +22818,6 @@ packages: react: 18.2.0 dev: false - /styled-jsx@5.1.1(react@18.2.0): - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - dependencies: - client-only: 0.0.1 - react: 18.2.0 - dev: false - /stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} dev: false @@ -23462,6 +23514,11 @@ packages: resolution: {integrity: sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==} dev: false + /ua-parser-js@1.0.39: + resolution: {integrity: sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==} + hasBin: true + dev: false + /uglify-js@2.8.29: resolution: {integrity: sha512-qLq/4y2pjcU3vhlhseXGGJ7VbFO4pBANu0kwl8VCa9KEI0V8VfZIx2Fy3w01iSTA/pGwKZSmu/+I4etLNDdt5w==} engines: {node: '>=0.8.0'} @@ -23676,6 +23733,19 @@ packages: tslib: 2.6.2 dev: false + /use-composed-ref@1.4.0(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + react: 18.2.0 + dev: false + /use-intl@3.17.2(react@18.2.0): resolution: {integrity: sha512-9lPgt41nS8x4AYCLfIC9VKCmamnVxzPM2nze7lpp/I1uaSSQvIz5MQpYUFikv08cMUsCwAWahU0e+arHInpdcw==} peerDependencies: @@ -23686,6 +23756,33 @@ packages: react: 18.2.0 dev: false + /use-isomorphic-layout-effect@1.2.0(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + react: 18.2.0 + dev: false + + /use-latest@1.3.0(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + react: 18.2.0 + use-isomorphic-layout-effect: 1.2.0(@types/react@18.2.37)(react@18.2.0) + dev: false + /use-sidecar@1.1.2(@types/react@18.2.37)(react@18.2.0): resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'} From 376bc3609218683e6f1d63bab9bdc785f4f0cd8d Mon Sep 17 00:00:00 2001 From: lim Date: Fri, 20 Dec 2024 09:30:27 +0000 Subject: [PATCH 28/42] fix dynamic render --- .../(user)/logs/components/LogDetailModal.tsx | 554 ++++++++++++++++++ .../aiproxy/app/[lng]/(user)/logs/page.tsx | 526 +---------------- .../app/[lng]/(user)/logs/tools/handleTime.ts | 4 + .../common/ConstructMappingComponent.tsx | 2 +- .../common/ConstructModeMappingComponent.tsx | 4 +- .../aiproxy/components/common/MyTooltip.tsx | 80 +-- .../aiproxy/components/user/ModelList.tsx | 2 +- 7 files changed, 611 insertions(+), 561 deletions(-) create mode 100644 frontend/providers/aiproxy/app/[lng]/(user)/logs/components/LogDetailModal.tsx create mode 100644 frontend/providers/aiproxy/app/[lng]/(user)/logs/tools/handleTime.ts diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/components/LogDetailModal.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/logs/components/LogDetailModal.tsx new file mode 100644 index 00000000000..a6a1711df99 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/logs/components/LogDetailModal.tsx @@ -0,0 +1,554 @@ +'use client' + +import { + Box, + Flex, + Text, + Button, + Icon, + Modal, + useDisclosure, + ModalOverlay, + ModalHeader, + ModalCloseButton, + ModalBody, + ModalContent, + Grid +} from '@chakra-ui/react' +import { CurrencySymbol, MySelect, MyTooltip } from '@sealos/ui' +import { useMemo, useState } from 'react' + +import { getTokens, getUserLogs, getEnabledMode } from '@/api/platform' +import { useTranslationClientSide } from '@/app/i18n/client' +import SelectDateRange from '@/components/common/SelectDateRange' +import SwitchPage from '@/components/common/SwitchPage' +import { BaseTable } from '@/components/table/BaseTable' +import { useI18n } from '@/providers/i18n/i18nContext' +import { LogItem } from '@/types/user/logs' +import { useQuery } from '@tanstack/react-query' +import { getCoreRowModel, useReactTable, createColumnHelper } from '@tanstack/react-table' +import { QueryKey } from '@/types/query-key' +import { useBackendStore } from '@/store/backend' +import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' +import { getTranslationWithFallback } from '@/utils/common' +import ReactJson, { OnCopyProps } from 'react-json-view' +import { getTimeDiff } from '../tools/handleTime' + +export default function LogDetailModal({ + isOpen, + onClose, + rowData +}: { + isOpen: boolean + onClose: () => void + rowData: LogItem | null +}): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + + // 定义默认的网格配置 + const gridConfig = { + labelWidth: '153px', + rowHeight: '48px', + jsonContentHeight: '122px' + } + + const renderDetailRow = ( + leftLabel: string | React.ReactNode, + leftValue: string | number | React.ReactNode | undefined, + rightLabel?: string | React.ReactNode, + rightValue?: string | number | React.ReactNode | undefined, + options?: { + labelWidth?: string + rowHeight?: string + isFirst?: boolean + isLast?: boolean + } + ) => { + // 辅助函数:渲染标签 + const renderLabel = (label: string | React.ReactNode) => { + if (typeof label === 'string') { + return ( + + {label} + + ) + } + return label + } + + // 辅助函数:渲染值 + const renderValue = (value: string | number | React.ReactNode | undefined) => { + if (typeof value === 'string' || typeof value === 'number') { + return ( + + {value} + + ) + } + return value + } + return ( + + + + {renderLabel(leftLabel)} + + + {renderValue(leftValue)} + + + {rightLabel && ( + + + {renderLabel(rightLabel)} + + + {renderValue(rightValue)} + + + )} + + ) + } + + const renderJsonContent = ( + label: string, + content: string | undefined, + options?: { + labelWidth?: string + contentHeight?: string + isFirst?: boolean + isLast?: boolean + } + ) => { + const handleCopy = (copy: OnCopyProps) => { + if (typeof window === 'undefined') return + + const copyText = + typeof copy.src === 'object' ? JSON.stringify(copy.src, null, 2) : String(copy.src) + + navigator.clipboard.writeText(copyText) + } + + if (!content) return null + + let parsed = null + + try { + parsed = JSON.parse(content) + } catch { + parsed = content + } + return ( + + + + {label} + + + + {typeof parsed === 'object' ? ( + + ) : ( + + {parsed} + + )} + + + ) + } + + return ( + + + + + + + {t('logs.logDetail')} + + + + + + + {renderDetailRow( + t('logs.requestId'), + rowData?.request_id, + t('logs.status'), + + {rowData?.code === 200 + ? t('logs.success') + : `${t('logs.failed')} (${rowData?.code})`} + , + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: true + } + )} + {renderDetailRow( + 'Endpoint', + rowData?.endpoint, + t('logs.mode'), + getTranslationWithFallback( + `modeType.${String(rowData?.mode)}`, + 'modeType.0', + t as any + ), + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow( + t('logs.requestTime'), + new Date(rowData?.created_at || 0).toLocaleString(), + t('logs.totalTime'), + getTimeDiff(rowData?.created_at || 0, rowData?.request_at || 0), + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow( + t('logs.tokenName'), + rowData?.token_name, + t('logs.tokenId'), + rowData?.token_id, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow(t('logs.model'), rowData?.model, undefined, undefined, { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + })} + + {rowData?.content && + renderDetailRow( + t('logs.info'), + + + {rowData.content} + + , + undefined, + undefined, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + + {rowData?.request_detail?.request_body && + renderJsonContent(t('logs.requestBody'), rowData.request_detail.request_body, { + labelWidth: gridConfig.labelWidth, + contentHeight: gridConfig.jsonContentHeight, + isFirst: false + })} + {rowData?.request_detail?.response_body && + renderJsonContent(t('logs.responseBody'), rowData.request_detail.response_body, { + labelWidth: gridConfig.labelWidth, + contentHeight: gridConfig.jsonContentHeight, + isLast: false + })} + + {renderDetailRow( + + + {t('key.inputPrice')} + + + + /{t('price.per1kTokens').toLowerCase()} + + , + rowData?.price, + + + {t('key.outputPrice')} + + + + /{t('price.per1kTokens').toLowerCase()} + + , + rowData?.completion_price, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow( + t('logs.inputTokens'), + rowData?.prompt_tokens, + t('logs.outputTokens'), + rowData?.completion_tokens, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + + {renderDetailRow( + + + + {t('logs.total_price')} + + + + , + rowData?.used_amount || 0, + undefined, + undefined, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isLast: true + } + )} + + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx index d91a8c5485a..f23e912ed94 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx @@ -32,11 +32,13 @@ import { useBackendStore } from '@/store/backend' import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' import { getTranslationWithFallback } from '@/utils/common' import ReactJson, { OnCopyProps } from 'react-json-view' +import { getTimeDiff } from './tools/handleTime' +import dynamic from 'next/dynamic' -const getTimeDiff = (createdAt: number, requestAt: number) => { - const diff = Number(((createdAt - requestAt) / 1000).toFixed(4)).toString() - return `${diff}s` -} +const LogDetailModal = dynamic( + () => import('./components/LogDetailModal'), + { ssr: false } // 禁用服务端渲染 +) export default function Logs(): React.JSX.Element { const { lng } = useI18n() @@ -615,519 +617,3 @@ export default function Logs(): React.JSX.Element {
) } - -const LogDetailModal = ({ - isOpen, - onClose, - rowData -}: { - isOpen: boolean - onClose: () => void - rowData: LogItem | null -}) => { - const { lng } = useI18n() - const { t } = useTranslationClientSide(lng, 'common') - const { currencySymbol } = useBackendStore() - - // 定义默认的网格配置 - const gridConfig = { - labelWidth: '153px', - rowHeight: '48px', - jsonContentHeight: '122px' - } - - const renderDetailRow = ( - leftLabel: string | React.ReactNode, - leftValue: string | number | React.ReactNode | undefined, - rightLabel?: string | React.ReactNode, - rightValue?: string | number | React.ReactNode | undefined, - options?: { - labelWidth?: string - rowHeight?: string - isFirst?: boolean - isLast?: boolean - } - ) => { - // 辅助函数:渲染标签 - const renderLabel = (label: string | React.ReactNode) => { - if (typeof label === 'string') { - return ( - - {label} - - ) - } - return label - } - - // 辅助函数:渲染值 - const renderValue = (value: string | number | React.ReactNode | undefined) => { - if (typeof value === 'string' || typeof value === 'number') { - return ( - - {value} - - ) - } - return value - } - return ( - - - - {renderLabel(leftLabel)} - - - {renderValue(leftValue)} - - - {rightLabel && ( - - - {renderLabel(rightLabel)} - - - {renderValue(rightValue)} - - - )} - - ) - } - - const renderJsonContent = ( - label: string, - content: string | undefined, - options?: { - labelWidth?: string - contentHeight?: string - isFirst?: boolean - isLast?: boolean - } - ) => { - const handleCopy = (copy: OnCopyProps) => { - const copyText = - typeof copy.src === 'object' ? JSON.stringify(copy.src, null, 2) : String(copy.src) - navigator.clipboard.writeText(copyText) - } - - if (!content) return null - - let parsed = null - - try { - parsed = JSON.parse(content) - } catch { - parsed = content - } - return ( - - - - {label} - - - - {typeof parsed === 'object' ? ( - - ) : ( - - {parsed} - - )} - - - ) - } - - return ( - - - - - - - {t('logs.logDetail')} - - - - - - - {renderDetailRow( - t('logs.requestId'), - rowData?.request_id, - t('logs.status'), - - {rowData?.code === 200 - ? t('logs.success') - : `${t('logs.failed')} (${rowData?.code})`} - , - { - labelWidth: gridConfig.labelWidth, - rowHeight: gridConfig.rowHeight, - isFirst: true - } - )} - {renderDetailRow( - 'Endpoint', - rowData?.endpoint, - t('logs.mode'), - getTranslationWithFallback( - `modeType.${String(rowData?.mode)}`, - 'modeType.0', - t as any - ), - { - labelWidth: gridConfig.labelWidth, - rowHeight: gridConfig.rowHeight, - isFirst: false - } - )} - {renderDetailRow( - t('logs.requestTime'), - new Date(rowData?.created_at || 0).toLocaleString(), - t('logs.totalTime'), - getTimeDiff(rowData?.created_at || 0, rowData?.request_at || 0), - { - labelWidth: gridConfig.labelWidth, - rowHeight: gridConfig.rowHeight, - isFirst: false - } - )} - {renderDetailRow( - t('logs.tokenName'), - rowData?.token_name, - t('logs.tokenId'), - rowData?.token_id, - { - labelWidth: gridConfig.labelWidth, - rowHeight: gridConfig.rowHeight, - isFirst: false - } - )} - {renderDetailRow(t('logs.model'), rowData?.model, undefined, undefined, { - labelWidth: gridConfig.labelWidth, - rowHeight: gridConfig.rowHeight, - isFirst: false - })} - - {rowData?.content && - renderDetailRow( - t('logs.info'), - - - {rowData.content} - - , - undefined, - undefined, - { - labelWidth: gridConfig.labelWidth, - rowHeight: gridConfig.rowHeight, - isFirst: false - } - )} - - {rowData?.request_detail?.request_body && - renderJsonContent(t('logs.requestBody'), rowData.request_detail.request_body, { - labelWidth: gridConfig.labelWidth, - contentHeight: gridConfig.jsonContentHeight, - isFirst: false - })} - {rowData?.request_detail?.response_body && - renderJsonContent(t('logs.responseBody'), rowData.request_detail.response_body, { - labelWidth: gridConfig.labelWidth, - contentHeight: gridConfig.jsonContentHeight, - isLast: false - })} - - {renderDetailRow( - - - {t('key.inputPrice')} - - - - /{t('price.per1kTokens').toLowerCase()} - - , - rowData?.price, - - - {t('key.outputPrice')} - - - - /{t('price.per1kTokens').toLowerCase()} - - , - rowData?.completion_price, - { - labelWidth: gridConfig.labelWidth, - rowHeight: gridConfig.rowHeight, - isFirst: false - } - )} - {renderDetailRow( - t('logs.inputTokens'), - rowData?.prompt_tokens, - t('logs.outputTokens'), - rowData?.completion_tokens, - { - labelWidth: gridConfig.labelWidth, - rowHeight: gridConfig.rowHeight, - isFirst: false - } - )} - - {renderDetailRow( - - - - {t('logs.total_price')} - - - - , - rowData?.used_amount || 0, - undefined, - undefined, - { - labelWidth: gridConfig.labelWidth, - rowHeight: gridConfig.rowHeight, - isLast: true - } - )} - - - - - ) -} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/tools/handleTime.ts b/frontend/providers/aiproxy/app/[lng]/(user)/logs/tools/handleTime.ts new file mode 100644 index 00000000000..0b892c563f3 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/logs/tools/handleTime.ts @@ -0,0 +1,4 @@ +export const getTimeDiff = (createdAt: number, requestAt: number) => { + const diff = Number(((createdAt - requestAt) / 1000).toFixed(4)).toString() + return `${diff}s` +} diff --git a/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx index 23a9b5c7dc2..27e61b24556 100644 --- a/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx +++ b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx @@ -87,7 +87,7 @@ export const ConstructMappingComponent = function ({ whiteSpace="nowrap" // prevent text from wrapping css={{ '&::-webkit-scrollbar': { display: 'none' }, - '-ms-overflow-style': 'none', + msOverflowStyle: 'none', scrollbarWidth: 'none' }}> { - return ( - + } + } + } + + return ( + {children} ) diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx index 3d89404eb7b..bb1571e34f8 100644 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ b/frontend/providers/aiproxy/components/user/ModelList.tsx @@ -146,7 +146,7 @@ const ModelList: React.FC = () => { '&::-webkit-scrollbar': { display: 'none' }, - '-ms-overflow-style': 'none', + msOverflowStyle: 'none', scrollbarWidth: 'none' }}> {data?.map((modelConfig) => ( From 30e3b90a3d57bad0138ac7cd1a665c0268075212 Mon Sep 17 00:00:00 2001 From: lim Date: Mon, 30 Dec 2024 02:02:32 +0000 Subject: [PATCH 29/42] add dashboard --- frontend/providers/aiproxy/api/platform.ts | 5 + .../home/components/RequestDataChart.tsx | 104 ++ .../aiproxy/app/[lng]/(user)/home/page.tsx | 549 +++++++++- .../(user)/{logs => home}/tools/handleTime.ts | 0 .../aiproxy/app/[lng]/(user)/key/page.tsx | 43 + .../components/LogDetailModal.tsx | 0 .../app/[lng]/(user)/{logs => log}/page.tsx | 0 .../app/[lng]/(user)/log/tools/handleTime.ts | 4 + .../aiproxy/app/[lng]/(user)/price/page.tsx | 972 +++++++++++++++--- .../aiproxy/app/api/user/dashboard/route.ts | 129 +++ .../aiproxy/app/i18n/locales/en/common.json | 83 +- .../aiproxy/app/i18n/locales/zh/common.json | 83 +- .../aiproxy/components/user/ModelList.tsx | 15 +- .../aiproxy/components/user/Sidebar.tsx | 10 +- frontend/providers/aiproxy/package.json | 2 + frontend/providers/aiproxy/types/front.d.ts | 12 - .../providers/aiproxy/types/models/model.ts | 24 +- frontend/providers/aiproxy/types/query-key.ts | 1 + .../providers/aiproxy/types/user/dashboard.ts | 20 + .../aiproxy/ui/icons/mode-icons/index.tsx | 3 +- .../aiproxy/ui/svg/icons/modelist/default.svg | 6 + .../aiproxy/ui/svg/icons/sidebar/home.svg | 5 +- .../aiproxy/ui/svg/icons/sidebar/home_a.svg | 5 +- .../aiproxy/ui/svg/icons/sidebar/key.svg | 0 .../aiproxy/ui/svg/icons/sidebar/key_a.svg | 6 + .../aiproxy/ui/svg/icons/sidebar/price.svg | 6 +- .../aiproxy/ui/svg/icons/sidebar/price_a.svg | 6 +- 27 files changed, 1864 insertions(+), 229 deletions(-) create mode 100644 frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx rename frontend/providers/aiproxy/app/[lng]/(user)/{logs => home}/tools/handleTime.ts (100%) create mode 100644 frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx rename frontend/providers/aiproxy/app/[lng]/(user)/{logs => log}/components/LogDetailModal.tsx (100%) rename frontend/providers/aiproxy/app/[lng]/(user)/{logs => log}/page.tsx (100%) create mode 100644 frontend/providers/aiproxy/app/[lng]/(user)/log/tools/handleTime.ts create mode 100644 frontend/providers/aiproxy/app/api/user/dashboard/route.ts create mode 100644 frontend/providers/aiproxy/types/user/dashboard.ts create mode 100644 frontend/providers/aiproxy/ui/svg/icons/modelist/default.svg create mode 100644 frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg create mode 100644 frontend/providers/aiproxy/ui/svg/icons/sidebar/key_a.svg diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index 3b679c6479b..cfb8d2d07da 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -16,6 +16,8 @@ import { GetChannelTypeNamesResponse } from '@/app/api/admin/channel/type-name/r import { GroupQueryParams, GroupStatus } from '@/types/admin/group' import { GroupSearchResponse } from '@/app/api/admin/group/route' import { GetAllChannelResponse } from '@/app/api/admin/channel/all/route' +import { DashboardQueryParams } from '@/app/api/user/dashboard/route' +import { DashboardResponse } from '@/types/user/dashboard' export const initAppConfig = () => GET<{ aiproxyBackend: string; currencySymbol: 'shellCoin' | 'cny' | 'usd' }>( @@ -41,6 +43,9 @@ export const deleteToken = (id: number) => DELETE(`/api/user/token/${id}`) export const updateToken = (id: number, status: number) => POST(`/api/user/token/${id}`, { status: status }) +// dashboard +export const getDashboardData = (params: DashboardQueryParams) => + GET('/api/user/dashboard', params) // ------------------------------------------------------------ // diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx new file mode 100644 index 00000000000..5af258e0124 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx @@ -0,0 +1,104 @@ +'use client' + +import { Box } from '@chakra-ui/react' +import { useMemo } from 'react' +import ReactECharts from 'echarts-for-react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { ChartDataItem } from '@/types/user/dashboard' +import { useBackendStore } from '@/store/backend' + +export default function RequestDataChart({ data }: { data: ChartDataItem[] }): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + + const option = useMemo(() => { + return { + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'line', + lineStyle: { + color: '#E2E8F0' + } + } + }, + legend: { + data: [t('调用数'), t('异常数'), t('花费')], + bottom: 0 + }, + grid: { + left: '3%', + right: '4%', + bottom: '10%', + top: '3%', + containLabel: true + }, + xAxis: { + type: 'time', + boundaryGap: false, + axisLine: { + lineStyle: { + color: '#E2E8F0' + } + }, + splitLine: { + show: true, + lineStyle: { + color: '#E2E8F0', + type: 'dashed' + } + } + }, + yAxis: { + type: 'value', + splitLine: { + lineStyle: { + color: '#E2E8F0', + type: 'dashed' + } + } + }, + series: [ + { + name: t('调用数'), + type: 'line', + smooth: true, + data: data.map((item) => [item.timestamp, item.request_count]), + itemStyle: { + color: '#3B82F6' + } + }, + { + name: t('异常数'), + type: 'line', + smooth: true, + data: data.map((item) => [item.timestamp, item.exception_count]), + itemStyle: { + color: '#F59E0B' + } + }, + { + name: t('花费'), + type: 'line', + smooth: true, + data: data.map((item) => [item.timestamp, item.used_amount]), + itemStyle: { + color: '#10B981' + } + } + ] + } + }, [data, t]) + + return ( + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx index b227c94d5cc..ced7ff9a522 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx @@ -1,42 +1,529 @@ -import { Flex } from '@chakra-ui/react' +'use client' -import KeyList from '@/components/user/KeyList' -import ModelList from '@/components/user/ModelList' +import { + Box, + Flex, + Text, + Button, + Icon, + Modal, + useDisclosure, + ModalOverlay, + ModalHeader, + ModalCloseButton, + ModalBody, + ModalContent, + Grid +} from '@chakra-ui/react' +import { CurrencySymbol, MySelect, MyTooltip } from '@sealos/ui' +import { useMemo, useState } from 'react' -export default function Home(): JSX.Element { - return ( - - - - +import { getTokens, getUserLogs, getEnabledMode, getDashboardData } from '@/api/platform' +import { useTranslationClientSide } from '@/app/i18n/client' +import SelectDateRange from '@/components/common/SelectDateRange' +import SwitchPage from '@/components/common/SwitchPage' +import { BaseTable } from '@/components/table/BaseTable' +import { useI18n } from '@/providers/i18n/i18nContext' +import { LogItem } from '@/types/user/logs' +import { UseQueryResult, useQuery } from '@tanstack/react-query' +import { getCoreRowModel, useReactTable, createColumnHelper } from '@tanstack/react-table' +import { QueryKey } from '@/types/query-key' +import { useBackendStore } from '@/store/backend' +import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' +import { getTranslationWithFallback } from '@/utils/common' +import ReactJson, { OnCopyProps } from 'react-json-view' +import { getTimeDiff } from './tools/handleTime' +import dynamic from 'next/dynamic' +import { DashboardResponse } from '@/types/user/dashboard' +import RequestDataChart from './components/RequestDataChart' + +export default function Logs(): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + + const [tokenName, setTokenName] = useState('') + const [model, setModel] = useState('') + const [type, setType] = useState<'week' | 'day' | 'two_week' | 'month'>('week') + + const { data: dashboardData, isLoading }: UseQueryResult = useQuery( + [QueryKey.GetDashboardData, type, tokenName, model], + () => + getDashboardData({ + type, + ...(tokenName && { token_name: tokenName }), + ...(model && { model }) + }), + { + onSuccess: (data) => { + console.log(data) + } + } + ) + return ( + - + w="full" + flex="1"> + {/* -- header */} + + + + {t('dataDashboard.title')} + + + ({ + value: token, + label: token + })) || []) + ]} + placeholder={t('dataDashboard.selectToken')} + onchange={(token: string) => { + setTokenName(token) + }} + /> + + ({ + value: model, + label: model + })) || []) + ]} + onchange={(model: string) => { + setModel(model) + }} + /> + + + + {[ + { label: t('dataDashboard.day'), value: 'day' }, + { label: t('dataDashboard.week'), value: 'week' }, + { label: t('dataDashboard.twoWeek'), value: 'two_week' }, + { label: t('dataDashboard.month'), value: 'month' } + ].map((item) => ( + + ))} + + + {/* -- header end */} + + + {/* chart 1 */} + + + + + + + + + + + + + {t('dataDashboard.callCount')} + + + {dashboardData?.total_count + ? dashboardData.total_count >= 10000 + ? `${Number((dashboardData.total_count / 10000).toFixed(3))}W` + : dashboardData.total_count.toLocaleString() + : 0} + + + + + + + + + + + + + {t('dataDashboard.exceptionCount')} + + + {dashboardData?.exception_count || 0} + + + + + + + + + + + + + {t('dataDashboard.rpm')} + + + {dashboardData?.rpm || 0} + + + + + + + + + + + + + + {t('dataDashboard.tpm')} + + + {dashboardData?.tpm || 0} + + + + + + + + + + + + + + + {t('dataDashboard.cost')} + + + + + {dashboardData?.used_amount ? Number(dashboardData.used_amount.toFixed(6)) : 0} + + + + + {/* chart 1 end */} + + + + + {t('dataDashboard.requestData')} + + + + + ) diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/tools/handleTime.ts b/frontend/providers/aiproxy/app/[lng]/(user)/home/tools/handleTime.ts similarity index 100% rename from frontend/providers/aiproxy/app/[lng]/(user)/logs/tools/handleTime.ts rename to frontend/providers/aiproxy/app/[lng]/(user)/home/tools/handleTime.ts diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx new file mode 100644 index 00000000000..b227c94d5cc --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx @@ -0,0 +1,43 @@ +import { Flex } from '@chakra-ui/react' + +import KeyList from '@/components/user/KeyList' +import ModelList from '@/components/user/ModelList' + +export default function Home(): JSX.Element { + return ( + + + + + + + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/components/LogDetailModal.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/log/components/LogDetailModal.tsx similarity index 100% rename from frontend/providers/aiproxy/app/[lng]/(user)/logs/components/LogDetailModal.tsx rename to frontend/providers/aiproxy/app/[lng]/(user)/log/components/LogDetailModal.tsx diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx similarity index 100% rename from frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx rename to frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/log/tools/handleTime.ts b/frontend/providers/aiproxy/app/[lng]/(user)/log/tools/handleTime.ts new file mode 100644 index 00000000000..0b892c563f3 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/log/tools/handleTime.ts @@ -0,0 +1,4 @@ +export const getTimeDiff = (createdAt: number, requestAt: number) => { + const diff = Number(((createdAt - requestAt) / 1000).toFixed(4)).toString() + return `${diff}s` +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx index 9e5493cc9f8..9bedf9dd22a 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx @@ -11,13 +11,19 @@ import { Thead, Tr, Center, - Spinner + Spinner, + Button, + Icon, + Input, + InputGroup, + InputRightElement, + Badge } from '@chakra-ui/react' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { useQuery } from '@tanstack/react-query' +import { useQuery, UseQueryResult } from '@tanstack/react-query' import { getEnabledMode } from '@/api/platform' -import { useMemo } from 'react' +import { useMemo, useState, useEffect } from 'react' import { createColumnHelper, getCoreRowModel, @@ -25,55 +31,395 @@ import { flexRender } from '@tanstack/react-table' import { CurrencySymbol } from '@sealos/ui' -import { ModelIdentifier } from '@/types/front' import { MyTooltip } from '@/components/common/MyTooltip' import { useMessage } from '@sealos/ui' import { ModelConfig } from '@/types/models/model' import Image, { StaticImageData } from 'next/image' import { QueryKey } from '@/types/query-key' -// icons -import OpenAIIcon from '@/ui/svg/icons/modelist/openai.svg' -import QwenIcon from '@/ui/svg/icons/modelist/qianwen.svg' -import ChatglmIcon from '@/ui/svg/icons/modelist/chatglm.svg' -import DeepseekIcon from '@/ui/svg/icons/modelist/deepseek.svg' -import MoonshotIcon from '@/ui/svg/icons/modelist/moonshot.svg' -import SparkdeskIcon from '@/ui/svg/icons/modelist/sparkdesk.svg' -import AbabIcon from '@/ui/svg/icons/modelist/minimax.svg' -import DoubaoIcon from '@/ui/svg/icons/modelist/doubao.svg' -import ErnieIcon from '@/ui/svg/icons/modelist/ernie.svg' -import BaaiIcon from '@/ui/svg/icons/modelist/baai.svg' -import HunyuanIcon from '@/ui/svg/icons/modelist/hunyuan.svg' import { getTranslationWithFallback } from '@/utils/common' import { useBackendStore } from '@/store/backend' import { modelIcons } from '@/ui/icons/mode-icons' +import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' + +type SortDirection = 'asc' | 'desc' | false const getModelIcon = (modelOwner: string): StaticImageData => { const icon = modelIcons[modelOwner as keyof typeof modelIcons] || modelIcons['default'] return icon } -const getIdentifier = (modelName: string): ModelIdentifier => { - return modelName.toLowerCase().split(/[-._\d]/)[0] as ModelIdentifier +// 在组件外部定义样式配置 +const MODEL_TYPE_STYLES = { + 1: { + background: '#F0FBFF', + color: '#0884DD' + }, + 2: { + background: '#F4F4F7', + color: '#383F50' + }, + 3: { + background: '#EBFAF8', + color: '#007E7C' + }, + 4: { + background: '#FEF3F2', + color: '#F04438' + }, + 5: { + background: '#F0EEFF', + color: '#6F5DD7' + }, + 6: { + background: '#FFFAEB', + color: '#DC6803' + }, + 7: { + background: '#FAF1FF', + color: '#9E53C1' + }, + 8: { + background: '#FFF1F6', + color: '#E82F72' + }, + 9: { + background: '#F0F4FF', + color: '#3370FF' + }, + 10: { + background: '#EDFAFF', + color: '#0077A9' + }, + default: { + background: '#F4F4F7', + color: '#383F50' + } +} as const + +// 在组件中使用 +const getTypeStyle = (type: number) => { + return MODEL_TYPE_STYLES[type as keyof typeof MODEL_TYPE_STYLES] || MODEL_TYPE_STYLES.default } function Price() { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') + + const [modelOwner, setModelOwner] = useState('') + const [modelType, setModelType] = useState('') + const [modelName, setModelName] = useState('') + const [filteredModelConfigs, setFilteredModelConfigs] = useState([]) + + const { + isLoading, + data: modelConfigs = [] as ModelConfig[], + refetch + }: UseQueryResult = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode(), { + onSuccess: (data) => { + if (data) { + setFilteredModelConfigs(data) + } + } + }) + + interface FilterParams { + owner: string + type: string + name: string + } + + const filterModels = (modelConfigs: ModelConfig[], filterParams: FilterParams): ModelConfig[] => { + return modelConfigs.filter((config) => { + const ownerMatch = + !filterParams.owner || filterParams.owner === 'all' || config.owner === filterParams.owner + + const typeMatch = + !filterParams.type || + filterParams.type === 'all' || + getTranslationWithFallback(`modeType.${String(config.type)}`, 'modeType.0', t as any) === + filterParams.type + + const nameMatch = + !filterParams.name || config.model.toLowerCase().includes(filterParams.name.toLowerCase()) + + return ownerMatch && typeMatch && nameMatch + }) + } + return ( - - - {t('price.title')} - + + + {/* row 1 */} + + + {t('price.title')} + + + + {/* row 1 end */} + + {/* row 2 */} + + + + + {t('price.modelOwner')} + + + dropdownItems={['all', ...new Set(modelConfigs.map((config) => config.owner))]} + setSelectedItem={(modelOwner) => { + setModelOwner(modelOwner) + setFilteredModelConfigs( + filterModels(modelConfigs, { + owner: modelOwner, + type: modelType, + name: modelName + }) + ) + }} + handleDropdownItemFilter={(dropdownItems, inputValue) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) + ) + }} + handleDropdownItemDisplay={(dropdownItem) => { + const iconSrc = getModelIcon(dropdownItem) + if (dropdownItem === 'all') { + return ( + + default + + {dropdownItem} + + + ) + } + return ( + + {dropdownItem} + + {dropdownItem} + + + ) + }} + flexProps={{ w: '240px' }} + initSelectedItem="all" + /> + + + + {t('price.modelType')} + + + dropdownItems={[ + 'all', + ...new Set( + modelConfigs.map((config) => + getTranslationWithFallback( + `modeType.${String(config.type)}`, + 'modeType.0', + t as any + ) + ) + ) + ]} + setSelectedItem={(modelType) => { + setModelType(modelType) + setFilteredModelConfigs( + filterModels(modelConfigs, { + owner: modelOwner, + type: modelType, + name: modelName + }) + ) + }} + handleDropdownItemFilter={(dropdownItems, inputValue) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) + ) + }} + handleDropdownItemDisplay={(dropdownItem) => { + return ( + + {dropdownItem} + + ) + }} + flexProps={{ w: '240px' }} + initSelectedItem="all" + /> + + + + + + { + const searchValue = e.target.value + setModelName(searchValue) + + setFilteredModelConfigs( + filterModels(modelConfigs, { + owner: modelOwner, + type: modelType, + name: searchValue + }) + ) + }} + /> + + + + + + + + + + + - + {isLoading ? ( + + ) : ( + + )} @@ -81,67 +427,211 @@ function Price() { ) } -function PriceTable() { +const ModelComponent = ({ modelConfig }: { modelConfig: ModelConfig }) => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') - const { isLoading, data: modelConfigs = [] } = useQuery([QueryKey.GetEnabledModels], () => - getEnabledMode() + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const iconSrc = getModelIcon(modelConfig.owner) + + return ( + + + {modelConfig.model} + + + + navigator.clipboard.writeText(modelConfig.model).then( + () => { + message({ + status: 'success', + title: t('copySuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + }, + (err) => { + message({ + status: 'warning', + title: t('copyFailed'), + description: err?.message || t('copyFailed'), + isClosable: true, + position: 'top' + }) + } + ) + } + cursor="pointer"> + {modelConfig.model} + + + {modelConfig.config?.vision && ( + + + + + + {t('price.modelVision')} + + + )} + {modelConfig.config?.tool_choice && ( + + + + + + + {t('price.modelToolChoice')} + + + )} + {modelConfig.config?.max_context_tokens && ( + + + {`${Math.ceil(modelConfig.config.max_context_tokens / 1024)}K`} + + + )} + {modelConfig.config?.max_output_tokens && ( + + + {`${Math.ceil(modelConfig.config.max_output_tokens / 1024)}K ${t( + 'price.response' + )}`} + + + )} + + + ) +} - const { currencySymbol } = useBackendStore() +function PriceTable({ + modelConfigs, + isLoading +}: { + modelConfigs: ModelConfig[] + isLoading: boolean +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') - const ModelComponent = ({ modelName, modelOwner }: { modelName: string; modelOwner: string }) => { - const { message } = useMessage({ - warningBoxBg: 'var(--Yellow-50, #FFFAEB)', - warningIconBg: 'var(--Yellow-500, #F79009)', - warningIconFill: 'white', - successBoxBg: 'var(--Green-50, #EDFBF3)', - successIconBg: 'var(--Green-600, #039855)', - successIconFill: 'white' - }) + const { currencySymbol } = useBackendStore() - const iconSrc = getModelIcon(modelOwner) + const [sortConfig, setSortConfig] = useState({ + column: '', + direction: false as SortDirection + }) - return ( - - {modelName} - - - navigator.clipboard.writeText(modelName).then( - () => { - message({ - status: 'success', - title: t('copySuccess'), - isClosable: true, - duration: 2000, - position: 'top' - }) - }, - (err) => { - message({ - status: 'warning', - title: t('copyFailed'), - description: err?.message || t('copyFailed'), - isClosable: true, - position: 'top' - }) - } - ) - } - cursor="pointer"> - {modelName} - - - - ) + // 处理排序 + const handleSort = (column: string, direction: SortDirection) => { + // 如果点击相同的列并且方向相同,则取消排序 + if (sortConfig.column === column && sortConfig.direction === direction) { + setSortConfig({ column: '', direction: false }) + return + } + setSortConfig({ column, direction }) } const columnHelper = createColumnHelper() @@ -159,9 +649,7 @@ function PriceTable() { {t('key.name')} ), - cell: (info) => ( - - ) + cell: (info) => }), columnHelper.accessor((row) => row.type, { id: 'type', @@ -176,6 +664,75 @@ function PriceTable() { {t('key.modelType')} ), + cell: (info) => ( + + + {getTranslationWithFallback( + `modeType.${String(info.getValue())}`, + 'modeType.0', + t as any + )} + + + ) + }), + columnHelper.accessor((row) => row.rpm, { + id: 'rpm', + header: () => ( + + + {t('price.modelRpm')} + + + {t('price.modelRpmTooltip')} + + }> + + + + +
+ ), cell: (info) => ( - {getTranslationWithFallback( - `modeType.${String(info.getValue())}`, - 'modeType.0', - t as any - )} + {info.getValue()} ) }), columnHelper.accessor((row) => row.input_price, { - id: 'inputPrice', + id: 'input_price', header: () => { return ( - - + + - + + + + {t('price.sortUpTooltip')} + + }> + handleSort('input_price', 'asc')} + cursor="pointer" + _hover={{ opacity: 0.8 }}> + + + + + + + {t('price.sortDownTooltip')} + + }> + handleSort('input_price', 'desc')} + cursor="pointer" + _hover={{ opacity: 0.8 }}> + + + + + + +
) }, cell: (info) => ( @@ -236,10 +868,10 @@ function PriceTable() { ) }), columnHelper.accessor((row) => row.output_price, { - id: 'outputPrice', + id: 'output_price', header: () => ( - - + + - + + + {t('price.sortUpTooltip')} + + }> + handleSort('output_price', 'asc')} + cursor="pointer" + _hover={{ opacity: 0.8 }}> + + + + + + + {t('price.sortDownTooltip')} + + }> + handleSort('output_price', 'desc')} + cursor="pointer" + _hover={{ opacity: 0.8 }}> + + + + + + +
), cell: (info) => ( modelConfigs, [modelConfigs]) + const tableData = useMemo(() => { + if (!sortConfig.direction || !sortConfig.column) { + return modelConfigs + } + + return [...modelConfigs].sort((a, b) => { + let aValue = a[sortConfig.column as keyof ModelConfig] + let bValue = b[sortConfig.column as keyof ModelConfig] + + // 确保数值比较 + if (typeof aValue === 'string') aValue = parseFloat(aValue as string) || 0 + if (typeof bValue === 'string') bValue = parseFloat(bValue as string) || 0 + + if (sortConfig.direction === 'asc') { + return (aValue as number) - (bValue as number) + } else { + return (bValue as number) - (aValue as number) + } + }) + }, [modelConfigs, sortConfig]) const table = useReactTable({ data: tableData, @@ -286,11 +1015,7 @@ function PriceTable() { getCoreRowModel: getCoreRowModel() }) - return isLoading ? ( -
- -
- ) : ( + return ( @@ -311,18 +1036,33 @@ function PriceTable() { ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} + {isLoading ? ( + + - ))} + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ +
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
diff --git a/frontend/providers/aiproxy/app/api/user/dashboard/route.ts b/frontend/providers/aiproxy/app/api/user/dashboard/route.ts new file mode 100644 index 00000000000..2fbbd104e03 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/user/dashboard/route.ts @@ -0,0 +1,129 @@ +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { DashboardData } from '@/types/user/dashboard' +import { DashboardResponse } from '@/types/user/dashboard' + +// 定义API响应数据结构 +export type ApiProxyBackendDashboardResponse = ApiProxyBackendResp + +// 定义查询参数接口 +export interface DashboardQueryParams { + type: 'day' | 'week' | 'two_week' | 'month' + model?: string + token_name?: string +} + +// 验证查询参数 +function validateParams(params: DashboardQueryParams): string | null { + if (!params.type) { + return 'Type parameter is required' + } + if ( + params.type !== 'day' && + params.type !== 'week' && + params.type !== 'two_week' && + params.type !== 'month' + ) { + return 'Invalid type parameter. Must be one of: day, week, two_week, month' + } + return null +} + +// 获取仪表盘数据 +async function fetchDashboardData( + params: DashboardQueryParams, + group: string +): Promise { + try { + const url = new URL( + `/api/dashboard/${group}`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + url.searchParams.append('type', params.type) + if (params.model) { + url.searchParams.append('model', params.model) + } + + if (params.token_name) { + url.searchParams.append('token_name', params.token_name) + } + + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendDashboardResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'API request failed') + } + + return { + chart_data: result.data?.chart_data || [], + token_names: result.data?.token_names || [], + models: result.data?.models || [], + total_count: result.data?.total_count || 0, + exception_count: result.data?.exception_count || 0, + used_amount: result.data?.used_amount || 0, + rpm: result.data?.rpm || 0, + tpm: result.data?.tpm || 0 + } + } catch (error) { + console.error('Error fetching dashboard data:', error) + throw error + } +} + +export async function GET(request: NextRequest): Promise> { + try { + const group = await parseJwtToken(request.headers) + const searchParams = request.nextUrl.searchParams + + const queryParams: DashboardQueryParams = { + type: (searchParams.get('type') as 'day' | 'week' | 'two_week' | 'month') || 'week', + model: searchParams.get('model') || undefined, + token_name: searchParams.get('token_name') || undefined + } + + const validationError = validateParams(queryParams) + if (validationError) { + return NextResponse.json( + { + code: 400, + message: validationError, + error: validationError + }, + { status: 400 } + ) + } + + const dashboardData = await fetchDashboardData(queryParams, group) + + return NextResponse.json({ + code: 200, + data: dashboardData + } satisfies DashboardResponse) + } catch (error) { + console.error('Dashboard fetch error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/i18n/locales/en/common.json b/frontend/providers/aiproxy/app/i18n/locales/en/common.json index 63408987b20..2804e061236 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/en/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/en/common.json @@ -2,13 +2,14 @@ "title": "AI proxy", "description": "AI proxy", "Sidebar": { - "Home": "API Keys", + "Home": "Dashboard", "Logs": "Logs", - "Price": "Pricing", + "Price": "Models", "Dashboard": "Channels", "GlobalLogs": "Logs", "GlobalConfigs": "Config", - "NsManager": "NS Manage" + "NsManager": "NS Manage", + "Keys": "API Keys" }, "keyList": { "title": "API Keys" @@ -92,22 +93,19 @@ "copyFailed": "Copy failed", "noData": "You don’t have an API Key yet", "price": { - "title": "Pricing", - "per1kTokens": "1k tokens" - }, - "ernie": "Baidu-Ernie", - "qwen": "Alibaba-Qwen", - "chatglm": "BigModel-Chatglm", - "deepseek": "Deepseek", - "moonshot": "Moonshot", - "sparkdesk": "Sparkdesk", - "abab": "Minimax", - "doubao": "ByteDance-Doubao", - "glm": "Glm", - "o": "OpenAI", - "gpt": "OpenAI", - "bge": "BAAI", - "hunyuan": "Hunyuan", + "title": "Models", + "per1kTokens": "1k tokens", + "modelOwner": "Series/Manufacturer", + "modelType": "Type", + "modelName": "Model Name", + "modelRpm": "RPM", + "modelRpmTooltip": "Each model has its own RPM", + "sortUpTooltip": "Click to view in ascending order", + "sortDownTooltip": "Click to view in Descending order", + "modelVision": "Visual", + "modelToolChoice": "Tool Invocation", + "response": "Response" + }, "createKey2": "Create Key", "dashboard": { "title": "Channels", @@ -198,6 +196,38 @@ "9": "Audio", "10": "Rerank" }, + "modeOwner": { + "openai": "OpenAI", + "alibaba": "Alibaba", + "tencent": "Tencent", + "xunfei": "iFlytek", + "deepseek": "DeepSeek", + "moonshot": "Moonshot AI", + "minimax": "MiniMax", + "baidu": "Baidu", + "google": "Google", + "baai": "BAAI", + "funaudiollm": "FunAudio LLM", + "doubao": "Doubao", + "fishaudio": "Fish Audio", + "chatglm": "ChatGLM", + "stabilityai": "Stability AI", + "netease": "NetEase", + "ai360": "360 AI", + "anthropic": "Anthropic", + "meta": "Meta", + "baichuan": "Baichuan", + "mistral": "Mistral AI", + "openchat": "OpenChat", + "microsoft": "Microsoft", + "defog": "Defog", + "nexusflow": "NexusFlow", + "cohere": "Cohere", + "huggingface": "Hugging Face", + "lingyiwanwu": "Lingyi Wanwu", + "stepfun": "StepFun", + "unknown": "Unknown" + }, "GlobalLogs": { "selectModel": "model Name", "select_token_name": "token name", @@ -231,5 +261,20 @@ "updateFailed": "Update status failed", "deleteSuccess": "Delete successfully", "deleteFailed": "Delete failed" + }, + "dataDashboard": { + "title": "Dashboard", + "selectToken": "All Keys", + "selectModel": "All Models", + "day": "Last 24h", + "week": "Last 7 days", + "twoWeek": "Last 15 days", + "month": "Last 30 days", + "callCount": "Requests", + "exceptionCount": "Exceptions", + "rpm": "RPM (Real-time)", + "tpm": "TPM (Real-time)", + "cost": "Charges", + "requestData": "Call Data" } } diff --git a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json index 13a4681311c..a22193d9fa9 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json @@ -2,13 +2,14 @@ "title": "AI 代理", "description": "AI 代理", "Sidebar": { - "Home": "API Keys", + "Home": "仪表盘", "Logs": "调用日志", - "Price": "模型价格", + "Price": "模型广场", "Dashboard": "AI 渠道", "GlobalLogs": "全局日志", "GlobalConfigs": "全局配置", - "NsManager": "NS管理" + "NsManager": "NS管理", + "Keys": "API Keys" }, "keyList": { "title": "API Keys" @@ -92,22 +93,19 @@ "copyFailed": "复制失败", "noData": "你还没有 API Key", "price": { - "title": "模型价格", - "per1kTokens": "1k tokens" - }, - "ernie": "百度文心", - "qwen": "阿里千问", - "chatglm": "智谱", - "deepseek": "Deepseek", - "moonshot": "月之暗面", - "sparkdesk": "讯飞星火", - "abab": "Minimax", - "doubao": "字节豆包", - "glm": "智谱", - "o": "OpenAI", - "gpt": "OpenAI", - "bge": "智源研究院", - "hunyuan": "腾讯混元", + "title": "模型广场", + "per1kTokens": "1k tokens", + "modelOwner": "系列/厂商", + "modelType": "类型", + "modelName": "模型名", + "modelRpm": "RPM", + "modelRpmTooltip": "每个模型拥有独立的 RPM", + "sortUpTooltip": "点击升序", + "sortDownTooltip": "点击降序", + "modelVision": "Visual", + "modelToolChoice": "Tool Invocation", + "response": "响应" + }, "createKey2": "新建 Key", "dashboard": { "title": "Ai 渠道", @@ -198,6 +196,38 @@ "9": "音频翻译", "10": "重排序" }, + "modeOwner": { + "openai": "OpenAI", + "alibaba": "阿里", + "tencent": "腾讯", + "xunfei": "讯飞", + "deepseek": "DeepSeek", + "moonshot": "月之暗面", + "minimax": "MiniMax", + "baidu": "百度", + "google": "谷歌", + "baai": "BAAI", + "funaudiollm": "趣音大模型", + "doubao": "豆包", + "fishaudio": "鱼声科技", + "chatglm": "智谱清言", + "stabilityai": "Stability AI", + "netease": "网易", + "ai360": "360智脑", + "anthropic": "Anthropic", + "meta": "Meta", + "baichuan": "百川智能", + "mistral": "Mistral AI", + "openchat": "OpenChat", + "microsoft": "微软", + "defog": "Defog", + "nexusflow": "NexusFlow", + "cohere": "Cohere", + "huggingface": "Hugging Face", + "lingyiwanwu": "零一万物", + "stepfun": "StepFun", + "unknown": "未知" + }, "GlobalLogs": { "selectModel": "模型名称", "select_token_name": "token 名字", @@ -231,5 +261,20 @@ "updateFailed": "更新状态失败", "deleteSuccess": "删除成功", "deleteFailed": "删除失败" + }, + "dataDashboard": { + "title": "仪表盘", + "selectToken": "全部密钥", + "selectModel": "全部模型", + "day": "一天内", + "week": "近七天", + "twoWeek": "近15天", + "month": "近30天", + "callCount": "调用数", + "exceptionCount": "异常数", + "rpm": "RPM(实时)", + "tpm": "TPM(实时)", + "cost": "花费", + "requestData": "调用数据" } } diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx index bb1571e34f8..2579878b6e0 100644 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ b/frontend/providers/aiproxy/components/user/ModelList.tsx @@ -8,13 +8,9 @@ import { useQuery } from '@tanstack/react-query' import { getEnabledMode } from '@/api/platform' import { useMessage } from '@sealos/ui' import { MyTooltip } from '@/components/common/MyTooltip' -import { ModelIdentifier } from '@/types/front' import { QueryKey } from '@/types/query-key' import { modelIcons } from '@/ui/icons/mode-icons' - -const getIdentifier = (modelName: string): ModelIdentifier => { - return modelName.toLowerCase().split(/[-._\d]/)[0] as ModelIdentifier -} +import { getTranslationWithFallback } from '@/utils/common' const ModelComponent = ({ modelName, modelOwner }: { modelName: string; modelOwner: string }) => { const { lng } = useI18n() @@ -39,7 +35,14 @@ const ModelComponent = ({ modelName, modelOwner }: { modelName: string; modelOwn return ( {modelName} - + { activeIcon: homeIcon_a, display: true }, + { + id: 'keys', + url: '/key', + value: t('Sidebar.Keys'), + icon: homeIcon, + activeIcon: homeIcon_a, + display: true + }, { id: 'logs', - url: '/logs', + url: '/log', value: t('Sidebar.Logs'), icon: logsIcon, activeIcon: logsIcon_a, diff --git a/frontend/providers/aiproxy/package.json b/frontend/providers/aiproxy/package.json index efd4715c7b1..f799b304808 100644 --- a/frontend/providers/aiproxy/package.json +++ b/frontend/providers/aiproxy/package.json @@ -17,6 +17,8 @@ "axios": "^1.7.7", "date-fns": "^2.30.0", "downshift": "^9.0.8", + "echarts": "^5.4.3", + "echarts-for-react": "^3.0.2", "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.2", diff --git a/frontend/providers/aiproxy/types/front.d.ts b/frontend/providers/aiproxy/types/front.d.ts index de83dc1fd21..e69de29bb2d 100644 --- a/frontend/providers/aiproxy/types/front.d.ts +++ b/frontend/providers/aiproxy/types/front.d.ts @@ -1,12 +0,0 @@ -export type ModelIdentifier = - | 'ernie' - | 'qwen' - | 'chatglm' - | 'gpt' // Add these - | 'o' // Add these - | 'deepseek' - | 'moonshot' - | 'sparkdesk' - | 'abab' - | 'glm' - | 'doubao' diff --git a/frontend/providers/aiproxy/types/models/model.ts b/frontend/providers/aiproxy/types/models/model.ts index 9e59951ad72..b8085242fe3 100644 --- a/frontend/providers/aiproxy/types/models/model.ts +++ b/frontend/providers/aiproxy/types/models/model.ts @@ -1,6 +1,9 @@ import { ChannelType } from '@/types/admin/channels/channelInfo' -export interface ModelInfo { +export interface ModelConfig { + config?: ModelConfigDetail + created_at: number + updated_at: number image_prices: number[] | null model: string owner: string @@ -8,10 +11,11 @@ export interface ModelInfo { type: number input_price: number output_price: number + rpm: number } export type ChannelWithMode = { - [K in ChannelType]?: ModelInfo[] + [K in ChannelType]?: ModelConfig[] } export type ChannelDefaultModeMapping = { @@ -29,14 +33,10 @@ export type ChannelWithDefaultModelAndDefaultModeMapping = { models: ChannelDefaultModel } -export interface TokenConfig { - max_input_tokens: number - max_output_tokens: number - max_context_tokens: number -} - -export interface ModelConfig extends ModelInfo { - config: TokenConfig - created_at: number - updated_at: number +export interface ModelConfigDetail { + max_input_tokens?: number + max_output_tokens?: number + max_context_tokens?: number + vision?: boolean + tool_choice?: boolean } diff --git a/frontend/providers/aiproxy/types/query-key.ts b/frontend/providers/aiproxy/types/query-key.ts index 4834b0826a9..f9964ab17dd 100644 --- a/frontend/providers/aiproxy/types/query-key.ts +++ b/frontend/providers/aiproxy/types/query-key.ts @@ -4,6 +4,7 @@ export enum QueryKey { GetTokens = 'getTokens', GetUserLogs = 'getUserLogs', GetEnabledModels = 'getEnabledModels', + GetDashboardData = 'getDashboardData', // admin GetChannels = 'getChannels', GetAllChannels = 'getAllChannels', diff --git a/frontend/providers/aiproxy/types/user/dashboard.ts b/frontend/providers/aiproxy/types/user/dashboard.ts new file mode 100644 index 00000000000..fcefa76c6d6 --- /dev/null +++ b/frontend/providers/aiproxy/types/user/dashboard.ts @@ -0,0 +1,20 @@ +import { ApiResp } from '../api' + +export interface ChartDataItem { + timestamp: number + request_count: number + used_amount: number + exception_count: number +} +export interface DashboardData { + chart_data: ChartDataItem[] + token_names: string[] + models: string[] + total_count: number + exception_count: number + used_amount: number + rpm: number + tpm: number +} + +export type DashboardResponse = ApiResp diff --git a/frontend/providers/aiproxy/ui/icons/mode-icons/index.tsx b/frontend/providers/aiproxy/ui/icons/mode-icons/index.tsx index 01f8b7331a9..48ff3e4fca7 100644 --- a/frontend/providers/aiproxy/ui/icons/mode-icons/index.tsx +++ b/frontend/providers/aiproxy/ui/icons/mode-icons/index.tsx @@ -27,6 +27,7 @@ import CohereIcon from '@/ui/svg/icons/modelist/cohere.svg' import HuggingFaceIcon from '@/ui/svg/icons/modelist/huggingface.svg' import LingyiWanwuIcon from '@/ui/svg/icons/modelist/lingyiwanwu.svg' import StepFunIcon from '@/ui/svg/icons/modelist/stepfun.svg' +import DefaultIcon from '@/ui/svg/icons/modelist/default.svg' import AbabIcon from '@/ui/svg/icons/modelist/minimax.svg' import ErnieIcon from '@/ui/svg/icons/modelist/ernie.svg' @@ -61,5 +62,5 @@ export const modelIcons = { huggingface: HuggingFaceIcon, lingyiwanwu: LingyiWanwuIcon, stepfun: StepFunIcon, - default: OpenAIIcon + default: DefaultIcon } diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/default.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/default.svg new file mode 100644 index 00000000000..979bd0cbaf3 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/default.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/home.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/home.svg index 9fe618a35f7..3eb17800823 100644 --- a/frontend/providers/aiproxy/ui/svg/icons/sidebar/home.svg +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/home.svg @@ -1,6 +1,3 @@ - - - - + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/home_a.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/home_a.svg index 3ba408e7786..dfe212417ec 100644 --- a/frontend/providers/aiproxy/ui/svg/icons/sidebar/home_a.svg +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/home_a.svg @@ -1,6 +1,3 @@ - - - - + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/key_a.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/key_a.svg new file mode 100644 index 00000000000..3ba408e7786 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/key_a.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/price.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/price.svg index 4a014d509e7..f782e2a12e3 100644 --- a/frontend/providers/aiproxy/ui/svg/icons/sidebar/price.svg +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/price.svg @@ -1,4 +1,6 @@ - - + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/price_a.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/price_a.svg index 439cb2df3f9..2b83d4c9ba4 100644 --- a/frontend/providers/aiproxy/ui/svg/icons/sidebar/price_a.svg +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/price_a.svg @@ -1,4 +1,6 @@ - - + + + + \ No newline at end of file From b23466c0e0825db190e3c94e477a0e2079a5c976 Mon Sep 17 00:00:00 2001 From: lim Date: Mon, 30 Dec 2024 02:03:45 +0000 Subject: [PATCH 30/42] add react echart --- frontend/pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 30070915136..baa97454fa6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -506,6 +506,12 @@ importers: downshift: specifier: ^9.0.8 version: 9.0.8(react@18.2.0) + echarts: + specifier: ^5.4.3 + version: 5.4.3 + echarts-for-react: + specifier: ^3.0.2 + version: 3.0.2(echarts@5.4.3)(react@18.2.0) i18next: specifier: ^23.11.5 version: 23.12.1 From fe3a2b93fcd912e957c3e65a645313e7b06fef21 Mon Sep 17 00:00:00 2001 From: lim Date: Mon, 30 Dec 2024 02:08:54 +0000 Subject: [PATCH 31/42] chore --- .../(user)/home/components/RequestDataChart.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx index 5af258e0124..18e1b46f3e3 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx @@ -25,7 +25,11 @@ export default function RequestDataChart({ data }: { data: ChartDataItem[] }): R } }, legend: { - data: [t('调用数'), t('异常数'), t('花费')], + data: [ + t('dataDashboard.callCount'), + t('dataDashboard.exceptionCount'), + t('dataDashboard.cost') + ], bottom: 0 }, grid: { @@ -62,7 +66,7 @@ export default function RequestDataChart({ data }: { data: ChartDataItem[] }): R }, series: [ { - name: t('调用数'), + name: t('dataDashboard.callCount'), type: 'line', smooth: true, data: data.map((item) => [item.timestamp, item.request_count]), @@ -71,7 +75,7 @@ export default function RequestDataChart({ data }: { data: ChartDataItem[] }): R } }, { - name: t('异常数'), + name: t('dataDashboard.exceptionCount'), type: 'line', smooth: true, data: data.map((item) => [item.timestamp, item.exception_count]), @@ -80,7 +84,7 @@ export default function RequestDataChart({ data }: { data: ChartDataItem[] }): R } }, { - name: t('花费'), + name: t('dataDashboard.cost'), type: 'line', smooth: true, data: data.map((item) => [item.timestamp, item.used_amount]), From fa0deb95ecc08833d0aaf1e3fa4a43fd66c1fea9 Mon Sep 17 00:00:00 2001 From: lim Date: Fri, 3 Jan 2025 06:41:35 +0000 Subject: [PATCH 32/42] phase 2 --- frontend/providers/aiproxy/api/platform.ts | 4 + .../app/[lng]/(admin)/global-logs/page.tsx | 4 +- .../home/components/RequestDataChart.tsx | 412 +++++++++++++++--- .../aiproxy/app/[lng]/(user)/home/page.tsx | 119 ++--- .../app/[lng]/(user)/home/tools/handleTime.ts | 4 - .../aiproxy/app/[lng]/(user)/key/page.tsx | 19 - .../(user)/log/components/LogDetailModal.tsx | 120 ++++- .../aiproxy/app/[lng]/(user)/log/page.tsx | 166 ++++--- .../aiproxy/app/[lng]/(user)/price/page.tsx | 82 ++-- .../app/api/user/log/detail/[log_id]/route.ts | 85 ++++ .../aiproxy/app/api/user/log/route.ts | 8 +- .../aiproxy/app/i18n/locales/en/common.json | 33 +- .../aiproxy/app/i18n/locales/zh/common.json | 37 +- .../aiproxy/components/table/BaseTable.tsx | 1 + .../aiproxy/components/user/KeyList.tsx | 66 ++- .../aiproxy/components/user/Sidebar.tsx | 6 +- .../providers/aiproxy/hooks/useDebounce.ts | 17 + frontend/providers/aiproxy/types/query-key.ts | 2 + frontend/providers/aiproxy/types/user/logs.ts | 6 +- .../aiproxy/ui/svg/icons/sidebar/key.svg | 6 + 20 files changed, 897 insertions(+), 300 deletions(-) delete mode 100644 frontend/providers/aiproxy/app/[lng]/(user)/home/tools/handleTime.ts create mode 100644 frontend/providers/aiproxy/app/api/user/log/detail/[log_id]/route.ts create mode 100644 frontend/providers/aiproxy/hooks/useDebounce.ts diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index cfb8d2d07da..dd2b9af8229 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -18,6 +18,7 @@ import { GroupSearchResponse } from '@/app/api/admin/group/route' import { GetAllChannelResponse } from '@/app/api/admin/channel/all/route' import { DashboardQueryParams } from '@/app/api/user/dashboard/route' import { DashboardResponse } from '@/types/user/dashboard' +import { UserLogDetailResponse } from '@/app/api/user/log/detail/[log_id]/route' export const initAppConfig = () => GET<{ aiproxyBackend: string; currencySymbol: 'shellCoin' | 'cny' | 'usd' }>( @@ -31,6 +32,9 @@ export const getEnabledMode = () => GET('/api/ export const getUserLogs = (params: UserLogQueryParams) => GET('/api/user/log', params) +export const getUserLogDetail = (log_id: number) => + GET(`/api/user/log/detail/${log_id}`) + // token export const getTokens = (params: GetTokensQueryParams) => GET('/api/user/token', params) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx index 2d5a687a2ca..2c312557526 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx @@ -258,7 +258,7 @@ export default function Home(): React.JSX.Element { fontWeight="500" lineHeight="16px" letterSpacing="0.5px"> - {t('logs.name')} + {t('GlobalLogs.keyName')} - {t('logs.name')} + {t('GlobalLogs.groupId')}
(null) + const requestChartRef = useRef(null) + const costChartInstance = useRef() + const requestChartInstance = useRef() const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') const { currencySymbol } = useBackendStore() - const option = useMemo(() => { - return { + // Add helper function to determine date format + const getDateFormat = (timestamps: number[]) => { + if (timestamps.length < 2) return 'detailed' + + const timeDiff = timestamps[timestamps.length - 1] - timestamps[0] + // If time difference is more than 15 days (1296000 seconds), show daily format + return timeDiff > 1296000 ? 'daily' : 'detailed' + } + + // 初始化图表 + useEffect(() => { + if (costChartRef.current && requestChartRef.current) { + costChartInstance.current = echarts.init(costChartRef.current, undefined, { + renderer: 'svg' + }) + requestChartInstance.current = echarts.init(requestChartRef.current, undefined, { + renderer: 'svg' + }) + } + + return () => { + costChartInstance.current?.dispose() + requestChartInstance.current?.dispose() + costChartInstance.current = undefined + requestChartInstance.current = undefined + } + }, []) + + // 配置图表选项 + useEffect(() => { + if (!costChartInstance.current || !requestChartInstance.current) return + + const commonTooltipStyle: echarts.EChartsOption['tooltip'] = { + trigger: 'axis', + axisPointer: { + type: 'line', + lineStyle: { + color: '#219BF4' + } + }, + backgroundColor: 'white', + borderWidth: 0, + padding: [8, 12], + textStyle: { + color: '#111824', + fontSize: 12 + } + } + + const commonXAxis: echarts.EChartsOption['xAxis'] = { + type: 'time', + // boundaryGap: ['0%', '5%'] as [string, string], + boundaryGap: ['0%', '0%'] as [string, string], + axisLine: { + lineStyle: { + color: '#E8EBF0', + width: 2 + } + }, + splitLine: { + show: false, + lineStyle: { + color: '#DFE2EA', + type: 'dashed' as const + } + }, + axisTick: { + show: true, + lineStyle: { + color: '#E8EBF0', + width: 2 + } + }, + axisLabel: { + show: true, + color: '#667085', + formatter: (value: number) => { + const date = new Date(value * 1000) + const format = getDateFormat(data.map((item) => item.timestamp)) + + return date + .toLocaleString(lng, { + month: '2-digit', + day: '2-digit', + ...(format === 'detailed' && { + hour: '2-digit', + minute: '2-digit', + hour12: false + }) + }) + .replace(/\//g, '-') + }, + margin: 14, + align: 'left' + } + } + + // 成本图表配置 + const costOption: echarts.EChartsOption = { tooltip: { - trigger: 'axis', - axisPointer: { - type: 'line', - lineStyle: { - color: '#E2E8F0' - } + ...commonTooltipStyle, + formatter: function ( + params: + | echarts.DefaultLabelFormatterCallbackParams + | echarts.DefaultLabelFormatterCallbackParams[] + ) { + if (!params) return '' + const paramArray = Array.isArray(params) ? params : [params] + if (paramArray.length === 0) return '' + + const time = new Date((paramArray[0].value as [number, number])[0] * 1000) + const timeStr = time.toLocaleString(lng, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + + let result = ` +
${timeStr}
+
+ ` + + const currency = + currencySymbol === 'shellCoin' + ? ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + : currencySymbol === 'cny' + ? '¥' + : '$' + + paramArray.forEach((param) => { + const value = (param.value as [number, number])[1] + const formattedValue = Number(value).toLocaleString(lng, { + minimumFractionDigits: 0, + maximumFractionDigits: 4 + }) + result += ` +
+
+ ${param.marker} + ${param.seriesName} + ${currency} +
+
${formattedValue}
+
+ ` + }) + + return result } }, legend: { - data: [ - t('dataDashboard.callCount'), - t('dataDashboard.exceptionCount'), - t('dataDashboard.cost') - ], + show: false, + data: [t('dataDashboard.cost')], bottom: 0 }, grid: { - left: '3%', - right: '4%', - bottom: '10%', - top: '3%', + left: 0, + right: 0, + bottom: 10, + top: 10, containLabel: true }, - xAxis: { - type: 'time', - boundaryGap: false, - axisLine: { - lineStyle: { - color: '#E2E8F0' - } - }, + xAxis: commonXAxis, + yAxis: { + type: 'value', splitLine: { show: true, lineStyle: { - color: '#E2E8F0', + color: '#DFE2EA', type: 'dashed' } + }, + axisLine: { + show: false, + lineStyle: { + color: '#667085', + width: 2 + } + }, + axisLabel: { + // formatter: '${value}', + color: '#667085' + } + }, + series: [ + { + name: t('dataDashboard.cost'), + type: 'line', + smooth: true, + showSymbol: false, + data: data.map((item) => [item.timestamp, item.used_amount]), + itemStyle: { + color: '#13C4B9' + } + } + ] + } + + // 请求数图表配置 + const requestOption: echarts.EChartsOption = { + tooltip: { + ...commonTooltipStyle, + formatter: function (params) { + if (!params) return '' + const paramArray = Array.isArray(params) ? params : [params] + if (paramArray.length === 0) return '' + + const time = new Date((paramArray[0].value as [number, number])[0] * 1000) + const timeStr = time.toLocaleString(lng, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + + let result = ` +
${timeStr}
+
+ ` + + paramArray.forEach((param) => { + const value = (param.value as [number, number])[1] + result += ` +
+
+ ${param.marker} + ${param.seriesName} +
+
${value}
+
+ ` + }) + + return result } }, + legend: { + data: [t('dataDashboard.callCount'), t('dataDashboard.exceptionCount')], + bottom: 10 + }, + grid: { + left: 0, + right: 0, + bottom: 60, + top: 10, + containLabel: true + }, + xAxis: commonXAxis, yAxis: { type: 'value', splitLine: { + show: true, lineStyle: { - color: '#E2E8F0', + color: '#DFE2EA', type: 'dashed' } + }, + axisLine: { + show: false, + lineStyle: { + color: '#667085', + width: 2 + } + }, + axisLabel: { + color: '#667085' } }, series: [ @@ -69,40 +344,77 @@ export default function RequestDataChart({ data }: { data: ChartDataItem[] }): R name: t('dataDashboard.callCount'), type: 'line', smooth: true, + showSymbol: false, data: data.map((item) => [item.timestamp, item.request_count]), itemStyle: { - color: '#3B82F6' + color: '#11B6FC' } }, { name: t('dataDashboard.exceptionCount'), type: 'line', smooth: true, + showSymbol: false, data: data.map((item) => [item.timestamp, item.exception_count]), itemStyle: { - color: '#F59E0B' - } - }, - { - name: t('dataDashboard.cost'), - type: 'line', - smooth: true, - data: data.map((item) => [item.timestamp, item.used_amount]), - itemStyle: { - color: '#10B981' + color: '#FDB022' } } ] } - }, [data, t]) + + // 设置图表选项 + costChartInstance.current.setOption(costOption) + requestChartInstance.current.setOption(requestOption) + + // 图表联动 + costChartInstance.current.group = 'request-data' + requestChartInstance.current.group = 'request-data' + echarts.connect('request-data') + }, [data, t, lng]) + + // 处理窗口大小变化 + useEffect(() => { + const handleResize = () => { + costChartInstance.current?.resize() + requestChartInstance.current?.resize() + } + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + } + }, []) return ( - - + + + + {t('dataDashboard.cost')} + + + + + + {t('dataDashboard.callCount')} + + + ) } diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx index ced7ff9a522..3d7ab5e004d 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx @@ -1,43 +1,19 @@ 'use client' -import { - Box, - Flex, - Text, - Button, - Icon, - Modal, - useDisclosure, - ModalOverlay, - ModalHeader, - ModalCloseButton, - ModalBody, - ModalContent, - Grid -} from '@chakra-ui/react' -import { CurrencySymbol, MySelect, MyTooltip } from '@sealos/ui' -import { useMemo, useState } from 'react' +import { Box, Flex, Text, Button } from '@chakra-ui/react' +import { CurrencySymbol, MySelect } from '@sealos/ui' +import { useState } from 'react' -import { getTokens, getUserLogs, getEnabledMode, getDashboardData } from '@/api/platform' +import { getDashboardData } from '@/api/platform' import { useTranslationClientSide } from '@/app/i18n/client' -import SelectDateRange from '@/components/common/SelectDateRange' -import SwitchPage from '@/components/common/SwitchPage' -import { BaseTable } from '@/components/table/BaseTable' import { useI18n } from '@/providers/i18n/i18nContext' -import { LogItem } from '@/types/user/logs' import { UseQueryResult, useQuery } from '@tanstack/react-query' -import { getCoreRowModel, useReactTable, createColumnHelper } from '@tanstack/react-table' import { QueryKey } from '@/types/query-key' import { useBackendStore } from '@/store/backend' -import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' -import { getTranslationWithFallback } from '@/utils/common' -import ReactJson, { OnCopyProps } from 'react-json-view' -import { getTimeDiff } from './tools/handleTime' -import dynamic from 'next/dynamic' -import { DashboardResponse } from '@/types/user/dashboard' import RequestDataChart from './components/RequestDataChart' +import { DashboardResponse } from '@/types/user/dashboard' -export default function Logs(): React.JSX.Element { +export default function Home(): React.JSX.Element { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') const { currencySymbol } = useBackendStore() @@ -53,14 +29,11 @@ export default function Logs(): React.JSX.Element { type, ...(tokenName && { token_name: tokenName }), ...(model && { model }) - }), - { - onSuccess: (data) => { - console.log(data) - } - } + }) ) + console.log('dashboardData', dashboardData) + return ( + gap="24px" + flexWrap="wrap"> { - setTokenName(token) + if (token === 'all') { + setTokenName('') + } else { + setTokenName(token) + } }} /> @@ -130,11 +106,10 @@ export default function Logs(): React.JSX.Element { boxStyle={{ w: '100%' }} - maxW={'200px'} - w={'200px'} + w="200px" height="36px" placeholder={t('dataDashboard.selectModel')} - value={''} + value={model} list={[ { value: 'all', @@ -146,7 +121,11 @@ export default function Logs(): React.JSX.Element { })) || []) ]} onchange={(model: string) => { - setModel(model) + if (model === 'all') { + setModel('') + } else { + setModel(model) + } }} /> @@ -156,7 +135,7 @@ export default function Logs(): React.JSX.Element { alignItems="flex-start" p="3px" borderColor="gray.200" - bg="gray.50" + bg="grayModern.50" borderRadius="6px"> {[ { label: t('dataDashboard.day'), value: 'day' }, @@ -208,7 +187,7 @@ export default function Logs(): React.JSX.Element { + letterSpacing="0.25px"> {t('dataDashboard.callCount')} + letterSpacing="0.25px"> {t('dataDashboard.exceptionCount')} + letterSpacing="0.25px"> {t('dataDashboard.rpm')} + letterSpacing="0.25px"> {t('dataDashboard.tpm')} + letterSpacing="0.25px"> {t('dataDashboard.cost')} @@ -496,33 +470,14 @@ export default function Logs(): React.JSX.Element { fontSize="32px" fontWeight="500" lineHeight="40px"> - {dashboardData?.used_amount ? Number(dashboardData.used_amount.toFixed(6)) : 0} + {dashboardData?.used_amount ? Number(dashboardData.used_amount.toFixed(2)) : 0}
{/* chart 1 end */} - - - - {t('dataDashboard.requestData')} - - - - + diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/tools/handleTime.ts b/frontend/providers/aiproxy/app/[lng]/(user)/home/tools/handleTime.ts deleted file mode 100644 index 0b892c563f3..00000000000 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/tools/handleTime.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const getTimeDiff = (createdAt: number, requestAt: number) => { - const diff = Number(((createdAt - requestAt) / 1000).toFixed(4)).toString() - return `${diff}s` -} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx index b227c94d5cc..1af19640ee5 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx @@ -8,7 +8,6 @@ export default function Home(): JSX.Element { - - - - ) } diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/log/components/LogDetailModal.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/log/components/LogDetailModal.tsx index a6a1711df99..0268dd7ef62 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/log/components/LogDetailModal.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/log/components/LogDetailModal.tsx @@ -4,35 +4,30 @@ import { Box, Flex, Text, - Button, - Icon, Modal, - useDisclosure, ModalOverlay, ModalHeader, ModalCloseButton, ModalBody, ModalContent, - Grid + Grid, + Center, + Spinner } from '@chakra-ui/react' -import { CurrencySymbol, MySelect, MyTooltip } from '@sealos/ui' -import { useMemo, useState } from 'react' +import { CurrencySymbol } from '@sealos/ui' +import { MyTooltip } from '@/components/common/MyTooltip' -import { getTokens, getUserLogs, getEnabledMode } from '@/api/platform' +import { getUserLogDetail } from '@/api/platform' import { useTranslationClientSide } from '@/app/i18n/client' -import SelectDateRange from '@/components/common/SelectDateRange' -import SwitchPage from '@/components/common/SwitchPage' -import { BaseTable } from '@/components/table/BaseTable' import { useI18n } from '@/providers/i18n/i18nContext' import { LogItem } from '@/types/user/logs' import { useQuery } from '@tanstack/react-query' -import { getCoreRowModel, useReactTable, createColumnHelper } from '@tanstack/react-table' import { QueryKey } from '@/types/query-key' import { useBackendStore } from '@/store/backend' -import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' import { getTranslationWithFallback } from '@/utils/common' import ReactJson, { OnCopyProps } from 'react-json-view' import { getTimeDiff } from '../tools/handleTime' +import { useMessage } from '@sealos/ui' export default function LogDetailModal({ isOpen, @@ -47,6 +42,26 @@ export default function LogDetailModal({ const { t } = useTranslationClientSide(lng, 'common') const { currencySymbol } = useBackendStore() + const { data: logDetail, isLoading } = useQuery({ + queryKey: [QueryKey.GetUserLogDetail, rowData?.request_detail?.log_id], + queryFn: () => { + if (!rowData?.request_detail?.log_id) throw new Error('No log ID') + return getUserLogDetail(rowData.request_detail.log_id) + }, + enabled: !!rowData?.request_detail?.log_id + }) + + const isDetailLoading = !!rowData?.request_detail?.log_id && isLoading + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + // 定义默认的网格配置 const gridConfig = { labelWidth: '153px', @@ -184,6 +199,7 @@ export default function LogDetailModal({ isLast?: boolean } ) => { + if (!content) return null const handleCopy = (copy: OnCopyProps) => { if (typeof window === 'undefined') return @@ -193,8 +209,6 @@ export default function LogDetailModal({ navigator.clipboard.writeText(copyText) } - if (!content) return null - let parsed = null try { @@ -290,7 +304,50 @@ export default function LogDetailModal({ ) } - return ( + return isDetailLoading ? ( + + + + + + + {t('logs.logDetail')} + + + + + +
+ +
+
+
+
+ ) : ( @@ -424,7 +481,30 @@ export default function LogDetailModal({ fontWeight={500} lineHeight="20px" letterSpacing="0.1px" - whiteSpace="nowrap"> + whiteSpace="nowrap" + cursor="pointer" + onClick={() => { + navigator.clipboard.writeText(rowData.content || '').then( + () => { + message({ + status: 'success', + title: t('copySuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + }, + (err) => { + message({ + status: 'warning', + title: t('copyFailed'), + description: err?.message || t('copyFailed'), + isClosable: true, + position: 'top' + }) + } + ) + }}> {rowData.content} , @@ -437,14 +517,14 @@ export default function LogDetailModal({ } )} - {rowData?.request_detail?.request_body && - renderJsonContent(t('logs.requestBody'), rowData.request_detail.request_body, { + {logDetail?.request_body && + renderJsonContent(t('logs.requestBody'), logDetail.request_body, { labelWidth: gridConfig.labelWidth, contentHeight: gridConfig.jsonContentHeight, isFirst: false })} - {rowData?.request_detail?.response_body && - renderJsonContent(t('logs.responseBody'), rowData.request_detail.response_body, { + {logDetail?.response_body && + renderJsonContent(t('logs.responseBody'), logDetail.response_body, { labelWidth: gridConfig.labelWidth, contentHeight: gridConfig.jsonContentHeight, isLast: false diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx index f23e912ed94..34f90a94647 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx @@ -6,14 +6,10 @@ import { Text, Button, Icon, - Modal, useDisclosure, - ModalOverlay, - ModalHeader, - ModalCloseButton, - ModalBody, - ModalContent, - Grid + InputGroup, + InputRightElement, + Input } from '@chakra-ui/react' import { CurrencySymbol, MySelect, MyTooltip } from '@sealos/ui' import { useMemo, useState } from 'react' @@ -30,10 +26,9 @@ import { getCoreRowModel, useReactTable, createColumnHelper } from '@tanstack/re import { QueryKey } from '@/types/query-key' import { useBackendStore } from '@/store/backend' import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' -import { getTranslationWithFallback } from '@/utils/common' -import ReactJson, { OnCopyProps } from 'react-json-view' import { getTimeDiff } from './tools/handleTime' import dynamic from 'next/dynamic' +import { useDebounce } from '@/hooks/useDebounce' const LogDetailModal = dynamic( () => import('./components/LogDetailModal'), @@ -60,6 +55,8 @@ export default function Logs(): React.JSX.Element { const [pageSize, setPageSize] = useState(10) const [logData, setLogData] = useState([]) const [total, setTotal] = useState(0) + const [inputKeyword, setInputKeyword] = useState('') + const debouncedKeyword = useDebounce(inputKeyword, 500) // 500ms 延迟 0.5s const { data: modelConfigs = [] } = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) const { data: tokenData } = useQuery([QueryKey.GetTokens], () => @@ -67,13 +64,24 @@ export default function Logs(): React.JSX.Element { ) const { isLoading } = useQuery( - [QueryKey.GetUserLogs, page, pageSize, name, modelName, startTime, endTime, codeType], + [ + QueryKey.GetUserLogs, + page, + pageSize, + name, + modelName, + startTime, + endTime, + codeType, + debouncedKeyword + ], () => getUserLogs({ page, perPage: pageSize, token_name: name, model_name: modelName, + keyword: debouncedKeyword, code_type: codeType as 'all' | 'success' | 'error', start_timestamp: startTime.getTime().toString(), end_timestamp: endTime.getTime().toString() @@ -383,48 +391,104 @@ export default function Logs(): React.JSX.Element { letterSpacing="0.15px"> {t('logs.call_log')} - + + + + + + + + + + {/* -- the first row */} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx index 9bedf9dd22a..c6f8b6bec13 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx @@ -23,7 +23,7 @@ import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import { useQuery, UseQueryResult } from '@tanstack/react-query' import { getEnabledMode } from '@/api/platform' -import { useMemo, useState, useEffect } from 'react' +import { useMemo, useState } from 'react' import { createColumnHelper, getCoreRowModel, @@ -490,36 +490,50 @@ const ModelComponent = ({ modelConfig }: { modelConfig: ModelConfig }) => { {modelConfig.config?.vision && ( - - - - - - {t('price.modelVision')} - - + + {t('price.modelVisionLabel')} + + } + width="auto" + height="auto"> + + + + + + {t('price.modelVision')} + + + )} {modelConfig.config?.tool_choice && ( { fontWeight={500} lineHeight="16px" letterSpacing="0.5px"> - {`${Math.ceil(modelConfig.config.max_context_tokens / 1024)}K`} + {`${ + modelConfig.config.max_context_tokens % 1024 === 0 + ? Math.ceil(modelConfig.config.max_context_tokens / 1024) + : Math.ceil(modelConfig.config.max_context_tokens / 1000) + }K`} )} diff --git a/frontend/providers/aiproxy/app/api/user/log/detail/[log_id]/route.ts b/frontend/providers/aiproxy/app/api/user/log/detail/[log_id]/route.ts new file mode 100644 index 00000000000..a0bb12a9f68 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/user/log/detail/[log_id]/route.ts @@ -0,0 +1,85 @@ +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { RequestDetail } from '@/types/user/logs' +import { parseJwtToken } from '@/utils/backend/auth' +import { NextRequest, NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' +export type ApiProxyBackendUserLogDetailResponse = ApiProxyBackendResp + +export type UserLogDetailResponse = ApiResp + +export interface UserLogDetailParams { + log_id: string +} + +async function fetchLogs(log_id: string, group: string): Promise { + try { + const url = new URL( + `/api/log/${group}/detail/${log_id}`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendUserLogDetailResponse = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Get logs detail API request failed') + } + + return result.data || null + } catch (error) { + console.error('Get logs detail error:', error) + throw error + } +} + +export async function GET( + request: NextRequest, + { params }: { params: { log_id: string } } +): Promise> { + try { + const group = await parseJwtToken(request.headers) + + if (!params.log_id) { + return NextResponse.json( + { + code: 400, + message: 'Log_id is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + const detail = await fetchLogs(params.log_id, group) + + return NextResponse.json({ + code: 200, + data: detail || undefined + } satisfies UserLogDetailResponse) + } catch (error) { + console.error('Get logs detail error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/user/log/route.ts b/frontend/providers/aiproxy/app/api/user/log/route.ts index ba7c9c897c5..27e35aeab64 100644 --- a/frontend/providers/aiproxy/app/api/user/log/route.ts +++ b/frontend/providers/aiproxy/app/api/user/log/route.ts @@ -17,6 +17,7 @@ export type UserLogSearchResponse = ApiResp<{ export interface UserLogQueryParams { token_name?: string model_name?: string + keyword?: string start_timestamp?: string end_timestamp?: string code_type?: 'all' | 'success' | 'error' | undefined @@ -59,6 +60,10 @@ async function fetchLogs( url.searchParams.append('model_name', params.model_name) } + if (params.keyword) { + url.searchParams.append('keyword', params.keyword) + } + if (params.code_type) { url.searchParams.append('code_type', params.code_type) } @@ -111,7 +116,8 @@ export async function GET(request: NextRequest): Promise({ key={header.id} color="grayModern.600" border="none" + textTransform="none" borderTopLeftRadius={i === 0 ? '6px' : '0'} borderBottomLeftRadius={i === 0 ? '6px' : '0'} borderTopRightRadius={i === headers.headers.length - 1 ? '6px' : '0'} diff --git a/frontend/providers/aiproxy/components/user/KeyList.tsx b/frontend/providers/aiproxy/components/user/KeyList.tsx index 156e27f541e..a5ecd37f954 100644 --- a/frontend/providers/aiproxy/components/user/KeyList.tsx +++ b/frontend/providers/aiproxy/components/user/KeyList.tsx @@ -29,6 +29,7 @@ import { Center, Spinner } from '@chakra-ui/react' +import { CurrencySymbol } from '@sealos/ui' import { Column, createColumnHelper, @@ -85,7 +86,9 @@ export enum TableHeaderId { CREATED_AT = 'key.createdAt', LAST_USED_AT = 'key.lastUsedAt', STATUS = 'key.status', - ACTIONS = 'key.actions' + ACTIONS = 'key.actions', + REQUEST_COUNT = 'key.requestCount', + USED_AMOUNT = 'key.usedAmount' } enum KeyStatus { @@ -96,6 +99,23 @@ enum KeyStatus { } const CustomHeader = ({ column, t }: { column: Column; t: TFunction }) => { + const { currencySymbol } = useBackendStore() + if (column.id === TableHeaderId.USED_AMOUNT) { + return ( + + + {t(column.id as TableHeaderId)} + + + + ) + } return ( void }) => { ) }}> - {'sk-' + - info.getValue().substring(0, 8) + - '*'.repeat(Math.max(0, info.getValue().length - 8))} + {'sk-' + info.getValue().substring(0, 8) + '*'.repeat(3)} ) @@ -345,6 +363,46 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { } }), + columnHelper.accessor((row) => row.request_count, { + id: TableHeaderId.REQUEST_COUNT, + header: (props) => , + cell: (info) => ( + + {info.getValue()} + + ) + }), + + columnHelper.accessor((row) => row.used_amount, { + id: TableHeaderId.USED_AMOUNT, + header: (props) => , + cell: (info) => { + const value = Number(info.getValue()) + // 获取小数部分的长度 + const decimalLength = value.toString().split('.')[1]?.length || 0 + // 如果小数位超过6位则保留6位,否则保持原样 + const formattedValue = decimalLength > 6 ? value.toFixed(6) : value + + return ( + + {formattedValue} + + ) + } + }), + columnHelper.display({ id: TableHeaderId.ACTIONS, header: (props) => , diff --git a/frontend/providers/aiproxy/components/user/Sidebar.tsx b/frontend/providers/aiproxy/components/user/Sidebar.tsx index 6bc2d0f7301..0c05a4d6437 100644 --- a/frontend/providers/aiproxy/components/user/Sidebar.tsx +++ b/frontend/providers/aiproxy/components/user/Sidebar.tsx @@ -11,6 +11,8 @@ import logsIcon from '@/ui/svg/icons/sidebar/logs.svg' import logsIcon_a from '@/ui/svg/icons/sidebar/logs_a.svg' import priceIcon from '@/ui/svg/icons/sidebar/price.svg' import priceIcon_a from '@/ui/svg/icons/sidebar/price_a.svg' +import keysIcon from '@/ui/svg/icons/sidebar/key.svg' +import keysIcon_a from '@/ui/svg/icons/sidebar/key_a.svg' import { useI18n } from '@/providers/i18n/i18nContext' type Menu = { @@ -40,8 +42,8 @@ const SideBar = (): JSX.Element => { id: 'keys', url: '/key', value: t('Sidebar.Keys'), - icon: homeIcon, - activeIcon: homeIcon_a, + icon: keysIcon, + activeIcon: keysIcon_a, display: true }, { diff --git a/frontend/providers/aiproxy/hooks/useDebounce.ts b/frontend/providers/aiproxy/hooks/useDebounce.ts new file mode 100644 index 00000000000..aa770c4da03 --- /dev/null +++ b/frontend/providers/aiproxy/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react' + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/frontend/providers/aiproxy/types/query-key.ts b/frontend/providers/aiproxy/types/query-key.ts index f9964ab17dd..eff327f6e7c 100644 --- a/frontend/providers/aiproxy/types/query-key.ts +++ b/frontend/providers/aiproxy/types/query-key.ts @@ -5,6 +5,8 @@ export enum QueryKey { GetUserLogs = 'getUserLogs', GetEnabledModels = 'getEnabledModels', GetDashboardData = 'getDashboardData', + GetUserLogDetail = 'getUserLogDetail', + // admin GetChannels = 'getChannels', GetAllChannels = 'getAllChannels', diff --git a/frontend/providers/aiproxy/types/user/logs.ts b/frontend/providers/aiproxy/types/user/logs.ts index e72ed6613e9..cef98d63ddd 100644 --- a/frontend/providers/aiproxy/types/user/logs.ts +++ b/frontend/providers/aiproxy/types/user/logs.ts @@ -1,12 +1,12 @@ export interface RequestDetail { - request_body: string - response_body: string + request_body?: string + response_body?: string id: number log_id: number } export interface LogItem { - request_detail: RequestDetail + request_detail?: RequestDetail request_id: string request_at: number id: number diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg index e69de29bb2d..9fe618a35f7 100644 --- a/frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file From 986a5271abb7b44bb7d1188160c188f01374336b Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 8 Jan 2025 08:58:09 +0000 Subject: [PATCH 33/42] chore adjust ui --- .../home/components/RequestDataChart.tsx | 1 + .../aiproxy/app/[lng]/(user)/home/page.tsx | 180 +++++++++++++++++- .../aiproxy/app/[lng]/(user)/log/page.tsx | 51 ++--- .../aiproxy/app/[lng]/(user)/price/page.tsx | 123 ++++++------ .../aiproxy/app/api/user/log/route.ts | 16 +- .../aiproxy/app/i18n/locales/en/common.json | 9 +- .../aiproxy/app/i18n/locales/zh/common.json | 9 +- .../common/SingleSelectComboboxUnStyle.tsx | 9 +- frontend/providers/aiproxy/package.json | 1 - 9 files changed, 293 insertions(+), 106 deletions(-) diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx index e5a67b64437..a8d6ffdf894 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx @@ -83,6 +83,7 @@ export default function RequestDataChart({ data }: { data: ChartDataItem[] }): R }, axisTick: { show: true, + length: 6, lineStyle: { color: '#E8EBF0', width: 2 diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx index 3d7ab5e004d..fada55fc5a4 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { Box, Flex, Text, Button } from '@chakra-ui/react' +import { Box, Flex, Text, Button, Center } from '@chakra-ui/react' import { CurrencySymbol, MySelect } from '@sealos/ui' import { useState } from 'react' @@ -20,7 +20,7 @@ export default function Home(): React.JSX.Element { const [tokenName, setTokenName] = useState('') const [model, setModel] = useState('') - const [type, setType] = useState<'week' | 'day' | 'two_week' | 'month'>('week') + const [type, setType] = useState<'week' | 'day' | 'two_week' | 'month'>('week') // default is week const { data: dashboardData, isLoading }: UseQueryResult = useQuery( [QueryKey.GetDashboardData, type, tokenName, model], @@ -32,8 +32,6 @@ export default function Home(): React.JSX.Element { }) ) - console.log('dashboardData', dashboardData) - return ( - + {t('dataDashboard.cost')} - + {currencySymbol === 'shellCoin' ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : currencySymbol === 'cny' ? ( + '¥' + ) : ( + '$' + )} ([]) - const [total, setTotal] = useState(0) const [inputKeyword, setInputKeyword] = useState('') const debouncedKeyword = useDebounce(inputKeyword, 500) // 500ms 延迟 0.5s - const { data: modelConfigs = [] } = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) - const { data: tokenData } = useQuery([QueryKey.GetTokens], () => - getTokens({ page: 1, perPage: 100 }) - ) - - const { isLoading } = useQuery( + const { data: logData, isLoading } = useQuery( [ QueryKey.GetUserLogs, page, pageSize, - name, + keyName, modelName, startTime, endTime, @@ -79,24 +72,13 @@ export default function Logs(): React.JSX.Element { getUserLogs({ page, perPage: pageSize, - token_name: name, + token_name: keyName, model_name: modelName, keyword: debouncedKeyword, code_type: codeType as 'all' | 'success' | 'error', start_timestamp: startTime.getTime().toString(), end_timestamp: endTime.getTime().toString() - }), - { - onSuccess: (data) => { - if (!data?.logs) { - setLogData([]) - setTotal(0) - return - } - setLogData(data?.logs || []) - setTotal(data?.total || 0) - } - } + }) ) const columnHelper = createColumnHelper() @@ -348,7 +330,7 @@ export default function Logs(): React.JSX.Element { ) const table = useReactTable({ - data: logData, + data: logData?.logs || [], columns, getCoreRowModel: getCoreRowModel() }) @@ -473,8 +455,9 @@ export default function Logs(): React.JSX.Element { bg="white" boxShadow="0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)" onClick={() => { - setName('') + setKeyName('') setModelName('') + setCodeType('all') }}> - dropdownItems={['all', ...(tokenData?.tokens?.map((item) => item.name) || [])]} + dropdownItems={['all', ...(logData?.token_names || [])]} setSelectedItem={(value) => { if (value === 'all') { - setName('') + setKeyName('') } else { - setName(value) + setKeyName(value) } }} handleDropdownItemFilter={(dropdownItems, inputValue) => { @@ -563,7 +546,7 @@ export default function Logs(): React.JSX.Element { {t('logs.modal')} - dropdownItems={['all', ...modelConfigs.map((item) => item.model)]} + dropdownItems={['all', ...(logData?.models || [])]} setSelectedItem={(value) => { if (value === 'all') { setModelName('') @@ -619,15 +602,15 @@ export default function Logs(): React.JSX.Element { list={[ { value: 'all', - label: 'all' + label: t('logs.statusOptions.all') }, { value: 'success', - label: 'success' + label: t('logs.statusOptions.success') }, { value: 'error', - label: 'error' + label: t('logs.statusOptions.error') } ]} onchange={(val: string) => { @@ -669,8 +652,8 @@ export default function Logs(): React.JSX.Element { m="0" justifyContent={'end'} currentPage={page} - totalPage={Math.ceil(total / pageSize)} - totalItem={total} + totalPage={Math.ceil((logData?.total || 0) / pageSize)} + totalItem={logData?.total || 0} pageSize={pageSize} setCurrentPage={(idx: number) => setPage(idx)} /> diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx index c6f8b6bec13..efaeb2ac6ec 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx @@ -23,7 +23,7 @@ import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import { useQuery, UseQueryResult } from '@tanstack/react-query' import { getEnabledMode } from '@/api/platform' -import { useMemo, useState } from 'react' +import { useMemo, useState, useEffect } from 'react' import { createColumnHelper, getCoreRowModel, @@ -40,6 +40,7 @@ import { getTranslationWithFallback } from '@/utils/common' import { useBackendStore } from '@/store/backend' import { modelIcons } from '@/ui/icons/mode-icons' import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' +import { useDebounce } from '@/hooks/useDebounce' type SortDirection = 'asc' | 'desc' | false @@ -101,26 +102,15 @@ const getTypeStyle = (type: number) => { return MODEL_TYPE_STYLES[type as keyof typeof MODEL_TYPE_STYLES] || MODEL_TYPE_STYLES.default } -function Price() { +export default function Price() { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') const [modelOwner, setModelOwner] = useState('') const [modelType, setModelType] = useState('') const [modelName, setModelName] = useState('') - const [filteredModelConfigs, setFilteredModelConfigs] = useState([]) - - const { - isLoading, - data: modelConfigs = [] as ModelConfig[], - refetch - }: UseQueryResult = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode(), { - onSuccess: (data) => { - if (data) { - setFilteredModelConfigs(data) - } - } - }) + const [searchInput, setSearchInput] = useState('') + const debouncedSearch = useDebounce(searchInput, 500) interface FilterParams { owner: string @@ -131,11 +121,17 @@ function Price() { const filterModels = (modelConfigs: ModelConfig[], filterParams: FilterParams): ModelConfig[] => { return modelConfigs.filter((config) => { const ownerMatch = - !filterParams.owner || filterParams.owner === 'all' || config.owner === filterParams.owner + !filterParams.owner || + filterParams.owner === t('price.all') || + getTranslationWithFallback( + `modeOwner.${String(config.owner)}`, + 'modeOwner.unknown', + t as any + ) === filterParams.owner const typeMatch = !filterParams.type || - filterParams.type === 'all' || + filterParams.type === t('price.all') || getTranslationWithFallback(`modeType.${String(config.type)}`, 'modeType.0', t as any) === filterParams.type @@ -146,6 +142,24 @@ function Price() { }) } + const { + isLoading, + data: modelConfigs = [] as ModelConfig[], + refetch + }: UseQueryResult = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) + + const filteredModelConfigs = useMemo(() => { + return filterModels(modelConfigs, { + owner: modelOwner, + type: modelType, + name: debouncedSearch + }) + }, [modelConfigs, modelOwner, modelType, debouncedSearch]) + + useEffect(() => { + setModelName(debouncedSearch) + }, [debouncedSearch]) + return ( @@ -230,27 +244,37 @@ function Price() { letterSpacing="0.5px"> {t('price.modelOwner')} - - dropdownItems={['all', ...new Set(modelConfigs.map((config) => config.owner))]} - setSelectedItem={(modelOwner) => { - setModelOwner(modelOwner) - setFilteredModelConfigs( - filterModels(modelConfigs, { - owner: modelOwner, - type: modelType, - name: modelName - }) + + dropdownItems={[ + { icon: '', name: t('price.all') }, + ...Array.from( + new Map( + modelConfigs.map((config) => [ + config.owner, + { + icon: config.owner, + name: getTranslationWithFallback( + `modeOwner.${String(config.owner)}`, + 'modeOwner.unknown', + t as any + ) + } + ]) + ).values() ) + ]} + setSelectedItem={(modelOwner) => { + setModelOwner(modelOwner.name) }} handleDropdownItemFilter={(dropdownItems, inputValue) => { const lowerCasedInput = inputValue.toLowerCase() return dropdownItems.filter( - (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) + (item) => !inputValue || item.name.toLowerCase().includes(lowerCasedInput) ) }} handleDropdownItemDisplay={(dropdownItem) => { - const iconSrc = getModelIcon(dropdownItem) - if (dropdownItem === 'all') { + const iconSrc = getModelIcon(dropdownItem.icon) + if (dropdownItem.name === t('price.all')) { return ( default @@ -262,14 +286,14 @@ function Price() { fontWeight={500} lineHeight="16px" letterSpacing="0.5px"> - {dropdownItem} + {dropdownItem.name} ) } return ( - {dropdownItem} + {dropdownItem.icon} - {dropdownItem} + {dropdownItem.name} ) }} flexProps={{ w: '240px' }} - initSelectedItem="all" + initSelectedItem={{ icon: '', name: t('price.all') }} + handleInputDisplay={(dropdownItem) => dropdownItem.name} /> @@ -301,7 +326,7 @@ function Price() { dropdownItems={[ - 'all', + t('price.all'), ...new Set( modelConfigs.map((config) => getTranslationWithFallback( @@ -314,13 +339,6 @@ function Price() { ]} setSelectedItem={(modelType) => { setModelType(modelType) - setFilteredModelConfigs( - filterModels(modelConfigs, { - owner: modelOwner, - type: modelType, - name: modelName - }) - ) }} handleDropdownItemFilter={(dropdownItems, inputValue) => { const lowerCasedInput = inputValue.toLowerCase() @@ -343,7 +361,7 @@ function Price() { ) }} flexProps={{ w: '240px' }} - initSelectedItem="all" + initSelectedItem={t('price.all')} /> @@ -371,18 +389,9 @@ function Price() { lineHeight: '16px', letterSpacing: '0.048px' }} - value={modelName} + value={searchInput} onChange={(e) => { - const searchValue = e.target.value - setModelName(searchValue) - - setFilteredModelConfigs( - filterModels(modelConfigs, { - owner: modelOwner, - type: modelType, - name: searchValue - }) - ) + setSearchInput(e.target.value) }} /> @@ -551,8 +560,8 @@ const ModelComponent = ({ modelConfig }: { modelConfig: ModelConfig }) => { viewBox="0 0 14 14" fill="none"> @@ -1086,5 +1095,3 @@ function PriceTable({ ) } - -export default Price diff --git a/frontend/providers/aiproxy/app/api/user/log/route.ts b/frontend/providers/aiproxy/app/api/user/log/route.ts index 27e35aeab64..ce0c1909bec 100644 --- a/frontend/providers/aiproxy/app/api/user/log/route.ts +++ b/frontend/providers/aiproxy/app/api/user/log/route.ts @@ -7,11 +7,15 @@ export const dynamic = 'force-dynamic' export type ApiProxyBackendUserLogSearchResponse = ApiProxyBackendResp<{ logs: LogItem[] total: number + models: string[] + token_names: string[] }> export type UserLogSearchResponse = ApiResp<{ logs: LogItem[] total: number + models: string[] + token_names: string[] }> export interface UserLogQueryParams { @@ -43,7 +47,7 @@ function validateParams(params: UserLogQueryParams): string | null { async function fetchLogs( params: UserLogQueryParams, group: string -): Promise<{ logs: LogItem[]; total: number }> { +): Promise<{ logs: LogItem[]; total: number; models: string[]; token_names: string[] }> { try { const url = new URL( `/api/log/${group}/search`, @@ -96,7 +100,9 @@ async function fetchLogs( return { logs: result.data?.logs || [], - total: result.data?.total || 0 + total: result.data?.total || 0, + models: result.data?.models || [], + token_names: result.data?.token_names || [] } } catch (error) { console.error('Error fetching logs:', error) @@ -132,13 +138,15 @@ export async function GET(request: NextRequest): Promise(props: { setSelectedItem: (value: T) => void handleDropdownItemFilter: (dropdownItems: T[], inputValue: string) => T[] handleDropdownItemDisplay: (dropdownItem: T) => ReactNode + handleInputDisplay?: (item: T) => string initSelectedItem?: T flexProps?: FlexProps placeholder?: string @@ -18,6 +19,7 @@ export const SingleSelectComboboxUnstyle: (props: { setSelectedItem, handleDropdownItemFilter, handleDropdownItemDisplay, + handleInputDisplay, initSelectedItem, flexProps, placeholder @@ -26,6 +28,7 @@ export const SingleSelectComboboxUnstyle: (props: { setSelectedItem: (value: T) => void handleDropdownItemFilter: (dropdownItems: T[], inputValue: string) => T[] handleDropdownItemDisplay: (dropdownItem: T) => ReactNode + handleInputDisplay?: (item: T) => string initSelectedItem?: T flexProps?: FlexProps placeholder?: string @@ -50,9 +53,13 @@ export const SingleSelectComboboxUnstyle: (props: { onInputValueChange: ({ inputValue }) => { setGetFilteredDropdownItems(handleDropdownItemFilter(dropdownItems, inputValue)) }, - initialSelectedItem: initSelectedItem || undefined, + itemToString: (item) => { + if (!item) return '' + return handleInputDisplay ? handleInputDisplay(item) : String(item) + }, + onSelectedItemChange: ({ selectedItem }) => { const selectedDropdownItem = dropdownItems.find((item) => item === selectedItem) if (selectedDropdownItem) { diff --git a/frontend/providers/aiproxy/package.json b/frontend/providers/aiproxy/package.json index f799b304808..33b1952a9b3 100644 --- a/frontend/providers/aiproxy/package.json +++ b/frontend/providers/aiproxy/package.json @@ -18,7 +18,6 @@ "date-fns": "^2.30.0", "downshift": "^9.0.8", "echarts": "^5.4.3", - "echarts-for-react": "^3.0.2", "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.2", From d7d0adf4ba8b771d8a60d2fd8540b9db6d49b872 Mon Sep 17 00:00:00 2001 From: lim Date: Wed, 8 Jan 2025 08:58:34 +0000 Subject: [PATCH 34/42] chore --- frontend/pnpm-lock.yaml | 64 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index baa97454fa6..20eca39a951 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -509,9 +509,6 @@ importers: echarts: specifier: ^5.4.3 version: 5.4.3 - echarts-for-react: - specifier: ^3.0.2 - version: 3.0.2(echarts@5.4.3)(react@18.2.0) i18next: specifier: ^23.11.5 version: 23.12.1 @@ -532,7 +529,7 @@ importers: version: 9.0.2 next: specifier: 14.2.5 - version: 14.2.5(@babel/core@7.23.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.5) + version: 14.2.5(react-dom@18.2.0)(react@18.2.0) react: specifier: ^18 version: 18.2.0 @@ -19606,6 +19603,48 @@ packages: - babel-plugin-macros dev: false + /next@14.2.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + sass: + optional: true + dependencies: + '@next/env': 14.2.5 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001594 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(react@18.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.5 + '@next/swc-darwin-x64': 14.2.5 + '@next/swc-linux-arm64-gnu': 14.2.5 + '@next/swc-linux-arm64-musl': 14.2.5 + '@next/swc-linux-x64-gnu': 14.2.5 + '@next/swc-linux-x64-musl': 14.2.5 + '@next/swc-win32-arm64-msvc': 14.2.5 + '@next/swc-win32-ia32-msvc': 14.2.5 + '@next/swc-win32-x64-msvc': 14.2.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + dev: false + /node-abi@3.54.0: resolution: {integrity: sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA==} engines: {node: '>=10'} @@ -22824,6 +22863,23 @@ packages: react: 18.2.0 dev: false + /styled-jsx@5.1.1(react@18.2.0): + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + dependencies: + client-only: 0.0.1 + react: 18.2.0 + dev: false + /stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} dev: false From e7988930f84eba3657972ab3188da77c3a7d972c Mon Sep 17 00:00:00 2001 From: lim Date: Thu, 23 Jan 2025 06:56:38 +0000 Subject: [PATCH 35/42] temp user realname --- frontend/pnpm-lock.yaml | 212 +++++++++++++----- .../aiproxy/app/api/user/token/[id]/route.ts | 17 +- .../aiproxy/app/api/user/token/route.ts | 16 +- frontend/providers/aiproxy/package.json | 2 + .../providers/aiproxy/utils/backend/auth.ts | 25 +++ .../providers/aiproxy/utils/backend/db.ts | 54 +++++ 6 files changed, 264 insertions(+), 62 deletions(-) create mode 100644 frontend/providers/aiproxy/utils/backend/db.ts diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3860e245cf7..c51f381f47c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -494,6 +494,9 @@ importers: '@tanstack/react-table': specifier: ^8.10.7 version: 8.10.7(react-dom@18.2.0)(react@18.2.0) + '@types/pg': + specifier: ^8.11.10 + version: 8.11.10 accept-language: specifier: ^3.0.20 version: 3.0.20 @@ -529,7 +532,10 @@ importers: version: 9.0.2 next: specifier: 14.2.5 - version: 14.2.5(react-dom@18.2.0)(react@18.2.0) + version: 14.2.5(@babel/core@7.23.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.5) + pg: + specifier: ^8.13.1 + version: 8.13.1 react: specifier: ^18 version: 18.2.0 @@ -10449,6 +10455,14 @@ packages: resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} dev: false + /@types/pg@8.11.10: + resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} + dependencies: + '@types/node': 20.10.0 + pg-protocol: 1.7.0 + pg-types: 4.0.2 + dev: false + /@types/pluralize@0.0.33: resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} dev: true @@ -19615,48 +19629,6 @@ packages: - babel-plugin-macros dev: false - /next@14.2.5(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==} - engines: {node: '>=18.17.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 - react: ^18.2.0 - react-dom: ^18.2.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - sass: - optional: true - dependencies: - '@next/env': 14.2.5 - '@swc/helpers': 0.5.5 - busboy: 1.6.0 - caniuse-lite: 1.0.30001594 - graceful-fs: 4.2.11 - postcss: 8.4.31 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(react@18.2.0) - optionalDependencies: - '@next/swc-darwin-arm64': 14.2.5 - '@next/swc-darwin-x64': 14.2.5 - '@next/swc-linux-arm64-gnu': 14.2.5 - '@next/swc-linux-arm64-musl': 14.2.5 - '@next/swc-linux-x64-gnu': 14.2.5 - '@next/swc-linux-x64-musl': 14.2.5 - '@next/swc-win32-arm64-msvc': 14.2.5 - '@next/swc-win32-ia32-msvc': 14.2.5 - '@next/swc-win32-x64-msvc': 14.2.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - dev: false - /node-abi@3.54.0: resolution: {integrity: sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA==} engines: {node: '>=10'} @@ -19887,6 +19859,10 @@ packages: es-object-atoms: 1.0.0 dev: true + /obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + dev: false + /octokit@3.1.2: resolution: {integrity: sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==} engines: {node: '>= 18'} @@ -20123,6 +20099,86 @@ packages: /performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + /pg-cloudflare@1.1.1: + resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} + requiresBuild: true + dev: false + optional: true + + /pg-connection-string@2.7.0: + resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} + dev: false + + /pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + dev: false + + /pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + dev: false + + /pg-pool@3.7.0(pg@8.13.1): + resolution: {integrity: sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==} + peerDependencies: + pg: '>=8.0' + dependencies: + pg: 8.13.1 + dev: false + + /pg-protocol@1.7.0: + resolution: {integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==} + dev: false + + /pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + dev: false + + /pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + dev: false + + /pg@8.13.1: + resolution: {integrity: sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + dependencies: + pg-connection-string: 2.7.0 + pg-pool: 3.7.0(pg@8.13.1) + pg-protocol: 1.7.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.1.1 + dev: false + + /pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + dependencies: + split2: 4.2.0 + dev: false + /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -20303,6 +20359,54 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 + /postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + dev: false + + /postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + dev: false + + /postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + dependencies: + obuf: 1.1.2 + dev: false + + /postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + dev: false + + /postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + dependencies: + xtend: 4.0.2 + dev: false + + /postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + dev: false + + /postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + dev: false + /potpack@1.0.2: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} dev: false @@ -22537,6 +22641,11 @@ packages: extend-shallow: 3.0.2 dev: false + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true @@ -22875,23 +22984,6 @@ packages: react: 18.2.0 dev: false - /styled-jsx@5.1.1(react@18.2.0): - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - dependencies: - client-only: 0.0.1 - react: 18.2.0 - dev: false - /stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} dev: false diff --git a/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts b/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts index d0ec478cb39..cafa4ac8b11 100644 --- a/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts +++ b/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/backend/auth' +import { getSealosUserUid, parseJwtToken } from '@/utils/backend/auth' import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { validateSealosUserRealNameInfo } from '@/utils/backend/db' export const dynamic = 'force-dynamic' @@ -130,6 +131,20 @@ export async function POST( ) } + const sealosUserUid = await getSealosUserUid(request.headers) + const isRealName = await validateSealosUserRealNameInfo(sealosUserUid) + + if (!isRealName) { + return NextResponse.json( + { + code: 400, + message: 'user not real name', + error: 'user not real name' + }, + { status: 400 } + ) + } + const updateTokenBody: UpdateTokenRequestBody = await request.json() if (typeof updateTokenBody.status !== 'number') { diff --git a/frontend/providers/aiproxy/app/api/user/token/route.ts b/frontend/providers/aiproxy/app/api/user/token/route.ts index ad9771f9c3f..ea42e2cc967 100644 --- a/frontend/providers/aiproxy/app/api/user/token/route.ts +++ b/frontend/providers/aiproxy/app/api/user/token/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' import { TokenInfo } from '@/types/user/token' -import { parseJwtToken } from '@/utils/backend/auth' +import { getSealosUserUid, parseJwtToken } from '@/utils/backend/auth' import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { validateSealosUserRealNameInfo } from '@/utils/backend/db' export const dynamic = 'force-dynamic' @@ -190,6 +191,19 @@ export async function POST(request: NextRequest): Promise { return Promise.reject('Auth: Invalid token') } } + +export async function getSealosUserUid(headers: Headers): Promise { + try { + const token = headers.get('authorization') + if (!token) { + return Promise.reject('Auth: Token is missing') + } + + const decoded = jwt.verify( + token, + global.AppConfig?.auth.appTokenJwtKey || '' + ) as AppTokenPayload + const now = Math.floor(Date.now() / 1000) + if (decoded.exp && decoded.exp < now) { + return Promise.reject('Auth: Token expired') + } + if (!decoded.workspaceId) { + return Promise.reject('Auth: Invalid token') + } + return decoded.userUid + } catch (error) { + console.error('Auth: Token parsing error:', error) + return Promise.reject('Auth: Invalid token') + } +} diff --git a/frontend/providers/aiproxy/utils/backend/db.ts b/frontend/providers/aiproxy/utils/backend/db.ts new file mode 100644 index 00000000000..00bce8b110c --- /dev/null +++ b/frontend/providers/aiproxy/utils/backend/db.ts @@ -0,0 +1,54 @@ +import pg, { PoolConfig, QueryConfig } from 'pg' +const { Pool, types } = pg +const connectionString = `postgresql://${process.env.PG_USER}:${process.env.PG_PASSWD}@hzh.sealos.run:43243/defaultdb` + +types.setTypeParser(20, function (val: string) { + return BigInt(val) +}) + +let poolConfig: PoolConfig = { + connectionString: connectionString, + max: 20, // 连接池最大连接数 + idleTimeoutMillis: 10000, // 空闲连接超时时间,毫秒 + connectionTimeoutMillis: 2000, // 连接超时时间,毫秒 + ssl: { + rejectUnauthorized: false // 不验证SSL证书 + } +} + +export const pgPool = new Pool(poolConfig) + +type UserRealNameInfo = { + id: string + userUid: string + realName?: string + idCard?: string + phone?: string + isVerified: boolean + idVerifyFailedTimes: number + createdAt: string + updatedAt: string + additionalInfo?: object +} + +export async function validateSealosUserRealNameInfo(sealosUserUid: string): Promise { + const query: QueryConfig = { + text: 'SELECT * FROM "UserRealNameInfo" WHERE "userUid" = $1', + values: [sealosUserUid] + } + + try { + const res = await pgPool.query(query) + + if (res.rows.length === 0) { + return false + } + + const userRealNameInfo: UserRealNameInfo = res.rows[0] + + return userRealNameInfo.isVerified + } catch (error: any) { + console.error('Error executing query', error.stack) + throw error + } +} From 0246651dcfebab3803d35b3f7167fe4b2ff2c6af Mon Sep 17 00:00:00 2001 From: lim Date: Fri, 7 Feb 2025 09:05:50 +0000 Subject: [PATCH 36/42] Optimize real-name verification logic --- .../aiproxy/app/api/init-app-config/route.ts | 12 +++- .../aiproxy/app/api/user/token/[id]/route.ts | 10 ++- .../aiproxy/app/api/user/token/route.ts | 10 ++- .../aiproxy/app/i18n/locales/en/common.json | 3 +- .../aiproxy/app/i18n/locales/zh/common.json | 3 +- .../aiproxy/components/user/KeyList.tsx | 4 +- frontend/providers/aiproxy/package.json | 2 - .../providers/aiproxy/types/app-config.d.ts | 2 + .../providers/aiproxy/utils/backend/auth.ts | 68 ++++++++++++++++--- .../providers/aiproxy/utils/backend/db.ts | 54 --------------- 10 files changed, 86 insertions(+), 82 deletions(-) delete mode 100644 frontend/providers/aiproxy/utils/backend/db.ts diff --git a/frontend/providers/aiproxy/app/api/init-app-config/route.ts b/frontend/providers/aiproxy/app/api/init-app-config/route.ts index 91061dc0148..1378f774644 100644 --- a/frontend/providers/aiproxy/app/api/init-app-config/route.ts +++ b/frontend/providers/aiproxy/app/api/init-app-config/route.ts @@ -27,6 +27,12 @@ function getAppConfig(appConfig: AppConfigType): AppConfigType { if (process.env.CURRENCY_SYMBOL) { appConfig.currencySymbol = process.env.CURRENCY_SYMBOL as 'shellCoin' | 'cny' | 'usd' } + if (process.env.ACCOUNT_SERVER) { + appConfig.backend.accountServer = process.env.ACCOUNT_SERVER + } + if (process.env.ACCOUNT_SERVER_TOKEN_JWT_KEY) { + appConfig.auth.accountServerTokenJwtKey = process.env.ACCOUNT_SERVER_TOKEN_JWT_KEY + } return appConfig } @@ -35,11 +41,13 @@ function initAppConfig(): AppConfigType { const DefaultAppConfig: AppConfigType = { auth: { appTokenJwtKey: '', - aiProxyBackendKey: '' + aiProxyBackendKey: '', + accountServerTokenJwtKey: '' }, backend: { aiproxy: '', - aiproxyInternal: '' + aiproxyInternal: '', + accountServer: '' }, adminNameSpace: [], currencySymbol: 'shellCoin' diff --git a/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts b/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts index cafa4ac8b11..b02f58c0473 100644 --- a/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts +++ b/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' -import { getSealosUserUid, parseJwtToken } from '@/utils/backend/auth' +import { checkSealosUserIsRealName, parseJwtToken } from '@/utils/backend/auth' import { ApiProxyBackendResp, ApiResp } from '@/types/api' -import { validateSealosUserRealNameInfo } from '@/utils/backend/db' export const dynamic = 'force-dynamic' @@ -131,15 +130,14 @@ export async function POST( ) } - const sealosUserUid = await getSealosUserUid(request.headers) - const isRealName = await validateSealosUserRealNameInfo(sealosUserUid) + const isRealName = await checkSealosUserIsRealName(request.headers) if (!isRealName) { return NextResponse.json( { code: 400, - message: 'user not real name', - error: 'user not real name' + message: 'key.userNotRealName', + error: 'key.userNotRealName' }, { status: 400 } ) diff --git a/frontend/providers/aiproxy/app/api/user/token/route.ts b/frontend/providers/aiproxy/app/api/user/token/route.ts index ea42e2cc967..10791109f67 100644 --- a/frontend/providers/aiproxy/app/api/user/token/route.ts +++ b/frontend/providers/aiproxy/app/api/user/token/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { TokenInfo } from '@/types/user/token' -import { getSealosUserUid, parseJwtToken } from '@/utils/backend/auth' +import { checkSealosUserIsRealName, parseJwtToken } from '@/utils/backend/auth' import { ApiProxyBackendResp, ApiResp } from '@/types/api' -import { validateSealosUserRealNameInfo } from '@/utils/backend/db' export const dynamic = 'force-dynamic' @@ -191,15 +190,14 @@ export async function POST(request: NextRequest): Promise void }) => { message({ status: 'warning', title: t('key.updateFailed'), - description: err?.message || t('key.updateFailed'), + description: err?.message ? t(err.message) : t('key.updateFailed'), isClosable: true, position: 'top' }) @@ -906,7 +906,7 @@ function CreateKeyModal({ message({ status: 'warning', title: t('key.createFailed'), - description: err instanceof Error ? err.message : t('key.createFailed'), + description: err instanceof Error ? t(err.message as any) : t('key.createFailed'), isClosable: true, position: 'top' }) diff --git a/frontend/providers/aiproxy/package.json b/frontend/providers/aiproxy/package.json index 37535662be3..33b1952a9b3 100644 --- a/frontend/providers/aiproxy/package.json +++ b/frontend/providers/aiproxy/package.json @@ -13,7 +13,6 @@ "@sealos/ui": "workspace:*", "@tanstack/react-query": "^4.35.3", "@tanstack/react-table": "^8.10.7", - "@types/pg": "^8.11.10", "accept-language": "^3.0.20", "axios": "^1.7.7", "date-fns": "^2.30.0", @@ -26,7 +25,6 @@ "immer": "^10.1.1", "jsonwebtoken": "^9.0.2", "next": "14.2.5", - "pg": "^8.13.1", "react": "^18", "react-day-picker": "^8.8.2", "react-dom": "^18", diff --git a/frontend/providers/aiproxy/types/app-config.d.ts b/frontend/providers/aiproxy/types/app-config.d.ts index 28e923a889c..a47dead7be1 100644 --- a/frontend/providers/aiproxy/types/app-config.d.ts +++ b/frontend/providers/aiproxy/types/app-config.d.ts @@ -2,10 +2,12 @@ export type AppConfigType = { auth: { appTokenJwtKey: string aiProxyBackendKey: string + accountServerTokenJwtKey: string } backend: { aiproxy: string aiproxyInternal: string + accountServer: string } adminNameSpace: string[] currencySymbol: 'shellCoin' | 'cny' | 'usd' diff --git a/frontend/providers/aiproxy/utils/backend/auth.ts b/frontend/providers/aiproxy/utils/backend/auth.ts index 523407faeaf..d4c83e58b53 100644 --- a/frontend/providers/aiproxy/utils/backend/auth.ts +++ b/frontend/providers/aiproxy/utils/backend/auth.ts @@ -13,6 +13,15 @@ interface AppTokenPayload { exp: number } +type RealNameInfoResponse = { + data: { + userID: string + isRealName: boolean + } + error?: string + message: string +} + export async function parseJwtToken(headers: Headers): Promise { try { const token = headers.get('authorization') @@ -38,11 +47,22 @@ export async function parseJwtToken(headers: Headers): Promise { } } -export async function getSealosUserUid(headers: Headers): Promise { +export async function checkSealosUserIsRealName(headers: Headers): Promise { + if (!global.AppConfig?.backend.accountServer) { + console.warn('CheckSealosUserIsRealName: Account server is not set') + return true + } + + if (!global.AppConfig?.auth.accountServerTokenJwtKey) { + console.warn('CheckSealosUserIsRealName: Account server token jwt key is not set') + return true + } + try { const token = headers.get('authorization') if (!token) { - return Promise.reject('Auth: Token is missing') + console.error('CheckSealosUserIsRealName: Token is missing') + return false } const decoded = jwt.verify( @@ -51,14 +71,46 @@ export async function getSealosUserUid(headers: Headers): Promise { ) as AppTokenPayload const now = Math.floor(Date.now() / 1000) if (decoded.exp && decoded.exp < now) { - return Promise.reject('Auth: Token expired') + console.error('CheckSealosUserIsRealName: Token expired') + return false } - if (!decoded.workspaceId) { - return Promise.reject('Auth: Invalid token') + + if (!decoded.userUid && !decoded.userId) { + console.error('CheckSealosUserIsRealName: User uid or user id is missing, token is invalid') + return false } - return decoded.userUid + + const accountServerToken = jwt.sign( + { + userUid: decoded.userUid, + userId: decoded.userId + }, + global.AppConfig?.auth.accountServerTokenJwtKey, + { expiresIn: '5d' } + ) + + const response = await fetch( + `${global.AppConfig?.backend.accountServer}/account/v1alpha1/real-name-info`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accountServerToken}`, + 'Content-Type': 'application/json', + 'Accept-Encoding': 'gzip,deflate,compress' + }, + cache: 'no-store' + } + ) + const result: RealNameInfoResponse = await response.json() + console.log(result) + if (result.error) { + console.error(result.error) + return false + } + + return result.data.isRealName } catch (error) { - console.error('Auth: Token parsing error:', error) - return Promise.reject('Auth: Invalid token') + console.error('CheckSealosUserIsRealName: Token parsing error:', error) + return false } } diff --git a/frontend/providers/aiproxy/utils/backend/db.ts b/frontend/providers/aiproxy/utils/backend/db.ts deleted file mode 100644 index 00bce8b110c..00000000000 --- a/frontend/providers/aiproxy/utils/backend/db.ts +++ /dev/null @@ -1,54 +0,0 @@ -import pg, { PoolConfig, QueryConfig } from 'pg' -const { Pool, types } = pg -const connectionString = `postgresql://${process.env.PG_USER}:${process.env.PG_PASSWD}@hzh.sealos.run:43243/defaultdb` - -types.setTypeParser(20, function (val: string) { - return BigInt(val) -}) - -let poolConfig: PoolConfig = { - connectionString: connectionString, - max: 20, // 连接池最大连接数 - idleTimeoutMillis: 10000, // 空闲连接超时时间,毫秒 - connectionTimeoutMillis: 2000, // 连接超时时间,毫秒 - ssl: { - rejectUnauthorized: false // 不验证SSL证书 - } -} - -export const pgPool = new Pool(poolConfig) - -type UserRealNameInfo = { - id: string - userUid: string - realName?: string - idCard?: string - phone?: string - isVerified: boolean - idVerifyFailedTimes: number - createdAt: string - updatedAt: string - additionalInfo?: object -} - -export async function validateSealosUserRealNameInfo(sealosUserUid: string): Promise { - const query: QueryConfig = { - text: 'SELECT * FROM "UserRealNameInfo" WHERE "userUid" = $1', - values: [sealosUserUid] - } - - try { - const res = await pgPool.query(query) - - if (res.rows.length === 0) { - return false - } - - const userRealNameInfo: UserRealNameInfo = res.rows[0] - - return userRealNameInfo.isVerified - } catch (error: any) { - console.error('Error executing query', error.stack) - throw error - } -} From 18bfedef54fdb2267c113b476a35c6f34872ce08 Mon Sep 17 00:00:00 2001 From: lim Date: Fri, 7 Feb 2025 09:07:02 +0000 Subject: [PATCH 37/42] chore --- frontend/pnpm-lock.yaml | 212 ++++++++++++---------------------------- 1 file changed, 60 insertions(+), 152 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c51f381f47c..3860e245cf7 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -494,9 +494,6 @@ importers: '@tanstack/react-table': specifier: ^8.10.7 version: 8.10.7(react-dom@18.2.0)(react@18.2.0) - '@types/pg': - specifier: ^8.11.10 - version: 8.11.10 accept-language: specifier: ^3.0.20 version: 3.0.20 @@ -532,10 +529,7 @@ importers: version: 9.0.2 next: specifier: 14.2.5 - version: 14.2.5(@babel/core@7.23.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.5) - pg: - specifier: ^8.13.1 - version: 8.13.1 + version: 14.2.5(react-dom@18.2.0)(react@18.2.0) react: specifier: ^18 version: 18.2.0 @@ -10455,14 +10449,6 @@ packages: resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} dev: false - /@types/pg@8.11.10: - resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} - dependencies: - '@types/node': 20.10.0 - pg-protocol: 1.7.0 - pg-types: 4.0.2 - dev: false - /@types/pluralize@0.0.33: resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} dev: true @@ -19629,6 +19615,48 @@ packages: - babel-plugin-macros dev: false + /next@14.2.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + sass: + optional: true + dependencies: + '@next/env': 14.2.5 + '@swc/helpers': 0.5.5 + busboy: 1.6.0 + caniuse-lite: 1.0.30001594 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(react@18.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 14.2.5 + '@next/swc-darwin-x64': 14.2.5 + '@next/swc-linux-arm64-gnu': 14.2.5 + '@next/swc-linux-arm64-musl': 14.2.5 + '@next/swc-linux-x64-gnu': 14.2.5 + '@next/swc-linux-x64-musl': 14.2.5 + '@next/swc-win32-arm64-msvc': 14.2.5 + '@next/swc-win32-ia32-msvc': 14.2.5 + '@next/swc-win32-x64-msvc': 14.2.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + dev: false + /node-abi@3.54.0: resolution: {integrity: sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA==} engines: {node: '>=10'} @@ -19859,10 +19887,6 @@ packages: es-object-atoms: 1.0.0 dev: true - /obuf@1.1.2: - resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} - dev: false - /octokit@3.1.2: resolution: {integrity: sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==} engines: {node: '>= 18'} @@ -20099,86 +20123,6 @@ packages: /performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} - /pg-cloudflare@1.1.1: - resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} - requiresBuild: true - dev: false - optional: true - - /pg-connection-string@2.7.0: - resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} - dev: false - - /pg-int8@1.0.1: - resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} - engines: {node: '>=4.0.0'} - dev: false - - /pg-numeric@1.0.2: - resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} - engines: {node: '>=4'} - dev: false - - /pg-pool@3.7.0(pg@8.13.1): - resolution: {integrity: sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==} - peerDependencies: - pg: '>=8.0' - dependencies: - pg: 8.13.1 - dev: false - - /pg-protocol@1.7.0: - resolution: {integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==} - dev: false - - /pg-types@2.2.0: - resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} - engines: {node: '>=4'} - dependencies: - pg-int8: 1.0.1 - postgres-array: 2.0.0 - postgres-bytea: 1.0.0 - postgres-date: 1.0.7 - postgres-interval: 1.2.0 - dev: false - - /pg-types@4.0.2: - resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} - engines: {node: '>=10'} - dependencies: - pg-int8: 1.0.1 - pg-numeric: 1.0.2 - postgres-array: 3.0.2 - postgres-bytea: 3.0.0 - postgres-date: 2.1.0 - postgres-interval: 3.0.0 - postgres-range: 1.1.4 - dev: false - - /pg@8.13.1: - resolution: {integrity: sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==} - engines: {node: '>= 8.0.0'} - peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true - dependencies: - pg-connection-string: 2.7.0 - pg-pool: 3.7.0(pg@8.13.1) - pg-protocol: 1.7.0 - pg-types: 2.2.0 - pgpass: 1.0.5 - optionalDependencies: - pg-cloudflare: 1.1.1 - dev: false - - /pgpass@1.0.5: - resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} - dependencies: - split2: 4.2.0 - dev: false - /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -20359,54 +20303,6 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 - /postgres-array@2.0.0: - resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} - engines: {node: '>=4'} - dev: false - - /postgres-array@3.0.2: - resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} - engines: {node: '>=12'} - dev: false - - /postgres-bytea@1.0.0: - resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} - engines: {node: '>=0.10.0'} - dev: false - - /postgres-bytea@3.0.0: - resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} - engines: {node: '>= 6'} - dependencies: - obuf: 1.1.2 - dev: false - - /postgres-date@1.0.7: - resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} - engines: {node: '>=0.10.0'} - dev: false - - /postgres-date@2.1.0: - resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} - engines: {node: '>=12'} - dev: false - - /postgres-interval@1.2.0: - resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} - engines: {node: '>=0.10.0'} - dependencies: - xtend: 4.0.2 - dev: false - - /postgres-interval@3.0.0: - resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} - engines: {node: '>=12'} - dev: false - - /postgres-range@1.1.4: - resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - dev: false - /potpack@1.0.2: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} dev: false @@ -22641,11 +22537,6 @@ packages: extend-shallow: 3.0.2 dev: false - /split2@4.2.0: - resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} - engines: {node: '>= 10.x'} - dev: false - /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true @@ -22984,6 +22875,23 @@ packages: react: 18.2.0 dev: false + /styled-jsx@5.1.1(react@18.2.0): + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + dependencies: + client-only: 0.0.1 + react: 18.2.0 + dev: false + /stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} dev: false From 3f2c8f4b3cacaa5f20e52dfb3273146b39bd0e4b Mon Sep 17 00:00:00 2001 From: lim Date: Fri, 7 Feb 2025 10:10:01 +0000 Subject: [PATCH 38/42] chore --- .../aiproxy/app/[lng]/(user)/home/page.tsx | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx index fada55fc5a4..5d1e277e343 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx @@ -152,7 +152,7 @@ export default function Home(): React.JSX.Element { alignItems="center" gap="6px" borderRadius="4px" - color="grayModern.500" + color={type === item.value ? '#0884DD' : 'grayModern.500'} fontFamily="PingFang SC" fontSize="14px" fontWeight="500" @@ -188,7 +188,7 @@ export default function Home(): React.JSX.Element { bg="#EDFAFF" gap="16px" borderRadius="12px" - px="10px" + px="20px" py="28px" alignItems="center" alignSelf="stretch"> @@ -255,7 +255,7 @@ export default function Home(): React.JSX.Element { bg="yellow.50" gap="16px" borderRadius="12px" - px="10px" + px="20px" py="28px" alignItems="center" alignSelf="stretch"> @@ -308,7 +308,7 @@ export default function Home(): React.JSX.Element { bg="#F0F4FF" gap="16px" borderRadius="12px" - px="10px" + px="20px" py="28px" alignItems="center" alignSelf="stretch"> @@ -361,7 +361,7 @@ export default function Home(): React.JSX.Element { bg="purple.50" gap="16px" borderRadius="12px" - px="10px" + px="20px" py="28px" alignItems="center" alignSelf="stretch"> @@ -418,7 +418,7 @@ export default function Home(): React.JSX.Element { bg="teal.50" gap="16px" borderRadius="12px" - px="10px" + px="20px" py="28px" alignItems="center" alignSelf="stretch"> @@ -626,9 +626,27 @@ export default function Home(): React.JSX.Element { ) : currencySymbol === 'cny' ? ( - '¥' + + ¥ + ) : ( - '$' + + $ + )} Date: Mon, 10 Feb 2025 09:47:32 +0000 Subject: [PATCH 39/42] chore --- .../home/components/RequestDataChart.tsx | 20 ++++++++++++--- .../aiproxy/app/[lng]/(user)/home/page.tsx | 25 +++++++++++++++++-- .../aiproxy/app/[lng]/(user)/log/page.tsx | 20 ++++++--------- .../aiproxy/app/i18n/locales/zh/common.json | 4 +-- .../providers/aiproxy/utils/backend/auth.ts | 2 +- 5 files changed, 51 insertions(+), 20 deletions(-) diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx index a8d6ffdf894..4c6a4c586de 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx @@ -389,7 +389,21 @@ export default function RequestDataChart({ data }: { data: ChartDataItem[] }): R }, []) return ( - + {t('dataDashboard.cost')} - + {t('dataDashboard.callCount')} - + ) diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx index 5d1e277e343..f3586b8ee5f 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx @@ -180,9 +180,30 @@ export default function Home(): React.JSX.Element { gap="38px" alignItems="flex-start" justifyContent="center" - flexDirection="column"> + flexDirection="column" + overflowY="auto" + overflowX="hidden" + sx={{ + '&::-webkit-scrollbar': { + display: 'none' + }, + msOverflowStyle: 'none', + scrollbarWidth: 'none' + }}> {/* chart 1 */} - + + letterSpacing="0.048px"> {code !== 200 ? `${t('logs.failed')} (${row.original.code})` : code} {code !== 200 && ( @@ -233,12 +233,12 @@ export default function Logs(): React.JSX.Element { + letterSpacing="0.048px"> {t('logs.total_price')} @@ -268,21 +268,16 @@ export default function Logs(): React.JSX.Element { setSelectedRow(row.original) onOpen() }} + h="28px" variant="unstyled" display="inline-flex" padding="6px 8px" justifyContent="center" alignItems="center" gap="4px" - boxShadow="0px 1px 2px 0px rgba(19, 51, 107, 0.05), 0px 0px 1px 0px rgba(19, 51, 107, 0.08)" borderRadius="4px" background="grayModern.150" - fontSize="11px" - fontFamily="PingFang SC" - fontWeight="500" whiteSpace="nowrap" - lineHeight="16px" - letterSpacing="0.5px" transition="all 0.2s ease" _hover={{ transform: 'scale(1.05)', @@ -315,7 +310,8 @@ export default function Logs(): React.JSX.Element { diff --git a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json index 5ebacff054e..01a245db4fd 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json @@ -279,8 +279,8 @@ "title": "仪表盘", "selectToken": "全部密钥", "selectModel": "全部模型", - "day": "一天内", - "week": "近七天", + "day": "1天内", + "week": "近7天", "twoWeek": "近15天", "month": "近30天", "callCount": "请求数", diff --git a/frontend/providers/aiproxy/utils/backend/auth.ts b/frontend/providers/aiproxy/utils/backend/auth.ts index d4c83e58b53..1b3908c15fa 100644 --- a/frontend/providers/aiproxy/utils/backend/auth.ts +++ b/frontend/providers/aiproxy/utils/backend/auth.ts @@ -110,7 +110,7 @@ export async function checkSealosUserIsRealName(headers: Headers): Promise Date: Wed, 12 Feb 2025 09:30:18 +0000 Subject: [PATCH 40/42] chore --- .../providers/aiproxy/app/api/models/enabled/route.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/providers/aiproxy/app/api/models/enabled/route.ts b/frontend/providers/aiproxy/app/api/models/enabled/route.ts index 0db3d4a2c36..e2a78a75864 100644 --- a/frontend/providers/aiproxy/app/api/models/enabled/route.ts +++ b/frontend/providers/aiproxy/app/api/models/enabled/route.ts @@ -8,10 +8,10 @@ export type GetEnabledModelsResponse = ApiResp export const dynamic = 'force-dynamic' -async function fetchEnabledModels(): Promise { +async function fetchEnabledModels(namespace: string): Promise { try { const url = new URL( - '/api/models/enabled', + `/api/dashboard/${namespace}/models`, global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy ) @@ -42,11 +42,11 @@ async function fetchEnabledModels(): Promise { export async function GET(request: NextRequest): Promise> { try { - await parseJwtToken(request.headers) + const group = await parseJwtToken(request.headers) return NextResponse.json({ code: 200, - data: await fetchEnabledModels() + data: await fetchEnabledModels(group) } satisfies GetEnabledModelsResponse) } catch (error) { console.error('enabled models api: get enabled models error:', error) From d859892562c74eb202c11c449ce962abb5c5ecaa Mon Sep 17 00:00:00 2001 From: lim Date: Fri, 14 Feb 2025 02:47:25 +0000 Subject: [PATCH 41/42] fix admin channel create multiple input --- .../dashboard/components/UpdateChannelModal.tsx | 10 +++++++--- .../providers/aiproxy/app/[lng]/(user)/price/page.tsx | 6 +----- frontend/providers/aiproxy/utils/backend/auth.ts | 1 - 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx index 7130c07e89d..9ad31ddf8ee 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx @@ -115,10 +115,14 @@ export const UpdateChannelModal = function ({ ) => { const lowerCasedInputValue = inputValue.toLowerCase() - return dropdownItems.filter( - (item) => - !selectedItems.includes(item) && item.name.toLowerCase().includes(lowerCasedInputValue) + // First filter out items that are already selected + const unselectedItems = dropdownItems.filter( + (dropdownItem) => + !selectedItems.some((selectedItem) => selectedItem.name === dropdownItem.name) ) + + // Then filter by input value + return unselectedItems.filter((item) => item.name.toLowerCase().includes(lowerCasedInputValue)) } const handleModelDropdownItemDisplay = (dropdownItem: Model) => { diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx index efaeb2ac6ec..fd10b773076 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx @@ -108,7 +108,7 @@ export default function Price() { const [modelOwner, setModelOwner] = useState('') const [modelType, setModelType] = useState('') - const [modelName, setModelName] = useState('') + const [searchInput, setSearchInput] = useState('') const debouncedSearch = useDebounce(searchInput, 500) @@ -156,10 +156,6 @@ export default function Price() { }) }, [modelConfigs, modelOwner, modelType, debouncedSearch]) - useEffect(() => { - setModelName(debouncedSearch) - }, [debouncedSearch]) - return ( diff --git a/frontend/providers/aiproxy/utils/backend/auth.ts b/frontend/providers/aiproxy/utils/backend/auth.ts index 1b3908c15fa..a5280fd26eb 100644 --- a/frontend/providers/aiproxy/utils/backend/auth.ts +++ b/frontend/providers/aiproxy/utils/backend/auth.ts @@ -102,7 +102,6 @@ export async function checkSealosUserIsRealName(headers: Headers): Promise Date: Thu, 20 Feb 2025 10:10:13 +0000 Subject: [PATCH 42/42] Add Invitation Event Popup --- frontend/providers/aiproxy/api/platform.ts | 10 +- .../aiproxy/app/[lng]/(user)/key/page.tsx | 192 ++++++++++++++++-- .../aiproxy/app/[lng]/(user)/log/page.tsx | 2 +- .../aiproxy/app/api/init-app-config/route.ts | 20 +- .../aiproxy/app/i18n/locales/en/common.json | 9 +- .../aiproxy/app/i18n/locales/zh/common.json | 9 +- .../aiproxy/components/InitializeApp.tsx | 14 +- .../aiproxy/components/user/ModelList.tsx | 168 --------------- .../aiproxy/components/user/Sidebar.tsx | 16 +- frontend/providers/aiproxy/store/backend.ts | 14 +- .../providers/aiproxy/types/app-config.d.ts | 5 + 11 files changed, 255 insertions(+), 204 deletions(-) delete mode 100644 frontend/providers/aiproxy/components/user/ModelList.tsx diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index dd2b9af8229..a64ba9064fb 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -21,9 +21,13 @@ import { DashboardResponse } from '@/types/user/dashboard' import { UserLogDetailResponse } from '@/app/api/user/log/detail/[log_id]/route' export const initAppConfig = () => - GET<{ aiproxyBackend: string; currencySymbol: 'shellCoin' | 'cny' | 'usd' }>( - '/api/init-app-config' - ) + GET<{ + aiproxyBackend: string + currencySymbol: 'shellCoin' | 'cny' | 'usd' + docUrl: string + isInvitationActive: boolean + invitationUrl: string + }>('/api/init-app-config') // export const getEnabledMode = () => GET('/api/models/enabled') diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx index 1af19640ee5..882c5a6f5cf 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx @@ -1,24 +1,184 @@ -import { Flex } from '@chakra-ui/react' +'use client' + +import { Button, Flex, Link, Text } from '@chakra-ui/react' import KeyList from '@/components/user/KeyList' -import ModelList from '@/components/user/ModelList' +import { useBackendStore } from '@/store/backend' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useSessionStore } from '@/store/session' +import { MyTooltip } from '@/components/common/MyTooltip' +export default function Key(): JSX.Element { + const { isInvitationActive } = useBackendStore() + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { session } = useSessionStore.getState() + const { invitationUrl } = useBackendStore() + let userInvitationUrl = '' + if (isInvitationActive && invitationUrl) { + const userId = session?.user.id + const baseUrl = new URL(invitationUrl).origin + userInvitationUrl = `${baseUrl}/?uid=${userId}` + } -export default function Home(): JSX.Element { return ( - - - + {isInvitationActive ? ( + + + + + + + 🎉{t('keyList.invitationText1')} + + + + + {t('keyList.invitationText2')} + + + {t('keyList.invitationText3')} + + + + + {t('keyList.invitationText4')} + + + { + navigator.clipboard.writeText(userInvitationUrl) + }}> + {userInvitationUrl} + + + + + + + + + + + + + + ) : ( + + + + )} ) } diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx index bd0d868cf11..737671a09c1 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx @@ -44,7 +44,7 @@ export default function Logs(): React.JSX.Element { const [startTime, setStartTime] = useState(() => { const currentDate = new Date() - currentDate.setMonth(currentDate.getMonth() - 1) + currentDate.setDate(currentDate.getDate() - 3) return currentDate }) const [endTime, setEndTime] = useState(new Date()) diff --git a/frontend/providers/aiproxy/app/api/init-app-config/route.ts b/frontend/providers/aiproxy/app/api/init-app-config/route.ts index 1378f774644..705e9c9f937 100644 --- a/frontend/providers/aiproxy/app/api/init-app-config/route.ts +++ b/frontend/providers/aiproxy/app/api/init-app-config/route.ts @@ -33,12 +33,27 @@ function getAppConfig(appConfig: AppConfigType): AppConfigType { if (process.env.ACCOUNT_SERVER_TOKEN_JWT_KEY) { appConfig.auth.accountServerTokenJwtKey = process.env.ACCOUNT_SERVER_TOKEN_JWT_KEY } + if (process.env.DOC_URL) { + appConfig.common.docUrl = process.env.DOC_URL + } + if (process.env.IS_INVITATION_ACTIVE) { + appConfig.common.isInvitationActive = process.env.IS_INVITATION_ACTIVE === 'true' + } + if (process.env.INVITATION_URL) { + appConfig.common.invitationUrl = process.env.INVITATION_URL + } + return appConfig } function initAppConfig(): AppConfigType { // default config const DefaultAppConfig: AppConfigType = { + common: { + docUrl: '', + isInvitationActive: false, + invitationUrl: '' + }, auth: { appTokenJwtKey: '', aiProxyBackendKey: '', @@ -74,7 +89,10 @@ export async function GET(): Promise { message: 'Success', data: { aiproxyBackend: config.backend.aiproxy, - currencySymbol: config.currencySymbol + currencySymbol: config.currencySymbol, + docUrl: config.common.docUrl, + isInvitationActive: config.common.isInvitationActive, + invitationUrl: config.common.invitationUrl } }) } catch (error) { diff --git a/frontend/providers/aiproxy/app/i18n/locales/en/common.json b/frontend/providers/aiproxy/app/i18n/locales/en/common.json index 6f6d5909c7a..3914a94820c 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/en/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/en/common.json @@ -12,7 +12,13 @@ "Keys": "API Keys" }, "keyList": { - "title": "API Keys" + "title": "API Keys", + "invitationText1": "Limited time benefits!!", + "invite": "Event details", + "invitationText2": "Invite friends to register Sealos,", + "invitationText3": "Rebate immediately: 10 yuan balance!", + "invitationText4": "Invitation link:", + "invitationText5": "Click to copy" }, "key": { "key": "API Key", @@ -71,7 +77,6 @@ "inputTokens": "Input Tokens", "outputTokens": "Output Tokens", "searchByContent": "Enter search keyword", - "status": "State", "statusOptions": { "all": "All", "success": "Success", diff --git a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json index 01a245db4fd..a79ad7b4a85 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json @@ -12,7 +12,13 @@ "Keys": "API Keys" }, "keyList": { - "title": "API Keys" + "title": "API Keys", + "invitationText1": "限时福利!!", + "invite": "活动详情", + "invitationText2": "邀请好友注册 Sealos,", + "invitationText3": "立返 10元 余额!", + "invitationText4": "邀请链接:", + "invitationText5": "点击复制" }, "key": { "key": "API Key", @@ -71,7 +77,6 @@ "inputTokens": "输入 Tokens", "outputTokens": "输出 Tokens", "searchByContent": "输入搜索关键词", - "status": "状态", "statusOptions": { "all": "全部", "success": "成功", diff --git a/frontend/providers/aiproxy/components/InitializeApp.tsx b/frontend/providers/aiproxy/components/InitializeApp.tsx index 2ee26d59afb..ac707858581 100644 --- a/frontend/providers/aiproxy/components/InitializeApp.tsx +++ b/frontend/providers/aiproxy/components/InitializeApp.tsx @@ -16,7 +16,13 @@ export default function InitializeApp() { const pathname = usePathname() const { lng } = useI18n() const { i18n } = useTranslationClientSide(lng) - const { setAiproxyBackend, setCurrencySymbol } = useBackendStore() + const { + setAiproxyBackend, + setCurrencySymbol, + setDocUrl, + setIsInvitationActive, + setInvitationUrl + } = useBackendStore() const handleI18nChange = useCallback( (data: { currentLanguage: string }) => { @@ -111,9 +117,13 @@ export default function InitializeApp() { // init config const initConfig = async () => { try { - const { aiproxyBackend, currencySymbol } = await initAppConfig() + const { aiproxyBackend, currencySymbol, docUrl, isInvitationActive, invitationUrl } = + await initAppConfig() setAiproxyBackend(aiproxyBackend) setCurrencySymbol(currencySymbol) + setDocUrl(docUrl) + setIsInvitationActive(isInvitationActive) + setInvitationUrl(invitationUrl) console.info('aiproxy: init config success') } catch (error) { console.error('aiproxy: init config error:', error) diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx deleted file mode 100644 index 2579878b6e0..00000000000 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ /dev/null @@ -1,168 +0,0 @@ -'use client' -import { Badge, Center, Flex, Spinner, Text } from '@chakra-ui/react' -import { ListIcon } from '@/ui/icons/index' -import { useTranslationClientSide } from '@/app/i18n/client' -import { useI18n } from '@/providers/i18n/i18nContext' -import Image, { StaticImageData } from 'next/image' -import { useQuery } from '@tanstack/react-query' -import { getEnabledMode } from '@/api/platform' -import { useMessage } from '@sealos/ui' -import { MyTooltip } from '@/components/common/MyTooltip' -import { QueryKey } from '@/types/query-key' -import { modelIcons } from '@/ui/icons/mode-icons' -import { getTranslationWithFallback } from '@/utils/common' - -const ModelComponent = ({ modelName, modelOwner }: { modelName: string; modelOwner: string }) => { - const { lng } = useI18n() - const { t } = useTranslationClientSide(lng, 'common') - const { message } = useMessage({ - warningBoxBg: 'var(--Yellow-50, #FFFAEB)', - warningIconBg: 'var(--Yellow-500, #F79009)', - warningIconFill: 'white', - successBoxBg: 'var(--Green-50, #EDFBF3)', - successIconBg: 'var(--Green-600, #039855)', - successIconFill: 'white' - }) - - // get model icon - const getModelIcon = (modelOwner: string): StaticImageData => { - const icon = modelIcons[modelOwner as keyof typeof modelIcons] || modelIcons['default'] - return icon - } - - const iconSrc = getModelIcon(modelOwner) - - return ( - - {modelName} - - - navigator.clipboard.writeText(modelName).then( - () => { - message({ - status: 'success', - title: t('copySuccess'), - isClosable: true, - duration: 2000, - position: 'top' - }) - }, - (err) => { - message({ - status: 'warning', - title: t('copyFailed'), - description: err?.message || t('copyFailed'), - isClosable: true, - position: 'top' - }) - } - ) - } - cursor="pointer" - _hover={{ color: 'blue.500' }} - transition="color 0.2s ease"> - {modelName} - - - - ) -} - -const ModelList: React.FC = () => { - const { lng } = useI18n() - const { t } = useTranslationClientSide(lng, 'common') - const { isLoading, data } = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) - - return ( - <> - - - - - {t('modelList.title')} - - - - {data?.length || 0} - - - - - - {isLoading ? ( -
- -
- ) : ( - - {data?.map((modelConfig) => ( - - ))} - - )} - - ) -} - -export default ModelList diff --git a/frontend/providers/aiproxy/components/user/Sidebar.tsx b/frontend/providers/aiproxy/components/user/Sidebar.tsx index 0c05a4d6437..698b64cec59 100644 --- a/frontend/providers/aiproxy/components/user/Sidebar.tsx +++ b/frontend/providers/aiproxy/components/user/Sidebar.tsx @@ -30,14 +30,6 @@ const SideBar = (): JSX.Element => { const { t } = useTranslationClientSide(lng, 'common') const menus: Menu[] = [ - { - id: 'home', - url: '/home', - value: t('Sidebar.Home'), - icon: homeIcon, - activeIcon: homeIcon_a, - display: true - }, { id: 'keys', url: '/key', @@ -46,6 +38,14 @@ const SideBar = (): JSX.Element => { activeIcon: keysIcon_a, display: true }, + { + id: 'home', + url: '/home', + value: t('Sidebar.Home'), + icon: homeIcon, + activeIcon: homeIcon_a, + display: true + }, { id: 'logs', url: '/log', diff --git a/frontend/providers/aiproxy/store/backend.ts b/frontend/providers/aiproxy/store/backend.ts index de6bec27dae..6c92761ebba 100644 --- a/frontend/providers/aiproxy/store/backend.ts +++ b/frontend/providers/aiproxy/store/backend.ts @@ -4,8 +4,14 @@ import { persist } from 'zustand/middleware' interface BackendState { aiproxyBackend: string currencySymbol: 'shellCoin' | 'usd' | 'cny' + docUrl: string + invitationUrl: string + isInvitationActive: boolean setAiproxyBackend: (backend: string) => void setCurrencySymbol: (symbol: 'shellCoin' | 'usd' | 'cny') => void + setDocUrl: (url: string) => void + setIsInvitationActive: (active: boolean) => void + setInvitationUrl: (url: string) => void } export const useBackendStore = create()( @@ -13,8 +19,14 @@ export const useBackendStore = create()( (set) => ({ aiproxyBackend: '', currencySymbol: 'shellCoin', + docUrl: '', + invitationUrl: '', + isInvitationActive: false, setAiproxyBackend: (backend) => set({ aiproxyBackend: backend }), - setCurrencySymbol: (symbol) => set({ currencySymbol: symbol }) + setCurrencySymbol: (symbol) => set({ currencySymbol: symbol }), + setDocUrl: (url) => set({ docUrl: url }), + setIsInvitationActive: (active) => set({ isInvitationActive: active }), + setInvitationUrl: (url) => set({ invitationUrl: url }) }), { name: 'aiproxy-backend-storage' diff --git a/frontend/providers/aiproxy/types/app-config.d.ts b/frontend/providers/aiproxy/types/app-config.d.ts index a47dead7be1..7597536148d 100644 --- a/frontend/providers/aiproxy/types/app-config.d.ts +++ b/frontend/providers/aiproxy/types/app-config.d.ts @@ -1,4 +1,9 @@ export type AppConfigType = { + common: { + docUrl: string + isInvitationActive: boolean + invitationUrl: string + } auth: { appTokenJwtKey: string aiProxyBackendKey: string