From 54703c67c13492734924b517396c30c6978dfbf8 Mon Sep 17 00:00:00 2001 From: jialin Date: Fri, 26 Sep 2025 19:20:25 +0800 Subject: [PATCH] feat: yaml editor --- config/config.ts | 19 +- config/plugins.ts | 36 +++ config/routes.ts | 1 + package.json | 4 + pnpm-lock.yaml | 117 +++++++- src/assets/logo/lmdeploy.png | Bin 0 -> 28537 bytes src/components/copy-button/index.tsx | 4 +- src/components/editor-wrap/index.tsx | 90 +++---- .../editor/{viewer.tsx => index.tsx} | 76 ++++-- src/components/icon-font/icons.ts | 6 +- src/components/icon-font/index.tsx | 2 +- src/components/page-tools/index.tsx | 6 + src/components/seal-form/seal-textarea.tsx | 1 + src/components/segment-line/index.tsx | 3 + src/global.less | 7 - .../_components/collapse-panel/index.tsx | 55 ++++ .../column-wrapper/index.tsx} | 35 +-- .../column-wrapper/style.less} | 0 .../_components/command-viewer/index.tsx | 79 ++++++ src/pages/backends/backend-create.tsx | 249 ++++++++++++++++++ src/pages/backends/components/add-modal.tsx | 148 +++++++++++ .../backends/components/backend-card.tsx | 30 ++- src/pages/backends/components/import-yaml.tsx | 145 ++++++++++ src/pages/backends/components/yaml-editor.tsx | 167 ++++++++++++ src/pages/backends/config/index.ts | 24 +- src/pages/backends/config/schema.json | 56 ++++ src/pages/backends/config/test.ts | 8 +- src/pages/backends/config/types.ts | 9 +- src/pages/backends/forms/basic.tsx | 77 ++++++ src/pages/backends/forms/index.tsx | 73 +++++ src/pages/backends/forms/parameters.tsx | 32 +++ src/pages/backends/forms/versions-config.tsx | 139 ++++++++++ src/pages/backends/list.tsx | 76 +++--- src/pages/backends/step-forms/basic-form.tsx | 59 +++++ .../backends/step-forms/module-registry.ts | 16 ++ .../backends/step-forms/parameters-form.tsx | 55 ++++ .../backends/step-forms/use-step-list.tsx | 65 +++++ .../step-forms/version-config-form.tsx | 227 ++++++++++++++++ src/pages/llmodels/catalog.tsx | 7 +- src/pages/llmodels/components/data-form.tsx | 1 - .../components/deploy-builtin-modal.tsx | 2 +- .../llmodels/components/deploy-modal.tsx | 2 +- src/pages/llmodels/components/table-list.tsx | 7 +- .../llmodels/components/update-modal.tsx | 2 +- src/pages/llmodels/download/index.tsx | 2 +- src/pages/llmodels/download/target-form.tsx | 1 - .../components/view-common-code.tsx | 62 ++--- .../components/container-install.tsx | 26 +- 48 files changed, 2062 insertions(+), 246 deletions(-) create mode 100644 config/plugins.ts create mode 100644 src/assets/logo/lmdeploy.png rename src/components/editor/{viewer.tsx => index.tsx} (54%) create mode 100644 src/pages/_components/collapse-panel/index.tsx rename src/pages/{llmodels/components/column-wrapper.tsx => _components/column-wrapper/index.tsx} (51%) rename src/pages/{llmodels/style/column-wrapper.less => _components/column-wrapper/style.less} (100%) create mode 100644 src/pages/_components/command-viewer/index.tsx create mode 100644 src/pages/backends/backend-create.tsx create mode 100644 src/pages/backends/components/add-modal.tsx create mode 100644 src/pages/backends/components/import-yaml.tsx create mode 100644 src/pages/backends/components/yaml-editor.tsx create mode 100644 src/pages/backends/config/schema.json create mode 100644 src/pages/backends/forms/basic.tsx create mode 100644 src/pages/backends/forms/index.tsx create mode 100644 src/pages/backends/forms/parameters.tsx create mode 100644 src/pages/backends/forms/versions-config.tsx create mode 100644 src/pages/backends/step-forms/basic-form.tsx create mode 100644 src/pages/backends/step-forms/module-registry.ts create mode 100644 src/pages/backends/step-forms/parameters-form.tsx create mode 100644 src/pages/backends/step-forms/use-step-list.tsx create mode 100644 src/pages/backends/step-forms/version-config-form.tsx diff --git a/config/config.ts b/config/config.ts index 3d1a6558..8c16aba8 100644 --- a/config/config.ts +++ b/config/config.ts @@ -1,5 +1,6 @@ import { defineConfig } from '@umijs/max'; import keepAlive from './keep-alive'; +import { compressionPluginConfig, monacoPluginConfig } from './plugins'; import proxy from './proxy'; import routes from './routes'; import { getBranchInfo } from './utils'; @@ -29,7 +30,7 @@ export default defineConfig({ defaultSizes: 'parsed' // stat // gzip }, mfsu: { - exclude: ['lodash', 'ml-pca'] + exclude: ['lodash', 'ml-pca', 'monaco-editor'] }, base: process.env.npm_config_base || '/', ...(isProduction @@ -56,21 +57,13 @@ export default defineConfig({ config.output .filename(`js/[name].${t}.js`) .chunkFilename(`js/[name].${t}.chunk.js`); - config - .plugin('compression-webpack-plugin') - .use(CompressionWebpackPlugin, [ - { - filename: '[path][base].gz', - algorithm: 'gzip', - test: /\.(js|css|html|svg)$/, - threshold: 10240, - minRatio: 0.8 - } - ]); + compressionPluginConfig(config); } } : {}), - + chainWebpack(config) { + monacoPluginConfig(config); + }, favicons: ['/static/favicon.png'], jsMinifier: 'terser', cssMinifier: 'cssnano', diff --git a/config/plugins.ts b/config/plugins.ts new file mode 100644 index 00000000..ee8cf363 --- /dev/null +++ b/config/plugins.ts @@ -0,0 +1,36 @@ +import MonacoWebpackPlugin from 'monaco-editor-webpack-plugin'; +const CompressionWebpackPlugin = require('compression-webpack-plugin'); + +export function monacoPluginConfig(config: any) { + return config + .plugin('monaco-editor-webpack-plugin') + .use(MonacoWebpackPlugin, [ + { + languages: ['yaml'], + customLanguages: [ + { + label: 'yaml', + entry: 'monaco-yaml', + worker: { + id: 'monaco-yaml/yamlWorker', + entry: 'monaco-yaml/yaml.worker' + } + } + ] + } + ]); +} + +export function compressionPluginConfig(config: any) { + return config + .plugin('compression-webpack-plugin') + .use(CompressionWebpackPlugin, [ + { + filename: '[path][base].gz', + algorithm: 'gzip', + test: /\.(js|css|html|svg)$/, + threshold: 10240, + minRatio: 0.8 + } + ]); +} diff --git a/config/routes.ts b/config/routes.ts index d1b89eb1..003df2c5 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -114,6 +114,7 @@ export default [ selectedIcon: 'icon-backend-filled', defaultIcon: 'icon-backend', access: 'canSeeAdmin', + subMenu: ['/models/backends/create'], component: './backends/list' } ] diff --git a/package.json b/package.json index 6f687822..3c7053f4 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "has-ansi": "^5.0.1", "highlight.js": "^11.10.0", "jotai": "^2.8.4", + "js-yaml": "^4.1.0", "jszip": "^3.10.1", "katex": "^0.16.21", "lamejs": "github:zhuker/lamejs", @@ -55,6 +56,9 @@ "marked": "^14.1.0", "ml-dataset-iris": "^1.2.1", "ml-pca": "^4.1.1", + "monaco-editor": "^0.53.0", + "monaco-editor-webpack-plugin": "^7.1.0", + "monaco-yaml": "^5.4.0", "numeral": "^2.0.6", "overlayscrollbars": "^2.10.0", "overlayscrollbars-react": "^0.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0be4c58f..673b22f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ dependencies: version: 0.11.13 '@monaco-editor/react': specifier: ^4.6.0 - version: 4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1)(react@18.3.1) + version: 4.7.0(monaco-editor@0.53.0)(react-dom@18.3.1)(react@18.3.1) '@orcid/bibtex-parse-js': specifier: ^0.0.25 version: 0.0.25 @@ -99,6 +99,9 @@ dependencies: jotai: specifier: ^2.8.4 version: 2.13.1(@babel/core@7.28.3)(@types/react@18.3.24)(react@18.3.1) + js-yaml: + specifier: ^4.1.0 + version: 4.1.0 jszip: specifier: ^3.10.1 version: 3.10.1 @@ -126,6 +129,15 @@ dependencies: ml-pca: specifier: ^4.1.1 version: 4.1.1 + monaco-editor: + specifier: ^0.53.0 + version: 0.53.0 + monaco-editor-webpack-plugin: + specifier: ^7.1.0 + version: 7.1.0(monaco-editor@0.53.0)(webpack@5.101.3) + monaco-yaml: + specifier: ^5.4.0 + version: 5.4.0(monaco-editor@0.53.0) numeral: specifier: ^2.0.6 version: 2.0.6 @@ -2699,7 +2711,7 @@ packages: state-local: 1.0.7 dev: false - /@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1)(react@18.3.1): + /@monaco-editor/react@4.7.0(monaco-editor@0.53.0)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==, tarball: https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz} peerDependencies: monaco-editor: '>= 0.25.0 < 1' @@ -2707,7 +2719,7 @@ packages: react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 dependencies: '@monaco-editor/loader': 1.5.0 - monaco-editor: 0.52.2 + monaco-editor: 0.53.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) dev: false @@ -3637,6 +3649,10 @@ packages: /@types/stylis@4.2.7: resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==, tarball: https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz} + /@types/trusted-types@1.0.6: + resolution: {integrity: sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==, tarball: https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz} + dev: false + /@types/trusted-types@2.0.7: resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==, tarball: https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz} requiresBuild: true @@ -9332,6 +9348,10 @@ packages: engines: {node: '>=6'} hasBin: true + /jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==, tarball: https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz} + dev: false + /jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==, tarball: https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz} dependencies: @@ -10481,8 +10501,66 @@ packages: /moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==, tarball: https://registry.npmjs.org/moment/-/moment-2.30.1.tgz} - /monaco-editor@0.52.2: - resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==, tarball: https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz} + /monaco-editor-webpack-plugin@7.1.0(monaco-editor@0.53.0)(webpack@5.101.3): + resolution: {integrity: sha512-ZjnGINHN963JQkFqjjcBtn1XBtUATDZBMgNQhDQwd78w2ukRhFXAPNgWuacaQiDZsUr4h1rWv5Mv6eriKuOSzA==, tarball: https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-7.1.0.tgz} + peerDependencies: + monaco-editor: '>= 0.31.0' + webpack: ^4.5.0 || 5.x + dependencies: + loader-utils: 2.0.4 + monaco-editor: 0.53.0 + webpack: 5.101.3 + dev: false + + /monaco-editor@0.53.0: + resolution: {integrity: sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==, tarball: https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz} + dependencies: + '@types/trusted-types': 1.0.6 + dev: false + + /monaco-languageserver-types@0.4.0: + resolution: {integrity: sha512-QQ3BZiU5LYkJElGncSNb5AKoJ/LCs6YBMCJMAz9EA7v+JaOdn3kx2cXpPTcZfKA5AEsR0vc97sAw+5mdNhVBmw==, tarball: https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.0.tgz} + dependencies: + monaco-types: 0.1.0 + vscode-languageserver-protocol: 3.17.5 + vscode-uri: 3.1.0 + dev: false + + /monaco-marker-data-provider@1.2.4: + resolution: {integrity: sha512-4DsPgsAqpTyUDs3humXRBPUJoihTv+L6v9aupQWD80X2YXaCXUd11mWYeSCYHuPgdUmjFaNWCEOjQ6ewf/QA1Q==, tarball: https://registry.npmjs.org/monaco-marker-data-provider/-/monaco-marker-data-provider-1.2.4.tgz} + dependencies: + monaco-types: 0.1.0 + dev: false + + /monaco-types@0.1.0: + resolution: {integrity: sha512-aWK7SN9hAqNYi0WosPoMjenMeXJjwCxDibOqWffyQ/qXdzB/86xshGQobRferfmNz7BSNQ8GB0MD0oby9/5fTQ==, tarball: https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.0.tgz} + dev: false + + /monaco-worker-manager@2.0.1(monaco-editor@0.53.0): + resolution: {integrity: sha512-kdPL0yvg5qjhKPNVjJoym331PY/5JC11aPJXtCZNwWRvBr6jhkIamvYAyiY5P1AWFmNOy0aRDRoMdZfa71h8kg==, tarball: https://registry.npmjs.org/monaco-worker-manager/-/monaco-worker-manager-2.0.1.tgz} + peerDependencies: + monaco-editor: '>=0.30.0' + dependencies: + monaco-editor: 0.53.0 + dev: false + + /monaco-yaml@5.4.0(monaco-editor@0.53.0): + resolution: {integrity: sha512-tuBVDy1KAPrgO905GHTItu8AaA5bIzF5S4X0JVRAE/D66FpRhkDUk7tKi5bwKMVTTugtpMLsXN4ewh4CgE/FtQ==, tarball: https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.4.0.tgz} + peerDependencies: + monaco-editor: '>=0.36' + dependencies: + jsonc-parser: 3.3.1 + monaco-editor: 0.53.0 + monaco-languageserver-types: 0.4.0 + monaco-marker-data-provider: 1.2.4 + monaco-types: 0.1.0 + monaco-worker-manager: 2.0.1(monaco-editor@0.53.0) + path-browserify: 1.0.1 + prettier: 3.6.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + yaml: 2.8.1 dev: false /mrmime@2.0.1: @@ -11078,6 +11156,10 @@ packages: resolution: {integrity: sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==, tarball: https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz} dev: false + /path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==, tarball: https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz} + dev: false + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, tarball: https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz} engines: {node: '>=8'} @@ -15580,6 +15662,30 @@ packages: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==, tarball: https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz} dev: false + /vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==, tarball: https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz} + engines: {node: '>=14.0.0'} + dev: false + + /vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==, tarball: https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz} + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + dev: false + + /vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==, tarball: https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz} + dev: false + + /vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==, tarball: https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz} + dev: false + + /vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==, tarball: https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz} + dev: false + /walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==, tarball: https://registry.npmjs.org/walker/-/walker-1.0.8.tgz} dependencies: @@ -15873,7 +15979,6 @@ packages: resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==, tarball: https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz} engines: {node: '>= 14.6'} hasBin: true - dev: true /yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==, tarball: https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz} diff --git a/src/assets/logo/lmdeploy.png b/src/assets/logo/lmdeploy.png new file mode 100644 index 0000000000000000000000000000000000000000..191fbfccb044c2dad6bf3d07259632960e632357 GIT binary patch literal 28537 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 = ({ }); clipboardRef.current.on('error', (e: any) => { - message.success( - intl.formatMessage({ id: 'common.copy.fail' }) as string - ); + message.error(intl.formatMessage({ id: 'common.copy.fail' }) as string); e.clearSelection(); }); } diff --git a/src/components/editor-wrap/index.tsx b/src/components/editor-wrap/index.tsx index 6c51ee47..38926123 100644 --- a/src/components/editor-wrap/index.tsx +++ b/src/components/editor-wrap/index.tsx @@ -1,74 +1,66 @@ +import classNames from 'classnames'; import React from 'react'; import styled from 'styled-components'; -import CopyButton from '../copy-button'; -import SegmentLine from '../segment-line'; import './index.less'; -const Wrapper = styled.div<{ $height: number }>` - height: ${(props) => props.$height}px; +const HeaderWrapper = styled.div<{ $height?: number }>` + height: ${(props) => (props.$height ? `${props.$height}px` : 'auto')}; + display: flex; + padding-block: 0; + justify-content: space-between; + align-items: center; +`; + +const Wrapper = styled.div` + border-radius: var(--border-radius-mini); + overflow: hidden; + + &.bordered { + border: 1px solid var(--ant-color-border); + } + &.borderless { + border: none; + } + .code-pre { + margin-bottom: 0; + } + .scrollbar { + .slider { + border-radius: 6px; + } + } `; interface EditorwrapProps { headerHeight?: number; header?: React.ReactNode; children: React.ReactNode; - showHeader?: boolean; - copyText: string; - defaultValue?: string; - langOptions?: Global.BaseOption[]; + variant?: 'bordered' | 'borderless'; styles?: { wrapper?: React.CSSProperties; header?: React.CSSProperties; content?: React.CSSProperties; }; - onChangeLang?: (value: string | number) => void; } const EditorWrap: React.FC = ({ headerHeight = 40, header, children, - copyText, - langOptions = [], - onChangeLang, - defaultValue, - styles = {}, - showHeader = true + variant = 'borderless', + styles = {} }) => { - const handleChangeLang = (value: string | number) => { - onChangeLang?.(value); - }; - const renderHeader = () => { - if (header) { - return
{header}
; - } - if (showHeader) { - return ( - - - - - ); - } - return null; - }; return ( -
- {renderHeader()} -
{children}
-
+ + {header && {header}} +
{children}
+
); }; -export default React.memo(EditorWrap); +export default EditorWrap; diff --git a/src/components/editor/viewer.tsx b/src/components/editor/index.tsx similarity index 54% rename from src/components/editor/viewer.tsx rename to src/components/editor/index.tsx index 20452dc5..680a1dd2 100644 --- a/src/components/editor/viewer.tsx +++ b/src/components/editor/index.tsx @@ -1,30 +1,39 @@ +import { LoadingOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useRef +} from 'react'; import EditorWrap from '../editor-wrap'; - interface ViewerProps { + ref?: any; lang: string; defaultLang?: string; - langOptions?: Global.BaseOption[]; config?: any; value: string; height?: string | number; theme?: string; - showHeader?: boolean; + header?: React.ReactNode; + placeholder?: string; + variant?: 'bordered' | 'borderless'; } -const ViewerEditor: React.FC = (props) => { - const editorRef = useRef(null); +const ViewerEditor: React.FC = forwardRef((props, ref) => { const { lang, value, config, - langOptions, defaultLang, height = 380, - showHeader + theme = 'vs-dark', + header, + variant = 'borderless', + placeholder } = props; - const [langType, setLangType] = useState(defaultLang); + + const editorRef = useRef(null); const handleBeforeMount = (monaco: any) => { monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ @@ -36,12 +45,8 @@ const ViewerEditor: React.FC = (props) => { const handleEditorDidMount = (editor: any, monaco: any) => { editorRef.current = editor; - console.log('loaded====', editor, monaco); }; - const handleOnChangeLang = (value: string) => { - setLangType(value); - }; const formatCode = () => { if (editorRef.current) { setTimeout(() => { @@ -55,35 +60,50 @@ const ViewerEditor: React.FC = (props) => { } }; + useImperativeHandle(ref, () => ({ + format: () => { + formatCode(); + }, + getValue: () => { + return editorRef.current?.getValue?.(); + }, + setValue: (val: string) => { + editorRef.current?.setValue?.(val); + }, + editor: editorRef.current + })); + useEffect(() => { formatCode(); setTimeout(() => { - const lineCount = editorRef.current?.getModel().getLineCount(); // 获取总行数 - editorRef.current?.revealLine(lineCount); // 滚动到最后一行 - }, 100); // 可以调整延时,确保编辑器完全加载 + const lineCount = editorRef.current?.getModel().getLineCount(); + editorRef.current?.revealLine(lineCount); + }, 100); }, [value]); return ( - + } beforeMount={handleBeforeMount} onMount={handleEditorDidMount} /> ); -}; +}); -export default React.memo(ViewerEditor); +export default ViewerEditor; diff --git a/src/components/icon-font/icons.ts b/src/components/icon-font/icons.ts index 04c540d5..823d10f6 100644 --- a/src/components/icon-font/icons.ts +++ b/src/components/icon-font/icons.ts @@ -45,7 +45,11 @@ const icons = { Ascend: React.createElement(IconFont, { type: 'icon-ascend' }), Catalog1: React.createElement(IconFont, { type: 'icon-catalog1' }), AMD: React.createElement(IconFont, { type: 'icon-amd' }), - KubernetesFilled: React.createElement(IconFont, { type: 'icon-k8s-filled' }) + KubernetesFilled: React.createElement(IconFont, { type: 'icon-k8s-filled' }), + EditContent: React.createElement(IconFont, { type: 'icon-edit-content' }), + Yaml: React.createElement(IconFont, { type: 'icon-code_block' }), + Version: React.createElement(IconFont, { type: 'icon-version' }), + Parameter: React.createElement(IconFont, { type: 'icon-parameters' }) }; export default icons; diff --git a/src/components/icon-font/index.tsx b/src/components/icon-font/index.tsx index 2e9bc504..9ed10a53 100644 --- a/src/components/icon-font/index.tsx +++ b/src/components/icon-font/index.tsx @@ -2,7 +2,7 @@ import { createFromIconfontCN } from '@ant-design/icons'; // import './iconfont/iconfont.js'; const IconFont = createFromIconfontCN({ - scriptUrl: '//at.alicdn.com/t/c/font_4613488_wt33o3w25g.js' + scriptUrl: '//at.alicdn.com/t/c/font_4613488_c06q7b0n32v.js' }); export default IconFont; diff --git a/src/components/page-tools/index.tsx b/src/components/page-tools/index.tsx index 1c0839c6..89b91170 100644 --- a/src/components/page-tools/index.tsx +++ b/src/components/page-tools/index.tsx @@ -3,6 +3,7 @@ import { DeleteOutlined, DownOutlined, PlusOutlined, + SearchOutlined, SyncOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; @@ -170,6 +171,11 @@ export const FilterBar: React.FC = (props) => { left={ + } placeholder={ inputHolder || intl.formatMessage({ diff --git a/src/components/seal-form/seal-textarea.tsx b/src/components/seal-form/seal-textarea.tsx index 74d8ee66..536eabf0 100644 --- a/src/components/seal-form/seal-textarea.tsx +++ b/src/components/seal-form/seal-textarea.tsx @@ -127,6 +127,7 @@ const SealTextArea: React.FC = ( > ` border-radius: 2px; bottom: 0px; top: unset; + opacity: 1; } } .ant-segmented-item-selected { @@ -55,6 +56,7 @@ const SegmentedQwrapper = styled.div<{ $height: number }>` color: var(--color-white-primary); &::after { height: 2px; + opacity: 1; } } } @@ -68,6 +70,7 @@ const SegmentLine: React.FC = (props) => { className = 'segment-line', ...rest } = props; + return ( = ({ + items +}) => { + return ( + ( + + )} + items={items} + > + ); +}; + +export default CollapsePanel; diff --git a/src/pages/llmodels/components/column-wrapper.tsx b/src/pages/_components/column-wrapper/index.tsx similarity index 51% rename from src/pages/llmodels/components/column-wrapper.tsx rename to src/pages/_components/column-wrapper/index.tsx index 87152fcc..8cef3911 100644 --- a/src/pages/llmodels/components/column-wrapper.tsx +++ b/src/pages/_components/column-wrapper/index.tsx @@ -1,6 +1,6 @@ import useOverlayScroller from '@/hooks/use-overlay-scroller'; import React from 'react'; -import '../style/column-wrapper.less'; +import './style.less'; const ColumnWrapper: React.FC = ({ children, @@ -25,24 +25,25 @@ const ColumnWrapper: React.FC = ({ return ( <> - {footer ? ( -
-
-
- {children} -
+
+
+
+ {children}
- {
{footer}
}
- ) : ( -
-
{children}
-
- )} + {footer &&
{footer}
} +
); }; diff --git a/src/pages/llmodels/style/column-wrapper.less b/src/pages/_components/column-wrapper/style.less similarity index 100% rename from src/pages/llmodels/style/column-wrapper.less rename to src/pages/_components/column-wrapper/style.less diff --git a/src/pages/_components/command-viewer/index.tsx b/src/pages/_components/command-viewer/index.tsx new file mode 100644 index 00000000..0608f205 --- /dev/null +++ b/src/pages/_components/command-viewer/index.tsx @@ -0,0 +1,79 @@ +import CopyButton from '@/components/copy-button'; +import EditorWrap from '@/components/editor-wrap'; +import HighlightCode from '@/components/highlight-code'; +import SegmentLine from '@/components/segment-line'; +import React from 'react'; +import styled from 'styled-components'; + +interface ViewerProps { + code: string; + copyText?: string; + options?: Global.BaseOption[]; + defaultValue?: string; + headerHeight?: number; + lang?: string; + onChange?: (value: string | number) => void; +} + +const Header = styled.div` + width: 100%; + padding-inline: 12px 10px; + display: flex; + align-items: center; + justify-content: space-between; + background-color: var(--color-editor-header-bg); +`; + +const CommandViewer: React.FC = (props) => { + const { + code, + copyText = '', + defaultValue, + options = [], + headerHeight = 40, + lang, + onChange + } = props || {}; + + const [value, setValue] = React.useState(defaultValue || ''); + + const handlOnChange = (value: string | number) => { + setValue(value as string); + onChange?.(value); + }; + + return ( + + + + + + } + > + + + ); +}; + +export default CommandViewer; diff --git a/src/pages/backends/backend-create.tsx b/src/pages/backends/backend-create.tsx new file mode 100644 index 00000000..a8b4ede5 --- /dev/null +++ b/src/pages/backends/backend-create.tsx @@ -0,0 +1,249 @@ +import { PageAction } from '@/config'; +import { PageActionType } from '@/config/types'; +import ClusterSteps from '@/pages/cluster-management/components/cluster-steps'; +import FooterButtons from '@/pages/cluster-management/components/footer-buttons'; +import { PageContainer } from '@ant-design/pro-components'; +import { useIntl, useNavigate, useSearchParams } from '@umijs/max'; +import _ from 'lodash'; +import React, { useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { createBackend } from './apis'; +import { FormData } from './config/types'; +import { moduleMap, moduleRegistry } from './step-forms/module-registry'; +import useStepList from './step-forms/use-step-list'; + +const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const Nav = styled.div` + display: flex; + align-items: center; + height: 72px; + font-weight: 400; + font-size: 20px; + color: var(--ant-color-text-tertiary); + .level-2 { + color: var(--ant-color-text); + font-weight: 600; + } +`; + +const Content = styled.div` + width: 600px; +`; + +const HeaderContainer = styled.div` + position: relative; + display: grid; + grid-template-columns: 1fr; + align-items: center; + padding-inline: var(--layout-content-header-inlinepadding); + .text { + margin-right: 16px; + padding-right: 16px; + border-right: 1px solid var(--ant-color-split); + font-weight: 600; + font-size: 20px; + margin-right: 32px; + } +`; + +const ClusterCreate = () => { + const intl = useIntl(); + const startStep = 0; + const steps = useStepList(); + const [searchParams] = useSearchParams(); + const action = + (searchParams.get('action') as PageActionType) || PageAction.CREATE; + const navigate = useNavigate(); + + const [currentStep, setCurrentStep] = useState(startStep); + + const [extraData, setExtraData] = useState({ + default_version: '' + } as FormData); + const [formValues, setFormValues] = useState>({}); + + const formRefs: Record = { + [moduleMap.BasicForm]: useRef(null), + [moduleMap.ParamertersForm]: useRef(null), + [moduleMap.VersionConfigForm]: useRef(null) + }; + + const handleStepChange = (newStep: number) => { + setCurrentStep(newStep); + console.log('step========', newStep); + }; + + const getFormFieldsValue = () => { + setFormValues((prev) => { + const newFormValues = _.cloneDeep(prev); + for (const [formKey, formRef] of Object.entries(formRefs)) { + const formValues = formRef?.current?.getFieldsValue?.(); + if (formValues) { + newFormValues[formKey] = formValues; + } + } + return newFormValues; + }); + }; + + const onPrevious = () => { + getFormFieldsValue(); + setCurrentStep((prev) => Math.max(prev - 1, 0)); + }; + + const validateForms = async () => { + const step = steps[currentStep]; + const formKeys = step?.showForms || []; + if (formKeys?.length > 0) { + const results = await Promise.allSettled( + formKeys.map((key) => formRefs[key].current?.validateFields()) + ); + + console.log('results========', results); + + const resultsMap = new Map(); + results.forEach((result, index) => { + if (result.status === 'rejected') { + const values = result.reason?.values || {}; + resultsMap.set(formKeys[index], values); + } else if (result.status === 'fulfilled') { + resultsMap.set(formKeys[index], result.value); + } + }); + const newValues = _.merge(formValues, Object.fromEntries(resultsMap)); + + setFormValues(newValues); + + const isValid = results.every((result) => result.status === 'fulfilled'); + if (!isValid) { + return false; + } + return Object.assign({}, ...Object.values(newValues)); + } + return true; + }; + + const onNext = async (callback?: (values: FormData) => void) => { + try { + const result = await validateForms(); + console.log('results========2', result); + if (result) { + await callback?.(result as FormData); + const step = steps[currentStep]; + step.beforeNext?.(); + setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1)); + } + } catch (error) { + console.log('next error:', error); + } + }; + + const resetForm = () => { + setFormValues({}); + for (const [formKey, formRef] of Object.entries(formRefs)) { + formRefs[formKey] = React.createRef(); + } + }; + + const renderForms = () => { + const step = steps[currentStep]; + const formKeys = step?.showForms || []; + if (formKeys.length === 0) { + return null; + } + + return formKeys.map((key) => { + console.log('renderForms key========', key, formValues); + const FormComponent = moduleRegistry[key]; + return FormComponent ? ( + + ) : null; + }); + }; + + const showButtons = useMemo(() => { + const step = steps[currentStep]; + return step?.showButtons ? step?.showButtons?.() : {}; + }, [currentStep, steps]); + + const submit = async (values: FormData) => { + const data = { + ...extraData, + ...(typeof values === 'object' ? values : {}) + }; + const res = await createBackend({ data }); + + return true; + }; + + const handleSubmit = async () => { + onNext(submit); + }; + + const handleCancel = () => { + navigate(-1); + }; + + return ( + + ]} + header={{ + title: false, + style: { + paddingInline: 'var(--layout-content-header-inlinepadding)' + }, + breadcrumb: {} + }} + pageHeaderRender={() => ( + + + + + )} + > + + {renderForms()} + + + ); +}; + +export default ClusterCreate; diff --git a/src/pages/backends/components/add-modal.tsx b/src/pages/backends/components/add-modal.tsx new file mode 100644 index 00000000..4acbc7b6 --- /dev/null +++ b/src/pages/backends/components/add-modal.tsx @@ -0,0 +1,148 @@ +import IconFont from '@/components/icon-font'; +import ModalFooter from '@/components/modal-footer'; +import GSDrawer from '@/components/scroller-modal/gs-drawer'; +import { PageActionType } from '@/config/types'; +import { useIntl } from '@umijs/max'; +import { Segmented, Tabs } from 'antd'; +import React, { useRef, useState } from 'react'; +import styled from 'styled-components'; +import ColumnWrapper from '../../_components/column-wrapper'; +import { FormData, ListItem } from '../config/types'; +import BackendForm from '../forms'; +import ImportYAML from './import-yaml'; + +const ModalFooterStyle = { + padding: '16px 24px', + display: 'flex', + justifyContent: 'flex-end' +}; + +const SegmentedInner = styled(Segmented)` + width: 100%; + border-radius: 0; + .ant-segmented-item { + flex: 1; + } +`; + +const SegmentedHeader = styled.div` + margin-top: 16px; + padding: 0 24px; +`; + +interface AddModalProps { + action: PageActionType; + currentData?: ListItem; // Used when action is EDIT + onClose: () => void; + onSubmit: (values: FormData) => void; + open: boolean; + title?: string; +} + +const AddModal: React.FC = (props) => { + const { action, currentData, onClose, onSubmit, open, title } = props; + const formRef = useRef(null); + const editorRef = useRef(null); + const intl = useIntl(); + const [activeKey, setActiveKey] = useState('form'); + + const onOk = () => { + if (activeKey === 'yaml') { + const content = editorRef.current?.getContent(); + const valid = editorRef.current?.validate(); + if (valid) { + onSubmit({ content: content }); + } + } else { + formRef.current?.submit(); + } + }; + + const onFinish = (values: FormData) => { + onSubmit(values); + }; + + return ( + + + setActiveKey(value as string)} + options={[ + { + label: 'Form', + value: 'form', + icon: + }, + { + label: 'YAML', + value: 'yaml', + icon: + } + ]} + size="middle" + style={{ width: '100%' }} + /> + + + } + > + <>} + activeKey={activeKey} + defaultActiveKey={activeKey} + items={[ + { + key: 'form', + label: 'Form', + children: ( + + ) + }, + { + key: 'yaml', + label: 'YAML', + children: ( + + ) + } + ]} + > + + + ); +}; + +export default AddModal; diff --git a/src/pages/backends/components/backend-card.tsx b/src/pages/backends/components/backend-card.tsx index eaf90753..edf53377 100644 --- a/src/pages/backends/components/backend-card.tsx +++ b/src/pages/backends/components/backend-card.tsx @@ -1,6 +1,7 @@ import DropDownActions from '@/components/drop-down-actions'; import IconFont from '@/components/icon-font'; import Card from '@/components/templates/card'; +import { DockerOutlined } from '@ant-design/icons'; import { Button } from 'antd'; import styled from 'styled-components'; import { backendActions } from '../config'; @@ -22,8 +23,17 @@ const Content = styled.div` display: flex; flex-direction: column; align-items: flex-start; - margin-top: 12x; - gap: 16px; + margin-top: 8px; + gap: 12px; + color: var(--ant-color-text-secondary); + .description { + margin-block: 12px; + color: var(--ant-color-text-tertiary); + } + .text { + margin-left: 4px; + color: var(--ant-color-text-secondary); + } .title { color: var(--ant-color-text); font-weight: 500; @@ -47,12 +57,13 @@ const BackendCard: React.FC = ({ onClick={() => onClick(data)} clickable={true} disabled={false} - height={205} + height={'auto'} ghost header={
{data.backend_name} + {data.backend_name}
= ({ } > -
{data.backend_name}
-
This is a description
-
默认版本:{data.default_version}
+
This is a description
- 镜像:{data.version_configs?.[data.default_version]?.image_name} + Default + version: {data.default_version} +
+
+ Default Image:{' '} + + {data.version_configs?.[data.default_version]?.image_name} +
diff --git a/src/pages/backends/components/import-yaml.tsx b/src/pages/backends/components/import-yaml.tsx new file mode 100644 index 00000000..4da288a7 --- /dev/null +++ b/src/pages/backends/components/import-yaml.tsx @@ -0,0 +1,145 @@ +import { PageAction } from '@/config'; +import { PageActionType } from '@/config/types'; +import useUserSettings from '@/hooks/use-user-settings'; +import { ImportOutlined } from '@ant-design/icons'; +import { useIntl } from '@umijs/max'; +import { Button, message, Typography, Upload } from 'antd'; +import { RcFile } from 'antd/lib/upload'; +import React, { + forwardRef, + useImperativeHandle, + useRef, + useState +} from 'react'; +import styled from 'styled-components'; +import { yamlTemplate } from '../config'; +import YamlEditor from './yaml-editor'; + +const { Text } = Typography; + +const ErrorText = styled(Text)` + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 4px 6px; + background-color: var(--ant-color-bg-elevated); + border-radius: 0 0 var(--ant-border-radius) var(--ant-border-radius); +`; + +const Header = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + padding-inline: 10px; + border-bottom: 1px solid var(--ant-color-border); + background-color: var(--ant-color-fill-tertiary); +`; + +const Container = styled.div` + position: relative; + border: 1px solid var(--ant-color-border); + border-radius: var(--ant-border-radius); + .monaco-editor .scroll-decoration { + box-shadow: none; + } +`; + +interface ImportYAMLProps { + ref?: any; + action: PageActionType; + content?: string; + onSubmit?: (content: string) => void; +} + +const ImportYAML: React.FC = forwardRef( + ({ action, content = '' }, ref) => { + const intl = useIntl(); + const { userSettings } = useUserSettings(); + const editorRef = useRef(null); + const [fileContent, setFileContent] = useState( + action === PageAction.CREATE ? yamlTemplate : content + ); + const [error, setError] = useState(''); + + const beforeUpload = (file: RcFile) => { + const isYaml = + file.type === 'application/x-yaml' || + file.type === 'text/yaml' || + file.name.endsWith('.yaml') || + file.name.endsWith('.yml'); + if (!isYaml) { + message.error('You can only upload YAML file!'); + } + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result; + if (typeof content === 'string') { + setFileContent(content); + } else { + message.error('Failed to read file content!'); + } + }; + reader.readAsText(file); + // Prevent upload + return false; + }; + + const validate = () => { + const markers = editorRef.current?.validate(); + if (markers && markers.length > 0) { + setError(markers[0].message); + } + return markers.length === 0; + }; + + const renderHeader = () => { + return ( +
+ YAML Editor + + + +
+ ); + }; + + useImperativeHandle(ref, () => ({ + getContent: () => { + return editorRef.current?.getValue(); + }, + setContent: (val: string) => { + editorRef.current?.setValue?.(val); + }, + validate() { + return validate(); + } + })); + + return ( + + + {error && {error}} + + ); + } +); + +export default ImportYAML; diff --git a/src/pages/backends/components/yaml-editor.tsx b/src/pages/backends/components/yaml-editor.tsx new file mode 100644 index 00000000..198d6f8d --- /dev/null +++ b/src/pages/backends/components/yaml-editor.tsx @@ -0,0 +1,167 @@ +import EditorWrap from '@/components/editor-wrap'; +import { LoadingOutlined } from '@ant-design/icons'; +import Editor, { loader } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; +import { configureMonacoYaml } from 'monaco-yaml'; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useRef +} from 'react'; +import schema from '../config/schema.json'; + +loader.config({ monaco }); + +interface ViewerProps { + ref?: any; + lang: string; + defaultLang?: string; + config?: any; + value: string; + height?: string | number; + theme?: string; + header?: React.ReactNode; + placeholder?: string; + variant?: 'bordered' | 'borderless'; +} + +const path = 'inmemory://model/config.yaml'; + +const ViewerEditor: React.FC = forwardRef((props, ref) => { + const { + lang, + value, + config, + defaultLang, + height = 380, + theme = 'vs-dark', + header, + variant = 'borderless', + placeholder + } = props; + + const editorRef = useRef(null); + const monacoRef = useRef(null); + const monacoYamlRef = useRef(null); + + const handleBeforeMount = (monaco: any) => { + const yamlUri = monaco.Uri.parse(path); + + configureMonacoYaml(monaco, { + schemas: [ + { + uri: 'http://example.com/schema-name.json', + fileMatch: [yamlUri.toString()], + schema + } + ] + }); + }; + + const handleEditorDidMount = (editor: any, monaco: any) => { + editorRef.current = editor; + monacoRef.current = monaco; + const uri = monaco.Uri.parse(path); + + // monaco.editor.onDidChangeMarkers((uris: string[]) => { + // if (!uris.some((u) => u.toString() === uri.toString())) return; + + // const model = monaco.editor.getModel(uri); + // if (!model) return; + // console.log('Markers changed:', uris); + // const markers = monaco.editor.getModelMarkers({ resource: uri }); + // const adjusted = markers.map((m: any) => { + // if (m._adjusted) return m; + // return { + // ...m, + // _adjusted: true, + // severity: monaco.MarkerSeverity.ErrorText + // }; + // }); + + // monaco.editor.setModelMarkers( + // monaco.editor.getModel(uri), + // 'yaml', + // adjusted + // ); + // }); + }; + + const formatCode = () => { + if (editorRef.current) { + setTimeout(() => { + editorRef.current + ?.getAction?.('editor.action.formatDocument') + ?.run() + .then(() => { + console.log('format success'); + }); + }, 100); + } + }; + + const getMarkers = () => { + const uri = editorRef.current?.getModel()?.uri; + const markers = monacoRef.current?.editor.getModelMarkers({ + resource: uri + }); + console.log('Validation markers:', uri, markers); + return markers; + }; + + useImperativeHandle(ref, () => ({ + format: () => { + formatCode(); + }, + getValue: () => { + return editorRef.current?.getValue?.(); + }, + setValue: (val: string) => { + editorRef.current?.setValue?.(val); + }, + dispose: () => { + editorRef.current?.dispose?.(); + monacoYamlRef.current?.dispose?.(); + }, + validate() { + return getMarkers(); + }, + editor: editorRef.current + })); + + useEffect(() => { + formatCode(); + }, [value]); + + return ( + + } + beforeMount={handleBeforeMount} + onMount={handleEditorDidMount} + /> + + ); +}); + +export default ViewerEditor; diff --git a/src/pages/backends/config/index.ts b/src/pages/backends/config/index.ts index 43c163a9..e7ab5f4c 100644 --- a/src/pages/backends/config/index.ts +++ b/src/pages/backends/config/index.ts @@ -5,21 +5,21 @@ export const backendActions = [ label: 'Edit Versions', value: 'version', key: 'version', - icon: icons.EditOutlined, + icon: icons.EditContent, locale: false }, { label: 'Edit Parameters', value: 'parameter', key: 'parameter', - icon: icons.EditOutlined, + icon: icons.Parameter, locale: false }, { label: 'Edit by Yaml', value: 'yaml', key: 'edit', - icon: icons.EditOutlined, + icon: icons.Yaml, locale: false }, { @@ -31,3 +31,21 @@ export const backendActions = [ danger: true } ]; + +export const yamlTemplate = ` +name: SGLang +default_version: v0.5.1 +health_check_path: /health +allowed_proxy_uris: + - api/chat + - api/v1 +versions: + v0.0.1: + image: lm/sglang + command: run sglang + v0.0.2: + image: lm/sglang + command: run sglang +default_backend_parameters: + - --host + `; diff --git a/src/pages/backends/config/schema.json b/src/pages/backends/config/schema.json new file mode 100644 index 00000000..a1d94a46 --- /dev/null +++ b/src/pages/backends/config/schema.json @@ -0,0 +1,56 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "backend name", + "severity": "error" + }, + "default_version": { + "type": "string", + "description": "default backend version" + }, + "health_check_path": { + "type": "string", + "description": "health check path" + }, + "allowed_proxy_uris": { + "type": "array", + "items": { + "type": "string" + }, + "description": "allowed proxy URIs" + }, + "versions": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "type": "object", + "properties": { + "image": { + "type": "string", + "description": "image name" + }, + "command": { + "type": "string", + "description": "start command" + } + }, + "required": ["image", "command"], + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "multiple backend versions" + }, + "default_backend_parameters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "default backend parameters" + } + }, + "required": ["name", "default_version", "health_check_path", "versions"], + "additionalProperties": false +} diff --git a/src/pages/backends/config/test.ts b/src/pages/backends/config/test.ts index bef7b5b0..986a5837 100644 --- a/src/pages/backends/config/test.ts +++ b/src/pages/backends/config/test.ts @@ -1,4 +1,4 @@ -import nvidiaLogo from '@/assets/logo/nvidia.png'; +import lmDeployLogo from '@/assets/logo/lmdeploy.png'; import SGLangLogo from '@/assets/logo/sglang.png'; import vLLMLogo from '@/assets/logo/vllm.png'; @@ -37,8 +37,8 @@ export default [ }, { id: 3, - backend_name: 'TensorRT', - icon: nvidiaLogo, + backend_name: 'LMDeploy', + icon: lmDeployLogo, version_configs: { 'v1.0': { image_name: 'example/image:v1.0', @@ -49,6 +49,6 @@ export default [ health_check_path: '/v1/models', default_version: 'v1.0', default_backend_param: ['--param1', '--param2'], - description: 'TensorRT description' + description: 'LMDeploy description' } ]; diff --git a/src/pages/backends/config/types.ts b/src/pages/backends/config/types.ts index 87800c40..6364bbff 100644 --- a/src/pages/backends/config/types.ts +++ b/src/pages/backends/config/types.ts @@ -6,7 +6,7 @@ export interface VersionConfigs { } export interface FormData { - backend_name: string; + backend_name?: string; description?: string; version_configs?: VersionConfigs; compatibility_type?: string; @@ -14,10 +14,11 @@ export interface FormData { default_version?: string; default_backend_param?: string[]; allowed_proxy_uris?: string[]; + content?: string; } export interface ListItem extends FormData { - id: number; - created_at: string; - updated_at: string; + id?: number; + created_at?: string; + updated_at?: string; } diff --git a/src/pages/backends/forms/basic.tsx b/src/pages/backends/forms/basic.tsx new file mode 100644 index 00000000..524dde62 --- /dev/null +++ b/src/pages/backends/forms/basic.tsx @@ -0,0 +1,77 @@ +import ListInput from '@/components/list-input'; +import SealInput from '@/components/seal-form/seal-input'; +import SealSelect from '@/components/seal-form/seal-select'; +import { PageActionType } from '@/config/types'; +import useAppUtils from '@/hooks/use-app-utils'; +import { useIntl } from '@umijs/max'; +import { Form } from 'antd'; +import React from 'react'; +import { FormData } from '../config/types'; + +type AddModalProps = { + action: PageActionType; +}; +const BasicForm: React.FC = ({ action }) => { + const form = Form.useFormInstance(); + const intl = useIntl(); + const { getRuleMessage } = useAppUtils(); + + return ( + <> + + name="backend_name" + rules={[ + { + required: true, + message: getRuleMessage('input', 'common.table.name') + } + ]} + > + + + + + name="compatibility_type" + rules={[ + { + required: true, + message: getRuleMessage('select', 'template', false) + } + ]} + > + + + + + name="allowed_proxy_uris" + rules={[{ required: false }]} + > + { + form.setFieldValue('allowed_proxy_uris', data); + }} + btnText={'Add Allowed Proxy URI'} + label={'Proxy URI'} + > + + name="description" rules={[{ required: false }]}> + + + + ); +}; + +export default BasicForm; diff --git a/src/pages/backends/forms/index.tsx b/src/pages/backends/forms/index.tsx new file mode 100644 index 00000000..790d75dd --- /dev/null +++ b/src/pages/backends/forms/index.tsx @@ -0,0 +1,73 @@ +import { PageActionType } from '@/config/types'; +import CollapsePanel from '@/pages/_components/collapse-panel'; +import { Form } from 'antd'; +import React, { forwardRef, useEffect, useImperativeHandle } from 'react'; +import { FormData, ListItem } from '../config/types'; +import BasicForm from './basic'; +import ParametersForm from './parameters'; +import VersionsForm from './versions-config'; + +type AddModalProps = { + action: PageActionType; + currentData?: ListItem; // Used when action is EDIT + onFinish: (values: FormData) => void; + ref?: any; +}; +const BackendForm: React.FC = forwardRef( + ({ action, currentData, onFinish }, ref) => { + const [form] = Form.useForm(); + + useEffect(() => { + if (currentData) { + form.setFieldsValue(currentData); + } + }, [currentData]); + + useImperativeHandle(ref, () => ({ + resetFields: () => { + form.resetFields(); + }, + submit: () => { + form.submit(); + }, + setFieldsValue: (values: any) => { + form.setFieldsValue(values); + }, + getFieldsValue: () => { + return form.getFieldsValue(); + }, + validateFields: async () => { + return await form.validateFields(); + } + })); + + return ( +
+ + + }, + { + key: 'parameters', + label: 'Default Parameters', + children: + } + ]} + > +
+ ); + } +); + +export default BackendForm; diff --git a/src/pages/backends/forms/parameters.tsx b/src/pages/backends/forms/parameters.tsx new file mode 100644 index 00000000..c1ccadb7 --- /dev/null +++ b/src/pages/backends/forms/parameters.tsx @@ -0,0 +1,32 @@ +import ListInput from '@/components/list-input'; +import { PageActionType } from '@/config/types'; +import { Form } from 'antd'; +import React from 'react'; +import { FormData } from '../config/types'; + +type AddModalProps = { + action: PageActionType; +}; +const ParametersForm: React.FC = ({ action }) => { + const form = Form.useFormInstance(); + + return ( + <> + + name="default_backend_param" + rules={[{ required: false }]} + > + { + form.setFieldValue('default_backend_param', data); + }} + btnText={'Add Backend Parameter'} + label={'Default Backend Parameters'} + > + + + ); +}; + +export default ParametersForm; diff --git a/src/pages/backends/forms/versions-config.tsx b/src/pages/backends/forms/versions-config.tsx new file mode 100644 index 00000000..57417ce7 --- /dev/null +++ b/src/pages/backends/forms/versions-config.tsx @@ -0,0 +1,139 @@ +import SealInput from '@/components/seal-form/seal-input'; +import { PageActionType } from '@/config/types'; +import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Divider, Form } from 'antd'; +import React, { useEffect } from 'react'; +import styled from 'styled-components'; + +const Box = styled.div` + display: grid; + grid-template-columns: 200px 1fr; + gap: 16px; + align-items: center; +`; + +const ItemWrapper = styled.div` + &:hover { + .divider { + border-top: 1px dashed var(--ant-color-primary) !important; + } + } +`; + +const Label = styled.div` + color: var(--ant-color-text-tertiary); + font-size: var(--font-size-base); + margin-bottom: 10px; +`; + +type AddModalProps = { + action: PageActionType; +}; +const VersionsForm: React.FC = ({ action }) => { + const form = Form.useFormInstance(); + const versionList = [ + { + version_no: '', + image_name: '', + run_command: '', + is_default: false + } + ]; + + useEffect(() => { + const versions = form.getFieldValue('version_configs') || []; + if (Array.isArray(versions) && versions.length === 0) { + form.setFieldValue('version_configs', versionList); + } + }, []); + + return ( + + {(fields, { add, remove }) => { + return ( +
+ {fields.map(({ key, name, ...restField }, index) => ( + + + + + + + + + + + + +
+ + + Set as Default Version + + +
+ {fields.length > 1 && ( + + )} +
+
+ + {index === fields.length - 1 && ( + + )} +
+ ))} +
+ ); + }} +
+ ); +}; + +export default VersionsForm; diff --git a/src/pages/backends/list.tsx b/src/pages/backends/list.tsx index 5fe163eb..878473d3 100644 --- a/src/pages/backends/list.tsx +++ b/src/pages/backends/list.tsx @@ -1,21 +1,18 @@ import { FilterBar } from '@/components/page-tools'; +import { PageActionType } from '@/config/types'; import useTableFetch from '@/hooks/use-table-fetch'; -import { - DeleteOutlined, - ImportOutlined, - PlusOutlined -} from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-components'; import { useIntl } from '@umijs/max'; -import { Button, Space } from 'antd'; +import { useState } from 'react'; import { deleteBackend, INFERENCE_BACKEND_API, queryBackendsList } from './apis'; +import AddModal from './components/add-modal'; import BackendCardList from './components/backend-list'; import dataList from './config/test'; -import { ListItem } from './config/types'; +import { FormData, ListItem } from './config/types'; const BackendList = () => { const intl = useIntl(); @@ -40,10 +37,27 @@ const BackendList = () => { watch: false, contentForDelete: 'resources.modelfiles.modelfile' }); + const [openModalStatus, setOpenModalStatus] = useState<{ + open: boolean; + action: PageActionType; + currentData?: Partial; + }>({ open: false, action: 'create' }); - const handleClickDropdown = () => {}; + const handleOnSubmit = (values: FormData) => { + console.log('yaml content:', values); + setOpenModalStatus({ + open: false, + action: 'create', + currentData: undefined + }); + }; - const handleDeleteByBatch = () => {}; + const handleAddBackend = () => { + setOpenModalStatus({ + open: true, + action: 'create' + }); + }; return ( { width={{ input: 300 }} - actionType="dropdown" inputHolder={intl.formatMessage({ id: 'common.filter.name' })} - buttonText={intl.formatMessage({ - id: 'resources.modelfiles.download' - })} - handleDeleteByBatch={handleDeleteByBatch} - handleClickPrimary={handleClickDropdown} + buttonText={'Add Backend'} + handleDeleteByBatch={handleDeleteBatch} + handleClickPrimary={handleAddBackend} handleSearch={handleSearch} handleInputChange={handleNameChange} rowSelection={rowSelection} showSelect={false} - right={ - - - - - - } > { activeId={false} isFirst={!dataSource.loadend} > + setOpenModalStatus({ open: false, action: 'create' })} + onSubmit={handleOnSubmit} + currentData={openModalStatus.currentData} + open={openModalStatus.open} + title={ + openModalStatus.action === 'create' + ? 'Create Backend' + : 'Edit Backend' + } + > ); }; diff --git a/src/pages/backends/step-forms/basic-form.tsx b/src/pages/backends/step-forms/basic-form.tsx new file mode 100644 index 00000000..427d9b21 --- /dev/null +++ b/src/pages/backends/step-forms/basic-form.tsx @@ -0,0 +1,59 @@ +import PageTools from '@/components/page-tools'; +import { PageActionType } from '@/config/types'; +import { useIntl } from '@umijs/max'; +import { forwardRef, useImperativeHandle, useRef } from 'react'; +import styled from 'styled-components'; +import Basic from '../forms/basic'; + +const Title = styled.span` + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 700; + font-size: 16px; + .text { + font-size: 20px; + } +`; +interface BasicFormProps { + action: PageActionType; + currentData?: any; +} + +const BasicForm = forwardRef((props: BasicFormProps, ref) => { + const intl = useIntl(); + const { action, currentData } = props; + const formRef = useRef(null); + + const handleOnFinish = (values: any) => { + console.log(values); + }; + + useImperativeHandle(ref, () => ({ + validateFields: formRef.current?.validateFields, + getFieldsValue: formRef.current?.getFieldsValue, + submit: formRef.current?.submit + })); + + return ( +
+ + {intl.formatMessage({ id: 'clusters.create.configBasic' })} + + } + marginTop={0} + > + +
+ ); +}); + +export default BasicForm; diff --git a/src/pages/backends/step-forms/module-registry.ts b/src/pages/backends/step-forms/module-registry.ts new file mode 100644 index 00000000..0c2dc8c4 --- /dev/null +++ b/src/pages/backends/step-forms/module-registry.ts @@ -0,0 +1,16 @@ +import React from 'react'; +import BasicForm from './basic-form'; +import ParamertersForm from './parameters-form'; +import VersionConfigForm from './version-config-form'; + +export const moduleMap = { + BasicForm: 'BasicForm', + VersionConfigForm: 'VersionConfigForm', + ParamertersForm: 'ParamertersForm' +}; + +export const moduleRegistry: Record> = { + [moduleMap.BasicForm]: BasicForm, + [moduleMap.VersionConfigForm]: VersionConfigForm, + [moduleMap.ParamertersForm]: ParamertersForm +}; diff --git a/src/pages/backends/step-forms/parameters-form.tsx b/src/pages/backends/step-forms/parameters-form.tsx new file mode 100644 index 00000000..75788791 --- /dev/null +++ b/src/pages/backends/step-forms/parameters-form.tsx @@ -0,0 +1,55 @@ +import PageTools from '@/components/page-tools'; +import { PageActionType } from '@/config/types'; +import { useIntl } from '@umijs/max'; +import { forwardRef, useImperativeHandle, useRef } from 'react'; +import styled from 'styled-components'; +import Parameters from '../forms/parameters'; + +const Title = styled.span` + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 700; + font-size: 16px; + .text { + font-size: 20px; + } +`; +interface BasicFormProps { + action: PageActionType; + currentData?: any; +} + +const ParametersForm = forwardRef((props: BasicFormProps, ref) => { + const intl = useIntl(); + const { action, currentData } = props; + const formRef = useRef(null); + + const handleOnFinish = (values: any) => { + console.log(values); + }; + + useImperativeHandle(ref, () => ({ + validateFields: formRef.current?.validateFields, + getFieldsValue: formRef.current?.getFieldsValue, + submit: formRef.current?.submit + })); + + return ( +
+ Parameters Configuration} + marginTop={0} + > + +
+ ); +}); + +export default ParametersForm; diff --git a/src/pages/backends/step-forms/use-step-list.tsx b/src/pages/backends/step-forms/use-step-list.tsx new file mode 100644 index 00000000..5f21cb7b --- /dev/null +++ b/src/pages/backends/step-forms/use-step-list.tsx @@ -0,0 +1,65 @@ +import { useIntl, useNavigate } from '@umijs/max'; +import { useMemoizedFn } from 'ahooks'; +import { useMemo } from 'react'; +import { moduleMap } from './module-registry'; + +export default function useStepList() { + const intl = useIntl(); + const navigate = useNavigate(); + + const handleBack = useMemoizedFn(() => { + navigate(-1); + }); + + return useMemo( + () => [ + { + title: 'Basic Info', + content: '', + showButtons: () => { + return { + previous: false, + next: true, + save: false, + skip: false + }; + }, + defaultShow: true, + showForms: [moduleMap.BasicForm], + showModules: [] + }, + { + title: 'Version Configuration', + content: '', + showButtons: () => { + return { + previous: true, + next: true, + save: false, + skip: false + }; + }, + defaultShow: true, + showForms: [moduleMap.VersionConfigForm], + showModules: [] + }, + { + title: 'Parameters', + content: '', + showButtons: () => { + return { + previous: true, + next: false, + save: true, + skip: false + }; + }, + defaultShow: true, + showForms: [moduleMap.ParamertersForm], + showModules: [], + beforeNext: handleBack + } + ], + [handleBack, intl] + ); +} diff --git a/src/pages/backends/step-forms/version-config-form.tsx b/src/pages/backends/step-forms/version-config-form.tsx new file mode 100644 index 00000000..a033e6e3 --- /dev/null +++ b/src/pages/backends/step-forms/version-config-form.tsx @@ -0,0 +1,227 @@ +import PageTools from '@/components/page-tools'; +import { PageActionType } from '@/config/types'; +import { PlusOutlined } from '@ant-design/icons'; +import { useIntl } from '@umijs/max'; +import { Button, FormInstance } from 'antd'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react'; +import styled from 'styled-components'; +import { FormData } from '../config/types'; +import VersionsConfig from '../forms/versions-config'; + +const PoolContainer = styled.div` + display: flex; + align-items: flex-start; + gap: 16px; +`; + +const PoolFormWrapper = styled.div` + flex: 1; + margin-bottom: 16px; +`; + +const Title = styled.span` + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 700; + font-size: 16px; + .text { + font-size: 20px; + } +`; +interface WorkerPoolsFormProps { + action: PageActionType; + currentData?: any; +} + +const WorkerPoolsForm = forwardRef((props: WorkerPoolsFormProps, ref) => { + const intl = useIntl(); + const { action, currentData } = props; + const countRef = useRef(0); + const formRefs = useRef | null>>({}); + const [activeKey, setActiveKey] = useState>(new Set([0])); + const [versionConfigs, setVersionConfigs] = useState>( + new Map([ + [ + 0, + { + version_name: '', + image_name: '', + run_command: '' + } + ] + ]) as Map + ); + + const handleOnFinish = (values: any) => {}; + + const updateCount = () => { + countRef.current += 1; + return countRef.current; + }; + + const handleAddPool = () => { + const newId = updateCount(); + setVersionConfigs((prev) => + new Map(prev).set(newId, { + name: `` + } as FormData) + ); + + requestAnimationFrame(() => { + setActiveKey((prev) => new Set([newId])); + window.scrollTo({ + top: document.body.scrollHeight, + behavior: 'smooth' + }); + }); + }; + + const handleRemovePool = (id: number) => { + if (versionConfigs.size === 1) { + return; + } + + setVersionConfigs((prev) => { + const newList = new Map(prev); + newList.delete(id); + return newList; + }); + formRefs.current[id] = null; + }; + + const gatherFormValues = (results: PromiseSettledResult[]) => { + const resultList = results.map((result: PromiseSettledResult) => { + if (result.status === 'fulfilled') { + return result.value; + } + if (result.status === 'rejected') { + return result.reason?.values || {}; + } + return {}; + }); + console.log('gatherFormValues========', resultList); + return resultList.filter((item) => item); + }; + + const validateFields = async () => { + const promises = Array.from(Object.values(formRefs.current)).map((form) => + form?.validateFields() + ); + const results = await Promise.allSettled(promises); + console.log('results========0', promises, results); + if (results.some((result) => result.status === 'rejected')) { + return Promise.reject({ + values: { + worker_pools: gatherFormValues(results) + } + }); + } + return Promise.resolve({ + worker_pools: gatherFormValues(results) + }); + }; + + const setFieldsValue = (data: any) => { + const worker_pools = data.worker_pools || []; + const newWorkerPools = worker_pools.map( + (poolData: FormData, index: number) => [index, poolData] + ); + console.log('newWorkerPools========', newWorkerPools); + setVersionConfigs(new Map(newWorkerPools)); + }; + + const getFieldsValue = () => { + const values = Object.values(formRefs.current).map((form) => + form?.getFieldsValue() + ); + console.log('getFieldsValue========', values); + return { + worker_pools: values + }; + }; + + const handleOnToggle = (open: boolean, key: number) => { + console.log('Active keys changed:', key); + if (open) { + setActiveKey((prev) => new Set([key])); + } else { + setActiveKey((prev) => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + } + }; + + useImperativeHandle(ref, () => ({ + submit: () => {}, + validateFields: validateFields, + getFieldsValue: getFieldsValue + })); + + useEffect(() => { + if (currentData) { + console.log('currentData===========1=', currentData); + setFieldsValue(currentData); + } + }, [currentData]); + + return ( +
+ + Backend Versions + + } + right={ + + } + > + + {Array.from(versionConfigs.keys()).map((key, index) => ( + + + { + if (el) { + formRefs.current[key] = el; + } + }} + collapseProps={{ + collapsible: true, + open: activeKey.has(key), + defaultOpen: activeKey.has(key), + onToggle: (open: boolean) => handleOnToggle(open, key) + }} + showDelete={versionConfigs.size > 1} + onFinish={handleOnFinish} + currentData={versionConfigs.get(key)} + onDelete={() => handleRemovePool(key)} + > + + + ))} +
+ ); +}); + +export default WorkerPoolsForm; diff --git a/src/pages/llmodels/catalog.tsx b/src/pages/llmodels/catalog.tsx index 34036b88..49cdfe16 100644 --- a/src/pages/llmodels/catalog.tsx +++ b/src/pages/llmodels/catalog.tsx @@ -5,7 +5,7 @@ import BaseSelect from '@/components/seal-form/base/select'; import { PageAction } from '@/config'; import useBodyScroll from '@/hooks/use-body-scroll'; import { IS_FIRST_LOGIN, writeState } from '@/utils/localstore/index'; -import { SyncOutlined } from '@ant-design/icons'; +import { SearchOutlined, SyncOutlined } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-components'; import { useIntl, useNavigate } from '@umijs/max'; import { Button, Input, Pagination, Space, message } from 'antd'; @@ -266,6 +266,11 @@ const Catalog: React.FC = () => { left={ + } placeholder={intl.formatMessage({ id: 'common.filter.name' })} style={{ width: 230 }} size="large" diff --git a/src/pages/llmodels/components/data-form.tsx b/src/pages/llmodels/components/data-form.tsx index 426ace1b..d81cad9f 100644 --- a/src/pages/llmodels/components/data-form.tsx +++ b/src/pages/llmodels/components/data-form.tsx @@ -161,7 +161,6 @@ const DataForm: React.FC = forwardRef((props, ref) => { form={form} onFinish={handleOk} preserve={false} - style={{ padding: '16px 24px' }} clearOnDestroy={true} onValuesChange={handleOnValuesChange} scrollToFirstError={true} diff --git a/src/pages/llmodels/components/deploy-builtin-modal.tsx b/src/pages/llmodels/components/deploy-builtin-modal.tsx index d60a8fb6..5f55886b 100644 --- a/src/pages/llmodels/components/deploy-builtin-modal.tsx +++ b/src/pages/llmodels/components/deploy-builtin-modal.tsx @@ -8,6 +8,7 @@ import { Button } from 'antd'; import _ from 'lodash'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; +import ColumnWrapper from '../../_components/column-wrapper'; import { queryCatalogItemSpec } from '../apis'; import { defaultFormValues, @@ -21,7 +22,6 @@ import { CatalogSpec, FormData, ListItem, SourceType } from '../config/types'; import { generateGPUIds } from '../config/utils'; import { useCheckCompatibility } from '../hooks'; import useFormInitialValues from '../hooks/use-form-initial-values'; -import ColumnWrapper from './column-wrapper'; import CompatibilityAlert from './compatible-alert'; import DataForm from './data-form'; diff --git a/src/pages/llmodels/components/deploy-modal.tsx b/src/pages/llmodels/components/deploy-modal.tsx index 31ded897..b185a234 100644 --- a/src/pages/llmodels/components/deploy-modal.tsx +++ b/src/pages/llmodels/components/deploy-modal.tsx @@ -7,6 +7,7 @@ import { Button } from 'antd'; import _ from 'lodash'; import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; +import ColumnWrapper from '../../_components/column-wrapper'; import { defaultFormValues, deployFormKeyMap, modelSourceMap } from '../config'; import { backendOptionsMap } from '../config/backend-parameters'; import { BackendOption, FormData, SourceType } from '../config/types'; @@ -17,7 +18,6 @@ import { useSelectModel } from '../hooks'; import useCheckBackend from '../hooks/use-check-backend'; -import ColumnWrapper from './column-wrapper'; import CompatibilityAlert from './compatible-alert'; import DataForm from './data-form'; import GGUFResult from './gguf-result'; diff --git a/src/pages/llmodels/components/table-list.tsx b/src/pages/llmodels/components/table-list.tsx index 4c964db3..034fc77b 100644 --- a/src/pages/llmodels/components/table-list.tsx +++ b/src/pages/llmodels/components/table-list.tsx @@ -13,7 +13,7 @@ import useTableRowSelection from '@/hooks/use-table-row-selection'; import useTableSort from '@/hooks/use-table-sort'; import { ListItem as WorkerListItem } from '@/pages/resources/config/types'; import { handleBatchRequest } from '@/utils'; -import { DownOutlined, SyncOutlined } from '@ant-design/icons'; +import { DownOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-components'; import { useIntl, useNavigate } from '@umijs/max'; import { useMemoizedFn } from 'ahooks'; @@ -579,6 +579,11 @@ const Models: React.FC = ({ left={ + } placeholder={intl.formatMessage({ id: 'common.filter.name' })} style={{ width: 160 }} size="large" diff --git a/src/pages/llmodels/components/update-modal.tsx b/src/pages/llmodels/components/update-modal.tsx index bbd926ba..b8dbc604 100644 --- a/src/pages/llmodels/components/update-modal.tsx +++ b/src/pages/llmodels/components/update-modal.tsx @@ -4,6 +4,7 @@ import { useIntl } from '@umijs/max'; import { Button, Modal } from 'antd'; import _ from 'lodash'; import React, { useEffect, useMemo, useRef } from 'react'; +import ColumnWrapper from '../../_components/column-wrapper'; import { deployFormKeyMap, modelSourceMap, @@ -14,7 +15,6 @@ import { backendOptionsMap } from '../config/backend-parameters'; import { BackendOption, FormData } from '../config/types'; import { generateGPUSelector } from '../config/utils'; import { useCheckCompatibility } from '../hooks'; -import ColumnWrapper from './column-wrapper'; import CompatibilityAlert from './compatible-alert'; import DataForm from './data-form'; diff --git a/src/pages/llmodels/download/index.tsx b/src/pages/llmodels/download/index.tsx index 33aa1517..91a2c7be 100644 --- a/src/pages/llmodels/download/index.tsx +++ b/src/pages/llmodels/download/index.tsx @@ -4,7 +4,7 @@ import { ProviderValueMap } from '@/pages/cluster-management/config'; import { useIntl } from '@umijs/max'; import { debounce } from 'lodash'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import ColumnWrapper from '../components/column-wrapper'; +import ColumnWrapper from '../../_components/column-wrapper'; import CompatibilityAlert from '../components/compatible-alert'; import ModelCard from '../components/model-card'; import SearchModel from '../components/search-model'; diff --git a/src/pages/llmodels/download/target-form.tsx b/src/pages/llmodels/download/target-form.tsx index fda2afde..85992761 100644 --- a/src/pages/llmodels/download/target-form.tsx +++ b/src/pages/llmodels/download/target-form.tsx @@ -87,7 +87,6 @@ const TargetForm: React.FC = forwardRef((props, ref) => { form={form} onFinish={handleOk} preserve={false} - style={{ padding: '16px 24px' }} clearOnDestroy={true} initialValues={{ source: source diff --git a/src/pages/playground/components/view-common-code.tsx b/src/pages/playground/components/view-common-code.tsx index 7a325574..17019b4c 100644 --- a/src/pages/playground/components/view-common-code.tsx +++ b/src/pages/playground/components/view-common-code.tsx @@ -1,9 +1,8 @@ -import EditorWrap from '@/components/editor-wrap'; -import HighlightCode from '@/components/highlight-code'; import { BulbOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; import { Button, Modal } from 'antd'; -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; +import CommandViewer from '../../_components/command-viewer'; type ViewModalProps = { title?: string; @@ -32,30 +31,30 @@ const ViewCodeModal: React.FC = (props) => { const { title, open, onCancel, viewCodeContent } = props || {}; const intl = useIntl(); - const [lang, setLang] = useState(langMap.shell); - - const codeValue = useMemo(() => { - if (lang === langMap.shell) { - return viewCodeContent?.curlCode; - } - if (lang === langMap.javascript) { - return viewCodeContent?.nodeJsCode; - } - if (lang === langMap.python) { - return viewCodeContent?.pythonCode; - } - return ''; - }, [lang, viewCodeContent]); + const [codeValue, setCodeValue] = useState(viewCodeContent?.curlCode); const handleOnChangeLang = (value: string | number) => { - setLang(value as string); + if (value === langMap.shell) { + setCodeValue(viewCodeContent?.curlCode); + } + if (value === langMap.javascript) { + setCodeValue(viewCodeContent?.nodeJsCode); + } + if (value === langMap.python) { + setCodeValue(viewCodeContent?.pythonCode); + } }; const handleClose = () => { - setLang(langMap.shell); onCancel(); }; + useEffect(() => { + if (open) { + setCodeValue(viewCodeContent?.curlCode); + } + }, [open]); + return ( <> = (props) => { {intl.formatMessage({ id: 'playground.viewcode.info' })}
- - - + onChange={handleOnChangeLang} + >
@@ -128,4 +114,4 @@ const ViewCodeModal: React.FC = (props) => { ); }; -export default React.memo(ViewCodeModal); +export default ViewCodeModal; diff --git a/src/pages/resources/components/container-install.tsx b/src/pages/resources/components/container-install.tsx index 12d93d75..e822ea47 100644 --- a/src/pages/resources/components/container-install.tsx +++ b/src/pages/resources/components/container-install.tsx @@ -1,7 +1,7 @@ import { GPUStackVersionAtom } from '@/atoms/user'; import { getAtomStorage } from '@/atoms/utils'; -import EditorWrap from '@/components/editor-wrap'; import HighlightCode from '@/components/highlight-code'; +import CommandViewer from '@/pages/_components/command-viewer'; import { useIntl } from '@umijs/max'; import { Radio } from 'antd'; import React from 'react'; @@ -98,27 +98,15 @@ const AddWorker: React.FC = (props) => { >
)} {activeKey === 'npu' ? ( - - - + onChange={handleOnChange} + lang="bash" + code={code.replace(/\\/g, '')} + > ) : (