diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 7293969493d..c51f381f47c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -482,6 +482,9 @@ importers: providers/aiproxy: dependencies: + '@hookform/resolvers': + specifier: ^3.9.0 + version: 3.9.0(react-hook-form@7.48.2) '@sealos/ui': specifier: workspace:* version: link:../../packages/ui @@ -491,6 +494,9 @@ importers: '@tanstack/react-table': specifier: ^8.10.7 version: 8.10.7(react-dom@18.2.0)(react@18.2.0) + '@types/pg': + specifier: ^8.11.10 + version: 8.11.10 accept-language: specifier: ^3.0.20 version: 3.0.20 @@ -500,6 +506,12 @@ importers: date-fns: specifier: ^2.30.0 version: 2.30.0 + downshift: + specifier: ^9.0.8 + version: 9.0.8(react@18.2.0) + echarts: + specifier: ^5.4.3 + version: 5.4.3 i18next: specifier: ^23.11.5 version: 23.12.1 @@ -521,6 +533,9 @@ importers: next: specifier: 14.2.5 version: 14.2.5(@babel/core@7.23.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.5) + pg: + specifier: ^8.13.1 + version: 8.13.1 react: specifier: ^18 version: 18.2.0 @@ -533,9 +548,15 @@ importers: react-hook-form: specifier: ^7.46.2 version: 7.48.2(react@18.2.0) + react-json-view: + specifier: ^1.21.3 + version: 1.21.3(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) sealos-desktop-sdk: specifier: workspace:* version: link:../../packages/client-sdk + zod: + specifier: ^3.23.8 + version: 3.23.8 zustand: specifier: ^4.5.4 version: 4.5.4(@types/react@18.2.37)(immer@10.1.1)(react@18.2.0) @@ -5529,6 +5550,13 @@ packages: dependencies: regenerator-runtime: 0.14.0 + /@babel/runtime@7.26.0: + resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.0 + dev: false + /@babel/template@7.22.15: resolution: {integrity: sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==} engines: {node: '>=6.9.0'} @@ -10427,6 +10455,14 @@ packages: resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} dev: false + /@types/pg@8.11.10: + resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} + dependencies: + '@types/node': 20.10.0 + pg-protocol: 1.7.0 + pg-types: 4.0.2 + dev: false + /@types/pluralize@0.0.33: resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==} dev: true @@ -11779,6 +11815,10 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /base16@1.0.0: + resolution: {integrity: sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ==} + dev: false + /base64-arraybuffer@0.1.2: resolution: {integrity: sha512-ewBKKVVPIl78B26mYQHYlaxR7NydMiD/GxwLNIwTAfLIE4xhN2Gxcy30//azq5UrejXjzGpWjcBu3NUJxzMMzg==} engines: {node: '>= 0.6.0'} @@ -12425,6 +12465,10 @@ packages: resolution: {integrity: sha512-nadqwNxghAGTamwIqQSG433W6OADZx2vCo3UXHNrzTRHK/htu+7+L0zhjEoaeaQVNAi3YgqWDv8+tzf0hRfR+A==} dev: false + /compute-scroll-into-view@3.1.0: + resolution: {integrity: sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==} + dev: false + /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -12590,6 +12634,14 @@ packages: hasBin: true dev: false + /cross-fetch@3.1.8: + resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dev: false + /cross-fetch@4.0.0: resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} dependencies: @@ -13276,6 +13328,19 @@ packages: minimatch: 3.1.2 dev: false + /downshift@9.0.8(react@18.2.0): + resolution: {integrity: sha512-59BWD7+hSUQIM1DeNPLirNNnZIO9qMdIK5GQ/Uo8q34gT4B78RBlb9dhzgnh0HfQTJj4T/JKYD8KoLAlMWnTsA==} + peerDependencies: + react: '>=16.12.0' + dependencies: + '@babel/runtime': 7.26.0 + compute-scroll-into-view: 3.1.0 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 18.2.0 + tslib: 2.6.2 + dev: false + /drange@1.1.1: resolution: {integrity: sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==} engines: {node: '>=4'} @@ -15439,6 +15504,32 @@ packages: bser: 2.1.1 dev: true + /fbemitter@3.0.0: + resolution: {integrity: sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==} + dependencies: + fbjs: 3.0.5 + transitivePeerDependencies: + - encoding + dev: false + + /fbjs-css-vars@1.0.2: + resolution: {integrity: sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==} + dev: false + + /fbjs@3.0.5: + resolution: {integrity: sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==} + dependencies: + cross-fetch: 3.1.8 + fbjs-css-vars: 1.0.2 + loose-envify: 1.4.0 + object-assign: 4.1.1 + promise: 7.3.1 + setimmediate: 1.0.5 + ua-parser-js: 1.0.39 + transitivePeerDependencies: + - encoding + dev: false + /fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} dev: false @@ -15541,6 +15632,18 @@ packages: /flatted@3.2.9: resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} + /flux@4.0.4(react@18.2.0): + resolution: {integrity: sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==} + peerDependencies: + react: ^15.0.2 || ^16.0.0 || ^17.0.0 + dependencies: + fbemitter: 3.0.0 + fbjs: 3.0.5 + react: 18.2.0 + transitivePeerDependencies: + - encoding + dev: false + /fmin@0.0.2: resolution: {integrity: sha512-sSi6DzInhl9d8yqssDfGZejChO8d2bAGIpysPsvYsxFe898z89XhCZg6CPNV3nhUhFefeC/AXZK2bAJxlBjN6A==} dependencies: @@ -18002,9 +18105,17 @@ packages: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} dev: false + /lodash.curry@4.1.1: + resolution: {integrity: sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA==} + dev: false + /lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + /lodash.flow@3.5.0: + resolution: {integrity: sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==} + dev: false + /lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} dev: false @@ -19748,6 +19859,10 @@ packages: es-object-atoms: 1.0.0 dev: true + /obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + dev: false + /octokit@3.1.2: resolution: {integrity: sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==} engines: {node: '>= 18'} @@ -19984,6 +20099,86 @@ packages: /performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + /pg-cloudflare@1.1.1: + resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==} + requiresBuild: true + dev: false + optional: true + + /pg-connection-string@2.7.0: + resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==} + dev: false + + /pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + dev: false + + /pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + dev: false + + /pg-pool@3.7.0(pg@8.13.1): + resolution: {integrity: sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==} + peerDependencies: + pg: '>=8.0' + dependencies: + pg: 8.13.1 + dev: false + + /pg-protocol@1.7.0: + resolution: {integrity: sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==} + dev: false + + /pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + dev: false + + /pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + dev: false + + /pg@8.13.1: + resolution: {integrity: sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + dependencies: + pg-connection-string: 2.7.0 + pg-pool: 3.7.0(pg@8.13.1) + pg-protocol: 1.7.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.1.1 + dev: false + + /pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + dependencies: + split2: 4.2.0 + dev: false + /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} @@ -20164,6 +20359,54 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 + /postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + dev: false + + /postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + dev: false + + /postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + dependencies: + obuf: 1.1.2 + dev: false + + /postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + dev: false + + /postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + dev: false + + /postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + dependencies: + xtend: 4.0.2 + dev: false + + /postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + dev: false + + /postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + dev: false + /potpack@1.0.2: resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} dev: false @@ -20255,6 +20498,12 @@ packages: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} dev: false + /promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + dependencies: + asap: 2.0.6 + dev: false + /prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -20309,6 +20558,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + /pure-color@1.3.0: + resolution: {integrity: sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA==} + dev: false + /pure-rand@6.0.4: resolution: {integrity: sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==} dev: true @@ -20974,6 +21227,15 @@ packages: - prop-types dev: false + /react-base16-styling@0.6.0: + resolution: {integrity: sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==} + dependencies: + base16: 1.0.0 + lodash.curry: 4.1.1 + lodash.flow: 3.5.0 + pure-color: 1.3.0 + dev: false + /react-clientside-effect@1.2.6(react@18.2.0): resolution: {integrity: sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==} peerDependencies: @@ -21148,6 +21410,23 @@ packages: /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + /react-json-view@1.21.3(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==} + peerDependencies: + react: ^17.0.0 || ^16.3.0 || ^15.5.4 + react-dom: ^17.0.0 || ^16.3.0 || ^15.5.4 + dependencies: + flux: 4.0.4(react@18.2.0) + react: 18.2.0 + react-base16-styling: 0.6.0 + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + react-textarea-autosize: 8.5.6(@types/react@18.2.37)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - encoding + dev: false + /react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} dev: false @@ -21265,6 +21544,20 @@ packages: refractor: 3.6.0 dev: false + /react-textarea-autosize@8.5.6(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-aT3ioKXMa8f6zHYGebhbdMD2L00tKeRX1zuVuDx9YQK/JLLRSaSxq3ugECEmUB9z2kvk6bFSIoRHLkkUv0RJiw==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + '@babel/runtime': 7.26.0 + react: 18.2.0 + use-composed-ref: 1.4.0(@types/react@18.2.37)(react@18.2.0) + use-latest: 1.3.0(@types/react@18.2.37)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + dev: false + /react-universal-interface@0.6.2(react@18.2.0)(tslib@2.6.2): resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==} peerDependencies: @@ -22348,6 +22641,11 @@ packages: extend-shallow: 3.0.2 dev: false + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true @@ -23382,6 +23680,11 @@ packages: resolution: {integrity: sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==} dev: false + /ua-parser-js@1.0.39: + resolution: {integrity: sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==} + hasBin: true + dev: false + /uglify-js@2.8.29: resolution: {integrity: sha512-qLq/4y2pjcU3vhlhseXGGJ7VbFO4pBANu0kwl8VCa9KEI0V8VfZIx2Fy3w01iSTA/pGwKZSmu/+I4etLNDdt5w==} engines: {node: '>=0.8.0'} @@ -23596,6 +23899,19 @@ packages: tslib: 2.6.2 dev: false + /use-composed-ref@1.4.0(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + react: 18.2.0 + dev: false + /use-intl@3.17.2(react@18.2.0): resolution: {integrity: sha512-9lPgt41nS8x4AYCLfIC9VKCmamnVxzPM2nze7lpp/I1uaSSQvIz5MQpYUFikv08cMUsCwAWahU0e+arHInpdcw==} peerDependencies: @@ -23606,6 +23922,33 @@ packages: react: 18.2.0 dev: false + /use-isomorphic-layout-effect@1.2.0(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + react: 18.2.0 + dev: false + + /use-latest@1.3.0(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + react: 18.2.0 + use-isomorphic-layout-effect: 1.2.0(@types/react@18.2.37)(react@18.2.0) + dev: false + /use-sidecar@1.1.2(@types/react@18.2.37)(react@18.2.0): resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'} diff --git a/frontend/providers/aiproxy/api/platform.ts b/frontend/providers/aiproxy/api/platform.ts index 2f83f5ecd23..dd2b9af8229 100644 --- a/frontend/providers/aiproxy/api/platform.ts +++ b/frontend/providers/aiproxy/api/platform.ts @@ -1,25 +1,120 @@ -import { KeysSearchResponse } from '@/app/api/get-keys/route' -import { QueryParams, SearchResponse } from '@/app/api/get-logs/route' -import { QueryParams as KeysQueryParams } from '@/app/api/get-keys/route' -import { GET, POST, DELETE } from '@/utils/request' -import { ModelPrice } from '@/types/backend' +import { GET, POST, DELETE, PUT } from '@/utils/frontend/request' +import { ChannelQueryParams, GetChannelsResponse } from '@/app/api/admin/channel/route' +import { ChannelStatus, CreateChannelRequest } from '@/types/admin/channels/channelInfo' +import { ApiResp } from '@/types/api' +import { GetOptionResponse } from '@/app/api/admin/option/route' +import { BatchOptionData } from '@/types/admin/option' +import { GetEnabledModelsResponse } from '@/app/api/models/enabled/route' +import { GetTokensQueryParams, GetTokensResponse } from '@/app/api/user/token/route' +import { TokenInfo } from '@/types/user/token' +import { UserLogSearchResponse } from '@/app/api/user/log/route' +import { UserLogQueryParams } from '@/app/api/user/log/route' +import { GlobalLogQueryParams, GlobalLogSearchResponse } from '@/app/api/admin/log/route' +import { GetAllChannelEnabledModelsResponse } from '@/app/api/models/builtin/channel/route' +import { GetDefaultModelAndModeMappingResponse } from '@/app/api/models/default/route' +import { GetChannelTypeNamesResponse } from '@/app/api/admin/channel/type-name/route' +import { GroupQueryParams, GroupStatus } from '@/types/admin/group' +import { GroupSearchResponse } from '@/app/api/admin/group/route' +import { GetAllChannelResponse } from '@/app/api/admin/channel/all/route' +import { DashboardQueryParams } from '@/app/api/user/dashboard/route' +import { DashboardResponse } from '@/types/user/dashboard' +import { UserLogDetailResponse } from '@/app/api/user/log/detail/[log_id]/route' export const initAppConfig = () => GET<{ aiproxyBackend: string; currencySymbol: 'shellCoin' | 'cny' | 'usd' }>( '/api/init-app-config' ) -export const getModels = () => GET('/api/get-models') +// +export const getEnabledMode = () => GET('/api/models/enabled') -export const getModelPrices = () => GET('/api/get-mode-price') +// log +export const getUserLogs = (params: UserLogQueryParams) => + GET('/api/user/log', params) -export const getLogs = (params: QueryParams) => GET('/api/get-logs', params) +export const getUserLogDetail = (log_id: number) => + GET(`/api/user/log/detail/${log_id}`) -export const getKeys = (params: KeysQueryParams) => - GET('/api/get-keys', params) +// token +export const getTokens = (params: GetTokensQueryParams) => + GET('/api/user/token', params) -export const createKey = (name: string) => POST('/api/create-key', { name }) +export const createToken = (name: string) => + POST['data']>('/api/user/token', { name }) -export const deleteKey = (id: number) => DELETE(`/api/delete-key/${id}`) +export const deleteToken = (id: number) => DELETE(`/api/user/token/${id}`) -export const updateKey = (id: number, status: number) => POST(`/api/update-key/${id}`, { status }) +export const updateToken = (id: number, status: number) => + POST(`/api/user/token/${id}`, { status: status }) + +// dashboard +export const getDashboardData = (params: DashboardQueryParams) => + GET('/api/user/dashboard', params) +// ------------------------------------------------------------ +// + +export const getChannels = (params: ChannelQueryParams) => + GET('/api/admin/channel', params) + +// channel +export const createChannel = (params: CreateChannelRequest) => + POST('/api/admin/channel', params) + +export const updateChannel = (params: CreateChannelRequest, id: string) => + PUT(`/api/admin/channel/${id}`, params) + +export const updateChannelStatus = (id: string, status: ChannelStatus) => + POST(`/api/admin/channel/${id}/status`, { status }) + +export const getChannelTypeNames = () => + GET('/api/admin/channel/type-name') + +export const getAllChannels = () => GET('/api/admin/channel/all') + +export const deleteChannel = (id: string) => DELETE(`/api/admin/channel/${id}`) + +export const uploadChannels = (formData: FormData) => + POST('/api/admin/channel/upload', formData, { + headers: { + // Don't set Content-Type header here, it will be automatically set with the correct boundary + } + }) + +// channel built-in support models and default model default mode mapping +export const getChannelBuiltInSupportModels = () => + GET('/api/models/builtin/channel') + +export const getChannelDefaultModelAndDefaultModeMapping = () => + GET('/api/models/default') + +// option +export const getOption = () => GET('/api/admin/option') + +export const updateOption = (params: { key: string; value: string }) => + PUT(`/api/admin/option/`, params) + +export const batchOption = (params: BatchOptionData) => + PUT(`/api/admin/option/batch`, params) + +export const uploadOptions = (formData: FormData) => + POST('/api/admin/option/upload', formData, { + headers: { + // Don't set Content-Type header here, it will be automatically set with the correct boundary + } + }) + +// log +export const getGlobalLogs = (params: GlobalLogQueryParams) => + GET('/api/admin/log', params) + +// group +export const getGroups = (params: GroupQueryParams) => + GET('/api/admin/group', params) + +export const updateGroupStatus = (id: string, status: GroupStatus) => + POST(`/api/admin/group/${id}/status`, { status }) + +export const updateGroupQpm = (id: string, qpm: number) => + POST(`/api/admin/group/${id}/qpm`, { qpm }) + +export const deleteGroup = (id: string) => DELETE(`/api/admin/group/${id}`) diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx new file mode 100644 index 00000000000..db14c632bb8 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/ChannelTable.tsx @@ -0,0 +1,750 @@ +'use client' +import { + Checkbox, + Box, + Flex, + Table, + TableContainer, + Tbody, + Td, + Text, + Th, + Thead, + Tr, + Menu, + MenuButton, + MenuList, + MenuItem, + useDisclosure, + Spinner +} from '@chakra-ui/react' +import { + Column, + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable +} from '@tanstack/react-table' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { ChannelInfo, ChannelStatus, ChannelType } from '@/types/admin/channels/channelInfo' +import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' +import { useState, useMemo, useEffect } from 'react' +import { + deleteChannel, + getChannels, + getChannelTypeNames, + updateChannelStatus +} from '@/api/platform' +import SwitchPage from '@/components/common/SwitchPage' +import UpdateChannelModal from './UpdateChannelModal' +import { useMessage } from '@sealos/ui' +import { QueryKey } from '@/types/query-key' +import { downloadJson } from '@/utils/common' + +export default function ChannelTable({ + exportData +}: { + exportData: (data: ChannelInfo[]) => void +}) { + const { isOpen, onOpen, onClose } = useDisclosure() + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const [operationType, setOperationType] = useState<'create' | 'update'>('update') + const [channelInfo, setChannelInfo] = useState(undefined) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + + const [selectedChannels, setSelectedChannels] = useState([]) + + useEffect(() => { + exportData(selectedChannels) + }, [selectedChannels]) + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const queryClient = useQueryClient() + + const { isLoading: isChannelTypeNamesLoading, data: channelTypeNames } = useQuery({ + queryKey: [QueryKey.GetChannelTypeNames], + queryFn: () => getChannelTypeNames() + }) + + const { data, isLoading: isChannelsLoading } = useQuery({ + queryKey: [QueryKey.GetChannels, page, pageSize], + queryFn: () => getChannels({ page, perPage: pageSize }), + refetchOnReconnect: true, + onSuccess(data) { + setTotal(data?.total || 0) + } + }) + + const updateChannelStatusMutation = useMutation( + ({ id, status }: { id: string; status: number }) => updateChannelStatus(id, status), + { + onSuccess() { + message({ + status: 'success', + title: t('channel.updateSuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + queryClient.invalidateQueries([QueryKey.GetChannels]) + queryClient.invalidateQueries([QueryKey.GetChannelTypeNames]) + }, + onError(err: any) { + message({ + status: 'warning', + title: t('channel.updateFailed'), + description: err?.message || t('channel.updateFailed'), + isClosable: true, + position: 'top' + }) + } + } + ) + const deleteChannelMutation = useMutation(({ id }: { id: string }) => deleteChannel(id), { + onSuccess() { + message({ + status: 'success', + title: t('channel.deleteSuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + queryClient.invalidateQueries([QueryKey.GetChannels]) + queryClient.invalidateQueries([QueryKey.GetChannelTypeNames]) + }, + onError(err: any) { + message({ + status: 'warning', + title: t('channel.deleteFailed'), + description: err?.message || t('channel.deleteFailed'), + isClosable: true, + position: 'top' + }) + } + }) + + // Update the button click handlers in the table actions column: + const handleStatusUpdate = (id: string, currentStatus: number) => { + const newStatus = + currentStatus === ChannelStatus.ChannelStatusDisabled + ? ChannelStatus.ChannelStatusEnabled + : ChannelStatus.ChannelStatusDisabled + updateChannelStatusMutation.mutate({ id, status: newStatus }) + } + + const columnHelper = createColumnHelper() + + const handleHeaderCheckboxChange = (isChecked: boolean) => { + if (isChecked) { + setSelectedChannels(data?.channels || []) + } else { + setSelectedChannels([]) + } + } + + const handleRowCheckboxChange = (channel: ChannelInfo, isChecked: boolean) => { + if (isChecked) { + setSelectedChannels([...selectedChannels, channel]) + } else { + setSelectedChannels(selectedChannels.filter((c) => c.id !== channel.id)) + } + } + + const handleExportRow = (channel: ChannelInfo) => { + const channels = [channel] + const filename = `channel_${channels[0].id}_${new Date().toISOString()}.json` + downloadJson(channels, filename) + } + + const columns = [ + columnHelper.accessor((row) => row.id, { + id: 'id', + header: () => ( + + 0 && + selectedChannels.length === data.channels.length + } + onChange={(e) => handleHeaderCheckboxChange(e.target.checked)} + sx={{ + '.chakra-checkbox__control': { + width: '16px', + height: '16px', + border: '1px solid', + borderColor: 'grayModern.300', + background: 'grayModern.100', + transition: 'all 0.2s ease', + _checked: { + background: 'grayModern.500', + borderColor: 'grayModern.500' + } + } + }} + /> + + {t('channels.id')} + + + ), + cell: (info) => ( + + c.id === info.getValue())} + onChange={(e) => handleRowCheckboxChange(info.row.original, e.target.checked)} + sx={{ + '.chakra-checkbox__control': { + width: '16px', + height: '16px', + border: '1px solid', + borderColor: 'grayModern.300', + background: 'white', + _checked: { + background: 'grayModern.500', + borderColor: 'grayModern.500' + } + } + }} + /> + + {info.getValue()} + + + ) + }), + columnHelper.accessor((row) => row.name, { + id: 'name', + header: () => ( + + {t('channels.name')} + + ), + cell: (info) => ( + + {info.getValue()} + + ) + }), + columnHelper.accessor((row) => row.type, { + id: 'type', + header: () => ( + + {t('channels.type')} + + ), + cell: (info) => ( + + {channelTypeNames?.[String(info.getValue()) as ChannelType]} + + ) + }), + columnHelper.accessor((row) => row.request_count, { + id: 'request_count', + header: () => ( + + {t('channels.requestCount')} + + ), + cell: (info) => ( + + {info.getValue()} + + ) + }), + columnHelper.accessor((row) => row.status, { + id: 'status', + header: () => ( + + {t('channels.status')} + + ), + cell: (info) => { + const status = info.getValue() + let statusText = '' + let statusColor = '' + + switch (status) { + case ChannelStatus.ChannelStatusEnabled: + statusText = t('keystatus.enabled') + statusColor = 'green.600' + break + case ChannelStatus.ChannelStatusDisabled: + statusText = t('keystatus.disabled') + statusColor = 'red.600' + break + case ChannelStatus.ChannelStatusAutoDisabled: + statusText = t('channelStatus.autoDisabled') + statusColor = 'orange.500' + break + default: + statusText = t('keystatus.unknown') + statusColor = 'gray.500' + } + + return ( + + {statusText} + + ) + } + }), + + columnHelper.display({ + id: 'actions', + header: () => ( + + {t('channels.action')} + + ), + cell: (info) => ( + + + + + + + + {/* console.log('Export', info.row.original.id)}> + + + + + + + + + {t('channels.test')} + + */} + + handleStatusUpdate(String(info.row.original.id), info.row.original.status) + }> + {info.row.original.status === ChannelStatus.ChannelStatusEnabled ? ( + <> + + + + + {t('channels.disable')} + + + ) : ( + <> + + + + + {t('channels.enable')} + + + )} + + { + setOperationType('update') + setChannelInfo(info.row.original) + onOpen() + }}> + + + + + + + {t('channels.edit')} + + + handleExportRow(info.row.original)}> + + + + + {t('channels.export')} + + + + deleteChannelMutation.mutate({ id: String(info.row.original.id) })}> + + + + + {t('channels.delete')} + + + + + ) + }) + ] + + const tableData = useMemo(() => data?.channels || [], [data]) + + const table = useReactTable({ + data: tableData, + columns, + getCoreRowModel: getCoreRowModel() + }) + + return ( + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header, i) => ( + + ))} + + ))} + + + {isChannelTypeNamesLoading || isChannelsLoading ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ setPage(idx)} + /> + +
+ ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx new file mode 100644 index 00000000000..7130c07e89d --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/components/UpdateChannelModal.tsx @@ -0,0 +1,829 @@ +'use client' +import { + Button, + Flex, + Text, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + FormControl, + Input, + FormErrorMessage, + ModalFooter, + FormLabel, + VStack, + Center, + Spinner, + Badge +} from '@chakra-ui/react' +import { useMessage } from '@sealos/ui' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { ChannelInfo, ChannelStatus, ChannelType } from '@/types/admin/channels/channelInfo' +import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' +import { Dispatch, SetStateAction, useEffect } from 'react' +import { + getChannelBuiltInSupportModels, + getChannelDefaultModelAndDefaultModeMapping, + getChannelTypeNames +} from '@/api/platform' +import { FieldErrors, useForm, Controller } from 'react-hook-form' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { MultiSelectCombobox } from '@/components/common/MultiSelectCombobox' +import { SingleSelectCombobox } from '@/components/common/SingleSelectCombobox' +import ConstructModeMappingComponent from '@/components/common/ConstructModeMappingComponent' +import { createChannel, updateChannel } from '@/api/platform' +import { QueryKey } from '@/types/query-key' + +type Model = { + name: string + isDefault: boolean +} + +export const UpdateChannelModal = function ({ + isOpen, + onClose, + operationType, + channelInfo +}: { + isOpen: boolean + onClose: () => void + operationType: 'create' | 'update' + channelInfo?: ChannelInfo +}): JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const queryClient = useQueryClient() + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const { isLoading: isChannelTypeNamesLoading, data: channelTypeNames } = useQuery({ + queryKey: [QueryKey.GetChannelTypeNames], + queryFn: () => getChannelTypeNames() + }) + + const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ + queryKey: [QueryKey.GetAllChannelModes], + queryFn: () => getChannelBuiltInSupportModels() + }) + + const { isLoading: isDefaultEnabledModelsLoading, data: defaultEnabledModels } = useQuery({ + queryKey: [QueryKey.GetDefaultModelAndModeMapping], + queryFn: () => getChannelDefaultModelAndDefaultModeMapping() + }) + + // model type select combobox + const handleModelTypeDropdownItemFilter = (dropdownItems: string[], inputValue: string) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) + ) + } + + const handleModelTypeDropdownItemDisplay = (dropdownItem: string) => { + return ( + + {dropdownItem} + + ) + } + + // model select combobox + const handleModelFilteredDropdownItems = ( + dropdownItems: Model[], + selectedItems: Model[], + inputValue: string + ) => { + const lowerCasedInputValue = inputValue.toLowerCase() + + return dropdownItems.filter( + (item) => + !selectedItems.includes(item) && item.name.toLowerCase().includes(lowerCasedInputValue) + ) + } + + const handleModelDropdownItemDisplay = (dropdownItem: Model) => { + if (dropdownItem.isDefault) { + return ( + + + {dropdownItem.name} + + + + + + + + + + {t('channels.modelDefault')} + + + + ) + } + return ( + + {dropdownItem.name} + + ) + } + + const handleModelSelectedItemDisplay = (selectedItem: Model) => { + if (selectedItem.isDefault) { + return ( + + + {selectedItem.name} + + + + + + + + + + + ) + } + return ( + + {selectedItem.name} + + ) + } + + const handleSetCustomModel = ( + selectedItems: Model[], + setSelectedItems: Dispatch>, + customModeName: string, + setCustomModeName: Dispatch> + ) => { + if (customModeName.trim()) { + const newModel: Model = { + name: customModeName.trim(), + isDefault: false + } + + const exists = selectedItems.some((item) => item.name === customModeName.trim()) + + if (!exists) { + setSelectedItems([...selectedItems, newModel]) + setCustomModeName('') + } + } + } + + // form schema + const schema = z.object({ + id: z.number().optional(), + type: z.number(), + name: z.string().min(1, { message: t('channels.name_required') }), + key: z.string().min(1, { message: t('channels.key_required') }), + base_url: z.string(), + models: z.array(z.string()).default([]), + model_mapping: z.record(z.string(), z.any()).default({}) + }) + + const id = channelInfo?.id + type FormData = z.infer + + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { errors }, + control + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + id: id, + type: undefined, + name: '', + key: '', + base_url: '', + models: [], + model_mapping: {} + }, + mode: 'onChange', + reValidateMode: 'onChange' + }) + + useEffect(() => { + if (channelInfo) { + const { id, type, name, key, base_url, models, model_mapping } = channelInfo + reset({ id, type, name, key, base_url, models, model_mapping }) + } + }, [channelInfo]) + + const resetModalState = () => { + reset() + } + + const createChannelMutation = useMutation({ + mutationFn: createChannel, + onSuccess: () => { + message({ + title: t('channels.createSuccess'), + status: 'success' + }) + } + }) + + const updateChannelMutation = useMutation({ + mutationFn: (data: FormData) => + updateChannel( + { + type: data.type, + name: data.name, + key: data.key, + base_url: data.base_url, + models: data.models, + model_mapping: data.model_mapping + }, + data.id!.toString() + ), + onSuccess: () => { + message({ + title: t('channels.updateSuccess'), + status: 'success' + }) + } + }) + + const onValidate = async (data: FormData) => { + try { + switch (operationType) { + case 'create': + await createChannelMutation.mutateAsync({ + type: data.type, + name: data.name, + key: data.key, + base_url: data.base_url, + models: data.models, + model_mapping: data.model_mapping + }) + break + case 'update': + await updateChannelMutation.mutateAsync(data) + break + } + queryClient.invalidateQueries({ queryKey: [QueryKey.GetChannels] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetDefaultModelAndModeMapping] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetChannelTypeNames] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetAllChannelModes] }) + resetModalState() + onClose() + } catch (error) { + switch (operationType) { + case 'create': + message({ + title: t('channels.createFailed'), + status: 'error', + position: 'top', + duration: 2000, + isClosable: true, + description: error instanceof Error ? error.message : t('channels.createFailed') + }) + break + case 'update': + message({ + title: t('channels.updateFailed'), + status: 'error', + position: 'top', + duration: 2000, + isClosable: true, + description: error instanceof Error ? error.message : t('channels.updateFailed') + }) + break + } + } + } + + const onInvalid = (errors: FieldErrors): void => { + const firstErrorMessage = Object.values(errors)[0]?.message + if (firstErrorMessage) { + message({ + title: firstErrorMessage as string, + status: 'error', + position: 'top', + duration: 2000, + isClosable: true + }) + } + } + + const onSubmit = handleSubmit(onValidate, onInvalid) + + return ( + + {isOpen && + (isBuiltInSupportModelsLoading || + isDefaultEnabledModelsLoading || + isChannelTypeNamesLoading || + !builtInSupportModels || + !defaultEnabledModels || + !channelTypeNames ? ( + <> + + + + + + {operationType === 'create' ? t('channels.create') : t('channels.edit')} + + + + + +
+ +
+
+
+ + ) : ( + <> + + + {/* header */} + + + + {operationType === 'create' ? t('channels.create') : t('channels.edit')} + + + + + {/* body */} + + + + + + {t('channelsForm.name')} + + + + {errors.name && {errors.name.message}} + + + + + { + const availableChannels = Object.entries(channelTypeNames) + .filter(([channel]) => channel in builtInSupportModels) + .map(([_, name]) => name) + + const initSelectedItem = field.value + ? channelTypeNames[String(field.value) as ChannelType] + : undefined + + return ( + + dropdownItems={availableChannels} + initSelectedItem={initSelectedItem} + setSelectedItem={(channelName: string) => { + if (channelName) { + const channelType = Object.entries(channelTypeNames).find( + ([_, name]) => name === channelName + )?.[0] + + if (channelType) { + const numericChannel = Number(channelType) + field.onChange(numericChannel) + setValue('models', []) + setValue('model_mapping', {}) + } + } + }} + handleDropdownItemFilter={handleModelTypeDropdownItemFilter} + handleDropdownItemDisplay={handleModelTypeDropdownItemDisplay} + /> + ) + }} + /> + {errors.type && {errors.type.message}} + + + + { + const channelType = String(watch('type')) as ChannelType + + const builtInModes = + builtInSupportModels[channelType]?.map((mode) => mode.model) || [] + const defaultModes = defaultEnabledModels.models[channelType] || [] + + const allModes: Model[] = builtInModes.map((modeName) => ({ + name: modeName, + isDefault: defaultModes.includes(modeName) + })) + + const selectedModels: Model[] = field.value.map((modeName) => ({ + name: modeName, + isDefault: defaultModes.includes(modeName) + })) + + return ( + + dropdownItems={allModes} + selectedItems={selectedModels} + setSelectedItems={(models) => { + field.onChange((models as Model[]).map((m) => m.name)) + }} + handleFilteredDropdownItems={handleModelFilteredDropdownItems} + handleDropdownItemDisplay={handleModelDropdownItemDisplay} + handleSelectedItemDisplay={handleModelSelectedItemDisplay} + handleSetCustomSelectedItem={handleSetCustomModel} + /> + ) + }} + /> + {errors.models && {errors.models.message}} + + + + { + const channelType = String(watch('type')) as ChannelType + + const selectedModels = watch('models') + const defaultModes = defaultEnabledModels.models[channelType] || [] + + const covertedSelectedModels: Model[] = selectedModels.map((modeName) => ({ + name: modeName, + isDefault: defaultModes.includes(modeName) + })) + return ( + { + field.onChange(mapping) + }} + /> + ) + }} + /> + {errors.model_mapping?.message && ( + {errors.model_mapping.message.toString()} + )} + + + + + + {t('channelsForm.key')} + + + + {errors.key && {errors.key.message}} + + + + + + + {t('channelsForm.base_url')} + + + + {errors.base_url && ( + {errors.base_url.message} + )} + + + + + + + + + + ))} +
+ ) +} + +export default UpdateChannelModal diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx index d84c050af4e..c09c4b2ca10 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/dashboard/page.tsx @@ -1,17 +1,292 @@ 'use client' -import { Flex } from '@chakra-ui/react' +import { Button, Flex, Text, useDisclosure, useToast } from '@chakra-ui/react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import ChannelTable from './components/ChannelTable' +import UpdateChannelModal from './components/UpdateChannelModal' +import { useState, useRef } from 'react' +import { ChannelInfo } from '@/types/admin/channels/channelInfo' +import { getAllChannels, uploadChannels } from '@/api/platform' +import { QueryKey } from '@/types/query-key' +import { downloadJson } from '@/utils/common' +import { useMessage } from '@sealos/ui' export default function DashboardPage() { + const { isOpen, onOpen, onClose } = useDisclosure() + const [operationType, setOperationType] = useState<'create' | 'update'>('create') + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const [exportData, setExportData] = useState([]) + const fileInputRef = useRef(null) + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const queryClient = useQueryClient() + + const { + data: allChannels, + isFetching: isAllChannelsFetching, + refetch + } = useQuery({ + queryKey: [QueryKey.GetAllChannels], + queryFn: getAllChannels, + refetchOnReconnect: false, + enabled: false + }) + + const uploadMutation = useMutation({ + mutationFn: uploadChannels + }) + + const handleExport = async () => { + if (exportData.length === 0) { + const result = await refetch() + const dataToExport = result.data || [] + downloadJson(dataToExport, 'channels') + } else { + downloadJson(exportData, 'channels') + } + } + + const handleImport = () => { + fileInputRef.current?.click() + } + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + const formData = new FormData() + formData.append('file', file) + + try { + await uploadMutation.mutateAsync(formData) + message({ + title: t('dashboard.importSuccess'), + status: 'success', + duration: 3000, + isClosable: true + }) + queryClient.invalidateQueries([QueryKey.GetChannels]) + queryClient.invalidateQueries([QueryKey.GetChannelTypeNames]) + } catch (error) { + console.error('Import error:', error) + message({ + title: t('dashboard.importError'), + status: 'error', + duration: 3000, + isClosable: true + }) + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + return ( - <> - - Dashboard + + + {/* header */} + + + {t('dashboard.title')} + + + + + <> + + + + + + + {/* header end */} + {/* table */} + + {/* modal */} + - - + ) } - -function ChannelList() { - return ChannelList -} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx new file mode 100644 index 00000000000..b80c7ead987 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/CommonConfig.tsx @@ -0,0 +1,166 @@ +'use client' +import { Button, Flex, Text, useDisclosure } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' +import { Switch } from '@chakra-ui/react' +import { EditableText } from './EditableText' +import { getOption, updateOption } from '@/api/platform' +import { useMessage } from '@sealos/ui' +import { QueryKey } from '@/types/query-key' +import { useState } from 'react' +import { produce } from 'immer' + +export enum CommonConfigKey { + GlobalApiRateLimitNum = 'GlobalApiRateLimitNum', + DisableServe = 'DisableServe', + RetryTimes = 'RetryTimes', + GroupMaxTokenNum = 'GroupMaxTokenNum' +} + +type CommonConfig = { + [CommonConfigKey.GlobalApiRateLimitNum]: string + [CommonConfigKey.DisableServe]: string + [CommonConfigKey.RetryTimes]: string + [CommonConfigKey.GroupMaxTokenNum]: string +} + +const CommonConfig = () => { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const queryClient = useQueryClient() + + const [commonConfig, setCommonConfig] = useState(() => + produce({} as CommonConfig, (draft) => { + draft.GlobalApiRateLimitNum = '' + draft.DisableServe = '' + draft.RetryTimes = '' + draft.GroupMaxTokenNum = '' + }) + ) + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const { isLoading: isOptionLoading, data: optionData } = useQuery({ + queryKey: [QueryKey.GetCommonConfig], + queryFn: () => getOption(), + onSuccess: (data) => { + if (!data) return + + setCommonConfig( + produce(commonConfig, (draft) => { + draft.GlobalApiRateLimitNum = data.GlobalApiRateLimitNum || '' + draft.DisableServe = data.DisableServe || '' + draft.RetryTimes = data.RetryTimes || '' + draft.GroupMaxTokenNum = data.GroupMaxTokenNum || '' + }) + ) + } + }) + + const updateOptionMutation = useMutation({ + mutationFn: (params: { key: string; value: string }) => updateOption(params), + onSuccess: () => { + message({ + title: t('globalConfigs.saveCommonConfigSuccess'), + status: 'success' + }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetCommonConfig] }) + }, + onError: () => { + message({ + title: t('globalConfigs.saveCommonConfigFailed'), + status: 'error' + }) + } + }) + + const updateConfigField = (field: CommonConfigKey, value: string) => { + setCommonConfig( + produce((draft) => { + draft[field] = value + }) + ) + updateOptionMutation.mutate({ key: field, value }) + } + + const handleDisableServeChange = (checked: boolean) => { + const value = checked ? 'true' : 'false' + updateConfigField(CommonConfigKey.DisableServe, value) + } + + return ( + /* + h = 72px + 20px + 60px = 152px + EditableText (24px × 3) = 72px + Switch container (20px) = 20px + gap (20px × 3) = 60px + */ + + {/* title */} + + + {t('globalConfigs.common_config')} + + + {/* -- title end */} + + {/* config */} + + + {/* QPM Limit */} + updateConfigField(CommonConfigKey.GlobalApiRateLimitNum, value)} + flexProps={{ h: '24px' }} + /> + + {/* Pause Service */} + + {t('global_configs.pause_service')} + handleDisableServeChange(e.target.checked)} + /> + + + {/* Retry Count */} + updateConfigField(CommonConfigKey.RetryTimes, value)} + flexProps={{ h: '24px' }} + /> + + {/* Max Token */} + updateConfigField(CommonConfigKey.GroupMaxTokenNum, value)} + flexProps={{ h: '24px' }} + /> + + + {/* -- config end */} + + ) +} + +export default CommonConfig diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx new file mode 100644 index 00000000000..2ce5660629f --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/EditableText.tsx @@ -0,0 +1,185 @@ +'use client' +import React, { useState } from 'react' +import { + Flex, + Text, + Button, + Input, + useDisclosure, + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + HStack, + FlexProps, + Box +} from '@chakra-ui/react' +import { CheckIcon, CloseIcon } from '@chakra-ui/icons' + +interface EditableTextProps { + value: string | number + label: string + onSubmit: (value: string) => void + flexProps?: FlexProps +} + +export const EditableText = ({ value, label, onSubmit, flexProps }: EditableTextProps) => { + const [editValue, setEditValue] = useState(String(value)) + const { isOpen, onOpen, onClose } = useDisclosure() + + const handleSubmit = () => { + onSubmit(editValue) + onClose() + } + + const handleCancel = () => { + // 关闭时 恢复到传递来的初始值 + setEditValue(String(value)) + onClose() + } + + return ( + + + {label} + + + + + + {value} + + + + + + + + setEditValue(e.target.value)} + minW="0" + w="full" + h="28px" + borderRadius="6px" + border="1px solid var(--Gray-Modern-200, #E8EBF0)" + bgColor="white" + _hover={{ borderColor: 'grayModern.300' }} + _focus={{ borderColor: 'grayModern.300' }} + _focusVisible={{ borderColor: 'grayModern.300' }} + _active={{ borderColor: 'grayModern.300' }} + autoFocus + /> + + + + + + + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx new file mode 100644 index 00000000000..1d26ca7c6a8 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/components/ModelConfig.tsx @@ -0,0 +1,682 @@ +'use client' +import { Button, Flex, Text, FormControl, VStack, Skeleton } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { MultiSelectCombobox } from '@/components/common/MultiSelectCombobox' +import { SingleSelectCombobox } from '@/components/common/SingleSelectCombobox' +import { useForm, Controller, FieldErrors, FieldErrorsImpl, FieldError } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { + batchOption, + getChannelBuiltInSupportModels, + getChannelTypeNames, + getOption +} from '@/api/platform' +import { SetStateAction, Dispatch, useEffect, useState } from 'react' +import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' +import ConstructMappingComponent from '@/components/common/ConstructMappingComponent' +import { DefaultChannelModel, DefaultChannelModelMapping } from '@/types/admin/option' +import { ChannelType } from '@/types/admin/channels/channelInfo' +import { QueryKey } from '@/types/query-key' +import { useMessage } from '@sealos/ui' +import { BatchOptionData } from '@/types/admin/option' + +const ModelConfig = () => { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const queryClient = useQueryClient() + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const [allSupportChannel, setAllSupportChannel] = useState([]) + const [allSupportChannelWithMode, setAllSupportChannelWithMode] = useState<{ + [key in ChannelType]: string[] + }>({}) + + const [defaultModel, setDefaultModel] = useState({}) + const [defaultModelMapping, setDefaultModelMapping] = useState({}) + + const { isLoading: isChannelTypeNamesLoading, data: channelTypeNames } = useQuery({ + queryKey: [QueryKey.GetChannelTypeNames], + queryFn: () => getChannelTypeNames() + }) + + const { isLoading: isBuiltInSupportModelsLoading, data: builtInSupportModels } = useQuery({ + queryKey: [QueryKey.GetAllChannelModes], + queryFn: () => getChannelBuiltInSupportModels() + }) + + const { isLoading: isOptionLoading, data: optionData } = useQuery({ + queryKey: [QueryKey.GetOption], + queryFn: () => getOption(), + onSuccess: (data) => { + if (!data) return + + const defaultModels: DefaultChannelModel = JSON.parse(data.DefaultChannelModels) + const defaultModelMappings: DefaultChannelModelMapping = JSON.parse( + data.DefaultChannelModelMapping + ) + + setDefaultModel(defaultModels) + setDefaultModelMapping(defaultModelMappings) + } + }) + + useEffect(() => { + if (!channelTypeNames || !builtInSupportModels) return + + // 1. 处理 allSupportChannel + const supportedChannels = Object.entries(channelTypeNames) + .filter(([channel]) => channel in builtInSupportModels) + .map(([_, name]) => name) + + setAllSupportChannel(supportedChannels) + + // 2. 处理 allSupportChannelWithMode + // 渠道类型可能出现在 channelTypeNames 中,但不在 builtInSupportModels 中,所以需要过滤 + // 但在 builtInSupportModels 中,则一定在 channelTypeNames 中,所以 以 builtInSupportModels 为主 + const channelWithModes = Object.entries(channelTypeNames) + .filter(([channelType, _]) => channelType in builtInSupportModels) + .reduce((acc, [channelType, channelName]) => { + const modelInfos = builtInSupportModels[channelType as ChannelType] || [] + const models = [...new Set(modelInfos.map((info) => info.model))] + + return { + ...acc, + [channelType]: models + } + }, {} as { [key in ChannelType]: string[] }) + + setAllSupportChannelWithMode(channelWithModes) + }, [channelTypeNames, builtInSupportModels]) + + // form schema + const itemSchema = z.object({ + type: z.number(), + defaultMode: z.array(z.string()), + defaultModeMapping: z + .record(z.string(), z.string()) + .refine((mapping) => { + // 检查所有值不能为空字符串 + return Object.values(mapping).every((value) => value.trim() !== '') + }) + .default({}) + }) + + const schema = z.array(itemSchema) + + type ConfigItem = z.infer + type FormData = ConfigItem[] + + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { errors }, + control + } = useForm({ + resolver: zodResolver(schema), + defaultValues: [], + mode: 'onChange', + reValidateMode: 'onChange' + }) + + useEffect(() => { + if (!defaultModel || !defaultModelMapping) return + // Only proceed if both defaultModel and defaultModelMapping are available + if (Object.keys(defaultModel).length === 0) return + + // Transform the data into form format + const formData: FormData = Object.entries(defaultModel).map(([channelType, modes]) => { + return { + type: Number(channelType), + defaultMode: modes || [], // Using first mode as default + defaultModeMapping: defaultModelMapping[channelType as ChannelType] || {} + } + }) + + // Reset form with the new values + reset(formData) + }, [defaultModel, defaultModelMapping, reset]) + + const handleAddDefaultModel = () => { + const newItem = { + type: undefined, // Default type value + defaultMode: [], + defaultModeMapping: {} + } + + // Get current form values + const currentValues = watch() + // Create new array with new item at the beginning + const newValues = [newItem, ...Object.values(currentValues)] + // Reset form with new values + reset(newValues) + } + + const formValues = watch() + + const formValuesArray: FormData = Array.isArray(formValues) + ? formValues + : Object.values(formValues) + + const batchOptionMutation = useMutation({ + mutationFn: batchOption, + onSuccess: () => { + message({ + title: t('globalConfigs.saveDefaultModelSuccess'), + status: 'success' + }) + } + }) + + const transformFormDataToConfig = (formData: FormData): BatchOptionData => { + // 初始化两个对象 + const defaultChannelModelMapping: Record> = {} + const defaultChannelModels: Record = {} + + // 遍历 FormData + formData.forEach((item) => { + const type = item.type.toString() + + // 处理 DefaultChannelModelMapping + if (Object.keys(item.defaultModeMapping).length > 0) { + defaultChannelModelMapping[type] = item.defaultModeMapping + } + + // 处理 DefaultChannelModels + if (item.defaultMode.length > 0) { + defaultChannelModels[type] = item.defaultMode + } + }) + + return { + // 转换为 JSON 字符串 + DefaultChannelModelMapping: JSON.stringify(defaultChannelModelMapping), + DefaultChannelModels: JSON.stringify(defaultChannelModels) + } + } + + const resetForm = () => { + reset() + } + + type FieldErrorType = + | FieldError + | FieldErrorsImpl<{ + type: number + defaultMode: string[] + defaultModeMapping: Record + }> + + const getFirstErrorMessage = (errors: FieldErrors): string => { + // Iterate through top-level errors + for (const index in errors) { + const fieldError = errors[index] as FieldErrorType + if (!fieldError) continue + + // Check if error is an object + if (typeof fieldError === 'object') { + // If it has a direct message property + if ('message' in fieldError && fieldError.message) { + return `Item ${Number(index) + 1}: ${fieldError.message}` + } + + // Iterate through nested field errors + const errorKeys = Object.keys(fieldError) as Array + for (const fieldName of errorKeys) { + const nestedError = fieldError[fieldName] + if (nestedError && typeof nestedError === 'object' && 'message' in nestedError) { + // Map field names to their display labels + const fieldLabel = + { + type: 'Type', + defaultMode: 'Default Mode', + defaultModeMapping: 'Model Mapping' + }[fieldName as string] || fieldName + + return `Item ${Number(index) + 1} ${fieldLabel}: ${nestedError.message}` + } + } + } + } + return 'Form validation failed' + } + + const onValidate = async (data: FormData) => { + try { + const batchOptionData: BatchOptionData = transformFormDataToConfig(data) + await batchOptionMutation.mutateAsync(batchOptionData) + + queryClient.invalidateQueries({ queryKey: [QueryKey.GetOption] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetChannelTypeNames] }) + queryClient.invalidateQueries({ queryKey: [QueryKey.GetAllChannelModes] }) + resetForm() + } catch (error) { + message({ + title: t('globalConfigs.saveDefaultModelFailed'), + status: 'error', + position: 'top', + duration: 2000, + isClosable: true, + description: + error instanceof Error ? error.message : t('globalConfigs.saveDefaultModelFailed') + }) + console.error(error) + } + } + + const onInvalid = (errors: FieldErrors): void => { + console.error('errors', errors) + + const errorMessage = getFirstErrorMessage(errors) + + message({ + title: errorMessage, + status: 'error', + position: 'top', + duration: 2000, + isClosable: true + }) + } + + const onSubmit = handleSubmit(onValidate, onInvalid) + return ( + /* + 顶级 Flex 容器的高度: calc(100vh - 16px - 24px - 12px - 32px - 36px) + ModelConfig 的高度: calc(100vh - 16px - 24px - 12px - 32px - 36px)- CommonConfig 的高度(152px) -两个 gap 的高度(36px × 2 = 72px) + = calc(100vh - 16px - 24px - 12px - 32px - 36px - 152px - 72px) + = calc(100vh - 344px) + */ + + {/* title */} + + + {t('globalConfigs.model_config')} + + + {/* -- title end */} + + {/* config */} + + {/* add default model */} + + + {t('globalConfigs.defaultModel')} + + + + + + + + {/* default model */} + + + {isChannelTypeNamesLoading || + isBuiltInSupportModelsLoading || + isOptionLoading || + formValuesArray?.length === 0 ? ( + + ) : ( + formValuesArray && + formValuesArray.length > 0 && + channelTypeNames && + formValuesArray.map((value, index) => { + return ( + + + + + + { + // Get current form types + const currentFormTypes = Object.values(formValues) + .map((item) => String(item.type)) + .filter( + (type): type is ChannelType => + type !== undefined && type in channelTypeNames + ) + .map((type) => channelTypeNames[type]) + + // Filter available types + const availableTypes = allSupportChannel.filter( + (channelType) => + !currentFormTypes.includes(channelType) || + // 避免编辑时当前选中值"消失"的问题,即当前选择项也包含 + (field.value && + channelTypeNames[String(field.value) as ChannelType] === + channelType) + ) + + const initSelectedItem = field.value + ? channelTypeNames[String(field.value) as ChannelType] + : undefined + + return ( + + dropdownItems={availableTypes} + initSelectedItem={initSelectedItem} + setSelectedItem={(channelName: string) => { + if (channelName) { + const channelType = Object.entries(channelTypeNames).find( + ([_, name]) => name === channelName + )?.[0] + + if (channelType) { + const defaultModelField = + defaultModel[channelType as ChannelType] + const defaultModelMappingField = + defaultModelMapping[channelType as ChannelType] + + field.onChange(Number(channelType)) + setValue(`${index}.defaultMode`, defaultModelField || []) + setValue( + `${index}.defaultModeMapping`, + defaultModelMappingField || {} + ) + } + } + }} + handleDropdownItemFilter={( + dropdownItems: string[], + inputValue: string + ) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => + !inputValue || item.toLowerCase().includes(lowerCasedInput) + ) + }} + handleDropdownItemDisplay={(item: string) => ( + + {item} + + )} + /> + ) + }} + /> + + + + { + // Get the current type value from the form + const currentType = watch(`${index}.type`) + const dropdownItems = currentType + ? allSupportChannelWithMode[String(currentType) as ChannelType] || [] + : [] + + const handleSetCustomModel = ( + selectedItems: string[], + setSelectedItems: Dispatch>, + customModeName: string, + setCustomModeName: Dispatch> + ) => { + if (customModeName.trim()) { + const exists = field.value.some( + (item) => item === customModeName.trim() + ) + + if (!exists) { + field.onChange([...field.value, customModeName.trim()]) + setCustomModeName('') + } + } + } + + const handleModelFilteredDropdownItems = ( + dropdownItems: string[], + selectedItems: string[], + inputValue: string + ) => { + const lowerCasedInputValue = inputValue.toLowerCase() + + return dropdownItems.filter( + (item) => + !selectedItems.includes(item) && + item.toLowerCase().includes(lowerCasedInputValue) + ) + } + + return ( + + dropdownItems={dropdownItems || []} + selectedItems={field.value || []} // Use field.value for selected items + setSelectedItems={(models) => { + field.onChange(models) + }} + handleFilteredDropdownItems={handleModelFilteredDropdownItems} + handleDropdownItemDisplay={(item) => ( + + {item} + + )} + handleSelectedItemDisplay={(item) => ( + + {item} + + )} + handleSetCustomSelectedItem={handleSetCustomModel} + /> + ) + }} + /> + + + + { + const defaultMode = watch(`${index}.defaultMode`) + const defaultModeMapping = watch(`${index}.defaultModeMapping`) + + return ( + { + field.onChange(mapping) + }} + /> + ) + }} + /> + + + + ) + }) + )} + + + {/* -- config end */} + + ) +} + +export default ModelConfig diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx new file mode 100644 index 00000000000..876e2bf812f --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-configs/page.tsx @@ -0,0 +1,259 @@ +'use client' +import { Button, Divider, Flex, Text, useDisclosure } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import CommonConfig from './components/CommonConfig' +import ModelConfig from './components/ModelConfig' +import { getOption, uploadOptions } from '@/api/platform' +import { QueryKey } from '@/types/query-key' +import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query' +import { useMessage } from '@sealos/ui/src/components' +import { useRef } from 'react' +import { downloadJson } from '@/utils/common' + +export default function GlobalConfigPage() { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const fileInputRef = useRef(null) + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const queryClient = useQueryClient() + + const { + isFetching: isOptionFetching, + data: optionData, + refetch + } = useQuery({ + queryKey: [QueryKey.GetOption], + queryFn: () => getOption(), + refetchOnReconnect: false, + enabled: false + }) + + const uploadMutation = useMutation({ + mutationFn: uploadOptions + }) + + const handleExport = async () => { + const result = await refetch() + const dataToExport = result.data || [] + downloadJson(dataToExport, 'global_configs') + } + + const handleImport = () => { + fileInputRef.current?.click() + } + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + const formData = new FormData() + formData.append('file', file) + + try { + await uploadMutation.mutateAsync(formData) + message({ + title: t('dashboard.importSuccess'), + status: 'success', + duration: 3000, + isClosable: true + }) + queryClient.invalidateQueries([QueryKey.GetOption]) + queryClient.invalidateQueries([QueryKey.GetCommonConfig]) + } catch (error) { + console.error('Import error:', error) + message({ + title: t('dashboard.importError'), + status: 'error', + duration: 3000, + isClosable: true + }) + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + + return ( + + + {/* header */} + + + {t('global_configs.title')} + + + + <> + + + + + + + {/* header end */} + + {/* config */} + {/* + 100vh - 16px (父元素上下padding: 4px + 12px) + 24px (顶部padding: pt="24px") + 12px (底部padding: pb="12px") + 32px (header高度) + 36px (flex gap间距) + */} + + + + + + {/* -- config end */} + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx new file mode 100644 index 00000000000..2c312557526 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/global-logs/page.tsx @@ -0,0 +1,436 @@ +'use client' + +import { Box, Flex, Text, Button, Icon, Input } from '@chakra-ui/react' +import { CurrencySymbol, MyTooltip } from '@sealos/ui' +import { useMemo, useState } from 'react' + +import { getGlobalLogs, getEnabledMode } from '@/api/platform' +import { useTranslationClientSide } from '@/app/i18n/client' +import SelectDateRange from '@/components/common/SelectDateRange' +import SwitchPage from '@/components/common/SwitchPage' +import { BaseTable } from '@/components/table/BaseTable' +import { useI18n } from '@/providers/i18n/i18nContext' +import { GlobalLogItem } from '@/types/user/logs' +import { useQuery } from '@tanstack/react-query' +import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { QueryKey } from '@/types/query-key' +import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' +import { useBackendStore } from '@/store/backend' + +export default function Home(): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + + const [startTime, setStartTime] = useState(() => { + const currentDate = new Date() + currentDate.setMonth(currentDate.getMonth() - 1) + return currentDate + }) + const [endTime, setEndTime] = useState(new Date()) + const [groupId, setGroupId] = useState('') + const [name, setName] = useState('') + const [modelName, setModelName] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [logData, setLogData] = useState([]) + const [total, setTotal] = useState(0) + + const { data: models = [] } = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) + + const { isLoading } = useQuery( + [QueryKey.GetGlobalLogs, page, pageSize, name, modelName, startTime, endTime, groupId], + () => + getGlobalLogs({ + page, + perPage: pageSize, + token_name: name, + model_name: modelName, + start_timestamp: startTime.getTime().toString(), + end_timestamp: endTime.getTime().toString(), + group_id: groupId + }), + { + onSuccess: (data) => { + if (!data?.logs) { + setLogData([]) + setTotal(0) + return + } + setLogData(data?.logs || []) + setTotal(data?.total || 0) + } + } + ) + + const columns = useMemo[]>(() => { + return [ + { + header: t('GlobalLogs.groupId'), + accessorKey: 'group' + }, + { + header: t('GlobalLogs.tokenName'), + accessorKey: 'token_name' + }, + { + header: t('logs.model'), + accessorKey: 'model' + }, + { + header: t('GlobalLogs.channel'), + accessorKey: 'channel' + }, + { + header: t('logs.prompt_tokens'), + accessorKey: 'prompt_tokens' + }, + { + header: t('logs.completion_tokens'), + accessorKey: 'completion_tokens' + }, + + { + header: t('logs.status'), + accessorFn: (row) => (row.code === 200 ? t('logs.success') : t('logs.failed')), + cell: ({ getValue }) => { + const value = getValue() as string + return ( + + {value} + + ) + }, + id: 'status' + }, + { + header: t('logs.time'), + accessorFn: (row) => new Date(row.created_at).toLocaleString(), + id: 'created_at' + }, + { + accessorKey: 'used_amount', + id: 'used_amount', + header: () => { + return ( + + + + + {t('logs.total_price')} + + + + + + ) + } + } + ] + }, []) + + const table = useReactTable({ + data: logData, + columns, + getCoreRowModel: getCoreRowModel() + }) + + return ( + + + {/* -- header */} + + + + {t('logs.call_log')} + + + + + {/* -- the first row */} + + + + {t('GlobalLogs.keyName')} + + setName(e.target.value)} + /> + + + + + {t('logs.modal')} + + + + dropdownItems={['all', ...models.map((item) => item.model)]} + setSelectedItem={(value) => { + if (value === 'all') { + setModelName('') + } else { + setModelName(value) + } + }} + handleDropdownItemFilter={(dropdownItems, inputValue) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) + ) + }} + handleDropdownItemDisplay={(dropdownItem) => { + return ( + + {dropdownItem} + + ) + }} + flexProps={{ w: '500px' }} + placeholder={t('GlobalLogs.selectModel')} + /> + + + + {/* -- the first row end */} + + {/* -- the second row */} + + + + {t('GlobalLogs.groupId')} + + setGroupId(e.target.value)} + /> + + + + + {t('logs.time')} + + + + + {/* -- the second row end */} + + + {/* -- header end */} + + {/* -- table */} + + + setPage(idx)} + /> + + {/* -- table end */} + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx index 6454303cbdd..f3c187f8c9e 100644 --- a/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/layout.tsx @@ -1,17 +1,17 @@ -import { Flex } from '@chakra-ui/react' +import { Box, Flex } from '@chakra-ui/react' -export default function AdminLayout({ children }: { children: React.ReactNode }) { +import SideBar from '@/components/admin/Sidebar' + +export default function UserLayout({ children }: { children: React.ReactNode }) { return ( - - + + + + + {/* Main Content */} + {children} - + ) } diff --git a/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx b/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx new file mode 100644 index 00000000000..54952dd2dc4 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(admin)/ns-manager/page.tsx @@ -0,0 +1,556 @@ +'use client' + +import { + Box, + Flex, + Text, + Button, + Icon, + Input, + MenuItem, + MenuList, + Menu, + MenuButton +} from '@chakra-ui/react' +import { CurrencySymbol } from '@sealos/ui' +import { useMemo, useState } from 'react' + +import { deleteGroup, getGroups, updateGroupQpm, updateGroupStatus } from '@/api/platform' +import { useTranslationClientSide } from '@/app/i18n/client' +import SwitchPage from '@/components/common/SwitchPage' +import { BaseTable } from '@/components/table/BaseTable' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + ColumnDef, + getCoreRowModel, + useReactTable, + createColumnHelper +} from '@tanstack/react-table' +import { QueryKey } from '@/types/query-key' +import { useBackendStore } from '@/store/backend' +import { GroupInfo, GroupStatus } from '@/types/admin/group' +import { useMessage } from '@sealos/ui' +import { EditableTextNoLable } from '@/components/common/EditableTextNoLable' + +export default function Home(): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + const queryClient = useQueryClient() + + const [groupId, setGroupId] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [groupData, setGroupData] = useState([]) + const [total, setTotal] = useState(0) + + const { isLoading } = useQuery( + [QueryKey.GetGroups, page, pageSize, groupId], + () => + getGroups({ + page, + perPage: pageSize, + keyword: groupId + }), + { + onSuccess: (data) => { + if (!data?.groups) { + setGroupData([]) + setTotal(0) + return + } + setGroupData(data?.groups || []) + setTotal(data?.total || 0) + } + } + ) + + const deleteGroupMutation = useMutation(({ id }: { id: string }) => deleteGroup(id), { + onSuccess() { + message({ + status: 'success', + title: t('nsManager.deleteGroupSuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + queryClient.invalidateQueries([QueryKey.GetGroups]) + }, + onError(err: any) { + message({ + status: 'warning', + title: t('nsManager.deleteGroupFailed'), + description: err?.message || t('nsManager.deleteGroupFailed'), + isClosable: true, + position: 'top' + }) + } + }) + + const updateGroupStatusMutation = useMutation( + ({ id, status }: { id: string; status: number }) => updateGroupStatus(id, status), + { + onSuccess() { + message({ + status: 'success', + title: t('nsManager.updateGroupStatusSuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + queryClient.invalidateQueries([QueryKey.GetGroups]) + }, + onError(err: any) { + message({ + status: 'warning', + title: t('nsManager.updateGroupStatusFailed'), + description: err?.message || t('nsManager.updateGroupStatusFailed'), + isClosable: true, + position: 'top' + }) + } + } + ) + + const updateGroupQpmMutation = useMutation( + ({ id, qpm }: { id: string; qpm: number }) => updateGroupQpm(id, qpm), + { + onSuccess() { + message({ + status: 'success', + title: t('nsManager.updateGroupQpmSuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + queryClient.invalidateQueries([QueryKey.GetGroups]) + }, + onError(err: any) { + message({ + status: 'warning', + title: t('nsManager.updateGroupQpmFailed'), + description: err?.message || t('nsManager.updateGroupQpmFailed'), + isClosable: true, + position: 'top' + }) + } + } + ) + + // Update the button click handlers in the table actions column: + const handleStatusUpdate = (id: string, currentStatus: number) => { + const newStatus = + currentStatus === GroupStatus.DISABLED ? GroupStatus.ENABLED : GroupStatus.DISABLED + updateGroupStatusMutation.mutate({ id, status: newStatus }) + } + const columnHelper = createColumnHelper() + + const columns = useMemo[]>(() => { + return [ + { + header: t('nsManager.groupId'), + accessorKey: 'id' + }, + { + header: t('nsManager.qpm'), + id: 'qpm', + cell: (info) => ( + + updateGroupQpmMutation.mutate({ + id: info.row.original.id, + qpm: Number(value) + }) + } + /> + ) + }, + { + header: t('nsManager.created_at'), + accessorFn: (row) => new Date(row.created_at).toLocaleString(), + id: 'created_at' + }, + { + header: t('nsManager.accessed_at'), + accessorFn: (row) => { + if (row.accessed_at && row.accessed_at < 0) { + return t('key.unused') + } + + return new Date(row.accessed_at).toLocaleString() + }, + id: 'accessed_at' + }, + { + header: t('nsManager.request_count'), + accessorKey: 'request_count' + }, + { + header: t('nsManager.status'), + accessorFn: (row) => + row.status === GroupStatus.ENABLED ? t('nsManager.enabled') : t('nsManager.disabled'), + cell: ({ getValue }) => { + const value = getValue() as string + return ( + + {value} + + ) + }, + id: 'status' + }, + { + accessorKey: 'used_amount', + id: 'used_amount', + header: () => { + return ( + + + + {t('nsManager.used_amount')} + + + + + ) + } + }, + columnHelper.display({ + id: 'actions', + header: () => ( + + {t('nsManager.actions')} + + ), + cell: (info) => ( + + + + + + + + handleStatusUpdate(info.row.original.id, info.row.original.status)}> + {info.row.original.status === GroupStatus.ENABLED ? ( + <> + + + + + {t('channels.disable')} + + + ) : ( + <> + + + + + {t('channels.enable')} + + + )} + + deleteGroupMutation.mutate({ id: String(info.row.original.id) })}> + + + + + {t('channels.delete')} + + + + + ) + }) + ] + }, []) + + const table = useReactTable({ + data: groupData, + columns, + getCoreRowModel: getCoreRowModel() + }) + + return ( + + + {/* -- header */} + + + + {t('nsManager.ns_manager')} + + + + + {/* -- the first row */} + + + + {t('nsManager.groupId')} + + setGroupId(e.target.value)} + /> + + + + {/* -- the first row end */} + + + {/* -- header end */} + + {/* -- table */} + + + setPage(idx)} + /> + + {/* -- table end */} + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx new file mode 100644 index 00000000000..a8d6ffdf894 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/components/RequestDataChart.tsx @@ -0,0 +1,421 @@ +import { Box, Flex, Text } from '@chakra-ui/react' +import { useEffect, useRef } from 'react' +import * as echarts from 'echarts' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { ChartDataItem } from '@/types/user/dashboard' +import { useBackendStore } from '@/store/backend' + +export default function RequestDataChart({ data }: { data: ChartDataItem[] }): React.JSX.Element { + const costChartRef = useRef(null) + const requestChartRef = useRef(null) + const costChartInstance = useRef() + const requestChartInstance = useRef() + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + + // Add helper function to determine date format + const getDateFormat = (timestamps: number[]) => { + if (timestamps.length < 2) return 'detailed' + + const timeDiff = timestamps[timestamps.length - 1] - timestamps[0] + // If time difference is more than 15 days (1296000 seconds), show daily format + return timeDiff > 1296000 ? 'daily' : 'detailed' + } + + // 初始化图表 + useEffect(() => { + if (costChartRef.current && requestChartRef.current) { + costChartInstance.current = echarts.init(costChartRef.current, undefined, { + renderer: 'svg' + }) + requestChartInstance.current = echarts.init(requestChartRef.current, undefined, { + renderer: 'svg' + }) + } + + return () => { + costChartInstance.current?.dispose() + requestChartInstance.current?.dispose() + costChartInstance.current = undefined + requestChartInstance.current = undefined + } + }, []) + + // 配置图表选项 + useEffect(() => { + if (!costChartInstance.current || !requestChartInstance.current) return + + const commonTooltipStyle: echarts.EChartsOption['tooltip'] = { + trigger: 'axis', + axisPointer: { + type: 'line', + lineStyle: { + color: '#219BF4' + } + }, + backgroundColor: 'white', + borderWidth: 0, + padding: [8, 12], + textStyle: { + color: '#111824', + fontSize: 12 + } + } + + const commonXAxis: echarts.EChartsOption['xAxis'] = { + type: 'time', + // boundaryGap: ['0%', '5%'] as [string, string], + boundaryGap: ['0%', '0%'] as [string, string], + axisLine: { + lineStyle: { + color: '#E8EBF0', + width: 2 + } + }, + splitLine: { + show: false, + lineStyle: { + color: '#DFE2EA', + type: 'dashed' as const + } + }, + axisTick: { + show: true, + length: 6, + lineStyle: { + color: '#E8EBF0', + width: 2 + } + }, + axisLabel: { + show: true, + color: '#667085', + formatter: (value: number) => { + const date = new Date(value * 1000) + const format = getDateFormat(data.map((item) => item.timestamp)) + + return date + .toLocaleString(lng, { + month: '2-digit', + day: '2-digit', + ...(format === 'detailed' && { + hour: '2-digit', + minute: '2-digit', + hour12: false + }) + }) + .replace(/\//g, '-') + }, + margin: 14, + align: 'left' + } + } + + // 成本图表配置 + const costOption: echarts.EChartsOption = { + tooltip: { + ...commonTooltipStyle, + formatter: function ( + params: + | echarts.DefaultLabelFormatterCallbackParams + | echarts.DefaultLabelFormatterCallbackParams[] + ) { + if (!params) return '' + const paramArray = Array.isArray(params) ? params : [params] + if (paramArray.length === 0) return '' + + const time = new Date((paramArray[0].value as [number, number])[0] * 1000) + const timeStr = time.toLocaleString(lng, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + + let result = ` +
${timeStr}
+
+ ` + + const currency = + currencySymbol === 'shellCoin' + ? ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + : currencySymbol === 'cny' + ? '¥' + : '$' + + paramArray.forEach((param) => { + const value = (param.value as [number, number])[1] + const formattedValue = Number(value).toLocaleString(lng, { + minimumFractionDigits: 0, + maximumFractionDigits: 4 + }) + result += ` +
+
+ ${param.marker} + ${param.seriesName} + ${currency} +
+
${formattedValue}
+
+ ` + }) + + return result + } + }, + legend: { + show: false, + data: [t('dataDashboard.cost')], + bottom: 0 + }, + grid: { + left: 0, + right: 0, + bottom: 10, + top: 10, + containLabel: true + }, + xAxis: commonXAxis, + yAxis: { + type: 'value', + splitLine: { + show: true, + lineStyle: { + color: '#DFE2EA', + type: 'dashed' + } + }, + axisLine: { + show: false, + lineStyle: { + color: '#667085', + width: 2 + } + }, + axisLabel: { + // formatter: '${value}', + color: '#667085' + } + }, + series: [ + { + name: t('dataDashboard.cost'), + type: 'line', + smooth: true, + showSymbol: false, + data: data.map((item) => [item.timestamp, item.used_amount]), + itemStyle: { + color: '#13C4B9' + } + } + ] + } + + // 请求数图表配置 + const requestOption: echarts.EChartsOption = { + tooltip: { + ...commonTooltipStyle, + formatter: function (params) { + if (!params) return '' + const paramArray = Array.isArray(params) ? params : [params] + if (paramArray.length === 0) return '' + + const time = new Date((paramArray[0].value as [number, number])[0] * 1000) + const timeStr = time.toLocaleString(lng, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + + let result = ` +
${timeStr}
+
+ ` + + paramArray.forEach((param) => { + const value = (param.value as [number, number])[1] + result += ` +
+
+ ${param.marker} + ${param.seriesName} +
+
${value}
+
+ ` + }) + + return result + } + }, + legend: { + data: [t('dataDashboard.callCount'), t('dataDashboard.exceptionCount')], + bottom: 10 + }, + grid: { + left: 0, + right: 0, + bottom: 60, + top: 10, + containLabel: true + }, + xAxis: commonXAxis, + yAxis: { + type: 'value', + splitLine: { + show: true, + lineStyle: { + color: '#DFE2EA', + type: 'dashed' + } + }, + axisLine: { + show: false, + lineStyle: { + color: '#667085', + width: 2 + } + }, + axisLabel: { + color: '#667085' + } + }, + series: [ + { + name: t('dataDashboard.callCount'), + type: 'line', + smooth: true, + showSymbol: false, + data: data.map((item) => [item.timestamp, item.request_count]), + itemStyle: { + color: '#11B6FC' + } + }, + { + name: t('dataDashboard.exceptionCount'), + type: 'line', + smooth: true, + showSymbol: false, + data: data.map((item) => [item.timestamp, item.exception_count]), + itemStyle: { + color: '#FDB022' + } + } + ] + } + + // 设置图表选项 + costChartInstance.current.setOption(costOption) + requestChartInstance.current.setOption(requestOption) + + // 图表联动 + costChartInstance.current.group = 'request-data' + requestChartInstance.current.group = 'request-data' + echarts.connect('request-data') + }, [data, t, lng]) + + // 处理窗口大小变化 + useEffect(() => { + const handleResize = () => { + costChartInstance.current?.resize() + requestChartInstance.current?.resize() + } + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + } + }, []) + + return ( + + + + {t('dataDashboard.cost')} + + + + + + {t('dataDashboard.callCount')} + + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx index e43a476d36a..fada55fc5a4 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/home/page.tsx @@ -1,41 +1,652 @@ -import { Flex } from '@chakra-ui/react' +'use client' -import KeyList from '@/components/user/KeyList' -import ModelList from '@/components/user/ModelList' +import { Box, Flex, Text, Button, Center } from '@chakra-ui/react' +import { CurrencySymbol, MySelect } from '@sealos/ui' +import { useState } from 'react' -export default function Home(): JSX.Element { - return ( - - - - +import { getDashboardData } from '@/api/platform' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { UseQueryResult, useQuery } from '@tanstack/react-query' +import { QueryKey } from '@/types/query-key' +import { useBackendStore } from '@/store/backend' +import RequestDataChart from './components/RequestDataChart' +import { DashboardResponse } from '@/types/user/dashboard' + +export default function Home(): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + + const [tokenName, setTokenName] = useState('') + const [model, setModel] = useState('') + const [type, setType] = useState<'week' | 'day' | 'two_week' | 'month'>('week') // default is week + + const { data: dashboardData, isLoading }: UseQueryResult = useQuery( + [QueryKey.GetDashboardData, type, tokenName, model], + () => + getDashboardData({ + type, + ...(tokenName && { token_name: tokenName }), + ...(model && { model }) + }) + ) + return ( + - + w="full" + flex="1"> + {/* -- header */} + + + + {t('dataDashboard.title')} + + + ({ + value: token, + label: token + })) || []) + ]} + placeholder={t('dataDashboard.selectToken')} + onchange={(token: string) => { + if (token === 'all') { + setTokenName('') + } else { + setTokenName(token) + } + }} + /> + + ({ + value: model, + label: model + })) || []) + ]} + onchange={(model: string) => { + if (model === 'all') { + setModel('') + } else { + setModel(model) + } + }} + /> + + + + {[ + { label: t('dataDashboard.day'), value: 'day' }, + { label: t('dataDashboard.week'), value: 'week' }, + { label: t('dataDashboard.twoWeek'), value: 'two_week' }, + { label: t('dataDashboard.month'), value: 'month' } + ].map((item) => ( + + ))} + + + {/* -- header end */} + + + {/* chart 1 */} + + + + + + + + + + + + + {t('dataDashboard.callCount')} + + + {dashboardData?.total_count + ? dashboardData.total_count >= 10000 + ? `${Number((dashboardData.total_count / 10000).toFixed(3))}W` + : dashboardData.total_count.toLocaleString() + : 0} + + + + + + + + + + + + + {t('dataDashboard.exceptionCount')} + + + {dashboardData?.exception_count || 0} + + + + + + + + + + + + + {t('dataDashboard.rpm')} + + + {dashboardData?.rpm || 0} + + + + + + + + + + + + + + {t('dataDashboard.tpm')} + + + {dashboardData?.tpm || 0} + + + + + + + + + + + + + + + {t('dataDashboard.cost')} + + {currencySymbol === 'shellCoin' ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) : currencySymbol === 'cny' ? ( + '¥' + ) : ( + '$' + )} + + + {dashboardData?.used_amount ? Number(dashboardData.used_amount.toFixed(2)) : 0} + + + + + {/* chart 1 end */} + + + ) diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx new file mode 100644 index 00000000000..1af19640ee5 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/key/page.tsx @@ -0,0 +1,24 @@ +import { Flex } from '@chakra-ui/react' + +import KeyList from '@/components/user/KeyList' +import ModelList from '@/components/user/ModelList' + +export default function Home(): JSX.Element { + return ( + + + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/layout.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/layout.tsx index 62d16d0f4bc..658bcb8f280 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/layout.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/layout.tsx @@ -1,115 +1,15 @@ -'use client' import { Box, Flex } from '@chakra-ui/react' import SideBar from '@/components/user/Sidebar' -import { EVENT_NAME } from 'sealos-desktop-sdk' -import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app' -import { useCallback, useEffect } from 'react' -import { initAppConfig } from '@/api/platform' -import { useI18n } from '@/providers/i18n/i18nContext' -import { useBackendStore } from '@/store/backend' -import { useTranslationClientSide } from '@/app/i18n/client' -import { usePathname } from 'next/navigation' -import { useRouter } from 'next/navigation' - export default function UserLayout({ children }: { children: React.ReactNode }) { - const router = useRouter() - const pathname = usePathname() - const { lng } = useI18n() - const { i18n } = useTranslationClientSide(lng) - const { setAiproxyBackend, setCurrencySymbol } = useBackendStore() - - const handleI18nChange = useCallback( - (data: { currentLanguage: string }) => { - const currentLng = i18n.resolvedLanguage // get the latest resolvedLanguage - const newLng = data.currentLanguage - - if (currentLng !== newLng) { - const currentPath = window.location.pathname - const pathWithoutLang = currentPath.split('/').slice(2).join('/') - router.push(`/${newLng}/${pathWithoutLang}`) - } - }, - [i18n.resolvedLanguage] - ) - - // init session - useEffect(() => { - const cleanup = createSealosApp() - ;(async () => { - try { - const newSession = JSON.stringify(await sealosApp.getSession()) - const oldSession = localStorage.getItem('session') - if (newSession && newSession !== oldSession) { - localStorage.setItem('session', newSession) - window.location.reload() - } - console.log('aiproxy: app init success') - } catch (err) { - console.log('aiproxy: app is not running in desktop') - if (!process.env.NEXT_PUBLIC_MOCK_USER) { - localStorage.removeItem('session') - } - } - })() - return () => { - if (cleanup && typeof cleanup === 'function') { - cleanup() - } - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // init config and language - useEffect(() => { - const initConfig = async () => { - const { aiproxyBackend, currencySymbol } = await initAppConfig() - setAiproxyBackend(aiproxyBackend) - setCurrencySymbol(currencySymbol) - } - - initConfig() - - const initLanguage = async () => { - const pathLng = pathname.split('/')[1] - try { - const lang = await sealosApp.getLanguage() - if (pathLng !== lang.lng) { - const pathParts = pathname.split('/') - pathParts[1] = lang.lng - router.push(pathParts.join('/')) - router.refresh() - } - } catch (error) { - if (error instanceof Error) { - console.debug('Language initialization error:', error.message) - } else { - console.debug('Unknown language initialization error:', error) - } - } - } - - initLanguage() - - const cleanup = sealosApp?.addAppEventListen(EVENT_NAME.CHANGE_I18N, handleI18nChange) - - return () => { - if (cleanup && typeof cleanup === 'function') { - cleanup() - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - return ( - - + + {/* Main Content */} - + {children} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/log/components/LogDetailModal.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/log/components/LogDetailModal.tsx new file mode 100644 index 00000000000..0268dd7ef62 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/log/components/LogDetailModal.tsx @@ -0,0 +1,634 @@ +'use client' + +import { + Box, + Flex, + Text, + Modal, + ModalOverlay, + ModalHeader, + ModalCloseButton, + ModalBody, + ModalContent, + Grid, + Center, + Spinner +} from '@chakra-ui/react' +import { CurrencySymbol } from '@sealos/ui' +import { MyTooltip } from '@/components/common/MyTooltip' + +import { getUserLogDetail } from '@/api/platform' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { LogItem } from '@/types/user/logs' +import { useQuery } from '@tanstack/react-query' +import { QueryKey } from '@/types/query-key' +import { useBackendStore } from '@/store/backend' +import { getTranslationWithFallback } from '@/utils/common' +import ReactJson, { OnCopyProps } from 'react-json-view' +import { getTimeDiff } from '../tools/handleTime' +import { useMessage } from '@sealos/ui' + +export default function LogDetailModal({ + isOpen, + onClose, + rowData +}: { + isOpen: boolean + onClose: () => void + rowData: LogItem | null +}): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + + const { data: logDetail, isLoading } = useQuery({ + queryKey: [QueryKey.GetUserLogDetail, rowData?.request_detail?.log_id], + queryFn: () => { + if (!rowData?.request_detail?.log_id) throw new Error('No log ID') + return getUserLogDetail(rowData.request_detail.log_id) + }, + enabled: !!rowData?.request_detail?.log_id + }) + + const isDetailLoading = !!rowData?.request_detail?.log_id && isLoading + + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' + }) + + // 定义默认的网格配置 + const gridConfig = { + labelWidth: '153px', + rowHeight: '48px', + jsonContentHeight: '122px' + } + + const renderDetailRow = ( + leftLabel: string | React.ReactNode, + leftValue: string | number | React.ReactNode | undefined, + rightLabel?: string | React.ReactNode, + rightValue?: string | number | React.ReactNode | undefined, + options?: { + labelWidth?: string + rowHeight?: string + isFirst?: boolean + isLast?: boolean + } + ) => { + // 辅助函数:渲染标签 + const renderLabel = (label: string | React.ReactNode) => { + if (typeof label === 'string') { + return ( + + {label} + + ) + } + return label + } + + // 辅助函数:渲染值 + const renderValue = (value: string | number | React.ReactNode | undefined) => { + if (typeof value === 'string' || typeof value === 'number') { + return ( + + {value} + + ) + } + return value + } + return ( + + + + {renderLabel(leftLabel)} + + + {renderValue(leftValue)} + + + {rightLabel && ( + + + {renderLabel(rightLabel)} + + + {renderValue(rightValue)} + + + )} + + ) + } + + const renderJsonContent = ( + label: string, + content: string | undefined, + options?: { + labelWidth?: string + contentHeight?: string + isFirst?: boolean + isLast?: boolean + } + ) => { + if (!content) return null + const handleCopy = (copy: OnCopyProps) => { + if (typeof window === 'undefined') return + + const copyText = + typeof copy.src === 'object' ? JSON.stringify(copy.src, null, 2) : String(copy.src) + + navigator.clipboard.writeText(copyText) + } + + let parsed = null + + try { + parsed = JSON.parse(content) + } catch { + parsed = content + } + return ( + + + + {label} + + + + {typeof parsed === 'object' ? ( + + ) : ( + + {parsed} + + )} + + + ) + } + + return isDetailLoading ? ( + + + + + + + {t('logs.logDetail')} + + + + + +
+ +
+
+
+
+ ) : ( + + + + + + + {t('logs.logDetail')} + + + + + + + {renderDetailRow( + t('logs.requestId'), + rowData?.request_id, + t('logs.status'), + + {rowData?.code === 200 + ? t('logs.success') + : `${t('logs.failed')} (${rowData?.code})`} + , + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: true + } + )} + {renderDetailRow( + 'Endpoint', + rowData?.endpoint, + t('logs.mode'), + getTranslationWithFallback( + `modeType.${String(rowData?.mode)}`, + 'modeType.0', + t as any + ), + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow( + t('logs.requestTime'), + new Date(rowData?.created_at || 0).toLocaleString(), + t('logs.totalTime'), + getTimeDiff(rowData?.created_at || 0, rowData?.request_at || 0), + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow( + t('logs.tokenName'), + rowData?.token_name, + t('logs.tokenId'), + rowData?.token_id, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow(t('logs.model'), rowData?.model, undefined, undefined, { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + })} + + {rowData?.content && + renderDetailRow( + t('logs.info'), + + { + navigator.clipboard.writeText(rowData.content || '').then( + () => { + message({ + status: 'success', + title: t('copySuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + }, + (err) => { + message({ + status: 'warning', + title: t('copyFailed'), + description: err?.message || t('copyFailed'), + isClosable: true, + position: 'top' + }) + } + ) + }}> + {rowData.content} + + , + undefined, + undefined, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + + {logDetail?.request_body && + renderJsonContent(t('logs.requestBody'), logDetail.request_body, { + labelWidth: gridConfig.labelWidth, + contentHeight: gridConfig.jsonContentHeight, + isFirst: false + })} + {logDetail?.response_body && + renderJsonContent(t('logs.responseBody'), logDetail.response_body, { + labelWidth: gridConfig.labelWidth, + contentHeight: gridConfig.jsonContentHeight, + isLast: false + })} + + {renderDetailRow( + + + {t('key.inputPrice')} + + + + /{t('price.per1kTokens').toLowerCase()} + + , + rowData?.price, + + + {t('key.outputPrice')} + + + + /{t('price.per1kTokens').toLowerCase()} + + , + rowData?.completion_price, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + {renderDetailRow( + t('logs.inputTokens'), + rowData?.prompt_tokens, + t('logs.outputTokens'), + rowData?.completion_tokens, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isFirst: false + } + )} + + {renderDetailRow( + + + + {t('logs.total_price')} + + + + , + rowData?.used_amount || 0, + undefined, + undefined, + { + labelWidth: gridConfig.labelWidth, + rowHeight: gridConfig.rowHeight, + isLast: true + } + )} + + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx new file mode 100644 index 00000000000..b369f24f5cb --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/log/page.tsx @@ -0,0 +1,666 @@ +'use client' + +import { + Box, + Flex, + Text, + Button, + Icon, + useDisclosure, + InputGroup, + InputRightElement, + Input +} from '@chakra-ui/react' +import { CurrencySymbol, MySelect, MyTooltip } from '@sealos/ui' +import { useMemo, useState } from 'react' + +import { getTokens, getUserLogs, getEnabledMode } from '@/api/platform' +import { useTranslationClientSide } from '@/app/i18n/client' +import SelectDateRange from '@/components/common/SelectDateRange' +import SwitchPage from '@/components/common/SwitchPage' +import { BaseTable } from '@/components/table/BaseTable' +import { useI18n } from '@/providers/i18n/i18nContext' +import { LogItem } from '@/types/user/logs' +import { useQuery } from '@tanstack/react-query' +import { getCoreRowModel, useReactTable, createColumnHelper } from '@tanstack/react-table' +import { QueryKey } from '@/types/query-key' +import { useBackendStore } from '@/store/backend' +import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' +import { getTimeDiff } from './tools/handleTime' +import dynamic from 'next/dynamic' +import { useDebounce } from '@/hooks/useDebounce' + +const LogDetailModal = dynamic( + () => import('./components/LogDetailModal'), + { ssr: false } // 禁用服务端渲染 +) + +export default function Logs(): React.JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const { currencySymbol } = useBackendStore() + const { isOpen, onOpen, onClose } = useDisclosure() + const [selectedRow, setSelectedRow] = useState(null) + + const [startTime, setStartTime] = useState(() => { + const currentDate = new Date() + currentDate.setMonth(currentDate.getMonth() - 1) + return currentDate + }) + const [endTime, setEndTime] = useState(new Date()) + const [keyName, setKeyName] = useState('') + const [codeType, setCodeType] = useState('all') + const [modelName, setModelName] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const [inputKeyword, setInputKeyword] = useState('') + const debouncedKeyword = useDebounce(inputKeyword, 500) // 500ms 延迟 0.5s + + const { data: logData, isLoading } = useQuery( + [ + QueryKey.GetUserLogs, + page, + pageSize, + keyName, + modelName, + startTime, + endTime, + codeType, + debouncedKeyword + ], + () => + getUserLogs({ + page, + perPage: pageSize, + token_name: keyName, + model_name: modelName, + keyword: debouncedKeyword, + code_type: codeType as 'all' | 'success' | 'error', + start_timestamp: startTime.getTime().toString(), + end_timestamp: endTime.getTime().toString() + }) + ) + + const columnHelper = createColumnHelper() + + const columns = useMemo( + () => [ + columnHelper.accessor('token_name', { + header: () => ( + + {t('logs.name')} + + ), + id: 'token_name' + }), + columnHelper.accessor('model', { + header: () => ( + + {t('logs.model')} + + ), + id: 'model' + }), + columnHelper.accessor('prompt_tokens', { + header: () => ( + + {t('logs.prompt_tokens')} + + ), + id: 'prompt_tokens' + }), + columnHelper.accessor('completion_tokens', { + header: () => ( + + {t('logs.completion_tokens')} + + ), + id: 'completion_tokens' + }), + + columnHelper.display({ + header: () => ( + + {t('logs.totalTime')} + + ), + cell: ({ row }) => ( + + {getTimeDiff(row.original.created_at, row.original.request_at)} + + ), + id: 'total_time' + }), + + columnHelper.accessor('code', { + header: () => ( + + {t('logs.status')} + + ), + cell: ({ getValue, row }) => { + const code = getValue() + return ( + + + {code !== 200 ? `${t('logs.failed')} (${row.original.code})` : code} + + {code !== 200 && ( + + + + + + )} + + ) + }, + id: 'status' + }), + + columnHelper.accessor('created_at', { + header: () => ( + + {t('logs.time')} + + ), + cell: ({ row }) => new Date(row.original.created_at).toLocaleString(), + id: 'created_at' + }), + columnHelper.accessor('used_amount', { + header: () => { + return ( + + + + + {t('logs.total_price')} + + + + + + ) + }, + id: 'used_amount' + }), + + columnHelper.display({ + header: () => ( + + {t('logs.actions')} + + ), + cell: ({ row }) => ( + + ), + id: 'detail' + }) + ], + [t, currencySymbol] + ) + + const table = useReactTable({ + data: logData?.logs || [], + columns, + getCoreRowModel: getCoreRowModel() + }) + + return ( + + + {/* -- header */} + + + + {t('logs.call_log')} + + + + { + setInputKeyword(e.target.value) + }} + /> + + + + + + + + + + + + + {/* -- the first row */} + + + + {t('logs.name')} + + + dropdownItems={['all', ...(logData?.token_names || [])]} + setSelectedItem={(value) => { + if (value === 'all') { + setKeyName('') + } else { + setKeyName(value) + } + }} + handleDropdownItemFilter={(dropdownItems, inputValue) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) + ) + }} + handleDropdownItemDisplay={(dropdownItem) => { + return ( + + {dropdownItem} + + ) + }} + flexProps={{ w: '280px' }} + placeholder={t('logs.select_token_name')} + /> + + + + + {t('logs.modal')} + + + dropdownItems={['all', ...(logData?.models || [])]} + setSelectedItem={(value) => { + if (value === 'all') { + setModelName('') + } else { + setModelName(value) + } + }} + handleDropdownItemFilter={(dropdownItems, inputValue) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) + ) + }} + handleDropdownItemDisplay={(dropdownItem) => { + return ( + + {dropdownItem} + + ) + }} + flexProps={{ w: '280px' }} + placeholder={t('logs.select_modal')} + /> + + + + + {t('logs.status')} + + { + setCodeType(val) + }} + /> + + + + + {t('logs.time')} + + + + + {/* -- the first row end */} + + + {/* -- header end */} + + {/* -- table */} + + + setPage(idx)} + /> + + {/* -- table end */} + + + + ) +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/log/tools/handleTime.ts b/frontend/providers/aiproxy/app/[lng]/(user)/log/tools/handleTime.ts new file mode 100644 index 00000000000..0b892c563f3 --- /dev/null +++ b/frontend/providers/aiproxy/app/[lng]/(user)/log/tools/handleTime.ts @@ -0,0 +1,4 @@ +export const getTimeDiff = (createdAt: number, requestAt: number) => { + const diff = Number(((createdAt - requestAt) / 1000).toFixed(4)).toString() + return `${diff}s` +} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx deleted file mode 100644 index 7cbb2ef35ad..00000000000 --- a/frontend/providers/aiproxy/app/[lng]/(user)/logs/page.tsx +++ /dev/null @@ -1,260 +0,0 @@ -'use client' - -import { Box, Flex, Text, Button, Icon } from '@chakra-ui/react' -import { CurrencySymbol, MySelect, MyTooltip } from '@sealos/ui' -import { useMemo, useState } from 'react' - -import { getKeys, getLogs, getModels } from '@/api/platform' -import { useTranslationClientSide } from '@/app/i18n/client' -import SelectDateRange from '@/components/SelectDateRange' -import SwitchPage from '@/components/SwitchPage' -import { BaseTable } from '@/components/table/baseTable' -import { useI18n } from '@/providers/i18n/i18nContext' -import { LogItem } from '@/types/log' -import { useQuery } from '@tanstack/react-query' -import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useBackendStore } from '@/store/backend' - -const mockStatus = ['all', 'success', 'failed'] - -export default function Home(): React.JSX.Element { - const { lng } = useI18n() - const { t } = useTranslationClientSide(lng, 'common') - - const [startTime, setStartTime] = useState(() => { - const currentDate = new Date() - currentDate.setMonth(currentDate.getMonth() - 1) - return currentDate - }) - const [endTime, setEndTime] = useState(new Date()) - const [name, setName] = useState('') - const [modelName, setModelName] = useState('') - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(10) - const [logData, setLogData] = useState([]) - const [total, setTotal] = useState(0) - const { currencySymbol } = useBackendStore() - - const { data: models = [] } = useQuery(['getModels'], () => getModels()) - const { data: tokenData } = useQuery(['getKeys'], () => getKeys({ page: 1, perPage: 100 })) - - const { isLoading } = useQuery( - ['getLogs', page, pageSize, name, modelName, startTime, endTime], - () => - getLogs({ - page, - perPage: pageSize, - token_name: name, - model_name: modelName, - start_timestamp: startTime.getTime().toString(), - end_timestamp: endTime.getTime().toString() - }), - { - onSuccess: (data) => { - if (!data.logs) { - setLogData([]) - setTotal(0) - return - } - setLogData(data.logs) - setTotal(data.total) - } - } - ) - - const columns = useMemo[]>(() => { - return [ - { - header: t('logs.name'), - accessorKey: 'token_name' - }, - { - header: t('logs.model'), - accessorKey: 'model' - }, - { - header: t('logs.prompt_tokens'), - accessorKey: 'prompt_tokens' - }, - { - header: t('logs.completion_tokens'), - accessorKey: 'completion_tokens' - }, - - { - header: t('logs.status'), - accessorFn: (row) => (row.code === 200 ? t('logs.success') : t('logs.failed')), - cell: ({ getValue }) => { - const value = getValue() as string - return ( - - {value} - - ) - }, - id: 'status' - }, - { - header: t('logs.time'), - accessorFn: (row) => new Date(row.created_at).toLocaleString(), - id: 'created_at' - }, - { - accessorKey: 'used_amount', - id: 'used_amount', - header: () => { - return ( - - - - {t('logs.total_price')} - - - - - ) - } - } - ] - }, []) - - const table = useReactTable({ - data: logData, - columns, - getCoreRowModel: getCoreRowModel() - }) - - return ( - - - - {t('logs.call_log')} - - - - - - - - {t('logs.name')} - - ({ - value: item.name, - label: item.name - })) || []) - ]} - onchange={(val: string) => { - if (val === 'all') { - setName('') - } else { - setName(val) - } - }} - /> - - - - - {t('logs.modal')} - - ({ - value: item, - label: item - })) || [] - } - onchange={(val: string) => { - if (val === 'all') { - setModelName('') - } else { - setModelName(val) - } - }} - /> - - - - - - {t('logs.time')} - - - - - - - setPage(idx)} - /> - - - ) -} diff --git a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx index b4e5aa098b6..efaeb2ac6ec 100644 --- a/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx +++ b/frontend/providers/aiproxy/app/[lng]/(user)/price/page.tsx @@ -11,14 +11,19 @@ import { Thead, Tr, Center, - Spinner + Spinner, + Button, + Icon, + Input, + InputGroup, + InputRightElement, + Badge } from '@chakra-ui/react' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { useQuery } from '@tanstack/react-query' -import { ModelPrice } from '@/types/backend' -import { getModelPrices } from '@/api/platform' -import { useMemo } from 'react' +import { useQuery, UseQueryResult } from '@tanstack/react-query' +import { getEnabledMode } from '@/api/platform' +import { useMemo, useState, useEffect } from 'react' import { createColumnHelper, getCoreRowModel, @@ -26,46 +31,404 @@ import { flexRender } from '@tanstack/react-table' import { CurrencySymbol } from '@sealos/ui' -import { ModelIdentifier } from '@/types/front' -import { MyTooltip } from '@/components/MyTooltip' +import { MyTooltip } from '@/components/common/MyTooltip' import { useMessage } from '@sealos/ui' -// icons -import OpenAIIcon from '@/ui/svg/icons/modelist/openai.svg' -import QwenIcon from '@/ui/svg/icons/modelist/qianwen.svg' -import ChatglmIcon from '@/ui/svg/icons/modelist/chatglm.svg' -import DeepseekIcon from '@/ui/svg/icons/modelist/deepseek.svg' -import MoonshotIcon from '@/ui/svg/icons/modelist/moonshot.svg' -import SparkdeskIcon from '@/ui/svg/icons/modelist/sparkdesk.svg' -import AbabIcon from '@/ui/svg/icons/modelist/minimax.svg' -import DoubaoIcon from '@/ui/svg/icons/modelist/doubao.svg' -import ErnieIcon from '@/ui/svg/icons/modelist/ernie.svg' +import { ModelConfig } from '@/types/models/model' import Image, { StaticImageData } from 'next/image' +import { QueryKey } from '@/types/query-key' +import { getTranslationWithFallback } from '@/utils/common' import { useBackendStore } from '@/store/backend' +import { modelIcons } from '@/ui/icons/mode-icons' +import { SingleSelectComboboxUnstyle } from '@/components/common/SingleSelectComboboxUnStyle' +import { useDebounce } from '@/hooks/useDebounce' -function Price() { +type SortDirection = 'asc' | 'desc' | false + +const getModelIcon = (modelOwner: string): StaticImageData => { + const icon = modelIcons[modelOwner as keyof typeof modelIcons] || modelIcons['default'] + return icon +} + +// 在组件外部定义样式配置 +const MODEL_TYPE_STYLES = { + 1: { + background: '#F0FBFF', + color: '#0884DD' + }, + 2: { + background: '#F4F4F7', + color: '#383F50' + }, + 3: { + background: '#EBFAF8', + color: '#007E7C' + }, + 4: { + background: '#FEF3F2', + color: '#F04438' + }, + 5: { + background: '#F0EEFF', + color: '#6F5DD7' + }, + 6: { + background: '#FFFAEB', + color: '#DC6803' + }, + 7: { + background: '#FAF1FF', + color: '#9E53C1' + }, + 8: { + background: '#FFF1F6', + color: '#E82F72' + }, + 9: { + background: '#F0F4FF', + color: '#3370FF' + }, + 10: { + background: '#EDFAFF', + color: '#0077A9' + }, + default: { + background: '#F4F4F7', + color: '#383F50' + } +} as const + +// 在组件中使用 +const getTypeStyle = (type: number) => { + return MODEL_TYPE_STYLES[type as keyof typeof MODEL_TYPE_STYLES] || MODEL_TYPE_STYLES.default +} + +export default function Price() { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') + + const [modelOwner, setModelOwner] = useState('') + const [modelType, setModelType] = useState('') + const [modelName, setModelName] = useState('') + const [searchInput, setSearchInput] = useState('') + const debouncedSearch = useDebounce(searchInput, 500) + + interface FilterParams { + owner: string + type: string + name: string + } + + const filterModels = (modelConfigs: ModelConfig[], filterParams: FilterParams): ModelConfig[] => { + return modelConfigs.filter((config) => { + const ownerMatch = + !filterParams.owner || + filterParams.owner === t('price.all') || + getTranslationWithFallback( + `modeOwner.${String(config.owner)}`, + 'modeOwner.unknown', + t as any + ) === filterParams.owner + + const typeMatch = + !filterParams.type || + filterParams.type === t('price.all') || + getTranslationWithFallback(`modeType.${String(config.type)}`, 'modeType.0', t as any) === + filterParams.type + + const nameMatch = + !filterParams.name || config.model.toLowerCase().includes(filterParams.name.toLowerCase()) + + return ownerMatch && typeMatch && nameMatch + }) + } + + const { + isLoading, + data: modelConfigs = [] as ModelConfig[], + refetch + }: UseQueryResult = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) + + const filteredModelConfigs = useMemo(() => { + return filterModels(modelConfigs, { + owner: modelOwner, + type: modelType, + name: debouncedSearch + }) + }, [modelConfigs, modelOwner, modelType, debouncedSearch]) + + useEffect(() => { + setModelName(debouncedSearch) + }, [debouncedSearch]) + return ( - - - - - {t('price.title')} - + + + + + {/* row 1 */} + + + {t('price.title')} + + + + {/* row 1 end */} + + {/* row 2 */} + + + + + {t('price.modelOwner')} + + + dropdownItems={[ + { icon: '', name: t('price.all') }, + ...Array.from( + new Map( + modelConfigs.map((config) => [ + config.owner, + { + icon: config.owner, + name: getTranslationWithFallback( + `modeOwner.${String(config.owner)}`, + 'modeOwner.unknown', + t as any + ) + } + ]) + ).values() + ) + ]} + setSelectedItem={(modelOwner) => { + setModelOwner(modelOwner.name) + }} + handleDropdownItemFilter={(dropdownItems, inputValue) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => !inputValue || item.name.toLowerCase().includes(lowerCasedInput) + ) + }} + handleDropdownItemDisplay={(dropdownItem) => { + const iconSrc = getModelIcon(dropdownItem.icon) + if (dropdownItem.name === t('price.all')) { + return ( + + default + + {dropdownItem.name} + + + ) + } + return ( + + {dropdownItem.icon} + + {dropdownItem.name} + + + ) + }} + flexProps={{ w: '240px' }} + initSelectedItem={{ icon: '', name: t('price.all') }} + handleInputDisplay={(dropdownItem) => dropdownItem.name} + /> + + + + {t('price.modelType')} + + + dropdownItems={[ + t('price.all'), + ...new Set( + modelConfigs.map((config) => + getTranslationWithFallback( + `modeType.${String(config.type)}`, + 'modeType.0', + t as any + ) + ) + ) + ]} + setSelectedItem={(modelType) => { + setModelType(modelType) + }} + handleDropdownItemFilter={(dropdownItems, inputValue) => { + const lowerCasedInput = inputValue.toLowerCase() + return dropdownItems.filter( + (item) => !inputValue || item.toLowerCase().includes(lowerCasedInput) + ) + }} + handleDropdownItemDisplay={(dropdownItem) => { + return ( + + {dropdownItem} + + ) + }} + flexProps={{ w: '240px' }} + initSelectedItem={t('price.all')} + /> + + + + + + { + setSearchInput(e.target.value) + }} + /> + + + + + + + + + + + - + {isLoading ? ( + + ) : ( + + )} @@ -73,142 +436,235 @@ function Price() { ) } -function PriceTable() { +const ModelComponent = ({ modelConfig }: { modelConfig: ModelConfig }) => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') - const { isLoading, data } = useQuery({ - queryKey: ['getModelPrices'], - queryFn: () => getModelPrices(), - refetchOnReconnect: true + const { message } = useMessage({ + warningBoxBg: 'var(--Yellow-50, #FFFAEB)', + warningIconBg: 'var(--Yellow-500, #F79009)', + warningIconFill: 'white', + successBoxBg: 'var(--Green-50, #EDFBF3)', + successIconBg: 'var(--Green-600, #039855)', + successIconFill: 'white' }) - const { currencySymbol } = useBackendStore() - const modelGroups = { - ernie: { - icon: ErnieIcon, - identifiers: ['ernie'] - }, - qwen: { - icon: QwenIcon, - identifiers: ['qwen'] - }, - chatglm: { - icon: ChatglmIcon, - identifiers: ['chatglm', 'glm'] - }, - deepseek: { - icon: DeepseekIcon, - identifiers: ['deepseek'] - }, - moonshot: { - icon: MoonshotIcon, - identifiers: ['moonshot'] - }, - sparkdesk: { - icon: SparkdeskIcon, - identifiers: ['sparkdesk'] - }, - abab: { - icon: AbabIcon, - identifiers: ['abab'] - }, - doubao: { - icon: DoubaoIcon, - identifiers: ['doubao'] - } - } + const iconSrc = getModelIcon(modelConfig.owner) - const getIdentifier = (modelName: string): ModelIdentifier => { - return modelName.toLowerCase().split(/[-._\d]/)[0] as ModelIdentifier - } - - const getModelIcon = (modelName: string): StaticImageData => { - const identifier = getIdentifier(modelName) - const group = Object.values(modelGroups).find((group) => group.identifiers.includes(identifier)) - return group?.icon || OpenAIIcon - } - - const ModelComponent = ({ modelName }: { modelName: string }) => { - const { message } = useMessage({ - warningBoxBg: 'var(--Yellow-50, #FFFAEB)', - warningIconBg: 'var(--Yellow-500, #F79009)', - warningIconFill: 'white', - successBoxBg: 'var(--Green-50, #EDFBF3)', - successIconBg: 'var(--Green-600, #039855)', - successIconFill: 'white' - }) - const iconSrc = getModelIcon(modelName) - - return ( - - {modelName} - - - navigator.clipboard.writeText(modelName).then( - () => { - message({ - status: 'success', - title: t('copySuccess'), - isClosable: true, - duration: 2000, - position: 'top' - }) - }, - (err) => { - message({ - status: 'warning', - title: t('copyFailed'), - description: err?.message || t('copyFailed'), - isClosable: true, - position: 'top' - }) - } - ) - } - cursor="pointer"> - {modelName} - - + return ( + + + {modelConfig.model} + + + + navigator.clipboard.writeText(modelConfig.model).then( + () => { + message({ + status: 'success', + title: t('copySuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + }, + (err) => { + message({ + status: 'warning', + title: t('copyFailed'), + description: err?.message || t('copyFailed'), + isClosable: true, + position: 'top' + }) + } + ) + } + cursor="pointer"> + {modelConfig.model} + + + {modelConfig.config?.vision && ( + + {t('price.modelVisionLabel')} + + } + width="auto" + height="auto"> + + + + + + {t('price.modelVision')} + + + + )} + {modelConfig.config?.tool_choice && ( + + + + + + + {t('price.modelToolChoice')} + + + )} + {modelConfig.config?.max_context_tokens && ( + + + {`${ + modelConfig.config.max_context_tokens % 1024 === 0 + ? Math.ceil(modelConfig.config.max_context_tokens / 1024) + : Math.ceil(modelConfig.config.max_context_tokens / 1000) + }K`} + + + )} + {modelConfig.config?.max_output_tokens && ( + + + {`${Math.ceil(modelConfig.config.max_output_tokens / 1024)}K ${t( + 'price.response' + )}`} + + + )} + - ) - } - - const sortModelsByIdentifier = (models: ModelPrice[]): ModelPrice[] => { - const groupedModels = new Map() + + ) +} - // Group models by identifier - models.forEach((model) => { - const identifier = getIdentifier(model.name) - if (!groupedModels.has(identifier)) { - groupedModels.set(identifier, []) - } - groupedModels.get(identifier)!.push(model) - }) +function PriceTable({ + modelConfigs, + isLoading +}: { + modelConfigs: ModelConfig[] + isLoading: boolean +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') - // Define order based on modelGroups - const orderMap = new Map(Object.keys(modelGroups).map((key, index) => [key, index])) + const { currencySymbol } = useBackendStore() - // Sort based on modelGroups order, unknown models go to the end - const sortedEntries = Array.from(groupedModels.entries()).sort((a, b) => { - const orderA = orderMap.has(a[0]) ? orderMap.get(a[0])! : Number.MAX_VALUE - const orderB = orderMap.has(b[0]) ? orderMap.get(b[0])! : Number.MAX_VALUE - return orderA - orderB - }) + const [sortConfig, setSortConfig] = useState({ + column: '', + direction: false as SortDirection + }) - return sortedEntries.flatMap(([_, models]) => models) + // 处理排序 + const handleSort = (column: string, direction: SortDirection) => { + // 如果点击相同的列并且方向相同,则取消排序 + if (sortConfig.column === column && sortConfig.direction === direction) { + setSortConfig({ column: '', direction: false }) + return + } + setSortConfig({ column, direction }) } - const columnHelper = createColumnHelper() + const columnHelper = createColumnHelper() const columns = [ - columnHelper.accessor((row) => row.name, { - id: 'name', + columnHelper.accessor((row) => row.model, { + id: 'model', header: () => ( ), - cell: (info) => + cell: (info) => + }), + columnHelper.accessor((row) => row.type, { + id: 'type', + header: () => ( + + {t('key.modelType')} + + ), + cell: (info) => ( + + + {getTranslationWithFallback( + `modeType.${String(info.getValue())}`, + 'modeType.0', + t as any + )} + + + ) + }), + columnHelper.accessor((row) => row.rpm, { + id: 'rpm', + header: () => ( + + + {t('price.modelRpm')} + + + {t('price.modelRpmTooltip')} + + }> + + + + + + ), + cell: (info) => ( + + {info.getValue()} + + ) }), - columnHelper.accessor((row) => row.prompt, { - id: 'inputPrice', + columnHelper.accessor((row) => row.input_price, { + id: 'input_price', header: () => { return ( - - + + - + + + + {t('price.sortUpTooltip')} + + }> + handleSort('input_price', 'asc')} + cursor="pointer" + _hover={{ opacity: 0.8 }}> + + + + + + + {t('price.sortDownTooltip')} + + }> + handleSort('input_price', 'desc')} + cursor="pointer" + _hover={{ opacity: 0.8 }}> + + + + + + + ) }, cell: (info) => ( @@ -265,11 +894,11 @@ function PriceTable() { ) }), - columnHelper.accessor((row) => row.completion, { - id: 'outputPrice', + columnHelper.accessor((row) => row.output_price, { + id: 'output_price', header: () => ( - - + + - + + + {t('price.sortUpTooltip')} + + }> + handleSort('output_price', 'asc')} + cursor="pointer" + _hover={{ opacity: 0.8 }}> + + + + + + + {t('price.sortDownTooltip')} + + }> + handleSort('output_price', 'desc')} + cursor="pointer" + _hover={{ opacity: 0.8 }}> + + + + + + +
), cell: (info) => ( sortModelsByIdentifier(data || []), [data]) + const tableData = useMemo(() => { + if (!sortConfig.direction || !sortConfig.column) { + return modelConfigs + } + + return [...modelConfigs].sort((a, b) => { + let aValue = a[sortConfig.column as keyof ModelConfig] + let bValue = b[sortConfig.column as keyof ModelConfig] + + // 确保数值比较 + if (typeof aValue === 'string') aValue = parseFloat(aValue as string) || 0 + if (typeof bValue === 'string') bValue = parseFloat(bValue as string) || 0 + + if (sortConfig.direction === 'asc') { + return (aValue as number) - (bValue as number) + } else { + return (bValue as number) - (aValue as number) + } + }) + }, [modelConfigs, sortConfig]) const table = useReactTable({ - data: sortedData, + data: tableData, columns, getCoreRowModel: getCoreRowModel() }) - return isLoading ? ( -
- -
- ) : ( - + return ( + {table.getHeaderGroups().map((headerGroup) => ( @@ -341,22 +1063,35 @@ function PriceTable() { ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} + {isLoading ? ( + + - ))} + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ +
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
) } - -export default Price diff --git a/frontend/providers/aiproxy/app/[lng]/globals.css b/frontend/providers/aiproxy/app/[lng]/globals.css index e6e10f493dd..c356d55399f 100644 --- a/frontend/providers/aiproxy/app/[lng]/globals.css +++ b/frontend/providers/aiproxy/app/[lng]/globals.css @@ -100,42 +100,39 @@ textarea::placeholder { height: 100%; } -::-webkit-scrollbar, ::-webkit-scrollbar { - width: 8px; - height: 8px; - border-radius: 8px; + width: 6px; + height: 6px; + border-radius: 6px; } -::-webkit-scrollbar-track, + ::-webkit-scrollbar-track { + margin: 24px 0; background: transparent !important; - border-radius: 2px; + border-radius: 6px; } -::-webkit-scrollbar-thumb, + ::-webkit-scrollbar-thumb { - background: rgba(189, 193, 197, 1) !important; - border-radius: 2px; -} -::-webkit-scrollbar-thumb:hover, -::-webkit-scrollbar-thumb:hover { - background: rgba(189, 193, 197, 1) !important; + min-height: 40px; + border-radius: 6px; + background: var(--Gray-Modern-200, #e8ebf0) !important; } div { - &::-webkit-scrollbar-thumb, &::-webkit-scrollbar-thumb { + min-height: 40px; background: transparent !important; - border-radius: 2px; - transition: 1s; + border-radius: 6px; + transition: background 0.3s ease; } + + &::-webkit-scrollbar-track { + margin: 24px 0; + } + &:hover { - &::-webkit-scrollbar-thumb, &::-webkit-scrollbar-thumb { - background: rgba(189, 193, 197, 0.5) !important; - } - &::-webkit-scrollbar-thumb:hover, - &::-webkit-scrollbar-thumb:hover { - background: rgba(189, 193, 197, 1) !important; + background: var(--Gray-Modern-200, #e8ebf0) !important; } } } diff --git a/frontend/providers/aiproxy/app/[lng]/layout.tsx b/frontend/providers/aiproxy/app/[lng]/layout.tsx index 5b861c6a6b4..72d516fc5c1 100644 --- a/frontend/providers/aiproxy/app/[lng]/layout.tsx +++ b/frontend/providers/aiproxy/app/[lng]/layout.tsx @@ -5,7 +5,8 @@ import { useTranslationServerSide } from '@/app/i18n/server' import { fallbackLng, languages } from '@/app/i18n/settings' import ChakraProviders from '@/providers/chakra/providers' import { I18nProvider } from '@/providers/i18n/i18nContext' -import QueryProvider from '@/providers/chakra/QueryProvider' +import QueryProvider from '@/providers/tanstack-query/QueryProvider' +import InitializeApp from '@/components/InitializeApp' import './globals.css' import 'react-day-picker/dist/style.css' @@ -47,7 +48,10 @@ export default async function RootLayout({ - {children} + + + {children} + diff --git a/frontend/providers/aiproxy/app/api/admin/channel/[id]/route.ts b/frontend/providers/aiproxy/app/api/admin/channel/[id]/route.ts new file mode 100644 index 00000000000..5f30495b079 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/channel/[id]/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ChannelInfo } from '@/types/admin/channels/channelInfo' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' + +export const dynamic = 'force-dynamic' + +export type GetChannelsResponse = ApiResp<{ + channels: ChannelInfo[] + total: number +}> + +async function updateChannel(channelData: CreateChannelRequest, id: string): Promise { + try { + const url = new URL( + `/api/channel/${id}`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify(channelData), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to create channel') + } + } catch (error) { + console.error('admin channels api: create channel error:## ', error) + throw error + } +} + +// update channel +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + if (!params.id) { + return NextResponse.json( + { + code: 400, + message: 'Channel id is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + const channelData: CreateChannelRequest = await request.json() + await updateChannel(channelData, params.id) + + return NextResponse.json({ + code: 200, + message: 'Channel created successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin channels api: create channel error:## ', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} + +async function deleteChannel(id: string): Promise { + try { + const url = new URL( + `/api/channel/${id}`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'admin channels api:ai proxy backend error') + } + } catch (error) { + console.error('admin channels api: delete channel error:', error) + throw error + } +} + +// delete channel +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + if (!params.id) { + return NextResponse.json( + { + code: 400, + message: 'Channel id is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + await deleteChannel(params.id) + return NextResponse.json({ + code: 200, + message: 'Channel deleted successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin channels api: delete channel error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/channel/[id]/status/route.ts b/frontend/providers/aiproxy/app/api/admin/channel/[id]/status/route.ts new file mode 100644 index 00000000000..55d47d688ec --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/channel/[id]/status/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ChannelInfo } from '@/types/admin/channels/channelInfo' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' + +export const dynamic = 'force-dynamic' + +// update channel status + +async function updateChannelStatus(id: string, status: number): Promise { + try { + const url = new URL( + `/api/channel/${id}/status`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify({ status }), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to update channel status') + } + } catch (error) { + console.error('admin channels api: update channel status error:## ', error) + throw error + } +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + if (!params.id) { + return NextResponse.json( + { + code: 400, + message: 'Channel id is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + const { status }: { status: number } = await request.json() + await updateChannelStatus(params.id, status) + + return NextResponse.json({ + code: 200, + message: 'Channel status updated successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin channels api: update channel status error:## ', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/channel/all/route.ts b/frontend/providers/aiproxy/app/api/admin/channel/all/route.ts new file mode 100644 index 00000000000..31fdf12323d --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/channel/all/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ChannelInfo } from '@/types/admin/channels/channelInfo' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' + +export const dynamic = 'force-dynamic' + +type ApiProxyBackendAllChannelResponse = ApiProxyBackendResp + +export type GetAllChannelResponse = ApiResp + +async function fetchChannels(): Promise { + try { + const url = new URL( + `/api/channels/all`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + const result: ApiProxyBackendAllChannelResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'admin channels api:ai proxy backend error') + } + return result?.data || [] + } catch (error) { + console.error('admin channels api: fetch all channels from ai proxy backend error:', error) + throw error + } +} + +// get all channels +export async function GET(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + const channels = await fetchChannels() + return NextResponse.json({ + code: 200, + data: channels + } satisfies GetAllChannelResponse) + } catch (error) { + console.error('admin channels api: get all channels error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies GetAllChannelResponse, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/channel/route.ts b/frontend/providers/aiproxy/app/api/admin/channel/route.ts new file mode 100644 index 00000000000..58c73c63843 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/channel/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ChannelInfo } from '@/types/admin/channels/channelInfo' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' + +export const dynamic = 'force-dynamic' + +type ApiProxyBackendChannelsSearchResponse = ApiProxyBackendResp<{ + channels: ChannelInfo[] + total: number +}> + +export type ChannelQueryParams = { + page: number + perPage: number +} + +export type GetChannelsResponse = ApiResp<{ + channels: ChannelInfo[] + total: number +}> + +function validateParams(queryParams: ChannelQueryParams): string | null { + if (queryParams.page < 1) { + return 'Page number must be greater than 0' + } + if (queryParams.perPage < 1 || queryParams.perPage > 100) { + return 'Per page must be between 1 and 100' + } + return null +} + +async function fetchChannels( + queryParams: ChannelQueryParams +): Promise<{ channels: ChannelInfo[]; total: number }> { + try { + const url = new URL( + `/api/channels/search`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + url.searchParams.append('p', queryParams.page.toString()) + url.searchParams.append('per_page', queryParams.perPage.toString()) + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + const result: ApiProxyBackendChannelsSearchResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'admin channels api:ai proxy backend error') + } + return { + channels: result?.data?.channels || [], + total: result?.data?.total || 0 + } + } catch (error) { + console.error('admin channels api: fetch channels from ai proxy backend error:', error) + throw error + } +} + +async function createChannel(channelData: CreateChannelRequest): Promise { + try { + const url = new URL( + `/api/channel/`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify(channelData), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to create channel') + } + } catch (error) { + console.error('admin channels api: create channel error:', error) + throw error + } +} + +// get channels +export async function GET(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + const searchParams = request.nextUrl.searchParams + + const queryParams: ChannelQueryParams = { + page: parseInt(searchParams.get('page') || '1', 10), + perPage: parseInt(searchParams.get('perPage') || '10', 10) + } + + const validationError = validateParams(queryParams) + if (validationError) { + return NextResponse.json( + { + code: 400, + message: validationError, + error: validationError + }, + { status: 400 } + ) + } + + const { channels, total } = await fetchChannels(queryParams) + return NextResponse.json({ + code: 200, + data: { + channels: channels, + total: total + } + } satisfies GetChannelsResponse) + } catch (error) { + console.error('admin channels api: get channels error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies GetChannelsResponse, + { status: 500 } + ) + } +} + +// create channel +export async function POST(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + const channelData: CreateChannelRequest = await request.json() + await createChannel(channelData) + + return NextResponse.json({ + code: 200, + message: 'Channel created successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin channels api: create channel error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/channel/type-name/route.ts b/frontend/providers/aiproxy/app/api/admin/channel/type-name/route.ts new file mode 100644 index 00000000000..605ec2fb285 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/channel/type-name/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ChannelTypeMapName } from '@/types/admin/channels/channelInfo' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' + +export const dynamic = 'force-dynamic' + +type ApiProxyBackendChannelTypeMapNameResponse = ApiProxyBackendResp + +export type GetChannelTypeNamesResponse = ApiResp + +async function fetchChannelTypeNames(): Promise { + try { + const url = new URL( + `/api/channels/type_names`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + const result: ApiProxyBackendChannelTypeMapNameResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'admin channels api:ai proxy backend error') + } + return result.data + } catch (error) { + console.error( + 'admin channels api: fetch channel type names from ai proxy backend error:', + error + ) + throw error + } +} + +export async function GET( + request: NextRequest +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + const channelTypeNames = await fetchChannelTypeNames() + + return NextResponse.json({ + code: 200, + data: channelTypeNames + } satisfies GetChannelTypeNamesResponse) + } catch (error) { + console.error('admin channels api: get channel type names error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies GetChannelTypeNamesResponse, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/channel/upload/route.ts b/frontend/providers/aiproxy/app/api/admin/channel/upload/route.ts new file mode 100644 index 00000000000..6c2a7e0153d --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/channel/upload/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { CreateChannelRequest } from '@/types/admin/channels/channelInfo' + +export const dynamic = 'force-dynamic' + +// 解析文件内容 +async function parseFormData(req: NextRequest): Promise { + try { + const formData = await req.formData() + const file = formData.get('file') + + if (!file || !(file instanceof File)) { + throw new Error('No file uploaded') + } + + // 读取文件内容 + const fileContent = await file.text() + const channelData = JSON.parse(fileContent) + + if (!Array.isArray(channelData)) { + throw new Error('Invalid file format: expected array of channel data') + } + + return channelData + } catch (error) { + throw error + } +} + +// 创建通道 +async function createChannels(channelData: CreateChannelRequest[]): Promise { + const url = new URL( + '/api/channels/', + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify(channelData), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to create channels') + } +} + +// 处理上传请求 +export async function POST(request: NextRequest): Promise> { + try { + // 验证管理员权限 + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + // 解析上传的文件 + const channelData = await parseFormData(request) + + // 创建通道 + await createChannels(channelData) + + return NextResponse.json({ + code: 200, + message: 'Channels created successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin channels api: create channels error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/group/[id]/qpm/route.ts b/frontend/providers/aiproxy/app/api/admin/group/[id]/qpm/route.ts new file mode 100644 index 00000000000..f5c3ce62e71 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/group/[id]/qpm/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' + +export const dynamic = 'force-dynamic' + +async function updateGroupQpm(qpm: number, id: string): Promise { + try { + const url = new URL( + `/api/group/${id}/qpm`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify({ qpm: qpm }), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to update group qpm') + } + } catch (error) { + console.error('admin groups api: update group qpm error:## ', error) + throw error + } +} + +// update group qpm +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + if (!params.id) { + return NextResponse.json( + { + code: 400, + message: 'Group id is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + const { qpm }: { qpm: number } = await request.json() + await updateGroupQpm(qpm, params.id) + + return NextResponse.json({ + code: 200, + message: 'Group qpm updated successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin groups api: update group qpm error:## ', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/delete-key/[id]/route.ts b/frontend/providers/aiproxy/app/api/admin/group/[id]/route.ts similarity index 61% rename from frontend/providers/aiproxy/app/api/delete-key/[id]/route.ts rename to frontend/providers/aiproxy/app/api/admin/group/[id]/route.ts index 26ed34f6ff2..d197311e895 100644 --- a/frontend/providers/aiproxy/app/api/delete-key/[id]/route.ts +++ b/frontend/providers/aiproxy/app/api/admin/group/[id]/route.ts @@ -1,18 +1,16 @@ +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { parseJwtToken } from '@/utils/backend/auth' +import { isAdmin } from '@/utils/backend/isAdmin' import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/auth' -export const dynamic = 'force-dynamic' -interface DeleteTokenResponse { - message: string - success: boolean -} - -async function deleteToken(group: string, id: string): Promise { +// delete +async function deleteGroup(id: string): Promise { try { const url = new URL( - `/api/token/${group}/${id}`, + `/api/group/${id}`, global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy ) + const token = global.AppConfig?.auth.aiProxyBackendKey const response = await fetch(url.toString(), { @@ -28,12 +26,13 @@ async function deleteToken(group: string, id: string): Promise { throw new Error(`HTTP error! status: ${response.status}`) } - const result: DeleteTokenResponse = await response.json() + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { - throw new Error(result.message || 'Failed to delete token') + throw new Error(result.message || 'API request failed') } } catch (error) { - console.error('Error deleting token:', error) + console.error('Error deleting group:', error) throw error } } @@ -41,32 +40,30 @@ async function deleteToken(group: string, id: string): Promise { export async function DELETE( request: NextRequest, { params }: { params: { id: string } } -): Promise { +): Promise> { try { - // 验证用户权限 - const userGroup = await parseJwtToken(request.headers) + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) - // 验证 ID 参数 if (!params.id) { return NextResponse.json( { code: 400, - message: 'Token ID is required', + message: 'Group id is required', error: 'Bad Request' }, { status: 400 } ) } - // 删除 Token - await deleteToken(userGroup, params.id) + await deleteGroup(params.id) return NextResponse.json({ code: 200, - message: 'Token deleted successfully' - }) + message: 'Group deleted successfully' + } satisfies ApiResp) } catch (error) { - console.error('Token deletion error:', error) + console.error('Groups search error:', error) return NextResponse.json( { code: 500, diff --git a/frontend/providers/aiproxy/app/api/admin/group/[id]/status/route.ts b/frontend/providers/aiproxy/app/api/admin/group/[id]/status/route.ts new file mode 100644 index 00000000000..1671e00afa8 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/group/[id]/status/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { GroupStatus } from '@/types/admin/group' + +export const dynamic = 'force-dynamic' + +async function updateGroup(status: GroupStatus, id: string): Promise { + try { + const url = new URL( + `/api/group/${id}/status`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify({ status: status }), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to update group status') + } + } catch (error) { + console.error('admin groups api: update group status error:## ', error) + throw error + } +} + +// update group status +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + if (!params.id) { + return NextResponse.json( + { + code: 400, + message: 'Group id is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + const { status }: { status: GroupStatus } = await request.json() + await updateGroup(status, params.id) + + return NextResponse.json({ + code: 200, + message: 'Group status updated successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin groups api: update group status error:## ', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/group/route.ts b/frontend/providers/aiproxy/app/api/admin/group/route.ts new file mode 100644 index 00000000000..6f3586197a5 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/group/route.ts @@ -0,0 +1,125 @@ +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { parseJwtToken } from '@/utils/backend/auth' +import { isAdmin } from '@/utils/backend/isAdmin' +import { NextRequest, NextResponse } from 'next/server' +import { GroupInfo } from '@/types/admin/group' + +export const dynamic = 'force-dynamic' + +export type ApiProxyBackendGroupSearchResponse = ApiProxyBackendResp<{ + groups: GroupInfo[] + total: number +}> + +export type GroupSearchResponse = ApiResp<{ + groups: GroupInfo[] + total: number +}> + +export interface GroupQueryParams { + keyword?: string + page: number + perPage: number +} + +function validateParams(params: GroupQueryParams): string | null { + if (params.page < 1) { + return 'Page number must be greater than 0' + } + if (params.perPage < 1 || params.perPage > 100) { + return 'Per page must be between 1 and 100' + } + return null +} + +async function fetchGroups( + params: GroupQueryParams +): Promise<{ groups: GroupInfo[]; total: number }> { + try { + const url = new URL( + `/api/groups/search`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + url.searchParams.append('p', params.page.toString()) + url.searchParams.append('per_page', params.perPage.toString()) + + if (params.keyword) { + url.searchParams.append('keyword', params.keyword) + } + + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendGroupSearchResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'API request failed') + } + + return { + groups: result.data?.groups || [], + total: result.data?.total || 0 + } + } catch (error) { + console.error('Error fetching groups:', error) + throw error + } +} + +export async function GET(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + const searchParams = request.nextUrl.searchParams + + const queryParams: GroupQueryParams = { + page: parseInt(searchParams.get('page') || '1', 10), + perPage: parseInt(searchParams.get('perPage') || '10', 10), + keyword: searchParams.get('keyword') || undefined + } + + const validationError = validateParams(queryParams) + if (validationError) { + return NextResponse.json( + { + code: 400, + message: validationError, + error: validationError + }, + { status: 400 } + ) + } + + const { groups, total } = await fetchGroups(queryParams) + + return NextResponse.json({ + code: 200, + data: { + groups, + total + } + } satisfies GroupSearchResponse) + } catch (error) { + console.error('Groups search error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/get-logs/route.ts b/frontend/providers/aiproxy/app/api/admin/log/route.ts similarity index 73% rename from frontend/providers/aiproxy/app/api/get-logs/route.ts rename to frontend/providers/aiproxy/app/api/admin/log/route.ts index 5f9fd0a93be..da52fe7275f 100644 --- a/frontend/providers/aiproxy/app/api/get-logs/route.ts +++ b/frontend/providers/aiproxy/app/api/admin/log/route.ts @@ -1,28 +1,32 @@ -import { LogItem } from '@/types/log' -import { parseJwtToken } from '@/utils/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { GlobalLogItem } from '@/types/user/logs' +import { parseJwtToken } from '@/utils/backend/auth' +import { isAdmin } from '@/utils/backend/isAdmin' import { NextRequest, NextResponse } from 'next/server' export const dynamic = 'force-dynamic' -export interface SearchResponse { - data: { - logs: LogItem[] - total: number - } - message: string - success: boolean -} +export type ApiProxyBackendGlobalLogSearchResponse = ApiProxyBackendResp<{ + logs: GlobalLogItem[] + total: number +}> + +export type GlobalLogSearchResponse = ApiResp<{ + logs: GlobalLogItem[] + total: number +}> -export interface QueryParams { +export interface GlobalLogQueryParams { token_name?: string model_name?: string code?: string start_timestamp?: string end_timestamp?: string + group_id?: string page: number perPage: number } -function validateParams(params: QueryParams): string | null { +function validateParams(params: GlobalLogQueryParams): string | null { if (params.page < 1) { return 'Page number must be greater than 0' } @@ -38,12 +42,11 @@ function validateParams(params: QueryParams): string | null { } async function fetchLogs( - params: QueryParams, - group: string -): Promise<{ logs: LogItem[]; total: number }> { + params: GlobalLogQueryParams +): Promise<{ logs: GlobalLogItem[]; total: number }> { try { const url = new URL( - `/api/log/${group}/search`, + `/api/logs/search`, global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy ) @@ -59,6 +62,9 @@ async function fetchLogs( if (params.code) { url.searchParams.append('code', params.code) } + if (params.group_id) { + url.searchParams.append('group_id', params.group_id) + } if (params.start_timestamp) { url.searchParams.append('start_timestamp', params.start_timestamp) } @@ -81,14 +87,14 @@ async function fetchLogs( throw new Error(`HTTP error! status: ${response.status}`) } - const result: SearchResponse = await response.json() + const result: ApiProxyBackendGlobalLogSearchResponse = await response.json() if (!result.success) { throw new Error(result.message || 'API request failed') } return { - logs: result.data.logs, - total: result.data.total + logs: result.data?.logs || [], + total: result.data?.total || 0 } } catch (error) { console.error('Error fetching logs:', error) @@ -96,12 +102,13 @@ async function fetchLogs( } } -export async function GET(request: NextRequest): Promise { +export async function GET(request: NextRequest): Promise> { try { - const group = await parseJwtToken(request.headers) + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) const searchParams = request.nextUrl.searchParams - const queryParams: QueryParams = { + const queryParams: GlobalLogQueryParams = { page: parseInt(searchParams.get('page') || '1', 10), perPage: parseInt(searchParams.get('perPage') || '10', 10), token_name: searchParams.get('token_name') || undefined, @@ -123,7 +130,7 @@ export async function GET(request: NextRequest): Promise { ) } - const { logs, total } = await fetchLogs(queryParams, group) + const { logs, total } = await fetchLogs(queryParams) return NextResponse.json({ code: 200, @@ -131,7 +138,7 @@ export async function GET(request: NextRequest): Promise { logs, total } - }) + } satisfies GlobalLogSearchResponse) } catch (error) { console.error('Logs search error:', error) return NextResponse.json( diff --git a/frontend/providers/aiproxy/app/api/admin/option/batch/route.ts b/frontend/providers/aiproxy/app/api/admin/option/batch/route.ts new file mode 100644 index 00000000000..70210e5402a --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/option/batch/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { BatchOptionData } from '@/types/admin/option' + +export const dynamic = 'force-dynamic' + +async function batchOption(batchOptionData: BatchOptionData): Promise { + try { + const url = new URL( + `/api/option/batch`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify(batchOptionData), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to batch option') + } + + return result.message + } catch (error) { + console.error('admin batch options api: update option error:', error) + throw error + } +} + +function validateBatchOptionData(batchOptionData: BatchOptionData): boolean { + if (typeof batchOptionData.DefaultChannelModelMapping !== 'string') { + return false + } + if (typeof batchOptionData.DefaultChannelModels !== 'string') { + return false + } + return true +} + +export async function PUT(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + const body = await request.json() + if (!validateBatchOptionData(body)) { + return NextResponse.json( + { + code: 400, + message: 'Invalid request body', + error: 'Invalid request body' + } satisfies ApiResp, + { status: 400 } + ) + } + + await batchOption(body) + + return NextResponse.json({ + code: 200, + message: 'Option batch updated successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin batch options api: put option error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/option/route.ts b/frontend/providers/aiproxy/app/api/admin/option/route.ts new file mode 100644 index 00000000000..564ce5e7226 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/option/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from 'next/server' +import { OptionData } from '@/types/admin/option' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' + +export const dynamic = 'force-dynamic' + +type ApiProxyBackendOptionResponse = ApiProxyBackendResp + +export type GetOptionResponse = ApiResp + +async function fetchOptions(): Promise { + try { + const url = new URL( + `/api/option/`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendOptionResponse = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Failed to fetch options') + } + + return result.data + } catch (error) { + console.error('admin options api: fetch options error:', error) + throw error + } +} + +export async function GET(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + const optionData = await fetchOptions() + + return NextResponse.json({ + code: 200, + data: optionData + } satisfies GetOptionResponse) + } catch (error) { + console.error('admin options api: get options error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies GetOptionResponse, + { status: 500 } + ) + } +} + +async function updateOption(key: string, value: string): Promise { + try { + const url = new URL( + `/api/option`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify({ key, value }), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendOptionResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to update option') + } + + return result.message + } catch (error) { + console.error('admin options api: update option error:', error) + throw error + } +} + +export async function PUT(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + const body = await request.json() + if (!body.key || typeof body.key !== 'string') { + return NextResponse.json( + { + code: 400, + message: 'Invalid request body: key is required and must be a string', + error: 'Invalid request parameters' + } satisfies GetOptionResponse, + { status: 400 } + ) + } + + if (!body.value || typeof body.value !== 'string') { + return NextResponse.json( + { + code: 400, + message: 'Invalid request body: value is required and must be a string', + error: 'Invalid request parameters' + } satisfies GetOptionResponse, + { status: 400 } + ) + } + + await updateOption(body.key, body.value) + + return NextResponse.json({ + code: 200, + message: 'Option updated successfully' + } satisfies GetOptionResponse) + } catch (error) { + console.error('admin options api: put option error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies GetOptionResponse, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/admin/option/upload/route.ts b/frontend/providers/aiproxy/app/api/admin/option/upload/route.ts new file mode 100644 index 00000000000..799fdc1d8dc --- /dev/null +++ b/frontend/providers/aiproxy/app/api/admin/option/upload/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { OptionData } from '@/types/admin/option' + +export const dynamic = 'force-dynamic' + +async function parseFormData(req: NextRequest): Promise { + try { + const formData = await req.formData() + const file = formData.get('file') + + if (!file || !(file instanceof File)) { + throw new Error('No file uploaded') + } + + const fileContent = await file.text() + const optionData = JSON.parse(fileContent) + + if (typeof optionData !== 'object' || optionData === null) { + throw new Error('Invalid file format: expected option data object') + } + + return optionData + } catch (error) { + throw error + } +} + +async function batchOption(batchOptionData: OptionData): Promise { + try { + const url = new URL( + `/api/option/batch`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify(batchOptionData), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to batch option') + } + + return result.message + } catch (error) { + console.error('admin batch options upload api: update option error:', error) + throw error + } +} + +export async function POST(request: NextRequest): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + const optionData = await parseFormData(request) + + await batchOption(optionData) + + return NextResponse.json({ + code: 200, + message: 'Option batch uploaded successfully' + } satisfies ApiResp) + } catch (error) { + console.error('admin batch options upload api: put option error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/create-key/route.ts b/frontend/providers/aiproxy/app/api/create-key/route.ts deleted file mode 100644 index 89161f4457e..00000000000 --- a/frontend/providers/aiproxy/app/api/create-key/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/auth' - -export const dynamic = 'force-dynamic' - -interface CreateTokenRequest { - name: string -} - -interface TokenInfo { - id: number - group: string - key: string - status: number - name: string - quota: number - used_amount: number - request_count: number - models: string[] | null - subnet: string - created_at: number - accessed_at: number - expired_at: number -} - -interface CreateTokenResponse { - data: TokenInfo - message: string - success: boolean -} - -function validateCreateParams(body: CreateTokenRequest): string | null { - if (!body.name) { - return 'Name parameter is required' - } - return null -} - -async function createToken(name: string, group: string): Promise { - try { - const url = new URL( - `/api/token/${group}?auto_create_group=true`, - global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy - ) - const token = global.AppConfig?.auth.aiProxyBackendKey - const response = await fetch(url.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `${token}` - }, - cache: 'no-store', - body: JSON.stringify({ - name - }) - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const result: CreateTokenResponse = await response.json() - if (!result.success) { - throw new Error(result.message || 'Failed to create token') - } - - return result.data - } catch (error) { - console.error('Error creating token:', error) - throw error - } -} - -export async function POST(request: NextRequest): Promise { - try { - const group = await parseJwtToken(request.headers) - const body: CreateTokenRequest = await request.json() - - const validationError = validateCreateParams(body) - if (validationError) { - return NextResponse.json( - { - code: 400, - message: validationError, - error: validationError - }, - { status: 400 } - ) - } - - // 创建Token - const newToken = await createToken(body.name, group) - - return NextResponse.json({ - code: 200, - data: newToken, - message: 'Token created successfully' - }) - } catch (error) { - console.error('Token creation error:', error) - return NextResponse.json( - { - code: 500, - message: error instanceof Error ? error.message : 'Internal server error', - error: error instanceof Error ? error.message : 'Internal server error' - }, - { status: 500 } - ) - } -} diff --git a/frontend/providers/aiproxy/app/api/get-keys/route.ts b/frontend/providers/aiproxy/app/api/get-keys/route.ts deleted file mode 100644 index 13d3ff9c136..00000000000 --- a/frontend/providers/aiproxy/app/api/get-keys/route.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { TokenInfo } from '@/types/getKeys' - -import { parseJwtToken } from '@/utils/auth' - -export const dynamic = 'force-dynamic' -export interface KeysSearchResponse { - data: { - tokens: TokenInfo[] - total: number - } - message: string - success: boolean -} - -export interface QueryParams { - page: number - perPage: number -} - -function validateParams(page: number, perPage: number): string | null { - if (page < 1) { - return 'Page number must be greater than 0' - } - - if (perPage < 1 || perPage > 100) { - return 'Per page must be between 1 and 100' - } - - return null -} - -async function fetchTokens( - page: number, - perPage: number, - group: string -): Promise<{ tokens: TokenInfo[]; total: number }> { - try { - const url = new URL( - `/api/token/${group}/search`, - global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy - ) - url.searchParams.append('p', page.toString()) - url.searchParams.append('per_page', perPage.toString()) - - const token = global.AppConfig?.auth.aiProxyBackendKey - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `${token}` - }, - cache: 'no-store' - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const result: KeysSearchResponse = await response.json() - - if (!result.success) { - throw new Error(result.message || 'API request failed') - } - - return { - tokens: result.data.tokens.sort((a, b) => a.name.localeCompare(b.name)), - total: result.data.total - } - } catch (error) { - console.error('Error fetching tokens:', error) - return { - tokens: [], - total: 0 - } - } -} - -export async function GET(request: NextRequest): Promise { - try { - const group = await parseJwtToken(request.headers) - - const searchParams = request.nextUrl.searchParams - const page = parseInt(searchParams.get('page') || '1', 10) - const perPage = parseInt(searchParams.get('perPage') || '10', 10) - - const validationError = validateParams(page, perPage) - if (validationError) { - return NextResponse.json( - { - code: 400, - message: validationError, - error: validationError - }, - { status: 400 } - ) - } - - const { tokens, total } = await fetchTokens(page, perPage, group) - - return NextResponse.json({ - code: 200, - data: { - tokens, - total - } - }) - } catch (error) { - console.error('Token search error:', error) - - return NextResponse.json( - { - code: 500, - message: error instanceof Error ? error.message : 'Internal server error', - error: error instanceof Error ? error.message : 'Internal server error' - }, - { status: 500 } - ) - } -} diff --git a/frontend/providers/aiproxy/app/api/get-mode-price/route.ts b/frontend/providers/aiproxy/app/api/get-mode-price/route.ts deleted file mode 100644 index f9b6e0680e5..00000000000 --- a/frontend/providers/aiproxy/app/api/get-mode-price/route.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/auth' -import { ModelPrice } from '@/types/backend' - -export const dynamic = 'force-dynamic' - -interface PriceResponse { - data: Record< - string, - { - prompt: number - completion: number - } - > - message: string - success: boolean -} - -function transformToList( - data: Record -): ModelPrice[] { - return Object.entries(data).map(([name, prices]) => ({ - name, - prompt: prices.prompt, - completion: prices.completion - })) -} - -async function fetchModelPrices(): Promise { - try { - const url = new URL( - `/api/models/enabled/price`, - global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy - ) - const token = global.AppConfig?.auth.aiProxyBackendKey - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `${token}` - }, - cache: 'no-store' - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const result: PriceResponse = await response.json() - - if (!result.success) { - throw new Error(result.message || 'get model prices API request failed') - } - - return transformToList(result.data) - } catch (error) { - console.error('Error fetching model prices:', error) - return Promise.reject(error) - } -} - -export async function GET(request: NextRequest): Promise { - try { - await parseJwtToken(request.headers) - const modelPrices = await fetchModelPrices() - - return NextResponse.json({ - code: 200, - data: modelPrices - }) - } catch (error) { - console.error('get model prices error:', error) - return NextResponse.json( - { - code: 500, - message: error instanceof Error ? error.message : 'Internal server error', - error: error instanceof Error ? error.message : 'Internal server error' - }, - { status: 500 } - ) - } -} diff --git a/frontend/providers/aiproxy/app/api/get-models/route.ts b/frontend/providers/aiproxy/app/api/get-models/route.ts deleted file mode 100644 index 7f2d205fb48..00000000000 --- a/frontend/providers/aiproxy/app/api/get-models/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' - -import { parseJwtToken } from '@/utils/auth' - -export const dynamic = 'force-dynamic' - -interface SearchResponse { - data: string[] - message: string - success: boolean -} - -async function fetchModels(): Promise { - try { - const url = new URL( - `/api/models/enabled`, - global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy - ) - const token = global.AppConfig?.auth.aiProxyBackendKey - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `${token}` - }, - cache: 'no-store' - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const result: SearchResponse = await response.json() - - if (!result.success) { - throw new Error(result.message || 'get models API request failed') - } - - return result.data.sort((a, b) => a.localeCompare(b)) - } catch (error) { - console.error('Error fetching models:', error) - return Promise.reject(error) - } -} - -export async function GET(request: NextRequest): Promise { - try { - await parseJwtToken(request.headers) - - const models = await fetchModels() - - return NextResponse.json({ - code: 200, - data: models - }) - } catch (error) { - console.error('get models error:', error) - - return NextResponse.json( - { - code: 500, - message: error instanceof Error ? error.message : 'Internal server error', - error: error instanceof Error ? error.message : 'Internal server error' - }, - { status: 500 } - ) - } -} diff --git a/frontend/providers/aiproxy/app/api/init-app-config/route.ts b/frontend/providers/aiproxy/app/api/init-app-config/route.ts index 359ad4d4ec3..91061dc0148 100644 --- a/frontend/providers/aiproxy/app/api/init-app-config/route.ts +++ b/frontend/providers/aiproxy/app/api/init-app-config/route.ts @@ -1,9 +1,13 @@ import { NextResponse } from 'next/server' -import type { AppConfigType } from '@/types/appConfig' +import type { AppConfigType } from '@/types/app-config' export const dynamic = 'force-dynamic' +function getAdminNamespaces(): string[] { + return process.env.ADMIN_NAMESPACES?.split(',') || [] +} + function getAppConfig(appConfig: AppConfigType): AppConfigType { if (process.env.APP_TOKEN_JWT_KEY) { appConfig.auth.appTokenJwtKey = process.env.APP_TOKEN_JWT_KEY @@ -17,6 +21,9 @@ function getAppConfig(appConfig: AppConfigType): AppConfigType { if (process.env.AI_PROXY_BACKEND_INTERNAL) { appConfig.backend.aiproxyInternal = process.env.AI_PROXY_BACKEND_INTERNAL } + if (process.env.ADMIN_NAMESPACES) { + appConfig.adminNameSpace = getAdminNamespaces() + } if (process.env.CURRENCY_SYMBOL) { appConfig.currencySymbol = process.env.CURRENCY_SYMBOL as 'shellCoin' | 'cny' | 'usd' } @@ -34,13 +41,15 @@ function initAppConfig(): AppConfigType { aiproxy: '', aiproxyInternal: '' }, + adminNameSpace: [], currencySymbol: 'shellCoin' } + if (!global.AppConfig) { try { global.AppConfig = getAppConfig(DefaultAppConfig) } catch (error) { - console.error('Config initialization error:', error) + console.error('init-app-config: Config initialization error:', error) global.AppConfig = DefaultAppConfig } } diff --git a/frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts b/frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts new file mode 100644 index 00000000000..b425fe6b76b --- /dev/null +++ b/frontend/providers/aiproxy/app/api/models/builtin/channel/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { ChannelWithMode } from '@/types/models/model' + +export const dynamic = 'force-dynamic' + +type ApiProxyBackendAllChannelEnabledModeResponse = ApiProxyBackendResp +export type GetAllChannelEnabledModelsResponse = ApiResp + +async function fetchAllChannelEnabledModels(): Promise { + try { + const url = new URL( + '/api/models/builtin/channel', + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${global.AppConfig?.auth.aiProxyBackendKey}` + }, + cache: 'no-store' + }) + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + const result: ApiProxyBackendAllChannelEnabledModeResponse = await response.json() + + if (!result.success) { + throw new Error(result.message || 'builtin channel api: ai proxy backend error') + } + + return result.data || {} + } catch (error) { + console.error('builtin channel api: fetch enabled models error:', error) + throw error + } +} + +export async function GET( + request: NextRequest +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + return NextResponse.json({ + code: 200, + data: await fetchAllChannelEnabledModels() + } satisfies GetAllChannelEnabledModelsResponse) + } catch (error) { + console.error('builtin channel api: get enabled models error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/models/default/route.ts b/frontend/providers/aiproxy/app/api/models/default/route.ts new file mode 100644 index 00000000000..02eccc49e7f --- /dev/null +++ b/frontend/providers/aiproxy/app/api/models/default/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { isAdmin } from '@/utils/backend/isAdmin' +import { ChannelWithDefaultModelAndDefaultModeMapping } from '@/types/models/model' + +export const dynamic = 'force-dynamic' + +type ApiProxyBackendDefaultModelAndModeMappingResponse = + ApiProxyBackendResp +export type GetDefaultModelAndModeMappingResponse = + ApiResp + +async function fetchDefaultModeAndModeMapping(): Promise< + ChannelWithDefaultModelAndDefaultModeMapping | undefined +> { + try { + const url = new URL( + '/api/models/default', + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${global.AppConfig?.auth.aiProxyBackendKey}` + }, + cache: 'no-store' + }) + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + const result: ApiProxyBackendDefaultModelAndModeMappingResponse = await response.json() + + if (!result.success) { + throw new Error(result.message || 'default enabled models api: ai proxy backend error') + } + + return result?.data + } catch (error) { + console.error('default enabled models api: fetch enabled models error:', error) + throw error + } +} + +export async function GET( + request: NextRequest +): Promise> { + try { + const namespace = await parseJwtToken(request.headers) + await isAdmin(namespace) + + return NextResponse.json({ + code: 200, + data: await fetchDefaultModeAndModeMapping() + }) + } catch (error) { + console.error('default enabled models api: get enabled models error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/models/enabled/route.ts b/frontend/providers/aiproxy/app/api/models/enabled/route.ts new file mode 100644 index 00000000000..0db3d4a2c36 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/models/enabled/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { ModelConfig } from '@/types/models/model' + +type ApiProxyBackendEnabledModelsResponse = ApiProxyBackendResp +export type GetEnabledModelsResponse = ApiResp + +export const dynamic = 'force-dynamic' + +async function fetchEnabledModels(): Promise { + try { + const url = new URL( + '/api/models/enabled', + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${global.AppConfig?.auth.aiProxyBackendKey}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error, status code: ${response.status}`) + } + + const result: ApiProxyBackendEnabledModelsResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'enabled models api: ai proxy backend error') + } + + return result.data || [] + } catch (error) { + console.error('enabled models api: fetch enabled models error:', error) + throw error + } +} + +export async function GET(request: NextRequest): Promise> { + try { + await parseJwtToken(request.headers) + + return NextResponse.json({ + code: 200, + data: await fetchEnabledModels() + } satisfies GetEnabledModelsResponse) + } catch (error) { + console.error('enabled models api: get enabled models error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'server error', + error: error instanceof Error ? error.message : 'server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/update-key/[id]/route.ts b/frontend/providers/aiproxy/app/api/update-key/[id]/route.ts deleted file mode 100644 index c0665abd3ff..00000000000 --- a/frontend/providers/aiproxy/app/api/update-key/[id]/route.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { parseJwtToken } from '@/utils/auth' - -export const dynamic = 'force-dynamic' -interface UpdateTokenResponse { - message: string - success: boolean -} - -interface UpdateTokenBody { - status: number -} - -async function updateToken(group: string, id: string, status: number): Promise { - try { - if (status !== 1 && status !== 2) { - throw new Error('Invalid status') - } - const url = new URL( - `/api/token/${group}/${id}/status`, - global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy - ) - const token = global.AppConfig?.auth.aiProxyBackendKey - - const response = await fetch(url.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `${token}` - }, - body: JSON.stringify({ status }), - cache: 'no-store' - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const result: UpdateTokenResponse = await response.json() - if (!result.success) { - throw new Error(result.message || 'Failed to update token') - } - } catch (error) { - console.error('Error updating token:', error) - throw error - } -} - -export async function POST( - request: NextRequest, - { params }: { params: { id: string } } -): Promise { - try { - // 验证用户权限 - const userGroup = await parseJwtToken(request.headers) - - // 验证 ID 参数 - if (!params.id) { - return NextResponse.json( - { - code: 400, - message: 'Token ID is required', - error: 'Bad Request' - }, - { status: 400 } - ) - } - - // 获取请求体 - const body: UpdateTokenBody = await request.json() - - // 验证状态参数 - if (typeof body.status !== 'number') { - return NextResponse.json( - { - code: 400, - message: 'Status must be a number', - error: 'Bad Request' - }, - { status: 400 } - ) - } - - // 更新 Token - await updateToken(userGroup, params.id, body.status) - - return NextResponse.json({ - code: 200, - message: 'Token updated successfully' - }) - } catch (error) { - console.error('Token update error:', error) - return NextResponse.json( - { - code: 500, - message: error instanceof Error ? error.message : 'Internal server error', - error: error instanceof Error ? error.message : 'Internal server error' - }, - { status: 500 } - ) - } -} diff --git a/frontend/providers/aiproxy/app/api/user/dashboard/route.ts b/frontend/providers/aiproxy/app/api/user/dashboard/route.ts new file mode 100644 index 00000000000..2fbbd104e03 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/user/dashboard/route.ts @@ -0,0 +1,129 @@ +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { NextRequest, NextResponse } from 'next/server' +import { parseJwtToken } from '@/utils/backend/auth' +import { DashboardData } from '@/types/user/dashboard' +import { DashboardResponse } from '@/types/user/dashboard' + +// 定义API响应数据结构 +export type ApiProxyBackendDashboardResponse = ApiProxyBackendResp + +// 定义查询参数接口 +export interface DashboardQueryParams { + type: 'day' | 'week' | 'two_week' | 'month' + model?: string + token_name?: string +} + +// 验证查询参数 +function validateParams(params: DashboardQueryParams): string | null { + if (!params.type) { + return 'Type parameter is required' + } + if ( + params.type !== 'day' && + params.type !== 'week' && + params.type !== 'two_week' && + params.type !== 'month' + ) { + return 'Invalid type parameter. Must be one of: day, week, two_week, month' + } + return null +} + +// 获取仪表盘数据 +async function fetchDashboardData( + params: DashboardQueryParams, + group: string +): Promise { + try { + const url = new URL( + `/api/dashboard/${group}`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + url.searchParams.append('type', params.type) + if (params.model) { + url.searchParams.append('model', params.model) + } + + if (params.token_name) { + url.searchParams.append('token_name', params.token_name) + } + + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendDashboardResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'API request failed') + } + + return { + chart_data: result.data?.chart_data || [], + token_names: result.data?.token_names || [], + models: result.data?.models || [], + total_count: result.data?.total_count || 0, + exception_count: result.data?.exception_count || 0, + used_amount: result.data?.used_amount || 0, + rpm: result.data?.rpm || 0, + tpm: result.data?.tpm || 0 + } + } catch (error) { + console.error('Error fetching dashboard data:', error) + throw error + } +} + +export async function GET(request: NextRequest): Promise> { + try { + const group = await parseJwtToken(request.headers) + const searchParams = request.nextUrl.searchParams + + const queryParams: DashboardQueryParams = { + type: (searchParams.get('type') as 'day' | 'week' | 'two_week' | 'month') || 'week', + model: searchParams.get('model') || undefined, + token_name: searchParams.get('token_name') || undefined + } + + const validationError = validateParams(queryParams) + if (validationError) { + return NextResponse.json( + { + code: 400, + message: validationError, + error: validationError + }, + { status: 400 } + ) + } + + const dashboardData = await fetchDashboardData(queryParams, group) + + return NextResponse.json({ + code: 200, + data: dashboardData + } satisfies DashboardResponse) + } catch (error) { + console.error('Dashboard fetch error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/user/log/detail/[log_id]/route.ts b/frontend/providers/aiproxy/app/api/user/log/detail/[log_id]/route.ts new file mode 100644 index 00000000000..a0bb12a9f68 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/user/log/detail/[log_id]/route.ts @@ -0,0 +1,85 @@ +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { RequestDetail } from '@/types/user/logs' +import { parseJwtToken } from '@/utils/backend/auth' +import { NextRequest, NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' +export type ApiProxyBackendUserLogDetailResponse = ApiProxyBackendResp + +export type UserLogDetailResponse = ApiResp + +export interface UserLogDetailParams { + log_id: string +} + +async function fetchLogs(log_id: string, group: string): Promise { + try { + const url = new URL( + `/api/log/${group}/detail/${log_id}`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendUserLogDetailResponse = await response.json() + + if (!result.success) { + throw new Error(result.message || 'Get logs detail API request failed') + } + + return result.data || null + } catch (error) { + console.error('Get logs detail error:', error) + throw error + } +} + +export async function GET( + request: NextRequest, + { params }: { params: { log_id: string } } +): Promise> { + try { + const group = await parseJwtToken(request.headers) + + if (!params.log_id) { + return NextResponse.json( + { + code: 400, + message: 'Log_id is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + const detail = await fetchLogs(params.log_id, group) + + return NextResponse.json({ + code: 200, + data: detail || undefined + } satisfies UserLogDetailResponse) + } catch (error) { + console.error('Get logs detail error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/user/log/route.ts b/frontend/providers/aiproxy/app/api/user/log/route.ts new file mode 100644 index 00000000000..ce0c1909bec --- /dev/null +++ b/frontend/providers/aiproxy/app/api/user/log/route.ts @@ -0,0 +1,163 @@ +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { LogItem } from '@/types/user/logs' +import { parseJwtToken } from '@/utils/backend/auth' +import { NextRequest, NextResponse } from 'next/server' + +export const dynamic = 'force-dynamic' +export type ApiProxyBackendUserLogSearchResponse = ApiProxyBackendResp<{ + logs: LogItem[] + total: number + models: string[] + token_names: string[] +}> + +export type UserLogSearchResponse = ApiResp<{ + logs: LogItem[] + total: number + models: string[] + token_names: string[] +}> + +export interface UserLogQueryParams { + token_name?: string + model_name?: string + keyword?: string + start_timestamp?: string + end_timestamp?: string + code_type?: 'all' | 'success' | 'error' | undefined + page: number + perPage: number +} + +function validateParams(params: UserLogQueryParams): string | null { + if (params.page < 1) { + return 'Page number must be greater than 0' + } + if (params.perPage < 1 || params.perPage > 100) { + return 'Per page must be between 1 and 100' + } + if (params.start_timestamp && params.end_timestamp) { + if (parseInt(params.start_timestamp) > parseInt(params.end_timestamp)) { + return 'Start timestamp cannot be greater than end timestamp' + } + } + return null +} + +async function fetchLogs( + params: UserLogQueryParams, + group: string +): Promise<{ logs: LogItem[]; total: number; models: string[]; token_names: string[] }> { + try { + const url = new URL( + `/api/log/${group}/search`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + + url.searchParams.append('p', params.page.toString()) + url.searchParams.append('per_page', params.perPage.toString()) + + if (params.token_name) { + url.searchParams.append('token_name', params.token_name) + } + if (params.model_name) { + url.searchParams.append('model_name', params.model_name) + } + + if (params.keyword) { + url.searchParams.append('keyword', params.keyword) + } + + if (params.code_type) { + url.searchParams.append('code_type', params.code_type) + } + if (params.start_timestamp) { + url.searchParams.append('start_timestamp', params.start_timestamp) + } + if (params.end_timestamp) { + url.searchParams.append('end_timestamp', params.end_timestamp) + } + + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendUserLogSearchResponse = await response.json() + if (!result.success) { + throw new Error(result.message || 'API request failed') + } + + return { + logs: result.data?.logs || [], + total: result.data?.total || 0, + models: result.data?.models || [], + token_names: result.data?.token_names || [] + } + } catch (error) { + console.error('Error fetching logs:', error) + throw error + } +} + +export async function GET(request: NextRequest): Promise> { + try { + const group = await parseJwtToken(request.headers) + const searchParams = request.nextUrl.searchParams + + const queryParams: UserLogQueryParams = { + page: parseInt(searchParams.get('page') || '1', 10), + perPage: parseInt(searchParams.get('perPage') || '10', 10), + token_name: searchParams.get('token_name') || undefined, + model_name: searchParams.get('model_name') || undefined, + code_type: (searchParams.get('code_type') as 'all' | 'success' | 'error') || undefined, + start_timestamp: searchParams.get('start_timestamp') || undefined, + end_timestamp: searchParams.get('end_timestamp') || undefined, + keyword: searchParams.get('keyword') || undefined + } + + const validationError = validateParams(queryParams) + if (validationError) { + return NextResponse.json( + { + code: 400, + message: validationError, + error: validationError + }, + { status: 400 } + ) + } + + const { logs, total, models, token_names } = await fetchLogs(queryParams, group) + + return NextResponse.json({ + code: 200, + data: { + logs, + total, + models, + token_names + } + } satisfies UserLogSearchResponse) + } catch (error) { + console.error('Logs search error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts b/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts new file mode 100644 index 00000000000..cafa4ac8b11 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/user/token/[id]/route.ts @@ -0,0 +1,178 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSealosUserUid, parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { validateSealosUserRealNameInfo } from '@/utils/backend/db' + +export const dynamic = 'force-dynamic' + +async function deleteToken(group: string, id: string): Promise { + try { + const url = new URL( + `/api/token/${group}/${id}`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to delete token') + } + } catch (error) { + console.error('Error deleting token:', error) + throw error + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +): Promise> { + try { + const userGroup = await parseJwtToken(request.headers) + + if (!params.id) { + return NextResponse.json( + { + code: 400, + message: 'Token ID is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + await deleteToken(userGroup, params.id) + + return NextResponse.json({ + code: 200, + message: 'Token deleted successfully' + } satisfies ApiResp) + } catch (error) { + console.error('Token deletion error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} + +// update token status +interface UpdateTokenRequestBody { + status: number +} + +async function updateToken(group: string, id: string, status: number): Promise { + try { + if (status !== 1 && status !== 2) { + throw new Error('Invalid status') + } + const url = new URL( + `/api/token/${group}/${id}/status`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + body: JSON.stringify({ status }), + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to update token') + } + } catch (error) { + console.error('Error updating token:', error) + throw error + } +} + +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +): Promise> { + try { + const userGroup = await parseJwtToken(request.headers) + + if (!params.id) { + return NextResponse.json( + { + code: 400, + message: 'Token ID is required', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + const sealosUserUid = await getSealosUserUid(request.headers) + const isRealName = await validateSealosUserRealNameInfo(sealosUserUid) + + if (!isRealName) { + return NextResponse.json( + { + code: 400, + message: 'user not real name', + error: 'user not real name' + }, + { status: 400 } + ) + } + + const updateTokenBody: UpdateTokenRequestBody = await request.json() + + if (typeof updateTokenBody.status !== 'number') { + return NextResponse.json( + { + code: 400, + message: 'Status must be a number', + error: 'Bad Request' + }, + { status: 400 } + ) + } + + await updateToken(userGroup, params.id, updateTokenBody.status) + + return NextResponse.json({ + code: 200, + message: 'Token updated successfully' + } satisfies ApiResp) + } catch (error) { + console.error('Token update error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/api/user/token/route.ts b/frontend/providers/aiproxy/app/api/user/token/route.ts new file mode 100644 index 00000000000..ea42e2cc967 --- /dev/null +++ b/frontend/providers/aiproxy/app/api/user/token/route.ts @@ -0,0 +1,226 @@ +import { NextRequest, NextResponse } from 'next/server' +import { TokenInfo } from '@/types/user/token' + +import { getSealosUserUid, parseJwtToken } from '@/utils/backend/auth' +import { ApiProxyBackendResp, ApiResp } from '@/types/api' +import { validateSealosUserRealNameInfo } from '@/utils/backend/db' + +export const dynamic = 'force-dynamic' + +type ApiProxyBackendTokenSearchResponse = ApiProxyBackendResp<{ + tokens: TokenInfo[] + total: number +}> + +export type GetTokensResponse = ApiResp<{ + tokens: TokenInfo[] + total: number +}> + +export interface GetTokensQueryParams { + page: number + perPage: number +} + +function validateParams(queryParams: GetTokensQueryParams): string | null { + if (queryParams.page < 1) { + return 'Page number must be greater than 0' + } + + if (queryParams.perPage < 1 || queryParams.perPage > 100) { + return 'Per page must be between 1 and 100' + } + + return null +} + +async function fetchTokens( + queryParams: GetTokensQueryParams, + group: string +): Promise<{ tokens: TokenInfo[]; total: number }> { + try { + const url = new URL( + `/api/token/${group}/search`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + url.searchParams.append('p', queryParams.page.toString()) + url.searchParams.append('per_page', queryParams.perPage.toString()) + + const token = global.AppConfig?.auth.aiProxyBackendKey + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store' + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendTokenSearchResponse = await response.json() + + if (!result.success) { + throw new Error(result.message || 'API request failed') + } + + return { + tokens: result?.data?.tokens || [], + total: result?.data?.total || 0 + } + } catch (error) { + console.error('Error fetching tokens:', error) + return { + tokens: [], + total: 0 + } + } +} + +export async function GET(request: NextRequest): Promise> { + try { + const group = await parseJwtToken(request.headers) + + const searchParams = request.nextUrl.searchParams + const queryParams: GetTokensQueryParams = { + page: parseInt(searchParams.get('page') || '1', 10), + perPage: parseInt(searchParams.get('perPage') || '10', 10) + } + + const validationError = validateParams(queryParams) + + if (validationError) { + return NextResponse.json( + { + code: 400, + message: validationError, + error: validationError + }, + { status: 400 } + ) + } + + const { tokens, total } = await fetchTokens(queryParams, group) + + return NextResponse.json({ + code: 200, + data: { + tokens, + total + } + } satisfies GetTokensResponse) + } catch (error) { + console.error('Token search error:', error) + + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + }, + { status: 500 } + ) + } +} + +// create token + +interface CreateTokenRequest { + name: string +} + +function validateCreateParams(body: CreateTokenRequest): string | null { + if (!body.name) { + return 'Name parameter is required' + } + return null +} + +async function createToken(name: string, group: string): Promise { + try { + const url = new URL( + `/api/token/${group}?auto_create_group=true`, + global.AppConfig?.backend.aiproxyInternal || global.AppConfig?.backend.aiproxy + ) + const token = global.AppConfig?.auth.aiProxyBackendKey + const response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${token}` + }, + cache: 'no-store', + body: JSON.stringify({ + name + }) + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const result: ApiProxyBackendResp = await response.json() + if (!result.success) { + throw new Error(result.message || 'Failed to create token') + } + + return result?.data + } catch (error) { + console.error('Error creating token:', error) + throw error + } +} + +export async function POST(request: NextRequest): Promise>> { + try { + const group = await parseJwtToken(request.headers) + const body: CreateTokenRequest = await request.json() + + const validationError = validateCreateParams(body) + if (validationError) { + return NextResponse.json( + { + code: 400, + message: validationError, + error: validationError + } satisfies ApiResp, + { status: 400 } + ) + } + + const sealosUserUid = await getSealosUserUid(request.headers) + const isRealName = await validateSealosUserRealNameInfo(sealosUserUid) + + if (!isRealName) { + return NextResponse.json( + { + code: 400, + message: 'user not real name', + error: 'user not real name' + }, + { status: 400 } + ) + } + // 创建Token + const newToken = await createToken(body.name, group) + + return NextResponse.json({ + code: 200, + data: newToken, + message: 'Token created successfully' + } satisfies ApiResp) + } catch (error) { + console.error('Token creation error:', error) + return NextResponse.json( + { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + error: error instanceof Error ? error.message : 'Internal server error' + } satisfies ApiResp, + { status: 500 } + ) + } +} diff --git a/frontend/providers/aiproxy/app/i18n/locales/en/common.json b/frontend/providers/aiproxy/app/i18n/locales/en/common.json index e5d2271905a..b04f3bb2b0f 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/en/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/en/common.json @@ -2,9 +2,14 @@ "title": "AI proxy", "description": "AI proxy", "Sidebar": { - "Home": "API Keys", + "Home": "Dashboard", "Logs": "Logs", - "Price": "Pricing" + "Price": "Models", + "Dashboard": "Channels", + "GlobalLogs": "Logs", + "GlobalConfigs": "Config", + "NsManager": "NS Manage", + "Keys": "API Keys" }, "keyList": { "title": "API Keys" @@ -15,8 +20,8 @@ "createdAt": "Creation time", "lastUsedAt": "Last use time", "status": "State", - "namePlaceholder": "Please enter name", - "nameRequired": "Please enter key name", + "namePlaceholder": "please enter name", + "nameRequired": "please enter key name", "nameMaxLength": "Key length is illegal", "nameOnlyLettersAndNumbers": "key name contains special characters", "createSuccess": "Created successfully", @@ -29,29 +34,53 @@ "unused": "Not use", "inputPrice": "Input price", "outputPrice": "Output price", - "createName": "Name" + "createName": "Name", + "modelType": "Type", + "requestCount": "Request Count", + "usedAmount": "Amount Charged" }, "logs": { "call_log": "Logs", - "name": "Name", + "name": "Key Name", "status": "State", "time": "Time", "modal": "Model", - "prompt_tokens": "Input", - "completion_tokens": "Output", + "prompt_tokens": "Input Tokens", + "completion_tokens": "Output Tokens", "price": "Price", - "select_modal": "Please select model", - "select_token_name": "Please select name", + "select_modal": "please select model", + "select_token_name": "please select name", "model": "Model", - "total_price": "Total amount", + "total_price": "Total Charged", "total_price_tip": "(Number of input tokens × Input price) + (Number of output tokens × Output price)", "success": "Success", - "failed": "Failed" + "failed": "Failed", + "logDetail": "Log details", + "actions": "Operation", + "detail": "Details", + "requestId": "RequestID", + "mode": "Type", + "requestTime": "Request At", + "totalTime": "Duration (s)", + "tokenName": "Key name", + "tokenId": "Key ID", + "info": "Message", + "requestBody": "Request Body", + "responseBody": "Response Body", + "inputTokens": "Input Tokens", + "outputTokens": "Output Tokens", + "searchByContent": "Enter search keyword", + "status": "State", + "statusOptions": { + "all": "All", + "success": "Success", + "error": "Error" + } }, "Page": "Page", "Total": "Total", "modelList": { - "title": "Supported models" + "title": "Supported Models" }, "copy": "Copy", "createKey": "New", @@ -73,19 +102,191 @@ "copyFailed": "Copy failed", "noData": "You don’t have an API Key yet", "price": { - "title": "Pricing", - "per1kTokens": "1k tokens" - }, - "ernie": "Baidu-Ernie", - "qwen": "Alibaba-Qwen", - "chatglm": "BigModel-Chatglm", - "deepseek": "Deepseek", - "moonshot": "Moonshot", - "sparkdesk": "Sparkdesk", - "abab": "Minimax", - "doubao": "ByteDance-Doubao", - "glm": "Glm", - "o": "OpenAI", - "gpt": "OpenAI", - "createKey2": "Create Key" + "all": "All", + "title": "Models", + "per1kTokens": "1k tokens", + "modelOwner": "Series/Manufacturer", + "modelType": "Type", + "modelName": "Model Name", + "modelRpm": "RPM", + "modelRpmTooltip": "Each model has its own RPM", + "sortUpTooltip": "Click to view in ascending order", + "sortDownTooltip": "Click to view in Descending order", + "modelVision": "Vision", + "modelVisionLabel": "This model supports vision capabilities", + "modelToolChoice": "Tool Chocice", + "response": "Output" + }, + "createKey2": "Create Key", + "dashboard": { + "title": "Channels", + "create": "New", + "import": "Import", + "export": "Export", + "exportAll": "Export All", + "importSuccess": "Import successful", + "importError": "Import failed" + }, + "channelStatus": { + "autoDisabled": "System disabled" + }, + "channels": { + "name": "Name", + "type": "Manufacturer", + "test": "Test", + "disable": "Disable", + "enable": "Enable", + "edit": "Edit", + "export": "Export", + "create": "New", + "name_required": "The name is illegal", + "key_required": "Key is illegal", + "modelDefault": "Channel default model", + "createFailed": "Failed to create channel", + "createSuccess": "Channel created successfully", + "updateSuccess": "Update channel successful", + "updateFailed": "Update channel failed", + "id": "ID", + "requestCount": "Request Times", + "status": "Status", + "action": "Operation", + "delete": "Delete" + }, + "channelsForm": { + "name": "Custom Name", + "type": "Manufacturer", + "models": "Model", + "model_mapping": "Input Mode & Output Mode", + "add": "Add", + "key": "Key", + "base_url": "Proxy Address" + }, + "channelsFormPlaceholder": { + "name": "custom name", + "type": "open ai", + "model": "enter selection", + "modelInput": "enter custom model name", + "modelMappingInput": "input model", + "modelMappingOutput": "output model", + "key": "please enter the channel key", + "base_url": "please enter agent" + }, + "common": { + "add": "Add" + }, + "global_configs": { + "title": "Global configuration", + "export": "Export", + "import": "Import", + "qpm_limit": "Global QPM limits", + "pause_service": "Service Pause", + "retry_count": "Number of error retries", + "max_token": "Maximum number of tokens in the workspace" + }, + "globalConfigs": { + "defaultModel": "Default Model", + "addDefaultModel": "New", + "saveDefaultModel": "Save", + "saveDefaultModelFailed": "Failed To Save", + "saveDefaultModelSuccess": "Saved successfully", + "saveCommonConfigSuccess": "Update successful", + "saveCommonConfigFailed": "Update failed", + "common_config": "Common Configuration", + "model_config": "Model Settings" + }, + "modeType": { + "0": "Unknown", + "1": "Chat", + "2": "Text", + "3": "Embed", + "4": "Moderate", + "5": "Image", + "6": "Edit", + "7": "TTS", + "8": "STT", + "9": "Audio", + "10": "Rerank" + }, + "modeOwner": { + "openai": "OpenAI", + "alibaba": "Alibaba", + "tencent": "Tencent", + "xunfei": "iFlytek", + "deepseek": "DeepSeek", + "moonshot": "Moonshot AI", + "minimax": "MiniMax", + "baidu": "Baidu", + "google": "Google", + "baai": "BAAI", + "funaudiollm": "FunAudio LLM", + "doubao": "Doubao", + "fishaudio": "Fish Audio", + "chatglm": "ChatGLM", + "stabilityai": "Stability AI", + "netease": "NetEase", + "ai360": "360 AI", + "anthropic": "Anthropic", + "meta": "Meta", + "baichuan": "Baichuan", + "mistral": "Mistral AI", + "openchat": "OpenChat", + "microsoft": "Microsoft", + "defog": "Defog", + "nexusflow": "NexusFlow", + "cohere": "Cohere", + "huggingface": "Hugging Face", + "lingyiwanwu": "Lingyi Wanwu", + "stepfun": "StepFun", + "unknown": "Unknown" + }, + "GlobalLogs": { + "selectModel": "model Name", + "select_token_name": "token name", + "selectGroupId": "workspace", + "groupId": "Workspace", + "tokenName": "Key Name", + "channel": "Channel ID", + "keyName": "Key name" + }, + "nsManager": { + "groupId": "Workspace", + "qpm": "QPM Restrictions", + "created_at": "Creation Time", + "accessed_at": "Last use time", + "used_amount": "Amount used", + "status": "State", + "enabled": "Enabled", + "disabled": "Disabled", + "request_count": "Number of uses", + "actions": "Operation", + "ns_manager": "NS Management", + "select_group_id": "fill in the workspace", + "updateGroupStatusSuccess": "Update status successful", + "updateGroupStatusFailed": "Update status failed", + "updateGroupQpmSuccess": "Update QPM successful", + "updateGroupQpmFailed": "Update QPM failed", + "deleteGroupSuccess": "Delete successfully", + "deleteGroupFailed": "Delete failed" + }, + "channel": { + "updateSuccess": "Update status successful", + "updateFailed": "Update status failed", + "deleteSuccess": "Delete successfully", + "deleteFailed": "Delete failed" + }, + "dataDashboard": { + "title": "Dashboard", + "selectToken": "All Keys", + "selectModel": "All Models", + "day": "Last 24h", + "week": "Last 7 days", + "twoWeek": "Last 15 days", + "month": "Last 30 days", + "callCount": "Requests", + "exceptionCount": "Exceptions", + "rpm": "Current RPM", + "tpm": "Current TPM", + "cost": "Charges", + "requestData": "Call Data" + } } diff --git a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json index 5a6226556e4..c5c33589fdd 100644 --- a/frontend/providers/aiproxy/app/i18n/locales/zh/common.json +++ b/frontend/providers/aiproxy/app/i18n/locales/zh/common.json @@ -2,9 +2,14 @@ "title": "AI 代理", "description": "AI 代理", "Sidebar": { - "Home": "API Keys", - "Logs": "调用日志", - "Price": "模型价格" + "Home": "仪表盘", + "Logs": "日志", + "Price": "模型广场", + "Dashboard": "AI 渠道", + "GlobalLogs": "全局日志", + "GlobalConfigs": "全局配置", + "NsManager": "NS管理", + "Keys": "API Keys" }, "keyList": { "title": "API Keys" @@ -29,24 +34,48 @@ "unused": "未使用", "inputPrice": "输入单价", "outputPrice": "输出单价", - "createName": "名称" + "createName": "名称", + "modelType": "模型类型", + "requestCount": "请求次数", + "usedAmount": "消耗金额" }, "logs": { "call_log": "调用日志", - "name": "名称", + "name": "Key 名称", "modal": "模型", "status": "状态", "time": "时间", "model": "模型", - "prompt_tokens": "输入", - "completion_tokens": "输出", + "prompt_tokens": "输入 Tokens", + "completion_tokens": "输出 Tokens", "price": "价格", "select_modal": "请选择模型", "select_token_name": "请选择名称", "total_price": "总金额", "total_price_tip": "(输入 token 数×输入价格)+(输出 token 数 × 输出价格)", "success": "成功", - "failed": "失败" + "failed": "失败", + "logDetail": "日志详情", + "actions": "操作", + "detail": "详情", + "requestId": "RequestID", + "mode": "模型类型", + "requestTime": "请求时间", + "totalTime": "耗时(s)", + "tokenName": "密钥名称", + "tokenId": "密钥 ID", + "info": "信息", + "requestBody": "请求内容", + "responseBody": "响应内容", + "inputTokens": "输入 Tokens", + "outputTokens": "输出 Tokens", + "searchByContent": "输入搜索关键词", + "status": "状态", + "statusOptions": { + "all": "全部", + "success": "成功", + "error": "失败" + } }, "Page": "页", "Total": "总数", @@ -73,19 +102,191 @@ "copyFailed": "复制失败", "noData": "你还没有 API Key", "price": { - "title": "模型价格", - "per1kTokens": "1k tokens" - }, - "ernie": "百度文心", - "qwen": "阿里千问", - "chatglm": "智谱", - "deepseek": "Deepseek", - "moonshot": "月之暗面", - "sparkdesk": "讯飞星火", - "abab": "Minimax", - "doubao": "字节豆包", - "glm": "智谱", - "o": "OpenAI", - "gpt": "OpenAI", - "createKey2": "新建 Key" + "all": "全部", + "title": "模型广场", + "per1kTokens": "1k tokens", + "modelOwner": "系列/厂商", + "modelType": "类型", + "modelName": "模型名", + "modelRpm": "RPM", + "modelRpmTooltip": "每个模型拥有独立的 RPM", + "sortUpTooltip": "点击升序", + "sortDownTooltip": "点击降序", + "modelVision": "视觉", + "modelVisionLabel": "该模型支持视觉能力", + "modelToolChoice": "工具调用", + "response": "输出" + }, + "createKey2": "新建 Key", + "dashboard": { + "title": "Ai 渠道", + "create": "新建", + "import": "导入", + "export": "导出", + "exportAll": "全部导出", + "importSuccess": "导入成功", + "importError": "导入失败" + }, + "channelStatus": { + "autoDisabled": "系统禁用" + }, + "channels": { + "name": "名字", + "type": "厂商", + "test": "测试", + "disable": "禁用", + "enable": "启用", + "edit": "编辑", + "export": "导出", + "create": "新建", + "name_required": "名称不合法", + "key_required": "Key 不合法", + "modelDefault": "渠道默认模型", + "createFailed": "创建渠道失败", + "createSuccess": "成功创建渠道", + "updateSuccess": "更新渠道成功", + "updateFailed": "更新渠道失败", + "id": "ID", + "requestCount": "调用次数", + "status": "状态", + "action": "操作", + "delete": "删除" + }, + "channelsForm": { + "name": "自定义名称", + "type": "厂商", + "models": "模型", + "model_mapping": "输入 & 输出模型", + "add": "添加", + "key": "密钥", + "base_url": "代理" + }, + "channelsFormPlaceholder": { + "name": "自定义名称", + "type": "Open AI", + "model": "输入选择", + "modelInput": "输入自定义模型名称", + "modelMappingInput": "输入模型", + "modelMappingOutput": "输出模型", + "key": "请输入渠道对应的鉴权密钥", + "base_url": "请输入代理" + }, + "common": { + "add": "填入" + }, + "global_configs": { + "title": "全局配置", + "export": "导出", + "import": "导入", + "qpm_limit": "全局QPM限制", + "pause_service": "暂停服务", + "retry_count": "错误重试次数", + "max_token": "工作空间最大 token 数" + }, + "globalConfigs": { + "defaultModel": "默认模型", + "addDefaultModel": "新增模型", + "saveDefaultModel": "保存", + "saveDefaultModelFailed": "保存失败", + "saveDefaultModelSuccess": "保存成功", + "saveCommonConfigSuccess": "更新成功", + "saveCommonConfigFailed": "更新失败", + "common_config": "通用配置", + "model_config": "模型设置" + }, + "modeType": { + "0": "未知", + "1": "聊天补全", + "2": "文本补全", + "3": "文本嵌入", + "4": "内容审核", + "5": "图像生成", + "6": "文本编辑", + "7": "语音合成", + "8": "语音转录", + "9": "音频翻译", + "10": "重排序" + }, + "modeOwner": { + "openai": "OpenAI", + "alibaba": "阿里", + "tencent": "腾讯", + "xunfei": "讯飞", + "deepseek": "DeepSeek", + "moonshot": "月之暗面", + "minimax": "MiniMax", + "baidu": "百度", + "google": "谷歌", + "baai": "BAAI", + "funaudiollm": "趣音大模型", + "doubao": "豆包", + "fishaudio": "鱼声科技", + "chatglm": "智谱清言", + "stabilityai": "Stability AI", + "netease": "网易", + "ai360": "360智脑", + "anthropic": "Anthropic", + "meta": "Meta", + "baichuan": "百川智能", + "mistral": "Mistral AI", + "openchat": "OpenChat", + "microsoft": "微软", + "defog": "Defog", + "nexusflow": "NexusFlow", + "cohere": "Cohere", + "huggingface": "Hugging Face", + "lingyiwanwu": "零一万物", + "stepfun": "StepFun", + "unknown": "未知" + }, + "GlobalLogs": { + "selectModel": "模型名称", + "select_token_name": "Key 名称", + "selectGroupId": "工作空间", + "groupId": "工作空间", + "tokenName": "Key 名称", + "channel": "渠道 ID", + "keyName": "Key 名称" + }, + "nsManager": { + "groupId": "工作空间", + "qpm": "QPM 限制", + "created_at": "创建时间", + "accessed_at": "最后使用时间", + "used_amount": "使用金额", + "status": "状态", + "enabled": "启用", + "disabled": "禁用", + "request_count": "使用次数", + "actions": "操作", + "ns_manager": "NS 管理", + "select_group_id": "填入工作空间", + "updateGroupStatusSuccess": "更新状态成功", + "updateGroupStatusFailed": "更新状态失败", + "updateGroupQpmSuccess": "更新 QPM 成功", + "updateGroupQpmFailed": "更新 QPM 失败", + "deleteGroupSuccess": "删除成功", + "deleteGroupFailed": "删除失败" + }, + "channel": { + "updateSuccess": "更新状态成功", + "updateFailed": "更新状态失败", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败" + }, + "dataDashboard": { + "title": "仪表盘", + "selectToken": "全部密钥", + "selectModel": "全部模型", + "day": "一天内", + "week": "近七天", + "twoWeek": "近15天", + "month": "近30天", + "callCount": "请求数", + "exceptionCount": "异常数", + "rpm": "当前 RPM", + "tpm": "当前 TPM", + "cost": "花费", + "requestData": "调用数据" + } } diff --git a/frontend/providers/aiproxy/components/InitializeApp.tsx b/frontend/providers/aiproxy/components/InitializeApp.tsx new file mode 100644 index 00000000000..2ee26d59afb --- /dev/null +++ b/frontend/providers/aiproxy/components/InitializeApp.tsx @@ -0,0 +1,124 @@ +'use client' + +import { EVENT_NAME } from 'sealos-desktop-sdk' +import { createSealosApp, sealosApp } from 'sealos-desktop-sdk/app' +import { useCallback, useEffect } from 'react' +import { initAppConfig } from '@/api/platform' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useBackendStore } from '@/store/backend' +import { useTranslationClientSide } from '@/app/i18n/client' +import { usePathname } from 'next/navigation' +import { useRouter } from 'next/navigation' +import { useSessionStore } from '@/store/session' + +export default function InitializeApp() { + const router = useRouter() + const pathname = usePathname() + const { lng } = useI18n() + const { i18n } = useTranslationClientSide(lng) + const { setAiproxyBackend, setCurrencySymbol } = useBackendStore() + + const handleI18nChange = useCallback( + (data: { currentLanguage: string }) => { + const currentLng = i18n.resolvedLanguage // get the latest resolvedLanguage + const newLng = data.currentLanguage + + if (currentLng !== newLng) { + const currentPath = window.location.pathname + const pathWithoutLang = currentPath.split('/').slice(2).join('/') + router.push(`/${newLng}/${pathWithoutLang}`) + } + }, + [i18n.resolvedLanguage] + ) + + useEffect(() => { + const cleanupApp = createSealosApp() + let cleanupEventListener: (() => void) | undefined + + const initApp = async () => { + try { + await initLanguage() + + await initSession() + + await initConfig() + + cleanupEventListener = sealosApp?.addAppEventListen( + EVENT_NAME.CHANGE_I18N, + handleI18nChange + ) + } catch (error) { + console.error('aiproxy: init app error:', error) + } + } + + initApp() + + return () => { + if (cleanupEventListener && typeof cleanupEventListener === 'function') { + cleanupEventListener() + } + if (cleanupApp && typeof cleanupApp === 'function') { + cleanupApp() + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // init language + const initLanguage = async () => { + const pathLng = pathname.split('/')[1] + try { + const lang = await sealosApp.getLanguage() + if (pathLng !== lang.lng) { + const pathParts = pathname.split('/') + pathParts[1] = lang.lng + router.push(pathParts.join('/')) + router.refresh() + } + console.info('aiproxy: init language success') + } catch (error) { + if (error instanceof Error) { + console.debug('aiproxy: init language error:', error.message) + } else { + console.debug('aiproxy: unknown init language error:', error) + } + } + } + + // init session + const initSession = async () => { + const { setSession } = useSessionStore.getState() + + try { + const newSession = await sealosApp.getSession() + const currentSession = useSessionStore.getState().session + // Compare token from persisted session with new session token + if (newSession?.token !== currentSession?.token) { + setSession(newSession) + window.location.reload() + } + console.info('aiproxy: init session success') + } catch (err) { + console.info('aiproxy: app is not running in desktop') + if (!process.env.NEXT_PUBLIC_MOCK_USER) { + setSession(null) + } + } + } + + // init config + const initConfig = async () => { + try { + const { aiproxyBackend, currencySymbol } = await initAppConfig() + setAiproxyBackend(aiproxyBackend) + setCurrencySymbol(currencySymbol) + console.info('aiproxy: init config success') + } catch (error) { + console.error('aiproxy: init config error:', error) + } + } + + return null +} diff --git a/frontend/providers/aiproxy/components/MyTooltip/index.tsx b/frontend/providers/aiproxy/components/MyTooltip/index.tsx deleted file mode 100644 index dd5aefadf64..00000000000 --- a/frontend/providers/aiproxy/components/MyTooltip/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Tooltip, TooltipProps } from '@chakra-ui/react' - -export const MyTooltip = ({ children, ...tooltipProps }: TooltipProps) => { - return ( - - {children} - - ) -} diff --git a/frontend/providers/aiproxy/components/admin/Sidebar.tsx b/frontend/providers/aiproxy/components/admin/Sidebar.tsx new file mode 100644 index 00000000000..40af5a5d297 --- /dev/null +++ b/frontend/providers/aiproxy/components/admin/Sidebar.tsx @@ -0,0 +1,121 @@ +'use client' +import { Flex, Text } from '@chakra-ui/react' +import Image, { StaticImageData } from 'next/image' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +import { useTranslationClientSide } from '@/app/i18n/client' +import homeIcon from '@/ui/svg/icons/admin-sidebar/home.svg' +import homeIcon_a from '@/ui/svg/icons/admin-sidebar/home_a.svg' +import logsIcon from '@/ui/svg/icons/admin-sidebar/logs.svg' +import logsIcon_a from '@/ui/svg/icons/admin-sidebar/logs_a.svg' +import configIcon from '@/ui/svg/icons/admin-sidebar/config.svg' +import configIcon_a from '@/ui/svg/icons/admin-sidebar/config_a.svg' +import nsManagerIcon from '@/ui/svg/icons/admin-sidebar/nsManager.svg' +import nsManagerIcon_a from '@/ui/svg/icons/admin-sidebar/nsManager_a.svg' +import { useI18n } from '@/providers/i18n/i18nContext' + +type Menu = { + id: string + url: string + value: string + icon: StaticImageData + activeIcon: StaticImageData + display: boolean +} + +const SideBar = (): JSX.Element => { + const pathname = usePathname() + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const menus: Menu[] = [ + { + id: 'dashboard', + url: '/dashboard', + value: t('Sidebar.Dashboard'), + icon: homeIcon, + activeIcon: homeIcon_a, + display: true + }, + { + id: 'global-logs', + url: '/global-logs', + value: t('Sidebar.GlobalLogs'), + icon: logsIcon, + activeIcon: logsIcon_a, + display: true + }, + { + id: 'global-configs', + url: '/global-configs', + value: t('Sidebar.GlobalConfigs'), + icon: configIcon, + activeIcon: configIcon_a, + display: true + }, + { + id: 'ns-manager', + url: '/ns-manager', + value: t('Sidebar.NsManager'), + icon: nsManagerIcon, + activeIcon: nsManagerIcon_a, + display: true + } + ] + + return ( + + {menus + .filter((menu) => menu.display) + .map((menu) => { + const fullUrl = `/${lng}${menu.url}` + const isActive = pathname === fullUrl + + return ( + + + {menu.value} + + {menu.value} + + + + ) + })} + + ) +} + +export default SideBar diff --git a/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx new file mode 100644 index 00000000000..27e61b24556 --- /dev/null +++ b/frontend/providers/aiproxy/components/common/ConstructMappingComponent.tsx @@ -0,0 +1,331 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { VStack, Flex, FormLabel, Input, Button, Text, Box } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { CustomSelect } from './Select' +type MapKeyValuePair = { key: string; value: string } + +// mapKeys determines the available selection options +export const ConstructMappingComponent = function ({ + mapKeys, + mapData, + setMapData +}: { + mapKeys: string[] + mapData: Record + setMapData: (mapping: Record) => void +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const [mapKeyValuePairs, setMapkeyValuePairs] = useState>([]) + + // 组件初步渲染时,isInternalUpdate 为 false,可以保证每次重新渲染时,mapData 变化时,mapKeyValuePairs 重新初始化 + const [isInternalUpdate, setIsInternalUpdate] = useState(false) + + // 初始化并在 mapData 变化时更新 mapKeyValuePairs + useEffect(() => { + if (!isInternalUpdate) { + const entries = Object.entries(mapData) + setMapkeyValuePairs( + entries.length > 0 + ? entries.map(([key, value]) => ({ key, value })) + : [{ key: '', value: '' }] + ) + } + setIsInternalUpdate(false) + }, [mapData]) + + const handleDropdownItemDisplay = (dropdownItem: string) => { + if (dropdownItem === t('channelsFormPlaceholder.modelMappingInput')) { + return ( + + {t('channelsFormPlaceholder.modelMappingInput')} + + ) + } + return ( + + {dropdownItem} + + ) + } + + const handleSeletedItemDisplay = (selectedItem: string) => { + if (selectedItem === t('channelsFormPlaceholder.modelMappingInput')) { + return ( + + {t('channelsFormPlaceholder.modelMappingInput')} + + ) + } + return ( + + + {selectedItem} + + + ) + } + + // Handling mapData and mapKeyValuePairs cleanup when map keys change. + useEffect(() => { + // 1. Handle mapData cleanup + const removedKeysFromMapData = Object.keys(mapData).filter((key) => !mapKeys.includes(key)) + if (removedKeysFromMapData.length > 0) { + const newMapData = { ...mapData } + removedKeysFromMapData.forEach((key) => { + delete newMapData[key] + }) + setIsInternalUpdate(true) + setMapData(newMapData) + } + + // 2. Handle mapKeyValuePairs cleanup + const removedPairs = mapKeyValuePairs.filter((pair) => pair.key && !mapKeys.includes(pair.key)) + if (removedPairs.length > 0) { + const newMapKeyValuePairs = mapKeyValuePairs.filter( + (pair) => !pair.key || mapKeys.includes(pair.key) + ) + setMapkeyValuePairs(newMapKeyValuePairs) + } + }, [mapKeys]) + + // Get the keys that have been selected + const getSelectedMapKeys = (currentIndex: number) => { + const selected = new Set() + mapKeyValuePairs.forEach((mapKeyValuePair, idx) => { + if (idx !== currentIndex && mapKeyValuePair.key) { + selected.add(mapKeyValuePair.key) + } + }) + return selected + } + + // Handling adding a new row + const handleAddNewMapKeyPair = () => { + setMapkeyValuePairs([...mapKeyValuePairs, { key: '', value: '' }]) + } + + // Handling deleting a row + const handleRemoveMapKeyPair = (index: number) => { + const mapKeyValuePair = mapKeyValuePairs[index] + const newMapData = { ...mapData } + if (mapKeyValuePair.key) { + delete newMapData[mapKeyValuePair.key] + } + setIsInternalUpdate(true) + setMapData(newMapData) + + const newMapKeyValuePairs = mapKeyValuePairs.filter((_, idx) => idx !== index) + setMapkeyValuePairs(newMapKeyValuePairs) + } + + // Handling selection/input changes + const handleInputChange = (index: number, field: 'key' | 'value', value: string) => { + const newMapKeyValuePairs = [...mapKeyValuePairs] + const oldValue = newMapKeyValuePairs[index][field] + newMapKeyValuePairs[index][field] = value + + // Update the mapping relationship + const newMapData = { ...mapData } + if (field === 'key') { + if (oldValue) delete newMapData[oldValue] + + if (!value) { + newMapKeyValuePairs[index].value = '' + } + + if (value && newMapKeyValuePairs[index].value) { + newMapData[value] = newMapKeyValuePairs[index].value + } + } else { + if (newMapKeyValuePairs[index].key) { + newMapData[newMapKeyValuePairs[index].key] = value + } + } + + setMapkeyValuePairs(newMapKeyValuePairs) + setIsInternalUpdate(true) + setMapData(newMapData) + } + + // Check if there are still keys that can be selected + const hasAvailableKeys = useMemo(() => { + const usedKeys = new Set( + mapKeyValuePairs.map((mapKeyValuePair) => mapKeyValuePair.key).filter(Boolean) + ) + // Ensure mapKeyValuePairs length does not exceed mapKeys length + return ( + mapKeyValuePairs.length < mapKeys.length && mapKeys.some((mapKey) => !usedKeys.has(mapKey)) + ) + }, [mapKeys, mapKeyValuePairs]) + + return ( + + + {t('channelsForm.model_mapping')} + + + {mapKeyValuePairs.map((row, index) => ( + + + listItems={mapKeys.filter((key) => !getSelectedMapKeys(index).has(key))} + initSelectedItem={row.key !== '' && row.key ? row.key : undefined} + // when select placeholder, the newSelectedItem is null + handleSelectedItemChange={(newSelectedItem) => + handleInputChange(index, 'key', newSelectedItem) + } + handleDropdownItemDisplay={handleDropdownItemDisplay} + handleSelectedItemDisplay={handleSeletedItemDisplay} + placeholder={t('channelsFormPlaceholder.modelMappingInput')} + /> + + handleInputChange(index, 'value', e.target.value)} + placeholder={t('channelsFormPlaceholder.modelMappingOutput')} + py="8px" + px="12px" + borderRadius="6px" + border="1px solid var(--Gray-Modern-200, #E8EBF0)" + bgColor="white" + sx={{ + '&::placeholder': { + color: 'grayModern.500', + fontFamily: '"PingFang SC"', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: '16px', + letterSpacing: '0.048px' + } + }} + /> + + + + ))} + + {hasAvailableKeys && ( + + )} + + ) +} +export default ConstructMappingComponent diff --git a/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx new file mode 100644 index 00000000000..76e8138ee0b --- /dev/null +++ b/frontend/providers/aiproxy/components/common/ConstructModeMappingComponent.tsx @@ -0,0 +1,475 @@ +import React, { useState, useEffect, useMemo } from 'react' +import { Flex, FormLabel, Input, Button, Text, Box, Badge, VStack } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { CustomSelect } from './Select' +type MapKeyValuePair = { key: string; value: string } + +type Model = { + name: string + isDefault: boolean +} + +// mapKeys determines the available selection options +export const ConstructModeMappingComponent = function ({ + mapKeys, + mapData, + setMapData +}: { + mapKeys: Model[] + mapData: Record + setMapData: (mapping: Record) => void +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const [mapKeyValuePairs, setMapkeyValuePairs] = useState>([ + { key: '', value: '' } + ]) + + const [isInternalUpdate, setIsInternalUpdate] = useState(false) + + useEffect(() => { + if (!isInternalUpdate) { + const entries = Object.entries(mapData) + setMapkeyValuePairs( + entries.length > 0 + ? entries.map(([key, value]) => ({ key, value })) + : [{ key: '', value: '' }] + ) + } + setIsInternalUpdate(false) + }, [mapData]) + + const handleDropdownItemDisplay = (dropdownItem: Model | string) => { + if (dropdownItem === t('channelsFormPlaceholder.modelMappingInput')) { + return ( + + {t('channelsFormPlaceholder.modelMappingInput')} + + ) + } + + if ((dropdownItem as Model).isDefault) { + return ( + + + {(dropdownItem as Model).name} + + + + + + + + + + + ) + } + return ( + + {(dropdownItem as Model).name} + + ) + } + + const handleSeletedItemDisplay = (selectedItem: Model | string) => { + if (selectedItem === t('channelsFormPlaceholder.modelMappingInput')) { + return ( + + {t('channelsFormPlaceholder.modelMappingInput')} + + ) + } + + if ((selectedItem as Model).isDefault) { + return ( + + ) + } + return ( + + + {(selectedItem as Model).name} + + + ) + } + + // Handling mapData and mapKeyValuePairs cleanup when map keys change. + useEffect(() => { + // 1. Handle mapData cleanup + const removedKeys = Object.keys(mapData).filter( + (key) => !mapKeys.some((model) => model.name === key) + ) + if (removedKeys.length > 0) { + // If there are mappings with removed keys, delete them + const newMapData = { ...mapData } + removedKeys.forEach((key) => { + delete newMapData[key] + }) + setIsInternalUpdate(true) + setMapData(newMapData) + } + + // 2. Handle mapKeyValuePairs cleanup + const removedPairs = mapKeyValuePairs.filter( + (pair) => pair.key && !mapKeys.some((model) => model.name === pair.key) + ) + if (removedPairs.length > 0) { + const newMapKeyValuePairs = mapKeyValuePairs.filter( + (pair) => !pair.key || mapKeys.some((model) => model.name === pair.key) + ) + setMapkeyValuePairs(newMapKeyValuePairs) + } + }, [mapKeys]) + + // Get the keys that have been selected + const getSelectedMapKeys = (currentIndex: number) => { + const selected = new Set() + mapKeyValuePairs.forEach((mapKeyValuePair, idx) => { + if (idx !== currentIndex && mapKeyValuePair.key) { + selected.add(mapKeyValuePair.key) + } + }) + return selected + } + + // Handling adding a new row + const handleAddNewMapKeyPair = () => { + setMapkeyValuePairs([...mapKeyValuePairs, { key: '', value: '' }]) + } + + // Handling deleting a row + const handleRemoveMapKeyPair = (index: number) => { + const mapKeyValuePair = mapKeyValuePairs[index] + const newMapData = { ...mapData } + if (mapKeyValuePair.key) { + delete newMapData[mapKeyValuePair.key] + } + setIsInternalUpdate(true) + setMapData(newMapData) + + const newMapKeyValuePairs = mapKeyValuePairs.filter((_, idx) => idx !== index) + setMapkeyValuePairs(newMapKeyValuePairs) + } + + // Handling selection/input changes + const handleInputChange = (index: number, field: 'key' | 'value', value: string) => { + const newMapKeyValuePairs = [...mapKeyValuePairs] + const oldValue = newMapKeyValuePairs[index][field] + newMapKeyValuePairs[index][field] = value + + // Update the mapping relationship + const newMapData = { ...mapData } + if (field === 'key') { + if (oldValue) delete newMapData[oldValue] + + if (!value) { + newMapKeyValuePairs[index].value = '' + } + + if (value && newMapKeyValuePairs[index].value) { + newMapData[value] = newMapKeyValuePairs[index].value + } + } else { + if (newMapKeyValuePairs[index].key) { + newMapData[newMapKeyValuePairs[index].key] = value + } + } + + setMapkeyValuePairs(newMapKeyValuePairs) + setIsInternalUpdate(true) + setMapData(newMapData) + } + + // Check if there are still keys that can be selected + const hasAvailableKeys = useMemo(() => { + const usedKeys = new Set( + mapKeyValuePairs.map((mapKeyValuePair) => mapKeyValuePair.key).filter(Boolean) + ) + return ( + mapKeyValuePairs.length < mapKeys.length && + mapKeys.some((mapKey) => !usedKeys.has(mapKey.name)) + ) + }, [mapKeys, mapKeyValuePairs]) + + return ( + + + {t('channelsForm.model_mapping')} + + + {mapKeyValuePairs.map((row, index) => ( + + + + listItems={mapKeys.filter((model) => !getSelectedMapKeys(index).has(model.name))} + initSelectedItem={row.key ? mapKeys.find((item) => item.name === row.key) : undefined} + // when select placeholder, the newSelectedItem is null + handleSelectedItemChange={(newSelectedItem) => { + if (newSelectedItem) { + handleInputChange(index, 'key', newSelectedItem.name) + } else { + handleInputChange(index, 'key', '') + } + }} + handleDropdownItemDisplay={handleDropdownItemDisplay} + handleSelectedItemDisplay={handleSeletedItemDisplay} + placeholder={t('channelsFormPlaceholder.modelMappingInput')} + /> + + + + handleInputChange(index, 'value', e.target.value)} + placeholder={t('channelsFormPlaceholder.modelMappingOutput')} + py="8px" + px="12px" + borderRadius="6px" + border="1px solid var(--Gray-Modern-200, #E8EBF0)" + bgColor="white" + _hover={{ borderColor: 'grayModern.300' }} + _focus={{ borderColor: 'grayModern.300' }} + _focusVisible={{ borderColor: 'grayModern.300' }} + _active={{ borderColor: 'grayModern.300' }} + sx={{ + '&::placeholder': { + color: 'grayModern.500', + fontFamily: '"PingFang SC"', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: 400, + lineHeight: '16px', + letterSpacing: '0.048px' + } + }} + /> + + + + + ))} + + {hasAvailableKeys && ( + + )} + + ) +} +export default ConstructModeMappingComponent diff --git a/frontend/providers/aiproxy/components/common/EditableTextNoLable.tsx b/frontend/providers/aiproxy/components/common/EditableTextNoLable.tsx new file mode 100644 index 00000000000..0f9df6ff525 --- /dev/null +++ b/frontend/providers/aiproxy/components/common/EditableTextNoLable.tsx @@ -0,0 +1,172 @@ +'use client' +import React, { useState } from 'react' +import { + Flex, + Text, + Button, + Input, + useDisclosure, + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + HStack, + FlexProps, + Box +} from '@chakra-ui/react' +import { CheckIcon, CloseIcon } from '@chakra-ui/icons' + +interface EditableTextProps { + value: string | number + onSubmit: (value: string) => void + flexProps?: FlexProps +} + +export const EditableTextNoLable = ({ value, onSubmit, flexProps }: EditableTextProps) => { + const [editValue, setEditValue] = useState(String(value)) + const { isOpen, onOpen, onClose } = useDisclosure() + + const handleSubmit = () => { + onSubmit(editValue) + onClose() + } + + const handleCancel = () => { + // 关闭时 恢复到传递来的初始值 + setEditValue(String(value)) + onClose() + } + + return ( + + + + + {value} + + + + + + + + setEditValue(e.target.value)} + minW="0" + w="full" + h="28px" + borderRadius="6px" + border="1px solid var(--Gray-Modern-200, #E8EBF0)" + bgColor="white" + _hover={{ borderColor: 'grayModern.300' }} + _focus={{ borderColor: 'grayModern.300' }} + _focusVisible={{ borderColor: 'grayModern.300' }} + _active={{ borderColor: 'grayModern.300' }} + autoFocus + /> + + + + + + + + + ) +} diff --git a/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx b/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx new file mode 100644 index 00000000000..c0b912ae73a --- /dev/null +++ b/frontend/providers/aiproxy/components/common/MultiSelectCombobox.tsx @@ -0,0 +1,405 @@ +'use client' +import { + Box, + Button, + Flex, + Text, + InputGroup, + Input, + FormLabel, + VStack, + ListItem, + List +} from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useState, useMemo, Dispatch, SetStateAction, ReactNode } from 'react' +import { useCombobox, useMultipleSelection } from 'downshift' + +export const MultiSelectCombobox = function ({ + dropdownItems, + selectedItems, + setSelectedItems, + handleFilteredDropdownItems, + handleDropdownItemDisplay, + handleSelectedItemDisplay, + handleSetCustomSelectedItem +}: { + dropdownItems: T[] + selectedItems: T[] + setSelectedItems: Dispatch> + handleFilteredDropdownItems: (dropdownItems: T[], selectedItems: T[], inputValue: string) => T[] + handleDropdownItemDisplay: (dropdownItem: T) => ReactNode + handleSelectedItemDisplay: (selectedItem: T) => ReactNode + handleSetCustomSelectedItem?: ( + selectedItems: T[], + setSelectedItems: Dispatch>, + customSelectedItemName: string, + setCustomSelectedItemName: Dispatch> + ) => void +}): JSX.Element { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + + const [inputValue, setInputValue] = useState('') + const [customSelectedItemName, setCustomSelectedItemName] = useState('') + + // Dropdown list excludes already selected options and includes those matching the input. + const items = useMemo( + () => handleFilteredDropdownItems(dropdownItems, selectedItems, inputValue), + [inputValue, selectedItems, dropdownItems, handleFilteredDropdownItems] + ) + + // 对已经选中的项目 添加处理事件 + const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({ + selectedItems, + onStateChange({ selectedItems: newSelectedItems, type }) { + switch (type) { + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete: + case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace: + case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem: + if (newSelectedItems) { + setSelectedItems(newSelectedItems) + } + break + default: + break + } + } + }) + const { + isOpen, + getToggleButtonProps, + getLabelProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + selectedItem + } = useCombobox({ + items, + defaultHighlightedIndex: 0, // after selection, highlight the first item. + selectedItem: null, + inputValue, + stateReducer(state, actionAndChanges) { + const { changes, type } = actionAndChanges + + switch (type) { + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.ItemClick: + return { + ...changes, + isOpen: true, // keep the menu open after selection. + highlightedIndex: 0 // with the first option highlighted. + } + default: + return changes + } + }, + onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) { + switch (type) { + case useCombobox.stateChangeTypes.InputKeyDownEnter: + case useCombobox.stateChangeTypes.ItemClick: + case useCombobox.stateChangeTypes.InputBlur: + if (newSelectedItem) { + setSelectedItems([...selectedItems, newSelectedItem]) + setInputValue('') + } + break + + case useCombobox.stateChangeTypes.InputChange: + setInputValue(newInputValue ?? '') + + break + default: + break + } + } + }) + + return ( + + + + + + {t('channelsForm.models')} + + + * + + + {handleSetCustomSelectedItem && ( + + setCustomSelectedItemName(e.target.value)} + /> + + + )} + + + + + {selectedItems.map((selectedItemForRender, index) => ( + + + {handleSelectedItemDisplay(selectedItemForRender)} + { + e.stopPropagation() + removeSelectedItem(selectedItemForRender) + }}> + + + + + + + ))} + + + + + + + + + + + + {isOpen && + items.map((item, index) => ( + + {handleDropdownItemDisplay(item)} + + ))} + + + ) +} diff --git a/frontend/providers/aiproxy/components/common/MyTooltip.tsx b/frontend/providers/aiproxy/components/common/MyTooltip.tsx new file mode 100644 index 00000000000..acb61dce0b0 --- /dev/null +++ b/frontend/providers/aiproxy/components/common/MyTooltip.tsx @@ -0,0 +1,52 @@ +'use client' + +import { Tooltip, TooltipProps } from '@chakra-ui/react' + +export const MyTooltip = ({ children, ...tooltipProps }: TooltipProps) => { + const tooltipStyles = { + hasArrow: true, + placement: 'bottom' as const, + bg: 'white', + color: 'grayModern.900', + fontSize: '12px', + p: '8px 12px', + borderRadius: '8px', + display: 'flex', + w: '60px', + h: '34px', + justifyContent: 'center', + alignItems: 'center', + flexShrink: 0, + fontFamily: 'PingFang SC', + fontWeight: 400, + lineHeight: '16px', + letterSpacing: '0.048px', + boxShadow: + '0px 2px 4px 0px rgba(161, 167, 179, 0.25), 0px 0px 1px 0px rgba(121, 141, 159, 0.25)', + border: '1px solid #FFF', + sx: { + // CSS 变量定义 + '--tooltip-bg': 'white', + '--popper-arrow-bg': 'white', + '--popper-arrow-shadow-color': 'rgba(161, 167, 179, 0.25)', + '--md': '8px', + + // 箭头样式 + '& [data-popper-arrow]': { + '--popper-arrow-shadow-color': 'rgba(161, 167, 179, 0.25)', + '&::before': { + boxShadow: + '0px 2px 4px 0px rgba(161, 167, 179, 0.25), 0px 0px 1px 0px rgba(121, 141, 159, 0.25)', + border: '1px solid #FFF', + borderRadius: 'var(--md)' + } + } + } + } + + return ( + + {children} + + ) +} diff --git a/frontend/providers/aiproxy/components/common/Select.tsx b/frontend/providers/aiproxy/components/common/Select.tsx new file mode 100644 index 00000000000..9f17d6d6c5e --- /dev/null +++ b/frontend/providers/aiproxy/components/common/Select.tsx @@ -0,0 +1,141 @@ +'use client' +import { Box, Text, ListItem, List } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { ReactNode } from 'react' +import { useSelect } from 'downshift' + +export const CustomSelect = function ({ + listItems, + handleSelectedItemChange, + handleDropdownItemDisplay, + handleSelectedItemDisplay, + placeholder, + initSelectedItem +}: { + listItems: T[] + handleSelectedItemChange: (selectedItem: T) => void + handleDropdownItemDisplay: (dropdownItem: T) => ReactNode + handleSelectedItemDisplay: (selectedItem: T) => ReactNode + placeholder?: string + initSelectedItem?: T +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const items = [placeholder, ...listItems] + + const { + isOpen, + selectedItem, + getToggleButtonProps, + getMenuProps, + getItemProps, + highlightedIndex + } = useSelect({ + items: items, + initialSelectedItem: initSelectedItem, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { + if (newSelectedItem === placeholder) { + handleSelectedItemChange(undefined as T) + } else { + handleSelectedItemChange(newSelectedItem as T) + } + } + }) + + return ( + + + {selectedItem ? ( + handleSelectedItemDisplay(selectedItem as T) + ) : placeholder ? ( + + {placeholder} + + ) : ( + + Select + + )} + + + + + + + + + {isOpen && + items.map((item, index) => ( + + {handleDropdownItemDisplay(item as T)} + + ))} + + + ) +} diff --git a/frontend/providers/aiproxy/components/SelectDateRange/index.tsx b/frontend/providers/aiproxy/components/common/SelectDateRange.tsx similarity index 98% rename from frontend/providers/aiproxy/components/SelectDateRange/index.tsx rename to frontend/providers/aiproxy/components/common/SelectDateRange.tsx index 67862c57cf2..05d6e862941 100644 --- a/frontend/providers/aiproxy/components/SelectDateRange/index.tsx +++ b/frontend/providers/aiproxy/components/common/SelectDateRange.tsx @@ -4,6 +4,7 @@ import { Box, Button, Flex, + FlexProps, Icon, Input, Popover, @@ -25,8 +26,9 @@ export default function SelectDateRange({ startTime, setStartTime, endTime, - setEndTime -}: SelectDateRangeProps): JSX.Element { + setEndTime, + ...props +}: SelectDateRangeProps & FlexProps): JSX.Element { const initState = { from: startTime, to: endTime } const [selectedRange, setSelectedRange] = useState(initState) @@ -180,7 +182,8 @@ export default function SelectDateRange({ boxSizing={'border-box'} justify={'space-between'} border={'1px solid #DEE0E2'} - borderRadius="6px"> + borderRadius="6px" + {...props}> + + + + {isComboboxOpen && + getFilteredDropdownItems.map((item, index) => ( + + {handleDropdownItemDisplay(item)} + + ))} + + + ) +} diff --git a/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx b/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx new file mode 100644 index 00000000000..c742a769dc1 --- /dev/null +++ b/frontend/providers/aiproxy/components/common/SingleSelectComboboxUnStyle.tsx @@ -0,0 +1,193 @@ +'use client' +import { Button, InputGroup, Input, ListItem, List, Flex, FlexProps, Box } from '@chakra-ui/react' +import { useTranslationClientSide } from '@/app/i18n/client' +import { useI18n } from '@/providers/i18n/i18nContext' +import { useState, ReactNode, useEffect } from 'react' +import { useCombobox, UseComboboxReturnValue } from 'downshift' + +export const SingleSelectComboboxUnstyle: (props: { + dropdownItems: T[] + setSelectedItem: (value: T) => void + handleDropdownItemFilter: (dropdownItems: T[], inputValue: string) => T[] + handleDropdownItemDisplay: (dropdownItem: T) => ReactNode + handleInputDisplay?: (item: T) => string + initSelectedItem?: T + flexProps?: FlexProps + placeholder?: string +}) => JSX.Element = function ({ + dropdownItems, + setSelectedItem, + handleDropdownItemFilter, + handleDropdownItemDisplay, + handleInputDisplay, + initSelectedItem, + flexProps, + placeholder +}: { + dropdownItems: T[] + setSelectedItem: (value: T) => void + handleDropdownItemFilter: (dropdownItems: T[], inputValue: string) => T[] + handleDropdownItemDisplay: (dropdownItem: T) => ReactNode + handleInputDisplay?: (item: T) => string + initSelectedItem?: T + flexProps?: FlexProps + placeholder?: string +}) { + const { lng } = useI18n() + const { t } = useTranslationClientSide(lng, 'common') + const [getFilteredDropdownItems, setGetFilteredDropdownItems] = useState(dropdownItems) + useEffect(() => { + setGetFilteredDropdownItems(dropdownItems) + }, [dropdownItems]) + + const { + isOpen: isComboboxOpen, + getToggleButtonProps, + getMenuProps, + getInputProps, + highlightedIndex, + getItemProps, + selectedItem + }: UseComboboxReturnValue = useCombobox({ + items: getFilteredDropdownItems, + onInputValueChange: ({ inputValue }) => { + setGetFilteredDropdownItems(handleDropdownItemFilter(dropdownItems, inputValue)) + }, + initialSelectedItem: initSelectedItem || undefined, + + itemToString: (item) => { + if (!item) return '' + return handleInputDisplay ? handleInputDisplay(item) : String(item) + }, + + onSelectedItemChange: ({ selectedItem }) => { + const selectedDropdownItem = dropdownItems.find((item) => item === selectedItem) + if (selectedDropdownItem) { + setSelectedItem(selectedDropdownItem) + } + } + }) + + return ( + + + + + + + {isComboboxOpen && + getFilteredDropdownItems.map((item, index) => ( + + {handleDropdownItemDisplay(item)} + + ))} + + + ) +} diff --git a/frontend/providers/aiproxy/components/SwitchPage.tsx b/frontend/providers/aiproxy/components/common/SwitchPage.tsx similarity index 100% rename from frontend/providers/aiproxy/components/SwitchPage.tsx rename to frontend/providers/aiproxy/components/common/SwitchPage.tsx diff --git a/frontend/providers/aiproxy/components/table/baseTable.tsx b/frontend/providers/aiproxy/components/table/BaseTable.tsx similarity index 95% rename from frontend/providers/aiproxy/components/table/baseTable.tsx rename to frontend/providers/aiproxy/components/table/BaseTable.tsx index 21f569a6d95..7e38e398c9c 100644 --- a/frontend/providers/aiproxy/components/table/baseTable.tsx +++ b/frontend/providers/aiproxy/components/table/BaseTable.tsx @@ -16,7 +16,7 @@ export function BaseTable({ isLoading }: { table: ReactTable; isLoading: boolean } & TableContainerProps) { return ( - + {table.getHeaderGroups().map((headers) => { @@ -30,6 +30,7 @@ export function BaseTable({ key={header.id} color="grayModern.600" border="none" + textTransform="none" borderTopLeftRadius={i === 0 ? '6px' : '0'} borderBottomLeftRadius={i === 0 ? '6px' : '0'} borderTopRightRadius={i === headers.headers.length - 1 ? '6px' : '0'} diff --git a/frontend/providers/aiproxy/components/user/KeyList.tsx b/frontend/providers/aiproxy/components/user/KeyList.tsx index 09b7b5e9c7d..a5ecd37f954 100644 --- a/frontend/providers/aiproxy/components/user/KeyList.tsx +++ b/frontend/providers/aiproxy/components/user/KeyList.tsx @@ -26,8 +26,10 @@ import { Input, FormErrorMessage, useDisclosure, - Center + Center, + Spinner } from '@chakra-ui/react' +import { CurrencySymbol } from '@sealos/ui' import { Column, createColumnHelper, @@ -36,17 +38,18 @@ import { useReactTable } from '@tanstack/react-table' import { TFunction } from 'i18next' -import { createKey, deleteKey, getKeys, updateKey } from '@/api/platform' +import { createToken, deleteToken, getTokens, updateToken } from '@/api/platform' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' -import { ChainIcon } from '@/ui/icons/home/Icons' +import { ChainIcon } from '@/ui/icons/index' import { useMessage } from '@sealos/ui' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' -import { TokenInfo } from '@/types/getKeys' -import SwitchPage from '@/components/SwitchPage' +import { TokenInfo } from '@/types/user/token' +import SwitchPage from '@/components/common/SwitchPage' import { useBackendStore } from '@/store/backend' -import { MyTooltip } from '@/components/MyTooltip' +import { MyTooltip } from '@/components/common/MyTooltip' +import { QueryKey } from '@/types/query-key' export function KeyList(): JSX.Element { const { lng } = useI18n() @@ -55,6 +58,7 @@ export function KeyList(): JSX.Element { return ( <> + {/* gap is 13px */} - - {/* table */} - - {/* modal */} - - + {/* table */} + + {/* modal */} + ) } @@ -84,8 +86,11 @@ export enum TableHeaderId { CREATED_AT = 'key.createdAt', LAST_USED_AT = 'key.lastUsedAt', STATUS = 'key.status', - ACTIONS = 'key.actions' + ACTIONS = 'key.actions', + REQUEST_COUNT = 'key.requestCount', + USED_AMOUNT = 'key.usedAmount' } + enum KeyStatus { ENABLED = 1, DISABLED = 2, @@ -94,6 +99,23 @@ enum KeyStatus { } const CustomHeader = ({ column, t }: { column: Column; t: TFunction }) => { + const { currencySymbol } = useBackendStore() + if (column.id === TableHeaderId.USED_AMOUNT) { + return ( + + + {t(column.id as TableHeaderId)} + + + + ) + } return ( void }) => { }) const queryClient = useQueryClient() - const deleteKeyMutation = useMutation((id: number) => deleteKey(id), { + const { data, isLoading } = useQuery({ + queryKey: [QueryKey.GetTokens, page, pageSize], + queryFn: () => getTokens({ page, perPage: pageSize }), + refetchOnReconnect: true, + onSuccess(data) { + setTotal(data?.total || 0) + } + }) + + const deleteKeyMutation = useMutation((id: number) => deleteToken(id), { onSuccess() { - queryClient.invalidateQueries(['getKeys']) + queryClient.invalidateQueries([QueryKey.GetTokens]) message({ status: 'success', title: t('key.deleteSuccess'), @@ -147,10 +178,10 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { }) const updateKeyMutation = useMutation( - ({ id, status }: { id: number; status: number }) => updateKey(id, status), + ({ id, status }: { id: number; status: number }) => updateToken(id, status), { onSuccess() { - queryClient.invalidateQueries(['getKeys']) + queryClient.invalidateQueries([QueryKey.GetTokens]) message({ status: 'success', title: t('key.updateSuccess'), @@ -181,15 +212,6 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { deleteKeyMutation.mutate(id) } - const { data, isLoading } = useQuery({ - queryKey: ['getKeys', page, pageSize], - queryFn: () => getKeys({ page, perPage: pageSize }), - refetchOnReconnect: true, - onSuccess(data) { - setTotal(data.total) - } - }) - const columnHelper = createColumnHelper() const columns = [ @@ -244,9 +266,7 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { ) }}> - {'sk-' + - info.getValue().substring(0, 8) + - '*'.repeat(Math.max(0, info.getValue().length - 8))} + {'sk-' + info.getValue().substring(0, 8) + '*'.repeat(3)} ) @@ -343,6 +363,46 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { } }), + columnHelper.accessor((row) => row.request_count, { + id: TableHeaderId.REQUEST_COUNT, + header: (props) => , + cell: (info) => ( + + {info.getValue()} + + ) + }), + + columnHelper.accessor((row) => row.used_amount, { + id: TableHeaderId.USED_AMOUNT, + header: (props) => , + cell: (info) => { + const value = Number(info.getValue()) + // 获取小数部分的长度 + const decimalLength = value.toString().split('.')[1]?.length || 0 + // 如果小数位超过6位则保留6位,否则保持原样 + const formattedValue = decimalLength > 6 ? value.toFixed(6) : value + + return ( + + {formattedValue} + + ) + } + }), + columnHelper.display({ id: TableHeaderId.ACTIONS, header: (props) => , @@ -354,11 +414,13 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { setOpenPopoverId(info.row.original.id)}> void }) => { display="flex" padding="6px 4px" alignItems="center" - gap="2px" + gap="8px" alignSelf="stretch" borderRadius="4px" background="transparent" @@ -406,26 +468,32 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { background: 'rgba(17, 24, 36, 0.05)', color: 'brightBlue.600' }} - leftIcon={ - - - - } onClick={() => { handleStatusUpdate(info.row.original.id, info.row.original.status) setOpenPopoverId(null) }}> - {t('enable')} + + + + + {t('enable')} + ) : ( )} @@ -525,106 +605,109 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { return ( <> - {isLoading || data?.tokens.length === 0 ? ( - - -
- - - - - - - - - - - - - - - - - {t('noData')} - - - - + + + + {t('noData')} + -
-
+ + + + ) : ( - <> + + {/* header */} @@ -634,119 +717,138 @@ const ModelKeyTable = ({ t, onOpen }: { t: TFunction; onOpen: () => void }) => { fontSize="14px" fontWeight={500} lineHeight="20px" + whiteSpace="nowrap" letterSpacing="0.1px"> API Endpoint: - { - const endpoint = aiproxyBackend - navigator.clipboard.writeText(endpoint).then( - () => { - message({ - status: 'success', - title: t('copySuccess'), - isClosable: true, - duration: 2000, - position: 'top' - }) - }, - (err) => { - message({ - status: 'warning', - title: t('copyFailed'), - description: err?.message || t('copyFailed'), - isClosable: true, - position: 'top' - }) - } - ) - }}> - {aiproxyBackend} - + + { + const endpoint = aiproxyBackend + navigator.clipboard.writeText(endpoint).then( + () => { + message({ + status: 'success', + title: t('copySuccess'), + isClosable: true, + duration: 2000, + position: 'top' + }) + }, + (err) => { + message({ + status: 'warning', + title: t('copyFailed'), + description: err?.message || t('copyFailed'), + isClosable: true, + position: 'top' + }) + } + ) + }}> + {aiproxyBackend} + + + {/* header end */} - - -
+ {/* table */} + + +
- {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header, i) => ( - - ))} - - ))} + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header, i) => { + return ( + + ) + })} + + ) + })} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} + {table.getRowModel().rows.map((row) => { + return ( + + {row.getVisibleCells().map((cell) => { + return ( + + ) + })} + + ) + })}
- {flexRender(header.column.columnDef.header, header.getContext())} -
+ {flexRender(header.column.columnDef.header, header.getContext())} +
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
void }) => { setCurrentPage={(idx: number) => setPage(idx)} /> - + {/* table end */} +
)} ) @@ -784,11 +887,11 @@ function CreateKeyModal({ successIconFill: 'white' }) - const createKeyMutation = useMutation((name: string) => createKey(name), { + const createKeyMutation = useMutation((name: string) => createToken(name), { onSuccess(data) { createKeyMutation.reset() setName('') - queryClient.invalidateQueries(['getKeys']) + queryClient.invalidateQueries([QueryKey.GetTokens]) message({ status: 'success', title: t('key.createSuccess'), @@ -798,11 +901,12 @@ function CreateKeyModal({ }) onClose() }, - onError(err: any) { + onError(err) { + console.error(err) message({ status: 'warning', title: t('key.createFailed'), - description: err?.response?.data?.message || t('key.createFailed'), + description: err instanceof Error ? err.message : t('key.createFailed'), isClosable: true, position: 'top' }) diff --git a/frontend/providers/aiproxy/components/user/ModelList.tsx b/frontend/providers/aiproxy/components/user/ModelList.tsx index e58eca70739..2579878b6e0 100644 --- a/frontend/providers/aiproxy/components/user/ModelList.tsx +++ b/frontend/providers/aiproxy/components/user/ModelList.tsx @@ -1,101 +1,20 @@ 'use client' import { Badge, Center, Flex, Spinner, Text } from '@chakra-ui/react' -import { ListIcon } from '@/ui/icons/home/Icons' +import { ListIcon } from '@/ui/icons/index' import { useTranslationClientSide } from '@/app/i18n/client' import { useI18n } from '@/providers/i18n/i18nContext' import Image, { StaticImageData } from 'next/image' import { useQuery } from '@tanstack/react-query' -import { getModels } from '@/api/platform' +import { getEnabledMode } from '@/api/platform' import { useMessage } from '@sealos/ui' -// icons -import OpenAIIcon from '@/ui/svg/icons/modelist/openai.svg' -import QwenIcon from '@/ui/svg/icons/modelist/qianwen.svg' -import ChatglmIcon from '@/ui/svg/icons/modelist/chatglm.svg' -import DeepseekIcon from '@/ui/svg/icons/modelist/deepseek.svg' -import MoonshotIcon from '@/ui/svg/icons/modelist/moonshot.svg' -import SparkdeskIcon from '@/ui/svg/icons/modelist/sparkdesk.svg' -import AbabIcon from '@/ui/svg/icons/modelist/minimax.svg' -import DoubaoIcon from '@/ui/svg/icons/modelist/doubao.svg' -import ErnieIcon from '@/ui/svg/icons/modelist/ernie.svg' -import { useMemo } from 'react' -import { MyTooltip } from '@/components/MyTooltip' -import { ModelIdentifier } from '@/types/front' - -const getIdentifier = (modelName: string): ModelIdentifier => { - return modelName.toLowerCase().split(/[-._\d]/)[0] as ModelIdentifier -} - -const sortModels = (models: string[]): string[] => { - // group by identifier - const groupMap = new Map() - - // group by identifier - models.forEach((model) => { - const identifier = getIdentifier(model) - // special handle gpt and o1, group them as 'openai' - const groupKey = identifier === 'gpt' || identifier === 'o' ? 'openai' : identifier - if (!groupMap.has(groupKey)) { - groupMap.set(groupKey, []) - } - groupMap.get(groupKey)?.push(model) - }) - - // sort by identifier and flatten the result - return Array.from(groupMap.entries()) - .sort((a, b) => a[0].localeCompare(b[0])) // sort by identifier - .flatMap(([_, models]) => models.sort()) // flatten and keep the order in each group -} - -const ModelComponent = ({ modelName }: { modelName: string }) => { - const modelGroups = { - openai: { - icon: OpenAIIcon, - identifiers: ['gpt', 'o1'] - }, - ernie: { - icon: ErnieIcon, - identifiers: ['ernie'] - }, - qwen: { - icon: QwenIcon, - identifiers: ['qwen'] - }, - chatglm: { - icon: ChatglmIcon, - identifiers: ['chatglm', 'glm'] - }, - deepseek: { - icon: DeepseekIcon, - identifiers: ['deepseek'] - }, - moonshot: { - icon: MoonshotIcon, - identifiers: ['moonshot'] - }, - sparkdesk: { - icon: SparkdeskIcon, - identifiers: ['sparkdesk'] - }, - abab: { - icon: AbabIcon, - identifiers: ['abab'] - }, - doubao: { - icon: DoubaoIcon, - identifiers: ['doubao'] - } - } - - // get model icon - const getModelIcon = (modelName: string): StaticImageData => { - const identifier = getIdentifier(modelName) - const group = Object.values(modelGroups).find((group) => group.identifiers.includes(identifier)) - return group?.icon || OpenAIIcon - } +import { MyTooltip } from '@/components/common/MyTooltip' +import { QueryKey } from '@/types/query-key' +import { modelIcons } from '@/ui/icons/mode-icons' +import { getTranslationWithFallback } from '@/utils/common' +const ModelComponent = ({ modelName, modelOwner }: { modelName: string; modelOwner: string }) => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') - const iconSrc = getModelIcon(modelName) const { message } = useMessage({ warningBoxBg: 'var(--Yellow-50, #FFFAEB)', warningIconBg: 'var(--Yellow-500, #F79009)', @@ -105,10 +24,25 @@ const ModelComponent = ({ modelName }: { modelName: string }) => { successIconFill: 'white' }) + // get model icon + const getModelIcon = (modelOwner: string): StaticImageData => { + const icon = modelIcons[modelOwner as keyof typeof modelIcons] || modelIcons['default'] + return icon + } + + const iconSrc = getModelIcon(modelOwner) + return ( {modelName} - + { fontWeight={500} lineHeight="16px" letterSpacing="0.5px" + whiteSpace="nowrap" onClick={() => navigator.clipboard.writeText(modelName).then( () => { @@ -151,9 +86,7 @@ const ModelComponent = ({ modelName }: { modelName: string }) => { const ModelList: React.FC = () => { const { lng } = useI18n() const { t } = useTranslationClientSide(lng, 'common') - const { isLoading, data } = useQuery(['getModels'], () => getModels()) - - const sortedData = useMemo(() => sortModels(data || []), [data]) + const { isLoading, data } = useQuery([QueryKey.GetEnabledModels], () => getEnabledMode()) return ( <> @@ -190,15 +123,44 @@ const ModelList: React.FC = () => {
- - {isLoading ? ( -
- -
- ) : ( - sortedData.map((model) => ) - )} -
+ + {isLoading ? ( +
+ +
+ ) : ( + + {data?.map((modelConfig) => ( + + ))} + + )} ) } diff --git a/frontend/providers/aiproxy/components/user/Sidebar.tsx b/frontend/providers/aiproxy/components/user/Sidebar.tsx index 8b2b211db30..0c05a4d6437 100644 --- a/frontend/providers/aiproxy/components/user/Sidebar.tsx +++ b/frontend/providers/aiproxy/components/user/Sidebar.tsx @@ -11,6 +11,8 @@ import logsIcon from '@/ui/svg/icons/sidebar/logs.svg' import logsIcon_a from '@/ui/svg/icons/sidebar/logs_a.svg' import priceIcon from '@/ui/svg/icons/sidebar/price.svg' import priceIcon_a from '@/ui/svg/icons/sidebar/price_a.svg' +import keysIcon from '@/ui/svg/icons/sidebar/key.svg' +import keysIcon_a from '@/ui/svg/icons/sidebar/key_a.svg' import { useI18n } from '@/providers/i18n/i18nContext' type Menu = { @@ -36,9 +38,17 @@ const SideBar = (): JSX.Element => { activeIcon: homeIcon_a, display: true }, + { + id: 'keys', + url: '/key', + value: t('Sidebar.Keys'), + icon: keysIcon, + activeIcon: keysIcon_a, + display: true + }, { id: 'logs', - url: '/logs', + url: '/log', value: t('Sidebar.Logs'), icon: logsIcon, activeIcon: logsIcon_a, @@ -61,7 +71,7 @@ const SideBar = (): JSX.Element => { px="12px" gap="var(--md, 8px)" alignContent="center" - flexShrink={0}> + flex="1"> {menus .filter((menu) => menu.display) .map((menu) => { diff --git a/frontend/providers/aiproxy/hooks/useDebounce.ts b/frontend/providers/aiproxy/hooks/useDebounce.ts new file mode 100644 index 00000000000..aa770c4da03 --- /dev/null +++ b/frontend/providers/aiproxy/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react' + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/frontend/providers/aiproxy/package.json b/frontend/providers/aiproxy/package.json index d4e321884ec..37535662be3 100644 --- a/frontend/providers/aiproxy/package.json +++ b/frontend/providers/aiproxy/package.json @@ -9,12 +9,16 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^3.9.0", "@sealos/ui": "workspace:*", "@tanstack/react-query": "^4.35.3", "@tanstack/react-table": "^8.10.7", + "@types/pg": "^8.11.10", "accept-language": "^3.0.20", "axios": "^1.7.7", "date-fns": "^2.30.0", + "downshift": "^9.0.8", + "echarts": "^5.4.3", "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.2", @@ -22,11 +26,14 @@ "immer": "^10.1.1", "jsonwebtoken": "^9.0.2", "next": "14.2.5", + "pg": "^8.13.1", "react": "^18", "react-day-picker": "^8.8.2", "react-dom": "^18", "react-hook-form": "^7.46.2", + "react-json-view": "^1.21.3", "sealos-desktop-sdk": "workspace:*", + "zod": "^3.23.8", "zustand": "^4.5.4" }, "devDependencies": { @@ -42,4 +49,4 @@ "tailwindcss": "^3.4.1", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/frontend/providers/aiproxy/providers/chakra/QueryProvider.tsx b/frontend/providers/aiproxy/providers/tanstack-query/QueryProvider.tsx similarity index 100% rename from frontend/providers/aiproxy/providers/chakra/QueryProvider.tsx rename to frontend/providers/aiproxy/providers/tanstack-query/QueryProvider.tsx diff --git a/frontend/providers/aiproxy/store/session.ts b/frontend/providers/aiproxy/store/session.ts new file mode 100644 index 00000000000..90f7d521461 --- /dev/null +++ b/frontend/providers/aiproxy/store/session.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { immer } from 'zustand/middleware/immer' +import { SessionV1 } from 'sealos-desktop-sdk' + +interface SessionState { + session: SessionV1 | null + setSession: (session: SessionV1 | null) => void +} + +export const useSessionStore = create()( + persist( + immer((set) => ({ + session: null as SessionV1 | null, + setSession: (session) => + set((state) => { + state.session = session + }) + })), + { + name: 'session' + } + ) +) diff --git a/frontend/providers/aiproxy/types/admin/channels/channelInfo.ts b/frontend/providers/aiproxy/types/admin/channels/channelInfo.ts new file mode 100644 index 00000000000..80c08193fba --- /dev/null +++ b/frontend/providers/aiproxy/types/admin/channels/channelInfo.ts @@ -0,0 +1,43 @@ +export interface ChannelInfo { + model_mapping: Record + config: Record + other: string + key: string + name: string + base_url: string + models: any[] + balance: number + response_duration: number + id: number + used_amount: number + request_count: number + status: number + type: number + priority: number + created_at: number + accessed_at: number + test_at: number + balance_updated_at: number +} + +export type CreateChannelRequest = { + type: number + name: string + key: string + base_url: string + models: string[] + model_mapping: Record +} + +export enum ChannelStatus { + ChannelStatusUnknown = 0, + ChannelStatusEnabled = 1, + ChannelStatusDisabled = 2, + ChannelStatusAutoDisabled = 3 +} + +export type ChannelType = `${number}` + +export type ChannelTypeMapName = { + [key in ChannelType]: string +} diff --git a/frontend/providers/aiproxy/types/admin/group.ts b/frontend/providers/aiproxy/types/admin/group.ts new file mode 100644 index 00000000000..9eb7845fd8c --- /dev/null +++ b/frontend/providers/aiproxy/types/admin/group.ts @@ -0,0 +1,20 @@ +export interface GroupInfo { + id: string + status: number + used_amount: number + qpm: number + request_count: number + created_at: number + accessed_at: number +} + +export interface GroupQueryParams { + keyword?: string + page: number + perPage: number +} + +export enum GroupStatus { + ENABLED = 1, + DISABLED = 2 +} diff --git a/frontend/providers/aiproxy/types/admin/option.ts b/frontend/providers/aiproxy/types/admin/option.ts new file mode 100644 index 00000000000..bd7ed0de666 --- /dev/null +++ b/frontend/providers/aiproxy/types/admin/option.ts @@ -0,0 +1,34 @@ +import { ChannelType } from './channels/channelInfo' + +export interface BatchOptionData { + DefaultChannelModelMapping: string + DefaultChannelModels: string +} + +export interface OptionData { + ApproximateTokenEnabled: string + AutomaticDisableChannelEnabled: string + AutomaticEnableChannelWhenTestSucceedEnabled: string + BillingEnabled: string + CompletionPrice: string + DefaultChannelModelMapping: string + DefaultChannelModels: string + DefaultGroupQPM: string + DisableServe: string + GeminiSafetySetting: string + GeminiVersion: string + GlobalApiRateLimitNum: string + GroupMaxTokenNum: string + ModelPrice: string + RetryTimes: string +} + +export type DefaultChannelModel = { + [key in ChannelType]: string[] +} + +export type DefaultChannelModelMapping = { + [key in ChannelType]: { + [modelKey: string]: string + } +} diff --git a/frontend/providers/aiproxy/types/api.d.ts b/frontend/providers/aiproxy/types/api.d.ts index 71452bae229..22e8ddff50c 100644 --- a/frontend/providers/aiproxy/types/api.d.ts +++ b/frontend/providers/aiproxy/types/api.d.ts @@ -1,8 +1,23 @@ -export interface ApiResp { +export type ApiResp = { code: number + message?: string + error?: string + data?: T +} + +export type ApiProxyBackendResp = { + success: boolean message: string data?: T } -export const isApiResp = (x: unknown): x is ApiResp => - typeof x.code === 'number' && typeof x.message === 'string' +export const isApiResp = (x: unknown): x is ApiResp => { + return ( + typeof x === 'object' && + x !== null && + 'code' in x && + 'message' in x && + typeof (x as any).code === 'number' && + typeof (x as any).message === 'string' + ) +} diff --git a/frontend/providers/aiproxy/types/appConfig.d.ts b/frontend/providers/aiproxy/types/app-config.d.ts similarity index 92% rename from frontend/providers/aiproxy/types/appConfig.d.ts rename to frontend/providers/aiproxy/types/app-config.d.ts index 7ddd6046677..28e923a889c 100644 --- a/frontend/providers/aiproxy/types/appConfig.d.ts +++ b/frontend/providers/aiproxy/types/app-config.d.ts @@ -7,6 +7,7 @@ export type AppConfigType = { aiproxy: string aiproxyInternal: string } + adminNameSpace: string[] currencySymbol: 'shellCoin' | 'cny' | 'usd' } diff --git a/frontend/providers/aiproxy/types/backend.d.ts b/frontend/providers/aiproxy/types/backend.d.ts deleted file mode 100644 index d572c684e7c..00000000000 --- a/frontend/providers/aiproxy/types/backend.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ModelPrice { - name: string - prompt: number - completion: number -} diff --git a/frontend/providers/aiproxy/types/form.d.ts b/frontend/providers/aiproxy/types/form.d.ts deleted file mode 100644 index 3f5d605bbb5..00000000000 --- a/frontend/providers/aiproxy/types/form.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface LogForm { - name: string - modelName: string - createdAt: Date - endedAt: Date - page: number - pageSize: number -} diff --git a/frontend/providers/aiproxy/types/front.d.ts b/frontend/providers/aiproxy/types/front.d.ts index de83dc1fd21..e69de29bb2d 100644 --- a/frontend/providers/aiproxy/types/front.d.ts +++ b/frontend/providers/aiproxy/types/front.d.ts @@ -1,12 +0,0 @@ -export type ModelIdentifier = - | 'ernie' - | 'qwen' - | 'chatglm' - | 'gpt' // Add these - | 'o' // Add these - | 'deepseek' - | 'moonshot' - | 'sparkdesk' - | 'abab' - | 'glm' - | 'doubao' diff --git a/frontend/providers/aiproxy/types/models/model.ts b/frontend/providers/aiproxy/types/models/model.ts new file mode 100644 index 00000000000..b8085242fe3 --- /dev/null +++ b/frontend/providers/aiproxy/types/models/model.ts @@ -0,0 +1,42 @@ +import { ChannelType } from '@/types/admin/channels/channelInfo' + +export interface ModelConfig { + config?: ModelConfigDetail + created_at: number + updated_at: number + image_prices: number[] | null + model: string + owner: string + image_batch_size: number + type: number + input_price: number + output_price: number + rpm: number +} + +export type ChannelWithMode = { + [K in ChannelType]?: ModelConfig[] +} + +export type ChannelDefaultModeMapping = { + [K in ChannelType]?: { + [modelKey: string]: string + } +} + +export type ChannelDefaultModel = { + [K in ChannelType]?: string[] +} + +export type ChannelWithDefaultModelAndDefaultModeMapping = { + mapping: ChannelDefaultModeMapping + models: ChannelDefaultModel +} + +export interface ModelConfigDetail { + max_input_tokens?: number + max_output_tokens?: number + max_context_tokens?: number + vision?: boolean + tool_choice?: boolean +} diff --git a/frontend/providers/aiproxy/types/query-key.ts b/frontend/providers/aiproxy/types/query-key.ts new file mode 100644 index 00000000000..eff327f6e7c --- /dev/null +++ b/frontend/providers/aiproxy/types/query-key.ts @@ -0,0 +1,22 @@ +export enum QueryKey { + // 共用 + // common + GetTokens = 'getTokens', + GetUserLogs = 'getUserLogs', + GetEnabledModels = 'getEnabledModels', + GetDashboardData = 'getDashboardData', + GetUserLogDetail = 'getUserLogDetail', + + // admin + GetChannels = 'getChannels', + GetAllChannels = 'getAllChannels', + GetGlobalLogs = 'getGlobalLogs', + GetGroups = 'getGroups', + GetChannelTypeNames = 'getChannelTypeNames', + GetAllChannelModes = 'getAllChannelModes', + GetDefaultModelAndModeMapping = 'getDefaultModelAndModeMapping', + GetOption = 'getOption', + + // 组件自己管理 + GetCommonConfig = 'getCommonConfig' +} diff --git a/frontend/providers/aiproxy/types/user/dashboard.ts b/frontend/providers/aiproxy/types/user/dashboard.ts new file mode 100644 index 00000000000..fcefa76c6d6 --- /dev/null +++ b/frontend/providers/aiproxy/types/user/dashboard.ts @@ -0,0 +1,20 @@ +import { ApiResp } from '../api' + +export interface ChartDataItem { + timestamp: number + request_count: number + used_amount: number + exception_count: number +} +export interface DashboardData { + chart_data: ChartDataItem[] + token_names: string[] + models: string[] + total_count: number + exception_count: number + used_amount: number + rpm: number + tpm: number +} + +export type DashboardResponse = ApiResp diff --git a/frontend/providers/aiproxy/types/log.d.ts b/frontend/providers/aiproxy/types/user/logs.ts similarity index 53% rename from frontend/providers/aiproxy/types/log.d.ts rename to frontend/providers/aiproxy/types/user/logs.ts index 35e53134b77..cef98d63ddd 100644 --- a/frontend/providers/aiproxy/types/log.d.ts +++ b/frontend/providers/aiproxy/types/user/logs.ts @@ -1,8 +1,20 @@ +export interface RequestDetail { + request_body?: string + response_body?: string + id: number + log_id: number +} + export interface LogItem { + request_detail?: RequestDetail + request_id: string + request_at: number + id: number code: number content: string group: string model: string + mode: number used_amount: number price: number completion_price: number @@ -15,11 +27,4 @@ export interface LogItem { created_at: number } -export interface LogResponse { - data: { - logs: LogItem[] - total: number - } - message: string - success: boolean -} +export interface GlobalLogItem extends LogItem {} diff --git a/frontend/providers/aiproxy/types/getKeys.d.ts b/frontend/providers/aiproxy/types/user/token.ts similarity index 100% rename from frontend/providers/aiproxy/types/getKeys.d.ts rename to frontend/providers/aiproxy/types/user/token.ts diff --git a/frontend/providers/aiproxy/ui/icons/home/Icons.tsx b/frontend/providers/aiproxy/ui/icons/home/Icons.tsx deleted file mode 100644 index 54c78739c00..00000000000 --- a/frontend/providers/aiproxy/ui/icons/home/Icons.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Icon, IconProps } from '@chakra-ui/react' - -export const ChainIcon = (props: IconProps) => ( - - - -) - -export const ListIcon = (props: IconProps) => ( - - - - - - - - -) - -// 使用示例: -// diff --git a/frontend/providers/aiproxy/ui/icons/index.tsx b/frontend/providers/aiproxy/ui/icons/index.tsx index 14c182dd619..7c01da583b2 100644 --- a/frontend/providers/aiproxy/ui/icons/index.tsx +++ b/frontend/providers/aiproxy/ui/icons/index.tsx @@ -33,3 +33,43 @@ export function RightFirstIcon(props: IconProps) { ) } + +export const ChainIcon = (props: IconProps) => ( + + + +) + +export const ListIcon = (props: IconProps) => ( + + + + + + + + +) diff --git a/frontend/providers/aiproxy/ui/icons/mode-icons/index.tsx b/frontend/providers/aiproxy/ui/icons/mode-icons/index.tsx new file mode 100644 index 00000000000..48ff3e4fca7 --- /dev/null +++ b/frontend/providers/aiproxy/ui/icons/mode-icons/index.tsx @@ -0,0 +1,66 @@ +import OpenAIIcon from '@/ui/svg/icons/modelist/openai.svg' +import QwenIcon from '@/ui/svg/icons/modelist/qianwen.svg' +import ChatglmIcon from '@/ui/svg/icons/modelist/chatglm.svg' +import DeepseekIcon from '@/ui/svg/icons/modelist/deepseek.svg' +import MoonshotIcon from '@/ui/svg/icons/modelist/moonshot.svg' +import SparkdeskIcon from '@/ui/svg/icons/modelist/sparkdesk.svg' +import DoubaoIcon from '@/ui/svg/icons/modelist/doubao.svg' +import BaaiIcon from '@/ui/svg/icons/modelist/baai.svg' +import HunyuanIcon from '@/ui/svg/icons/modelist/hunyuan.svg' +import MiniMaxIcon from '@/ui/svg/icons/modelist/minimax.svg' +import BaiduIcon from '@/ui/svg/icons/modelist/baidu.svg' +import GoogleGeminiIcon from '@/ui/svg/icons/modelist/google.svg' +import AlibabaIcon from '@/ui/svg/icons/modelist/alibaba.svg' +import FishAudioIcon from '@/ui/svg/icons/modelist/fishaudio.svg' +import StabilityAIIcon from '@/ui/svg/icons/modelist/stabilityai.svg' +import NeteaseIcon from '@/ui/svg/icons/modelist/netease.svg' +import AI360Icon from '@/ui/svg/icons/modelist/ai360.svg' +import AnthropicIcon from '@/ui/svg/icons/modelist/anthropic.svg' +import BaichuanIcon from '@/ui/svg/icons/modelist/baichuan.svg' +import MetaIcon from '@/ui/svg/icons/modelist/meta.svg' +import MistralIcon from '@/ui/svg/icons/modelist/mistral.svg' +import OpenChatIcon from '@/ui/svg/icons/modelist/openchat.svg' +import DefogIcon from '@/ui/svg/icons/modelist/defog.svg' +import NexusFlowIcon from '@/ui/svg/icons/modelist/nexusflow.svg' +import MicrosoftIcon from '@/ui/svg/icons/modelist/microsoft.svg' +import CohereIcon from '@/ui/svg/icons/modelist/cohere.svg' +import HuggingFaceIcon from '@/ui/svg/icons/modelist/huggingface.svg' +import LingyiWanwuIcon from '@/ui/svg/icons/modelist/lingyiwanwu.svg' +import StepFunIcon from '@/ui/svg/icons/modelist/stepfun.svg' +import DefaultIcon from '@/ui/svg/icons/modelist/default.svg' + +import AbabIcon from '@/ui/svg/icons/modelist/minimax.svg' +import ErnieIcon from '@/ui/svg/icons/modelist/ernie.svg' + +export const modelIcons = { + openai: OpenAIIcon, + alibaba: QwenIcon, + tencent: HunyuanIcon, + xunfei: SparkdeskIcon, + deepseek: DeepseekIcon, + moonshot: MoonshotIcon, + minimax: MiniMaxIcon, + baidu: BaiduIcon, + google: GoogleGeminiIcon, + baai: BaaiIcon, + funaudiollm: AlibabaIcon, + doubao: DoubaoIcon, + fishaudio: FishAudioIcon, + chatglm: ChatglmIcon, + stabilityai: StabilityAIIcon, + netease: NeteaseIcon, + ai360: AI360Icon, + anthropic: AnthropicIcon, + meta: MetaIcon, + baichuan: BaichuanIcon, + mistral: MistralIcon, + openchat: OpenChatIcon, + microsoft: MicrosoftIcon, + defog: DefogIcon, + nexusflow: NexusFlowIcon, + cohere: CohereIcon, + huggingface: HuggingFaceIcon, + lingyiwanwu: LingyiWanwuIcon, + stepfun: StepFunIcon, + default: DefaultIcon +} diff --git a/frontend/providers/aiproxy/ui/icons/sidebar/HomeIcon.tsx b/frontend/providers/aiproxy/ui/icons/sidebar/HomeIcon.tsx deleted file mode 100644 index a2ddb485ae1..00000000000 --- a/frontend/providers/aiproxy/ui/icons/sidebar/HomeIcon.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { createIcon } from '@chakra-ui/react'; - -export const ConsoleIcon = createIcon({ - displayName: 'ConsoleIcon', - viewBox: '0 0 24 24', - path: ( - <> - - - - - - ) -}); diff --git a/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/config.svg b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/config.svg new file mode 100644 index 00000000000..1b3f2ee5f1e --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/config.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/config_a.svg b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/config_a.svg new file mode 100644 index 00000000000..b9c9f721f66 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/config_a.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/home.svg b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/home.svg new file mode 100644 index 00000000000..9fe618a35f7 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/home.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/home_a.svg b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/home_a.svg new file mode 100644 index 00000000000..3ba408e7786 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/home_a.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/logs.svg b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/logs.svg new file mode 100644 index 00000000000..d40349cbd0a --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/logs.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/logs_a.svg b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/logs_a.svg new file mode 100644 index 00000000000..69879256e10 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/logs_a.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/nsManager.svg b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/nsManager.svg new file mode 100644 index 00000000000..7eca78e7898 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/nsManager.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/nsManager_a.svg b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/nsManager_a.svg new file mode 100644 index 00000000000..c4a77533621 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/admin-sidebar/nsManager_a.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/ai360.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/ai360.svg new file mode 100644 index 00000000000..c0bde5157c0 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/ai360.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/alibaba.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/alibaba.svg new file mode 100644 index 00000000000..67e870a156a --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/alibaba.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/anthropic.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/anthropic.svg new file mode 100644 index 00000000000..626429dbcb7 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/anthropic.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/baai.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/baai.svg new file mode 100644 index 00000000000..ec85f45d150 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/baai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/baichuan.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/baichuan.svg new file mode 100644 index 00000000000..9c1ef6a9d60 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/baichuan.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/baidu.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/baidu.svg new file mode 100644 index 00000000000..c64f120991a --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/baidu.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/cohere.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/cohere.svg new file mode 100644 index 00000000000..150bc29de8f --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/cohere.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/default.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/default.svg new file mode 100644 index 00000000000..979bd0cbaf3 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/default.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/defog.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/defog.svg new file mode 100644 index 00000000000..0b125ba2d85 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/defog.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/fishaudio.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/fishaudio.svg new file mode 100644 index 00000000000..ec44029a902 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/fishaudio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/google.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/google.svg new file mode 100644 index 00000000000..759184e8808 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/google.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/huggingface.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/huggingface.svg new file mode 100644 index 00000000000..43c5d3c0c97 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/huggingface.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/hunyuan.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/hunyuan.svg new file mode 100644 index 00000000000..d7e6fc65521 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/hunyuan.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/lingyiwanwu.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/lingyiwanwu.svg new file mode 100644 index 00000000000..2b8ad4b4c3a --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/lingyiwanwu.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/meta.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/meta.svg new file mode 100644 index 00000000000..9390c42cb7b --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/meta.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/microsoft.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/microsoft.svg new file mode 100644 index 00000000000..76115f3cbb1 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/microsoft.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/mistral.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/mistral.svg new file mode 100644 index 00000000000..1680f52cb89 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/mistral.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/netease.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/netease.svg new file mode 100644 index 00000000000..b24bd07f50a --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/netease.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/nexusflow.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/nexusflow.svg new file mode 100644 index 00000000000..14bd9e47dfc --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/nexusflow.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/openchat.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/openchat.svg new file mode 100644 index 00000000000..f19c346008d --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/openchat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/stabilityai.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/stabilityai.svg new file mode 100644 index 00000000000..5c87e29fc9b --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/stabilityai.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/providers/aiproxy/ui/svg/icons/modelist/stepfun.svg b/frontend/providers/aiproxy/ui/svg/icons/modelist/stepfun.svg new file mode 100644 index 00000000000..ec0eed0dbd1 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/modelist/stepfun.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/home.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/home.svg index 9fe618a35f7..3eb17800823 100644 --- a/frontend/providers/aiproxy/ui/svg/icons/sidebar/home.svg +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/home.svg @@ -1,6 +1,3 @@ - - - - + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/home_a.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/home_a.svg index 3ba408e7786..dfe212417ec 100644 --- a/frontend/providers/aiproxy/ui/svg/icons/sidebar/home_a.svg +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/home_a.svg @@ -1,6 +1,3 @@ - - - - + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg new file mode 100644 index 00000000000..9fe618a35f7 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/key.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/key_a.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/key_a.svg new file mode 100644 index 00000000000..3ba408e7786 --- /dev/null +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/key_a.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/price.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/price.svg index 4a014d509e7..f782e2a12e3 100644 --- a/frontend/providers/aiproxy/ui/svg/icons/sidebar/price.svg +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/price.svg @@ -1,4 +1,6 @@ - - + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/ui/svg/icons/sidebar/price_a.svg b/frontend/providers/aiproxy/ui/svg/icons/sidebar/price_a.svg index 439cb2df3f9..2b83d4c9ba4 100644 --- a/frontend/providers/aiproxy/ui/svg/icons/sidebar/price_a.svg +++ b/frontend/providers/aiproxy/ui/svg/icons/sidebar/price_a.svg @@ -1,4 +1,6 @@ - - + + + + \ No newline at end of file diff --git a/frontend/providers/aiproxy/utils/auth.ts b/frontend/providers/aiproxy/utils/auth.ts deleted file mode 100644 index 59e9e2c0584..00000000000 --- a/frontend/providers/aiproxy/utils/auth.ts +++ /dev/null @@ -1,39 +0,0 @@ -import jwt from 'jsonwebtoken' - -// Token payload 类型定义 -interface AppTokenPayload { - workspaceUid: string - workspaceId: string - regionUid: string - userCrUid: string - userCrName: string - userId: string - userUid: string - iat: number - exp: number -} - -export async function parseJwtToken(headers: Headers): Promise { - try { - const token = headers.get('authorization') - if (!token) { - return Promise.reject('Token is missing') - } - - const decoded = jwt.verify( - token, - global.AppConfig?.auth.appTokenJwtKey || '' - ) as AppTokenPayload - const now = Math.floor(Date.now() / 1000) - if (decoded.exp && decoded.exp < now) { - return Promise.reject('Token expired') - } - if (!decoded.workspaceId) { - return Promise.reject('Invalid token') - } - return decoded.workspaceId - } catch (error) { - console.error('Token parsing error:', error) - return Promise.reject('Invalid token') - } -} diff --git a/frontend/providers/aiproxy/utils/backend/auth.ts b/frontend/providers/aiproxy/utils/backend/auth.ts new file mode 100644 index 00000000000..523407faeaf --- /dev/null +++ b/frontend/providers/aiproxy/utils/backend/auth.ts @@ -0,0 +1,64 @@ +import jwt from 'jsonwebtoken' + +// Token payload +interface AppTokenPayload { + workspaceUid: string + workspaceId: string + regionUid: string + userCrUid: string + userCrName: string + userId: string + userUid: string + iat: number + exp: number +} + +export async function parseJwtToken(headers: Headers): Promise { + try { + const token = headers.get('authorization') + if (!token) { + return Promise.reject('Auth: Token is missing') + } + + const decoded = jwt.verify( + token, + global.AppConfig?.auth.appTokenJwtKey || '' + ) as AppTokenPayload + const now = Math.floor(Date.now() / 1000) + if (decoded.exp && decoded.exp < now) { + return Promise.reject('Auth: Token expired') + } + if (!decoded.workspaceId) { + return Promise.reject('Auth: Invalid token') + } + return decoded.workspaceId + } catch (error) { + console.error('Auth: Token parsing error:', error) + return Promise.reject('Auth: Invalid token') + } +} + +export async function getSealosUserUid(headers: Headers): Promise { + try { + const token = headers.get('authorization') + if (!token) { + return Promise.reject('Auth: Token is missing') + } + + const decoded = jwt.verify( + token, + global.AppConfig?.auth.appTokenJwtKey || '' + ) as AppTokenPayload + const now = Math.floor(Date.now() / 1000) + if (decoded.exp && decoded.exp < now) { + return Promise.reject('Auth: Token expired') + } + if (!decoded.workspaceId) { + return Promise.reject('Auth: Invalid token') + } + return decoded.userUid + } catch (error) { + console.error('Auth: Token parsing error:', error) + return Promise.reject('Auth: Invalid token') + } +} diff --git a/frontend/providers/aiproxy/utils/backend/db.ts b/frontend/providers/aiproxy/utils/backend/db.ts new file mode 100644 index 00000000000..00bce8b110c --- /dev/null +++ b/frontend/providers/aiproxy/utils/backend/db.ts @@ -0,0 +1,54 @@ +import pg, { PoolConfig, QueryConfig } from 'pg' +const { Pool, types } = pg +const connectionString = `postgresql://${process.env.PG_USER}:${process.env.PG_PASSWD}@hzh.sealos.run:43243/defaultdb` + +types.setTypeParser(20, function (val: string) { + return BigInt(val) +}) + +let poolConfig: PoolConfig = { + connectionString: connectionString, + max: 20, // 连接池最大连接数 + idleTimeoutMillis: 10000, // 空闲连接超时时间,毫秒 + connectionTimeoutMillis: 2000, // 连接超时时间,毫秒 + ssl: { + rejectUnauthorized: false // 不验证SSL证书 + } +} + +export const pgPool = new Pool(poolConfig) + +type UserRealNameInfo = { + id: string + userUid: string + realName?: string + idCard?: string + phone?: string + isVerified: boolean + idVerifyFailedTimes: number + createdAt: string + updatedAt: string + additionalInfo?: object +} + +export async function validateSealosUserRealNameInfo(sealosUserUid: string): Promise { + const query: QueryConfig = { + text: 'SELECT * FROM "UserRealNameInfo" WHERE "userUid" = $1', + values: [sealosUserUid] + } + + try { + const res = await pgPool.query(query) + + if (res.rows.length === 0) { + return false + } + + const userRealNameInfo: UserRealNameInfo = res.rows[0] + + return userRealNameInfo.isVerified + } catch (error: any) { + console.error('Error executing query', error.stack) + throw error + } +} diff --git a/frontend/providers/aiproxy/utils/backend/isAdmin.ts b/frontend/providers/aiproxy/utils/backend/isAdmin.ts new file mode 100644 index 00000000000..0a3a054fd7f --- /dev/null +++ b/frontend/providers/aiproxy/utils/backend/isAdmin.ts @@ -0,0 +1,14 @@ +export async function isAdmin(namespace: string): Promise { + if (!namespace) { + return Promise.reject('Admin: Invalid namespace') + } + try { + if (global.AppConfig?.adminNameSpace.includes(namespace)) { + return namespace + } + return Promise.reject('Admin: Invalid namespace') + } catch (error) { + console.error('Admin: check namespace error:', error) + return Promise.reject('Admin: Invalid namespace') + } +} diff --git a/frontend/providers/aiproxy/utils/common.ts b/frontend/providers/aiproxy/utils/common.ts new file mode 100644 index 00000000000..25743fc8566 --- /dev/null +++ b/frontend/providers/aiproxy/utils/common.ts @@ -0,0 +1,36 @@ +// 根据枚举值获取枚举键 +export const getEnumKeyByValue = ( + enumObj: T, + value: string +): keyof T | undefined => { + const keys = Object.keys(enumObj) as Array + return keys.find((key) => enumObj[key] === value) +} + +/** + * 获取翻译,如果翻译不存在则返回指定的默认翻译 + * @param key - 翻译键 + * @param defaultKey - 默认翻译键 + * @param t - i18n 翻译函数 + */ +export const getTranslationWithFallback = ( + key: string, + defaultKey: string, + t: (key: string) => string +): string => { + const translated = t(key) + return translated === key ? t(defaultKey) : translated +} + +// 下载 JSON 文件 +export const downloadJson = (data: T, filename: string): void => { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${filename}_${new Date().toISOString()}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} diff --git a/frontend/providers/aiproxy/utils/frontend/request.ts b/frontend/providers/aiproxy/utils/frontend/request.ts new file mode 100644 index 00000000000..2b7561b5a1b --- /dev/null +++ b/frontend/providers/aiproxy/utils/frontend/request.ts @@ -0,0 +1,151 @@ +import { ApiResp } from '@/types/api' +import axios, { InternalAxiosRequestConfig, AxiosResponse, AxiosRequestConfig } from 'axios' +import { getAppToken } from './user' + +const request = axios.create({ + baseURL: '/', + withCredentials: true, + timeout: 60000 +}) + +// request interceptor +request.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + // auto append service prefix + if (config.url && !config.url.startsWith('/api/')) { + config.url = `/api${config.url}` + } + + // ensure headers exists + config.headers = config.headers || {} + + // append user session to Authorization header + const appToken = getAppToken() + if (appToken) { + config.headers['Authorization'] = appToken + } + + // set default Content-Type + if (!config.headers['Content-Type']) { + config.headers['Content-Type'] = 'application/json' + } + + // 如果是 FormData,删除 Content-Type,让浏览器自动设置 + if (config.data instanceof FormData) { + delete config.headers['Content-Type'] + } + + return config + }, + (error: any) => { + // handle request interceptor error + console.error('Request Interceptor Error:', error) + error.data = { + msg: 'An error occurred while making the request. Please try again later.' + } + return Promise.reject(error) // use reject to catch error in subsequent process + } +) + +request.interceptors.response.use( + (response: AxiosResponse) => { + // only process status code 200 + const { data } = response.data + return data + }, + (error: any) => { + if (axios.isCancel(error)) { + return Promise.reject(new Error(`cancel request: ${error.message || error}`)) + } + + const apiResponse = error?.response?.data as ApiResp + if (apiResponse?.error || apiResponse?.message) { + error.message = apiResponse.error || apiResponse.message + } else { + error.message = 'An unknown error occurred. Please try again later.' + } + + return Promise.reject(error) + } +) + +/** + * GET request + * @param url - request url + * @param data - request params (will be converted to query string) + * @param config - axios config + * @returns Promise + */ +export function GET( + url: string, + data?: { [key: string]: any }, + config?: AxiosRequestConfig +): Promise { + return request.get(url, { + params: data, + ...config + }) +} + +/** + * POST request + * @param url - request url + * @param data - request body data + * @param config - axios config + * @returns Promise + */ +export function POST( + url: string, + data?: { [key: string]: any }, + config?: AxiosRequestConfig +): Promise { + return request.post(url, data, config) +} + +/** + * DELETE request + * @param url - request url + * @param data - request params (will be converted to query string) + * @param config - axios config + * @returns Promise + */ +export function DELETE( + url: string, + data?: { [key: string]: any }, + config?: AxiosRequestConfig +): Promise { + return request.delete(url, { + params: data, + ...config + }) +} + +/** + * PATCH request + * @param url - request url + * @param data - request body data + * @param config - axios config + * @returns Promise + */ +export function PATCH( + url: string, + data?: { [key: string]: any }, + config?: AxiosRequestConfig +): Promise { + return request.patch(url, data, config) +} + +/** + * PUT request + * @param url - request url + * @param data - request body data + * @param config - axios config + * @returns Promise + */ +export function PUT( + url: string, + data?: { [key: string]: any }, + config?: AxiosRequestConfig +): Promise { + return request.put(url, data, config) +} diff --git a/frontend/providers/aiproxy/utils/frontend/user.ts b/frontend/providers/aiproxy/utils/frontend/user.ts new file mode 100644 index 00000000000..d95f55d156e --- /dev/null +++ b/frontend/providers/aiproxy/utils/frontend/user.ts @@ -0,0 +1,15 @@ +import { useSessionStore } from '@/store/session' + +export const getAppToken = () => { + let token = process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_MOCK_USER || '' : '' + + if (!token) { + // 从 store 获取 token + const { session } = useSessionStore.getState() + if (session?.token) { + token = session.token + } + } + + return token +} diff --git a/frontend/providers/aiproxy/utils/request.ts b/frontend/providers/aiproxy/utils/request.ts deleted file mode 100644 index f559b7e801b..00000000000 --- a/frontend/providers/aiproxy/utils/request.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ApiResp } from '@/types/api' -import axios, { - InternalAxiosRequestConfig, - AxiosHeaders, - AxiosResponse, - AxiosRequestConfig -} from 'axios' -import { getUserSession } from './user' - -const request = axios.create({ - baseURL: '/', - withCredentials: true, - timeout: 60000 -}) - -// request interceptor -request.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { - // auto append service prefix - if (config.url && !config.url?.startsWith('/api/')) { - config.url = '' + config.url - } - let _headers: AxiosHeaders = config.headers - - //获取token,并将其添加至请求头中 - _headers['Authorization'] = getUserSession() - - if (!config.headers || config.headers['Content-Type'] === '') { - _headers['Content-Type'] = 'application/json' - } - - config.headers = _headers - return config - }, - (error: any) => { - error.data = {} - error.data.msg = '服务器异常,请联系管理员!' - return Promise.resolve(error) - } -) - -// response interceptor -request.interceptors.response.use( - (response: AxiosResponse) => { - const { status, data } = response - if (status < 200 || status >= 300) { - return Promise.reject(status + ', ' + typeof data === 'string' ? data : String(data)) - } - - const apiResp = data as ApiResp - if (apiResp.code < 200 || apiResp.code >= 400) { - return Promise.reject(apiResp.code + ':' + apiResp.message) - } - - response.data = apiResp.data - return response.data - }, - (error: any) => { - if (axios.isCancel(error)) { - return Promise.reject('cancel request' + String(error)) - } else { - error.errMessage = '请求超时或服务器异常,请检查网络或联系管理员!' - } - return Promise.reject(error) - } -) - -export function GET( - url: string, - data?: { [key: string]: any }, - config?: AxiosRequestConfig -): Promise { - return request.get(url, { - params: data, - ...config - }) -} - -export function POST( - url: string, - data?: { [key: string]: any }, - config?: AxiosRequestConfig -): Promise { - return request.post(url, data, config) -} - -export function DELETE( - url: string, - data?: { [key: string]: any }, - config?: AxiosRequestConfig -): Promise { - return request.delete(url, { - params: data, - ...config - }) -} diff --git a/frontend/providers/aiproxy/utils/user.ts b/frontend/providers/aiproxy/utils/user.ts deleted file mode 100644 index c60c02883dc..00000000000 --- a/frontend/providers/aiproxy/utils/user.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const getUserSession = () => { - let token: string = - process.env.NODE_ENV === 'development' ? process.env.NEXT_PUBLIC_MOCK_USER || '' : '' - - try { - const store = localStorage.getItem('session') - if (!token && store) { - token = JSON.parse(store)?.token - } - } catch (err) { - err - } - return token -}