From f5508d631582be05f824f9a8bc9bece2b9f3ea4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=9B=E8=B7=83=E8=BE=89?= Date: Fri, 29 May 2026 17:12:38 +0800 Subject: [PATCH 01/10] feat: register json view, formInstance, launch model list, cache/env management --- frontend/package.json | 2 + frontend/pnpm-lock.yaml | 38 ++ .../src/app/launch-model/[modelType]/page.tsx | 13 + frontend/src/app/launch-model/page.tsx | 5 + frontend/src/app/running-model/page.tsx | 6 + .../src/components/layout/layout-content.tsx | 11 +- .../launch-model/cache-management.dialog.tsx | 174 +++++++ .../launch-model/env-management-dialog.tsx | 166 +++++++ .../components/pages/launch-model/index.tsx | 465 ++++++++++++++++++ .../pages/launch-model/launch-dialog.tsx | 159 ++++++ .../components/pages/launch-model/types.ts | 51 ++ .../components/pages/launch-model/utils.ts | 89 ++++ .../pages/register-model/auto-fill.tsx | 111 ++++- .../pages/register-model/chat-tepmlate.tsx | 139 ++++++ .../pages/register-model/form-content.tsx | 428 +++++++++++++--- .../components/pages/register-model/index.tsx | 124 ++++- .../pages/register-model/json-view.tsx | 23 + .../pages/register-model/languages.tsx | 4 +- .../pages/register-model/model-controlnet.tsx | 82 +++ .../pages/register-model/model-specs.tsx | 124 +++++ frontend/src/components/ui/alert-dialog.tsx | 20 +- frontend/src/components/ui/auto-complete.tsx | 361 ++++++++++++++ frontend/src/components/ui/dialog.tsx | 54 +- frontend/src/components/ui/form-field.tsx | 340 ++++--------- frontend/src/components/ui/form-list.tsx | 125 +++++ frontend/src/components/ui/form.tsx | 252 +++++----- .../components/ui/json-syntax-highlighter.tsx | 75 ++- frontend/src/components/ui/select.tsx | 363 +++++--------- frontend/src/components/ui/textarea.tsx | 57 ++- frontend/src/constants/index.ts | 236 +-------- frontend/src/constants/launch.ts | 22 + frontend/src/constants/register.ts | 408 +++++++++++++++ frontend/src/hooks/use-form.tsx | 349 ++++++++++--- frontend/src/i18n/locales/en.ts | 150 +++++- frontend/src/i18n/locales/ja.ts | 146 +++++- frontend/src/i18n/locales/ko.ts | 144 +++++- frontend/src/i18n/locales/zh.ts | 154 +++++- frontend/src/lib/form.ts | 244 +++++++++ frontend/src/lib/is.ts | 85 ++++ frontend/src/lib/utils.ts | 46 +- frontend/src/types/common.ts | 19 +- frontend/src/types/form.ts | 153 ++++++ frontend/src/types/nunjucks-browser.d.ts | 5 + frontend/src/types/services.ts | 39 +- 44 files changed, 4963 insertions(+), 1098 deletions(-) create mode 100644 frontend/src/app/launch-model/[modelType]/page.tsx create mode 100644 frontend/src/app/launch-model/page.tsx create mode 100644 frontend/src/app/running-model/page.tsx create mode 100644 frontend/src/components/pages/launch-model/cache-management.dialog.tsx create mode 100644 frontend/src/components/pages/launch-model/env-management-dialog.tsx create mode 100644 frontend/src/components/pages/launch-model/index.tsx create mode 100644 frontend/src/components/pages/launch-model/launch-dialog.tsx create mode 100644 frontend/src/components/pages/launch-model/types.ts create mode 100644 frontend/src/components/pages/launch-model/utils.ts create mode 100644 frontend/src/components/pages/register-model/chat-tepmlate.tsx create mode 100644 frontend/src/components/pages/register-model/json-view.tsx create mode 100644 frontend/src/components/pages/register-model/model-controlnet.tsx create mode 100644 frontend/src/components/pages/register-model/model-specs.tsx create mode 100644 frontend/src/components/ui/auto-complete.tsx create mode 100644 frontend/src/components/ui/form-list.tsx create mode 100644 frontend/src/constants/launch.ts create mode 100644 frontend/src/constants/register.ts create mode 100644 frontend/src/lib/form.ts create mode 100644 frontend/src/lib/is.ts create mode 100644 frontend/src/types/form.ts create mode 100644 frontend/src/types/nunjucks-browser.d.ts diff --git a/frontend/package.json b/frontend/package.json index 1160856d85..b711a5bb52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "marked": "^5.1.2", "next": "15.2.3", "next-themes": "^0.4.6", + "nunjucks": "^3.2.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.6.0", @@ -57,6 +58,7 @@ "@svgr/webpack": "^8.1.0", "@types/js-cookie": "^3.0.6", "@types/node": "^20", + "@types/nunjucks": "^3.2.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 346353829c..fde9213074 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -95,6 +95,9 @@ dependencies: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.0.0)(react@19.0.0) + nunjucks: + specifier: ^3.2.4 + version: 3.2.4 react: specifier: ^19.0.0 version: 19.0.0 @@ -142,6 +145,9 @@ devDependencies: '@types/node': specifier: ^20 version: 20.0.0 + '@types/nunjucks': + specifier: ^3.2.6 + version: 3.2.6 '@types/react': specifier: ^19 version: 19.0.0 @@ -3142,6 +3148,10 @@ packages: resolution: {integrity: sha512-cD2uPTDnQQCVpmRefonO98/PPijuOnnEy5oytWJFPY1N9aJCz2wJ5kSGWO+zJoed2cY2JxQh6yBuUq4vIn61hw==} dev: true + /@types/nunjucks@3.2.6: + resolution: {integrity: sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==} + dev: true + /@types/react-dom@19.0.0: resolution: {integrity: sha512-1KfiQKsH1o00p9m5ag12axHQSb3FOU9H20UTrujVSkNhuCrRHiQWFqgEnTNK5ZNfnzZv8UWrnXVqCmCF9fgY3w==} dependencies: @@ -3466,6 +3476,10 @@ packages: dev: true optional: true + /a-sync-waterfall@1.0.1: + resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} + dev: false + /acorn-jsx@5.3.2(acorn@8.16.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3621,6 +3635,10 @@ packages: is-array-buffer: 3.0.5 dev: true + /asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + dev: false + /ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} dev: true @@ -3921,6 +3939,11 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + /commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + dev: false + /commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -6205,6 +6228,21 @@ packages: boolbase: 1.0.0 dev: true + /nunjucks@3.2.4: + resolution: {integrity: sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==} + engines: {node: '>= 6.9.0'} + hasBin: true + peerDependencies: + chokidar: ^3.3.0 + peerDependenciesMeta: + chokidar: + optional: true + dependencies: + a-sync-waterfall: 1.0.1 + asap: 2.0.6 + commander: 5.1.0 + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} diff --git a/frontend/src/app/launch-model/[modelType]/page.tsx b/frontend/src/app/launch-model/[modelType]/page.tsx new file mode 100644 index 0000000000..e8b947dcc6 --- /dev/null +++ b/frontend/src/app/launch-model/[modelType]/page.tsx @@ -0,0 +1,13 @@ +import LaunchModel from '@/components/pages/launch-model'; +import { getLaunchModelRouteType } from '@/components/pages/launch-model/utils'; + +interface LaunchModelPageProps { + params: Promise<{ + modelType: string; + }>; +} + +export default async function LaunchModelPage({ params }: LaunchModelPageProps) { + const { modelType } = await params; + return ; +} diff --git a/frontend/src/app/launch-model/page.tsx b/frontend/src/app/launch-model/page.tsx new file mode 100644 index 0000000000..aec3664a96 --- /dev/null +++ b/frontend/src/app/launch-model/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function LaunchModelPage() { + redirect('/launch-model/llm'); +} diff --git a/frontend/src/app/running-model/page.tsx b/frontend/src/app/running-model/page.tsx new file mode 100644 index 0000000000..c58e8cc95e --- /dev/null +++ b/frontend/src/app/running-model/page.tsx @@ -0,0 +1,6 @@ +const RunningModel = ()=>{ + return ( +
+ ) +} +export default RunningModel; \ No newline at end of file diff --git a/frontend/src/components/layout/layout-content.tsx b/frontend/src/components/layout/layout-content.tsx index 32c4d49e70..a69a2f4a27 100644 --- a/frontend/src/components/layout/layout-content.tsx +++ b/frontend/src/components/layout/layout-content.tsx @@ -12,9 +12,16 @@ export function LayoutContent({ children }: LayoutContentProps) { const pathname = usePathname(); const hiddenSidebar = HIDE_SIDEBAR_PATHS.includes(pathname); return ( -
+
{!hiddenSidebar && } -
{children}
+
+ {children} +
); } diff --git a/frontend/src/components/pages/launch-model/cache-management.dialog.tsx b/frontend/src/components/pages/launch-model/cache-management.dialog.tsx new file mode 100644 index 0000000000..1cda3fbd3b --- /dev/null +++ b/frontend/src/components/pages/launch-model/cache-management.dialog.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { FC, PropsWithChildren, useState } from 'react'; +import { Copy, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import request from '@/lib/request'; +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { ModelCachedItem } from '@/types/services'; +import type { CatalogModel } from './types'; +import { useI18n } from '@/contexts/i18n-context'; + +interface CacheManagementDialogProps { + modelDetail: CatalogModel; + onCacheDelete: () => void; +} + +const CacheManagementDialog: FC> = ({ + children, + modelDetail, + onCacheDelete, +}) => { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [dataSource, setDataSource] = useState([]); + const [pendingDeleteItem, setPendingDeleteItem] = useState(); + const [deletingVersion, setDeletingVersion] = useState(''); + + const handleCopy = async (value: string) => { + if (!value) return; + + await navigator.clipboard?.writeText(value); + toast.success(t('common.copySuccess')); + }; + + const handleDelete = (data: ModelCachedItem) => { + setPendingDeleteItem(data); + }; + + const fetchCache = async () => { + const res = await request.get(`/v1/cache/models?model_name=${modelDetail.model_name}`); + const nextDataSource = Array.isArray(res?.list) ? res.list : []; + + setDataSource(nextDataSource); + return nextDataSource; + }; + + const handleConfirmDelete = async () => { + if (!pendingDeleteItem?.model_version) return; + + setDeletingVersion(pendingDeleteItem.model_version); + + try { + const params = new URLSearchParams(); + + params.set('model_version', pendingDeleteItem.model_version); + await request.delete(`/v1/cache/models?${params.toString()}`); + toast.success(t('common.deleteSuccess')); + setPendingDeleteItem(undefined); + + const nextDataSource = await fetchCache(); + + if (!nextDataSource.length) { + setOpen(false); + onCacheDelete(); + } + } finally { + setDeletingVersion(''); + } + }; + + const onOpenChange = (open: boolean) => { + setOpen(open); + if (open) fetchCache(); + if (!open) setPendingDeleteItem(undefined); + }; + + return ( + + {children} + + + {modelDetail.model_name} + +
+ + + + {t('launchModel.model_format')} + {t('launchModel.model_size_in_billions')} + {t('launchModel.quantizations')} + {t('launchModel.real_path')} + {t('launchModel.path')} + {t('launchModel.ipAddress')} + {t('common.operation')} + + + + {dataSource.length ? ( + dataSource.map((item) => ( + + {item.model_format || '-'} + {item.model_size_in_billions || '-'} + {item.quantization || '-'} + +
+ {item?.real_path} + handleCopy(item?.real_path)} + /> +
+
+ +
+ {item?.path} + handleCopy(item?.path)} + /> +
+
+ {item.actor_ip_address} + + + +
+ )) + ) : ( + + + No cache for now. + + + )} +
+
+
+ { + if (!open) setPendingDeleteItem(undefined); + }} + description={t('launchModel.confirmDeleteCacheFiles')} + onConfirm={handleConfirmDelete} + isLoading={Boolean(deletingVersion)} + /> +
+
+ ); +}; +export default CacheManagementDialog; diff --git a/frontend/src/components/pages/launch-model/env-management-dialog.tsx b/frontend/src/components/pages/launch-model/env-management-dialog.tsx new file mode 100644 index 0000000000..34d622dc8b --- /dev/null +++ b/frontend/src/components/pages/launch-model/env-management-dialog.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { FC, PropsWithChildren, useState } from 'react'; +import { Copy, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import request from '@/lib/request'; +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { ModelEnvItem } from '@/types/services'; +import type { CatalogModel } from './types'; +import { useI18n } from '@/contexts/i18n-context'; + +interface EnvManagementDialogProps { + modelDetail: CatalogModel; + onEnvDelete: () => void; +} + +const EnvManagementDialog: FC> = ({ + children, + modelDetail, + onEnvDelete, +}) => { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [dataSource, setDataSource] = useState([]); + const [pendingDeleteItem, setPendingDeleteItem] = useState(); + const [deletingLoading, setDeletingLoading] = useState(false); + + const handleCopy = async (value: string) => { + if (!value) return; + + await navigator.clipboard?.writeText(value); + toast.success(t('common.copySuccess')); + }; + + const handleDelete = (data: ModelEnvItem) => { + setPendingDeleteItem(data); + }; + + const fetchEnv = async () => { + const res = await request.get(`/v1/virtualenvs?model_name=${modelDetail.model_name}`); + const nextDataSource = Array.isArray(res?.list) ? res.list : []; + + setDataSource(nextDataSource); + return nextDataSource; + }; + + const handleConfirmDelete = async () => { + if(!pendingDeleteItem) return; + setDeletingLoading(true); + + try { + const params = new URLSearchParams(); + + params.set('model_name', modelDetail.model_name); + params.set('model_engine', pendingDeleteItem.model_engine); + params.set('python_version', pendingDeleteItem.python_version); + params.set('worker_ip', pendingDeleteItem.actor_ip_address); + + await request.delete(`/v1/virtualenvs?${params.toString()}`); + toast.success(t('common.deleteSuccess')); + setPendingDeleteItem(undefined); + + const nextDataSource = await fetchEnv(); + + if (!nextDataSource.length) { + setOpen(false); + onEnvDelete(); + } + } finally { + setDeletingLoading(false); + } + }; + + const onOpenChange = (open: boolean) => { + setOpen(open); + if (open) fetchEnv(); + if (!open) setPendingDeleteItem(undefined); + }; + + return ( + + {children} + + + {modelDetail.model_name} + +
+ + + + {t('launchModel.modelName')} + {t('launchModel.envPath')} + {t('launchModel.pythonVersion')} + {t('launchModel.ipAddress')} + {t('common.operation')} + + + + {dataSource.length ? ( + dataSource.map((item, index) => ( + + {item.model_name} + +
+ {item?.path} + handleCopy(item?.path)} + /> +
+
+ + {item.python_version} + {item.actor_ip_address} + + + +
+ )) + ) : ( + + + No virtual environments for now. + + + )} +
+
+
+ { + if (!open) setPendingDeleteItem(undefined); + }} + description={t('launchModel.confirmDeleteVirtualEnv')} + onConfirm={handleConfirmDelete} + isLoading={Boolean(deletingLoading)} + /> +
+
+ ); +}; +export default EnvManagementDialog; diff --git a/frontend/src/components/pages/launch-model/index.tsx b/frontend/src/components/pages/launch-model/index.tsx new file mode 100644 index 0000000000..de8606becb --- /dev/null +++ b/frontend/src/components/pages/launch-model/index.tsx @@ -0,0 +1,465 @@ +'use client'; + +import { type MouseEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Box, + ExternalLink, + Info, + Loader2, + RefreshCw, + Rocket, + Search, + Settings2, + Star, +} from 'lucide-react'; +import request from '@/lib/request'; +import PageContainer from '@/components/ui/page-container'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; +import { RadioGroup } from '@/components/ui/radio-group'; +import { useI18n } from '@/contexts/i18n-context'; +import { cn } from '@/lib/utils'; +import { ModelType } from '@/constants'; +import { + LAUNCH_MODEL_ROUTE_TABS, + COLLECTION_STORAGE_KEY, + LAUNCH_MODEL_UPDATE_OPTIONS, +} from '@/constants/launch'; +import LaunchDialog from './launch-dialog'; +import CacheManagementDialog from './cache-management.dialog'; +import EnvManagementDialog from './env-management-dialog'; +import type { CatalogModel, VirtualEnv, RouteModelType, RequestModelType } from './types'; +import { + getString, + isRecord, + normalizeModels, + getLaunchModelEndpointType, + getSortedModels, +} from './utils'; + +interface LaunchModelProps { + routeType: RouteModelType; +} + +const LaunchModel = ({ routeType }: LaunchModelProps) => { + const { t } = useI18n(); + const router = useRouter(); + const isCustomRoute = routeType === ModelType.Custom; + const [customType, setCustomType] = useState(ModelType.LLM); + const [models, setModels] = useState([]); + const [virtualenvs, setVirtualenvs] = useState([]); + const [loading, setLoading] = useState(false); + const [updateLoading, setUpdateLoading] = useState(false); + const [refreshType, setRefreshType] = useState(ModelType.LLM); + const [query, setQuery] = useState(''); + const [ability, setAbility] = useState(''); + const [status, setStatus] = useState(''); + const [favorites, setFavorites] = useState([]); + const [selectedModel, setSelectedModel] = useState(); + const requestType = isCustomRoute ? customType : (routeType as RequestModelType); + const showAbilityFilter = ![ModelType.Embedding, ModelType.Rerank, ModelType.Custom].includes( + routeType + ); + const showStatusFilter = !isCustomRoute; + + const statusOptions = [ + { label: t('launchModel.cached'), value: 'cached' }, + { label: t('launchModel.favorite'), value: 'favorites' }, + ]; + const customTypesRadioOption = [ + { value: ModelType.LLM, label: t('model.languageModels') }, + { value: ModelType.Embedding, label: t('model.embeddingModels') }, + { value: ModelType.Rerank, label: t('model.rerankModels') }, + { value: ModelType.Image, label: t('model.imageModels') }, + { value: ModelType.Audio, label: t('model.audioModels') }, + { value: ModelType.Flexible, label: t('model.flexibleModels') }, + ]; + + const fetchVirtualenvs = useCallback(async () => { + const res = await request.get('/v1/virtualenvs'); + setVirtualenvs(Array.isArray(res?.list) ? res.list : []); + }, []); + + const fetchModels = useCallback( + async (nextType = requestType) => { + setLoading(true); + + try { + const endpointType = getLaunchModelEndpointType(nextType); + const url = isCustomRoute + ? `/v1/model_registrations/${endpointType}` + : `/v1/model_registrations/${endpointType}?detailed=true`; + const res = await request.get(url); + const modelList = Array.isArray(res) ? res.filter(isRecord) : []; + const filteredList = modelList.filter((item) => + isCustomRoute ? item.is_builtin === false : item.is_builtin === true + ); + // Setting `detailed=true` when fetching custom models avoids using Promise.all for details, + // but it's slow when there are too many LLM models. + // TODO: Add an `is_builtin` param to the backend `/v1/model_registrations/{modelType}` endpoint later. + // This will save us from fetching single model details one by one. + const modelsWithDetails = isCustomRoute + ? await Promise.all( + filteredList.map(async (item) => { + const modelName = getString(item, ['model_name']); + + if (!modelName) { + return item; + } + + try { + const detail = await request.get( + `/v1/model_registrations/${endpointType}/${encodeURIComponent(modelName)}` + ); + + return isRecord(detail) + ? { + ...detail, + ...item, + model_name: modelName, + is_builtin: false, + } + : item; + } catch { + return item; + } + }) + ) + : filteredList; + setModels(normalizeModels(modelsWithDetails)); + } catch { + setModels([]); + } finally { + setLoading(false); + } + }, + [isCustomRoute, requestType] + ); + + useEffect(() => { + setAbility(''); + setStatus(''); + setQuery(''); + + if (isCustomRoute) { + setCustomType(ModelType.LLM); + setRefreshType(ModelType.LLM); + return; + } + + setRefreshType(routeType as RequestModelType); + }, [isCustomRoute, routeType]); + + useEffect(() => { + fetchModels(); + }, [fetchModels]); + + useEffect(() => { + fetchVirtualenvs(); + }, [fetchVirtualenvs]); + + useEffect(() => { + const rawValue = window.localStorage.getItem(COLLECTION_STORAGE_KEY); + + if (!rawValue) return; + + try { + const value = JSON.parse(rawValue); + + if (Array.isArray(value)) { + setFavorites(value.map(String)); + } + } catch { + setFavorites([]); + } + }, []); + + const abilityOptions = useMemo(() => { + const seen = new Set(); + const options: Array<{ label: string; value: string }> = []; + + models.forEach((model) => { + model.abilities.forEach((item) => { + if (seen.has(item)) return; + + seen.add(item); + options.push({ label: t(`launchModel.${item}`), value: item }); + }); + }); + + return options; + }, [models, t]); + + const visibleModels = useMemo(() => { + const keyword = query.trim().toLowerCase(); + + const filteredModels = models.filter((model) => { + const matchesKeyword = + !keyword || + model.model_name.toLowerCase().includes(keyword) || + model.model_description.toLowerCase().includes(keyword); + const matchesAbility = !showAbilityFilter || !ability || model.abilities.includes(ability); + const matchesStatus = + !showStatusFilter || + !status || + (status === 'cached' && model.cached) || + (status === 'favorites' && favorites.includes(model.model_name)); + + return matchesKeyword && matchesAbility && matchesStatus; + }); + + return getSortedModels(filteredModels, favorites); + }, [ability, favorites, models, query, showAbilityFilter, showStatusFilter, status]); + + const onTabChange = (value: string) => { + const target = LAUNCH_MODEL_ROUTE_TABS.find((item) => item.key === value); + + if (target) { + router.push(`/launch-model/${target.path}`); + } + }; + const updateModels = async () => { + setUpdateLoading(true); + try { + await request.post('/v1/models/update_type', { model_type: refreshType.toLowerCase() }); + onTabChange(refreshType); + } finally { + setUpdateLoading(false); + } + }; + const handleFavorite = (event: MouseEvent, model: CatalogModel) => { + event.stopPropagation(); + + setFavorites((prev) => { + const next = prev.includes(model.model_name) + ? prev.filter((item) => item !== model.model_name) + : [...prev, model.model_name]; + + window.localStorage.setItem(COLLECTION_STORAGE_KEY, JSON.stringify(next)); + + return next; + }); + }; + + const handleDetail = (event: MouseEvent, model: CatalogModel) => { + event.stopPropagation(); + + if (model.detailUrl) { + window.open(model.detailUrl, '_blank', 'noopener,noreferrer'); + } + }; + + const renderModelCard = (model: CatalogModel) => { + const isFavorite = favorites.includes(model.model_name); + const hasVirtualenv = virtualenvs.some((item) => item.model_name === model.model_name); + const tags = [...model.abilities, ...model.languages]; + return ( +
+
+

{model.model_name}

+ +
+ +
+ {tags.map((tag) => ( + + {tag} + + ))} + {model.cached && ( + + + + )} + {hasVirtualenv && ( + + + + )} +
+ +
+

+ {model.model_description} +

+
+ +
+ {!isCustomRoute && ( + + )} + +
+
+ ); + }; + + return ( + + +
+ +
+ {LAUNCH_MODEL_ROUTE_TABS.map((item) => ( + + {t(item.labelKey)} + + ))} +
+
+
+ setQuery(event.target.value)} + placeholder={t('launchModel.search')} + className="h-10 pl-9" + /> +
+
+ {showAbilityFilter && ( + + )} +
+
+ + {loading ? ( +
+ + Loading models... +
+ ) : visibleModels.length ? ( +
+ {visibleModels.map(renderModelCard)} +
+ ) : ( +
+ +

No models found

+

+ Try changing your search or filters. +

+
+ )} + +
+ + !open && setSelectedModel(undefined)} + /> +
+ ); +}; + +export default LaunchModel; diff --git a/frontend/src/components/pages/launch-model/launch-dialog.tsx b/frontend/src/components/pages/launch-model/launch-dialog.tsx new file mode 100644 index 0000000000..08120360cb --- /dev/null +++ b/frontend/src/components/pages/launch-model/launch-dialog.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Rocket } from 'lucide-react'; +import request from '@/lib/request'; +import { ModelType } from '@/constants'; +import { useI18n } from '@/contexts/i18n-context'; +import { useForm, useWatch } from '@/hooks/use-form'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Form } from '@/components/ui/form'; +import { FormField } from '@/components/ui/form-field'; +import { Select } from '@/components/ui/select'; +import { isCachedSpec } from './utils'; +import type { CatalogModel, RequestModelType } from './types'; + +import type { ModelEngine, ModelEngineItem } from '@/types/services'; +import type { FormValues } from '@/types/form'; + +interface LaunchDialogProps { + model?: CatalogModel; + modelType: RequestModelType; + onOpenChange: (open: boolean) => void; +} + +export default function LaunchDialog({ model, modelType, onOpenChange }: LaunchDialogProps) { + const isOpen = Boolean(model); + const [form] = useForm(); + const { t } = useI18n(); + const loading = false; + const [modelEngineMap, setModelEngineMap] = useState({}); + const modelEngineValue = useWatch('model_engine', form); + + const fetchModelEngine = useCallback(async () => { + if (!model?.model_name || [ModelType.Audio, ModelType.Video].includes(modelType)) { + setModelEngineMap({}); + return; + } + + const url = + modelType === ModelType.LLM + ? `/v1/engines/${model.model_name}` + : `/v1/engines/${modelType}/${model.model_name}`; + const res = await request.get(url); + + setModelEngineMap(res || {}); + }, [model?.model_name, modelType]); + + const modelEngineOptions = useMemo(() => { + const cachedFormats = new Set( + (model?.modelSpecs || []) + .filter((spec) => isCachedSpec(spec)) + .map((spec) => spec.model_format) + ); + + return Object.entries(modelEngineMap).map(([key, engineData]) => { + if (typeof engineData === 'string') { + return { + label: `${key} (${engineData})`, + value: key, + disabled: true, + }; + } + const cached = engineData.some((item) => cachedFormats.has(item.model_format)); + + return { + label: key, + value: key, + suffix: cached ? t('launchModel.cached') : undefined, + }; + }); + }, [model?.modelSpecs, modelEngineMap]); + console.log(modelEngineValue, 'modelEngineValue'); + + + const fields = { + modelEngine: ( + + + + ), + modelFormat: ( + + + + ), + }; + const modelTypeFields: Partial> = { + [ModelType.LLM]: [ + 'modelEngine', + 'modelFormat', + // 'model_size_in_billions', 'quantization' + ], + }; + const handleLaunch = async (values: FormValues) => { + console.log(values); + // if (!model) return; + + // setLoading(true); + + // try { + // await request.post('/v1/models', { + // model_type: modelType, + // model_name: model.model_name, + // model_uid: form.modelUid || model.model_name, + // n_gpu: form.nGpu === '' ? undefined : Number(form.nGpu), + // replica: form.replica === '' ? undefined : Number(form.replica), + // }); + // toast.success('Model launch request submitted'); + // onOpenChange(false); + // } finally { + // setLoading(false); + // } + }; + + useEffect(() => { + if (isOpen) { + fetchModelEngine(); + } + }, [fetchModelEngine, isOpen]); + return ( + + + + {model?.model_name} + +
+ {(modelTypeFields[modelType] || []).map((fieldKey) => ( +
{fields[fieldKey]}
+ ))} +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/components/pages/launch-model/types.ts b/frontend/src/components/pages/launch-model/types.ts new file mode 100644 index 0000000000..76106fadbc --- /dev/null +++ b/frontend/src/components/pages/launch-model/types.ts @@ -0,0 +1,51 @@ +import { ModelType } from '@/constants'; +export type UnknownRecord = Record; + +export interface ModelSpec { + cache_status: boolean | boolean[]; + model_format: string; + model_uri: string; + quantization: string; + model_size_in_billions: string | number; + [key: string]: unknown; +} + +export interface CatalogModel { + [key: string]: unknown; + model_name: string; + model_description: string; + abilities: string[]; + languages: string[]; + detailUrl?: string; + modelSpecs?: ModelSpec[]; + featured?: boolean; + cached?: boolean; +} + +export interface VirtualEnv { + model_name?: string; + model_engine?: string; + python_version?: string; + worker_ip?: string; + env_path?: string; + path?: string; + real_path?: string; + [key: string]: unknown; +} +export type RouteModelType = + | ModelType.LLM + | ModelType.Embedding + | ModelType.Rerank + | ModelType.Image + | ModelType.Audio + | ModelType.Video + | ModelType.Custom; + +export type RequestModelType = + | ModelType.LLM + | ModelType.Embedding + | ModelType.Rerank + | ModelType.Image + | ModelType.Audio + | ModelType.Video + | ModelType.Flexible; \ No newline at end of file diff --git a/frontend/src/components/pages/launch-model/utils.ts b/frontend/src/components/pages/launch-model/utils.ts new file mode 100644 index 0000000000..c4da1100fe --- /dev/null +++ b/frontend/src/components/pages/launch-model/utils.ts @@ -0,0 +1,89 @@ +import { XINFERENCE_IO, ModelType } from '@/constants'; +import { LAUNCH_MODEL_ROUTE_TABS } from '@/constants/launch'; +import type { + ModelSpec, + CatalogModel, + UnknownRecord, + VirtualEnv, + RequestModelType, + RouteModelType, +} from './types'; + +export function isCachedSpec(spec: unknown): boolean { + if (!isRecord(spec)) { + return false; + } + + const cacheStatus = spec.cache_status; + + if (Array.isArray(cacheStatus)) { + return cacheStatus.some(Boolean); + } + + return cacheStatus === true; +} + +export function isRecord(value: unknown): value is UnknownRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function toStringArray(value: unknown) { + if (Array.isArray(value)) { + return value.map((item) => String(item)).filter(Boolean); + } + + if (typeof value === 'string' && value) { + return [value]; + } + + return []; +} + +export function getString(record: UnknownRecord, keys: string[]) { + const value = keys.map((key) => record[key]).find((item) => typeof item === 'string'); + + return typeof value === 'string' ? value : ''; +} + +export function normalizeModels(response: unknown): CatalogModel[] { + if (!Array.isArray(response)) { + return []; + } + return response.map((model) => { + const modelSpecs = (Array.isArray(model.model_specs) ? model.model_specs : []) as ModelSpec[]; + return { + ...model, + model_description: model?.model_description || '', + abilities: toStringArray(model.model_ability), + languages: toStringArray(model.model_lang ?? model.language), + detailUrl: `${XINFERENCE_IO}/models/detail/${encodeURIComponent(model.model_name)}`, + modelSpecs, + featured: model.featured === true, + cached: modelSpecs.some((spec) => isCachedSpec(spec)), + }; + }); +} + +export function getVirtualEnvPath(item: VirtualEnv) { + return String(item.env_path || item.path || item.real_path || ''); +} + +export function getLaunchModelEndpointType(type: RequestModelType) { + return type === ModelType.LLM ? 'LLM' : type; +} + +export function getLaunchModelRouteType(value: string): RouteModelType { + const normalized = value.toLowerCase(); + const matched = LAUNCH_MODEL_ROUTE_TABS.find((item) => item.path === normalized); + + return matched?.key || ModelType.LLM; +} + +export function getSortedModels(models: CatalogModel[], favorites: string[]) { + return [...models].sort((a, b) => { + const aPriority = Number(Boolean(a.featured)) * 2 + Number(favorites.includes(a.model_name)); + const bPriority = Number(Boolean(b.featured)) * 2 + Number(favorites.includes(b.model_name)); + + return bPriority - aPriority; + }); +} diff --git a/frontend/src/components/pages/register-model/auto-fill.tsx b/frontend/src/components/pages/register-model/auto-fill.tsx index fa74bb72b3..bd773ea7b7 100644 --- a/frontend/src/components/pages/register-model/auto-fill.tsx +++ b/frontend/src/components/pages/register-model/auto-fill.tsx @@ -1,16 +1,113 @@ 'use client'; +import { FC, useMemo, useState } from 'react'; import { Sparkles } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from '@/components/ui/dialog'; +import { Form } from '@/components/ui/form'; +import { FormField } from '@/components/ui/form-field'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; import { useI18n } from '@/contexts/i18n-context'; +import { useForm } from '@/hooks/use-form'; +import { isEmpty } from '@/lib/is'; +import request from '@/lib/request'; +import type { ModelFamily } from '@/types/services'; +import type { Option } from '@/types/common'; -const AutoFill = ()=>{ +interface AutoFillProps { + modelFamilyMap: ModelFamily; + autoFillBack: (v: Record) => void; +} +const AutoFill: FC = ({ modelFamilyMap, autoFillBack }) => { const { t } = useI18n(); + const [form] = useForm(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const modelFamilyOptions = useMemo(() => { + const options: Option[] = []; + + if (isEmpty(modelFamilyMap)) return options; + + const seen = new Set(); + + Object.values(modelFamilyMap).forEach((list) => { + list.forEach((item) => { + if (seen.has(item)) return; + + seen.add(item); + options.push({ + label: item, + value: item, + }); + }); + }); + + return options; + }, [modelFamilyMap]); + + const handleSubmit = async (values: Record) => { + setLoading(true); + try { + const res = await request.post('/v1/models/llm/auto-register', values); + let newValues = { ...(res || {}) }; + if (Array.isArray(newValues?.model_specs)) { + newValues.model_specs = newValues.model_specs.map((item: any) => ({ + ...item, + model_size_in_billions: item?.model_size_in_billions?.includes('_') + ? item.model_size_in_billions.replace('_', '.') + : item?.model_size_in_billions, + })); + } + autoFillBack(newValues); + setOpen(false); + } finally { + setLoading(false); + } + }; return ( - <> - + + + + - - ) -} -export default AutoFill; \ No newline at end of file + + + {t('registerModel.autoFill')} + +
+ + + + +