diff --git a/app/package.json b/app/package.json index 509ac210..fbbc4514 100644 --- a/app/package.json +++ b/app/package.json @@ -40,6 +40,7 @@ "localforage": "^1.10.0", "lucide-react": "^0.289.0", "match-sorter": "^6.3.1", + "next-themes": "^0.2.1", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", "react-chartjs-2": "^5.2.0", @@ -53,6 +54,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^3.0.1", "remark-math": "^5.1.1", + "sonner": "^1.3.1", "sort-by": "^1.2.0", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 82aaaa24..ac9940d1 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -89,6 +89,9 @@ dependencies: match-sorter: specifier: ^6.3.1 version: 6.3.1 + next-themes: + specifier: ^0.2.1 + version: 0.2.1(next@14.0.4)(react-dom@18.2.0)(react@18.2.0) react: specifier: ^18.2.0 version: 18.2.0 @@ -128,6 +131,9 @@ dependencies: remark-math: specifier: ^5.1.1 version: 5.1.1 + sonner: + specifier: ^1.3.1 + version: 1.3.1(react-dom@18.2.0)(react@18.2.0) sort-by: specifier: ^1.2.0 version: 1.2.0 @@ -543,6 +549,91 @@ packages: resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} dev: false + /@next/env@14.0.4: + resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==} + dev: false + + /@next/swc-darwin-arm64@14.0.4: + resolution: {integrity: sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@next/swc-darwin-x64@14.0.4: + resolution: {integrity: sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-arm64-gnu@14.0.4: + resolution: {integrity: sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-arm64-musl@14.0.4: + resolution: {integrity: sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-x64-gnu@14.0.4: + resolution: {integrity: sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@next/swc-linux-x64-musl@14.0.4: + resolution: {integrity: sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@next/swc-win32-arm64-msvc@14.0.4: + resolution: {integrity: sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@next/swc-win32-ia32-msvc@14.0.4: + resolution: {integrity: sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@next/swc-win32-x64-msvc@14.0.4: + resolution: {integrity: sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1860,6 +1951,12 @@ packages: resolution: {integrity: sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==} dev: true + /@swc/helpers@0.5.2: + resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} + dependencies: + tslib: 2.6.2 + dev: false + /@swc/types@0.1.5: resolution: {integrity: sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==} dev: true @@ -2539,6 +2636,13 @@ packages: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true + /busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + dependencies: + streamsearch: 1.1.0 + dev: false + /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2557,7 +2661,6 @@ packages: /caniuse-lite@1.0.30001554: resolution: {integrity: sha512-A2E3U//MBwbJVzebddm1YfNp7Nud5Ip+IPn4BozBmn4KqVX7AvluoIDFWjsv5OkGnKUXQVmMSoMKLa3ScCblcQ==} - dev: true /ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2626,6 +2729,10 @@ packages: source-map: 0.6.1 dev: true + /client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + dev: false + /clsx@2.0.0: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} @@ -3235,7 +3342,6 @@ packages: /glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - dev: true /glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} @@ -3279,7 +3385,6 @@ packages: /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: true /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -4276,6 +4381,58 @@ packages: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} dev: true + /next-themes@0.2.1(next@14.0.4)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} + peerDependencies: + next: '*' + react: '*' + react-dom: '*' + dependencies: + next: 14.0.4(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /next@14.0.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + sass: + optional: true + dependencies: + '@next/env': 14.0.4 + '@swc/helpers': 0.5.2 + busboy: 1.6.0 + caniuse-lite: 1.0.30001554 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(react@18.2.0) + watchpack: 2.4.0 + optionalDependencies: + '@next/swc-darwin-arm64': 14.0.4 + '@next/swc-darwin-x64': 14.0.4 + '@next/swc-linux-arm64-gnu': 14.0.4 + '@next/swc-linux-arm64-musl': 14.0.4 + '@next/swc-linux-x64-gnu': 14.0.4 + '@next/swc-linux-x64-musl': 14.0.4 + '@next/swc-win32-arm64-msvc': 14.0.4 + '@next/swc-win32-ia32-msvc': 14.0.4 + '@next/swc-win32-x64-msvc': 14.0.4 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + dev: false + /no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: @@ -5063,6 +5220,16 @@ packages: engines: {node: '>=8'} dev: true + /sonner@1.3.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-+rOAO56b2eI3q5BtgljERSn2umRk63KFIvgb2ohbZ5X+Eb5u+a/7/0ZgswYqgBMg8dyl7n6OXd9KasA8QF9ToA==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /sort-by@1.2.0: resolution: {integrity: sha512-aRyW65r3xMnf4nxJRluCg0H/woJpksU1dQxRtXYzau30sNBOmf5HACpDd9MZDhKh7ALQ5FgSOfMPwZEtUmMqcg==} dependencies: @@ -5093,6 +5260,11 @@ packages: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} dev: false + /streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + dev: false + /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -5111,6 +5283,23 @@ packages: inline-style-parser: 0.1.1 dev: false + /styled-jsx@5.1.1(react@18.2.0): + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + dependencies: + client-only: 0.0.1 + react: 18.2.0 + dev: false + /sucrase@3.34.0: resolution: {integrity: sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==} engines: {node: '>=8'} @@ -5588,7 +5777,6 @@ packages: dependencies: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 - dev: true /web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} diff --git a/app/src/App.tsx b/app/src/App.tsx index e3709e1c..08cf09b9 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -2,11 +2,13 @@ import { Provider } from "react-redux"; import store from "./store/index.ts"; import AppProvider from "./components/app/AppProvider.tsx"; import { AppRouter } from "./router.tsx"; +import { Toaster } from "@/components/ui/sonner"; function App() { return ( + ); diff --git a/app/src/admin/market.ts b/app/src/admin/market.ts new file mode 100644 index 00000000..91c193f6 --- /dev/null +++ b/app/src/admin/market.ts @@ -0,0 +1,40 @@ +export const marketEditableTags = [ + "official", + "unstable", + "web", + "high-quality", + "high-price", + "open-source", + "image-generation", + "multi-modal", + "fast", + "english-model", +]; + +export const modelImages = [ + "gpt35turbo.png", + "gpt35turbo16k.webp", + "gpt4.png", + "gpt432k.webp", + "gpt4v.png", + "gpt4dalle.png", + "claude.png", + "claude100k.png", + "stablediffusion.jpeg", + "llama2.webp", + "llamacode.webp", + "dalle.jpeg", + "midjourney.jpg", + "newbing.jpg", + "palm2.webp", + "gemini.jpeg", + "chatglm.png", + "tongyi.png", + "sparkdesk.jpg", + "hunyuan.png", + "360gpt.png", + "baichuan.png", + "skylark.jpg", +]; + +export const marketTags = [...marketEditableTags, "free", "high-context"]; diff --git a/app/src/api/types.ts b/app/src/api/types.ts index 3e06e501..7c490a58 100644 --- a/app/src/api/types.ts +++ b/app/src/api/types.ts @@ -13,9 +13,12 @@ export type Message = { export type Model = { id: string; name: string; + description?: string; free: boolean; auth: boolean; + default: boolean; high_context: boolean; + avatar: string; tag?: string[]; }; diff --git a/app/src/assets/admin/all.less b/app/src/assets/admin/all.less index 96398b6e..ba6094ed 100644 --- a/app/src/assets/admin/all.less +++ b/app/src/assets/admin/all.less @@ -1,5 +1,6 @@ @import "menu"; @import "dashboard"; +@import "market"; @import "management"; @import "broadcast"; @import "channel"; diff --git a/app/src/assets/admin/dashboard.less b/app/src/assets/admin/dashboard.less index 712faddd..b3a7df3a 100644 --- a/app/src/assets/admin/dashboard.less +++ b/app/src/assets/admin/dashboard.less @@ -46,7 +46,6 @@ padding: 0.75rem 1.5rem; border-radius: var(--radius); background: hsl(var(--background)); - border: 1px solid hsl(var(--border)); box-shadow: 0.5rem 0.5rem 1rem 0 var(--shadow); user-select: none; max-width: 460px; @@ -116,7 +115,6 @@ border-radius: var(--radius); background: hsl(var(--background)); box-shadow: 0.5rem 0.5rem 1rem 0 var(--shadow); - border: 1px solid hsl(var(--border)); user-select: none; @media (max-width: 680px) { diff --git a/app/src/assets/admin/market.less b/app/src/assets/admin/market.less new file mode 100644 index 00000000..2f806f89 --- /dev/null +++ b/app/src/assets/admin/market.less @@ -0,0 +1,148 @@ +.market { + width: 100%; + height: max-content; + padding: 2rem; + display: flex; + flex-direction: column; + + .market-card { + width: 100%; + height: 100%; + min-height: 20vh; + } + + & > * { + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } +} + +.market-list { + display: flex; + flex-direction: column; + + & > * { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + } + + .market-item { + display: flex; + flex-direction: row; + padding: 1.5rem 1rem; + border-radius: var(--radius); + border: 1px solid hsl(var(--border)); + user-select: none; + width: 100%; + height: max-content; + align-items: center; + background: hsl(var(--card)); + + .market-tags { + display: flex; + flex-direction: row; + gap: 0.5rem; + flex-wrap: wrap; + width: 100%; + + .market-tag { + white-space: nowrap; + padding: 0.25rem 0.75rem !important; + } + } + + .market-images { + display: flex; + flex-direction: row; + gap: 0.5rem; + flex-wrap: wrap; + width: 100%; + + .market-image { + width: 2.5rem; + height: 2.5rem; + padding: 0.25rem; + transition: 0.1s; + + img { + width: 2rem; + height: 2rem; + opacity: 0.6; + border-radius: calc(var(--radius) - 2px); + transition: 0.1s; + } + + &.active { + img { + opacity: 1; + } + } + } + } + + svg { + flex-shrink: 0; + } + + .drop-icon { + color: hsl(var(--text-secondary)); + transition: color 0.25s ease; + } + + &:hover { + .drop-icon { + color: hsl(var(--text)); + } + } + + .model-wrapper { + display: flex; + flex-direction: column; + flex-grow: 1; + flex-basis: 0; + + & > * { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + } + } + + .market-row { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + + & > span { + display: flex; + flex-direction: row; + align-items: center; + font-size: 0.9rem; + white-space: nowrap; + min-width: 68px; + text-align: center; + + svg { + transform: translateY(1px); + } + } + + & > * { + margin-right: 0.75rem; + + &:last-child { + margin-right: 0; + } + } + } + } +} diff --git a/app/src/assets/pages/home.less b/app/src/assets/pages/home.less index 7ca9f85e..d0704770 100644 --- a/app/src/assets/pages/home.less +++ b/app/src/assets/pages/home.less @@ -99,6 +99,7 @@ animation: fadein 0.25s forwards ease-in-out; opacity: 0; width: calc(100% - 1rem); + background: hsla(var(--background-container)); @keyframes fadein { from { opacity: 0; transform: translateY(2.5rem); } diff --git a/app/src/components/admin/MenuBar.tsx b/app/src/components/admin/MenuBar.tsx index 4f437661..b0803bd4 100644 --- a/app/src/components/admin/MenuBar.tsx +++ b/app/src/components/admin/MenuBar.tsx @@ -2,6 +2,7 @@ import { useDispatch, useSelector } from "react-redux"; import { closeMenu, selectMenu } from "@/store/menu.ts"; import React, { useMemo } from "react"; import { + BookCopy, CandlestickChart, GitFork, LayoutDashboard, @@ -54,6 +55,11 @@ function MenuBar() { path={"/"} /> } path={"/users"} /> + } + path={"/market"} + /> } diff --git a/app/src/components/home/ModelMarket.tsx b/app/src/components/home/ModelMarket.tsx index 646aba84..950376aa 100644 --- a/app/src/components/home/ModelMarket.tsx +++ b/app/src/components/home/ModelMarket.tsx @@ -11,7 +11,7 @@ import { X, } from "lucide-react"; import React, { useMemo, useState } from "react"; -import { modelAvatars, supportModels } from "@/conf.ts"; +import { supportModels } from "@/conf.ts"; import { splitList } from "@/utils/base.ts"; import { Model } from "@/api/types.ts"; import { useDispatch, useSelector } from "react-redux"; @@ -103,10 +103,7 @@ function ModelItem({ return getPlanModels(level).includes(model.id); }, [model, level, student]); - const avatar = useMemo(() => { - const source = modelAvatars[model.id] || modelAvatars[supportModels[0].id]; - return `/icons/${source}`; - }, [model]); + const avatar = useMemo(() => `/icons/${model.avatar}`, [model]); return (
; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/app/src/conf.ts b/app/src/conf.ts index 5b8a92b9..3c52c09a 100644 --- a/app/src/conf.ts +++ b/app/src/conf.ts @@ -20,135 +20,168 @@ export const deploy: boolean = true; export let rest_api: string = getRestApi(deploy); export let ws_api: string = getWebsocketApi(deploy); export const tokenField = getTokenField(deploy); + export let supportModels: Model[] = loadPreferenceModels([ // openai models { id: "gpt-3.5-turbo-0613", name: "GPT-3.5", + avatar: "gpt35turbo.png", free: true, auth: false, high_context: false, + default: true, tag: ["free", "official"], }, { id: "gpt-3.5-turbo-16k-0613", name: "GPT-3.5-16k", + avatar: "gpt35turbo16k.webp", free: true, auth: true, high_context: true, + default: true, tag: ["free", "official", "high-context"], }, { id: "gpt-3.5-turbo-1106", name: "GPT-3.5 1106", + avatar: "gpt35turbo16k.webp", free: true, auth: true, high_context: true, + default: true, tag: ["free", "official"], }, { id: "gpt-3.5-turbo-fast", name: "GPT-3.5 Fast", + avatar: "gpt35turbo16k.webp", free: false, auth: true, high_context: false, + default: true, tag: ["official"], }, { id: "gpt-3.5-turbo-16k-fast", name: "GPT-3.5 16K Fast", + avatar: "gpt35turbo16k.webp", free: false, auth: true, high_context: true, + default: true, tag: ["official"], }, { id: "gpt-4-0613", name: "GPT-4", + avatar: "gpt4.png", free: false, auth: true, high_context: true, + default: true, tag: ["official", "high-quality"], }, { id: "gpt-4-1106-preview", name: "GPT-4 Turbo 128k", + avatar: "gpt432k.webp", free: false, auth: true, high_context: true, + default: true, tag: ["official", "high-context", "unstable"], }, { id: "gpt-4-vision-preview", name: "GPT-4 Vision 128k", + avatar: "gpt4v.png", free: false, auth: true, high_context: true, + default: true, tag: ["official", "high-context", "multi-modal", "unstable"], }, { id: "gpt-4-v", name: "GPT-4 Vision", + avatar: "gpt4v.png", free: false, auth: true, high_context: true, + default: true, tag: ["official", "unstable", "multi-modal"], }, { id: "gpt-4-dalle", name: "GPT-4 DALLE", + avatar: "gpt4dalle.png", free: false, auth: true, high_context: true, + default: true, tag: ["official", "unstable", "image-generation"], }, { id: "azure-gpt-3.5-turbo", name: "Azure GPT-3.5", + avatar: "gpt35turbo.png", free: false, auth: true, high_context: false, + default: true, tag: ["official"], }, { id: "azure-gpt-3.5-turbo-16k", name: "Azure GPT-3.5 16K", + avatar: "gpt35turbo16k.webp", free: false, auth: true, high_context: true, + default: true, tag: ["official"], }, { id: "azure-gpt-4", name: "Azure GPT-4", + avatar: "gpt4.png", free: false, auth: true, high_context: true, + default: true, tag: ["official", "high-quality"], }, { id: "azure-gpt-4-1106-preview", name: "Azure GPT-4 Turbo 128k", + avatar: "gpt432k.webp", free: false, auth: true, high_context: true, + default: true, tag: ["official", "high-context", "unstable"], }, { id: "azure-gpt-4-vision-preview", name: "Azure GPT-4 Vision 128k", + avatar: "gpt4v.png", free: false, auth: true, high_context: true, + default: true, tag: ["official", "high-context", "multi-modal"], }, { id: "azure-gpt-4-32k", name: "Azure GPT-4 32k", + avatar: "gpt432k.webp", free: false, auth: true, high_context: true, + default: true, tag: ["official", "multi-modal"], }, @@ -156,25 +189,31 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "spark-desk-v3", name: "讯飞星火 V3", + avatar: "sparkdesk.jpg", free: false, auth: true, high_context: false, + default: true, tag: ["official", "high-quality"], }, { id: "spark-desk-v2", name: "讯飞星火 V2", + avatar: "sparkdesk.jpg", free: false, auth: true, high_context: false, + default: false, tag: ["official"], }, { id: "spark-desk-v1.5", name: "讯飞星火 V1.5", + avatar: "sparkdesk.jpg", free: false, auth: true, high_context: false, + default: false, tag: ["official"], }, @@ -182,33 +221,41 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "qwen-plus-net", name: "通义千问 Plus Net", + avatar: "tongyi.png", free: false, auth: true, high_context: false, + default: true, tag: ["official", "high-quality", "web"], }, { id: "qwen-plus", name: "通义千问 Plus", + avatar: "tongyi.png", free: false, auth: true, high_context: false, + default: true, tag: ["official", "high-quality"], }, { id: "qwen-turbo-net", name: "通义千问 Turbo Net", + avatar: "tongyi.png", free: false, auth: true, high_context: false, + default: false, tag: ["official", "web"], }, { id: "qwen-turbo", name: "通义千问 Turbo", + avatar: "tongyi.png", free: false, auth: true, high_context: false, + default: false, tag: ["official"], }, @@ -216,9 +263,11 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "hunyuan", name: "腾讯混元 Pro", + avatar: "hunyuan.png", free: false, auth: true, high_context: false, + default: true, tag: ["official"], }, @@ -226,9 +275,11 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "zhipu-chatglm-turbo", name: "ChatGLM Turbo", + avatar: "chatglm.png", free: false, auth: true, high_context: true, + default: true, tag: ["official", "open-source", "high-context"], }, @@ -236,9 +287,11 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "baichuan-53b", name: "百川 Baichuan 53B", + avatar: "baichuan.png", free: false, auth: true, high_context: false, + default: true, tag: ["official", "open-source"], }, @@ -246,9 +299,11 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "skylark-chat", name: "抖音豆包 Skylark", + avatar: "skylark.jpg", free: false, auth: true, high_context: false, + default: true, tag: ["official"], }, @@ -256,34 +311,42 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "360-gpt-v9", name: "360 智脑", + avatar: "360gpt.png", free: false, auth: true, high_context: false, + default: false, tag: ["official"], }, { id: "claude-1-100k", name: "Claude", + avatar: "claude.png", free: true, auth: true, high_context: true, + default: true, tag: ["free", "unstable"], }, { id: "claude-2", name: "Claude 100k", + avatar: "claude100k.png", free: false, auth: true, high_context: true, + default: true, tag: ["official", "high-context"], }, { id: "claude-2.1", name: "Claude 200k", + avatar: "claude100k.png", free: false, auth: true, high_context: true, + default: true, tag: ["official", "high-context"], }, @@ -291,50 +354,62 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "llama-2-70b", name: "LLaMa-2 70B", + avatar: "llama2.webp", free: false, auth: true, high_context: false, + default: true, tag: ["open-source", "unstable"], }, { id: "llama-2-13b", name: "LLaMa-2 13B", + avatar: "llama2.webp", free: false, auth: true, high_context: false, + default: false, tag: ["open-source", "unstable"], }, { id: "llama-2-7b", name: "LLaMa-2 7B", + avatar: "llama2.webp", free: false, auth: true, high_context: false, + default: false, tag: ["open-source", "unstable"], }, { id: "code-llama-34b", name: "Code LLaMa 34B", + avatar: "llamacode.webp", free: false, auth: true, high_context: false, + default: true, tag: ["open-source", "unstable"], }, { id: "code-llama-13b", name: "Code LLaMa 13B", + avatar: "llamacode.webp", free: false, auth: true, high_context: false, + default: false, tag: ["open-source", "unstable"], }, { id: "code-llama-7b", name: "Code LLaMa 7B", + avatar: "llamacode.webp", free: false, auth: true, high_context: false, + default: false, tag: ["open-source", "unstable"], }, @@ -342,9 +417,11 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "bing-creative", name: "New Bing", + avatar: "newbing.jpg", free: true, auth: true, high_context: true, + default: true, tag: ["free", "unstable", "web"], }, @@ -352,9 +429,11 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "chat-bison-001", name: "Google PaLM2", + avatar: "palm2.webp", free: true, auth: true, high_context: false, + default: false, tag: ["free", "english-model"], }, @@ -362,17 +441,21 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "gemini-pro", name: "Gemini Pro", + avatar: "gemini.jpeg", free: true, auth: true, high_context: true, + default: true, tag: ["free", "official"], }, { id: "gemini-pro-vision", name: "Gemini Pro Vision", + avatar: "gemini.jpeg", free: true, auth: true, high_context: true, + default: true, tag: ["free", "official", "multi-modal"], }, @@ -380,96 +463,76 @@ export let supportModels: Model[] = loadPreferenceModels([ { id: "midjourney", name: "Midjourney", + avatar: "midjourney.jpg", free: false, auth: true, high_context: false, + default: true, tag: ["official", "image-generation"], }, { id: "midjourney-fast", name: "Midjourney Fast", + avatar: "midjourney.jpg", free: false, auth: true, high_context: false, + default: true, tag: ["official", "fast", "image-generation"], }, { id: "midjourney-turbo", name: "Midjourney Turbo", + avatar: "midjourney.jpg", free: false, auth: true, high_context: false, + default: true, tag: ["official", "fast", "image-generation"], }, { id: "stable-diffusion", name: "Stable Diffusion XL", + avatar: "stablediffusion.jpeg", free: false, auth: true, high_context: false, + default: false, tag: ["open-source", "unstable", "image-generation"], }, { id: "dall-e-2", name: "DALLE 2", + avatar: "dalle.jpeg", free: true, auth: true, high_context: false, + default: true, tag: ["free", "official", "image-generation"], }, { id: "dall-e-3", name: "DALLE 3", + avatar: "dalle.jpeg", free: false, auth: true, high_context: false, + default: true, tag: ["official", "image-generation"], }, { id: "gpt-4-32k-0613", name: "GPT-4-32k", + avatar: "gpt432k.webp", free: false, auth: true, high_context: true, + default: false, tag: ["official", "high-quality", "high-price"], }, ]); -export const defaultModels = [ - "gpt-3.5-turbo-0613", - "gpt-3.5-turbo-16k-0613", - "gpt-4-0613", - "gpt-4-1106-preview", - - "gpt-4-v", - "gpt-4-dalle", - - "azure-gpt-3.5-turbo", - "azure-gpt-3.5-turbo-16k", - "azure-gpt-4", - "azure-gpt-4-1106-preview", - "azure-gpt-4-vision-preview", - "azure-gpt-4-32k", - - "claude-1-100k", - "claude-2", - "claude-2.1", - - "spark-desk-v3", - "qwen-plus", - "hunyuan", - "zhipu-chatglm-turbo", - "baichuan-53b", - - "gemini-pro", - "gemini-pro-vision", - - "dall-e-2", - "midjourney-fast", - "stable-diffusion", -]; - export let allModels: string[] = supportModels.map((model) => model.id); export const planModels: PlanModel[] = [ @@ -485,58 +548,6 @@ export const planModels: PlanModel[] = [ { id: "midjourney-fast", level: 1 }, ]; -export const modelAvatars: Record = { - "gpt-3.5-turbo-0613": "gpt35turbo.png", - "gpt-3.5-turbo-16k-0613": "gpt35turbo16k.webp", - "gpt-3.5-turbo-1106": "gpt35turbo16k.webp", - "gpt-3.5-turbo-fast": "gpt35turbo16k.webp", - "gpt-3.5-turbo-16k-fast": "gpt35turbo16k.webp", - "gpt-4-0613": "gpt4.png", - "gpt-4-1106-preview": "gpt432k.webp", - "gpt-4-vision-preview": "gpt4v.png", - "gpt-4-all": "gpt4.png", - "gpt-4-32k-0613": "gpt432k.webp", - "gpt-4-v": "gpt4v.png", - "gpt-4-dalle": "gpt4dalle.png", - "azure-gpt-3.5-turbo": "gpt35turbo.png", - "azure-gpt-3.5-turbo-16k": "gpt35turbo16k.webp", - "azure-gpt-4": "gpt4.png", - "azure-gpt-4-1106-preview": "gpt432k.webp", - "azure-gpt-4-vision-preview": "gpt4v.png", - "azure-gpt-4-32k": "gpt432k.webp", - "claude-1-100k": "claude.png", - "claude-2": "claude100k.png", - "claude-2.1": "claude100k.png", - "stable-diffusion": "stablediffusion.jpeg", - "llama-2-70b": "llama2.webp", - "llama-2-13b": "llama2.webp", - "llama-2-7b": "llama2.webp", - "code-llama-34b": "llamacode.webp", - "code-llama-13b": "llamacode.webp", - "code-llama-7b": "llamacode.webp", - "dall-e-3": "dalle.jpeg", - "dall-e-2": "dalle.jpeg", - midjourney: "midjourney.jpg", - "midjourney-fast": "midjourney.jpg", - "midjourney-turbo": "midjourney.jpg", - "bing-creative": "newbing.jpg", - "chat-bison-001": "palm2.webp", - "gemini-pro": "gemini.jpeg", - "gemini-pro-vision": "gemini.jpeg", - "zhipu-chatglm-turbo": "chatglm.png", - "qwen-plus-net": "tongyi.png", - "qwen-plus": "tongyi.png", - "qwen-turbo-net": "tongyi.png", - "qwen-turbo": "tongyi.png", - "spark-desk-v3": "sparkdesk.jpg", - "spark-desk-v2": "sparkdesk.jpg", - "spark-desk-v1.5": "sparkdesk.jpg", - hunyuan: "hunyuan.png", - "360-gpt-v9": "360gpt.png", - "baichuan-53b": "baichuan.png", - "skylark-chat": "skylark.jpg", -}; - export const subscriptionPrize: Record = { 1: 42, 2: 76, diff --git a/app/src/resources/i18n/cn.json b/app/src/resources/i18n/cn.json index 73e87c66..c5e67263 100644 --- a/app/src/resources/i18n/cn.json +++ b/app/src/resources/i18n/cn.json @@ -31,6 +31,7 @@ "true": "是", "false": "否", "unknown": "未知", + "update": "更新", "scroll-down": "滚至最新", "broadcast": "公告", "fatal": "应用崩溃", @@ -400,6 +401,25 @@ "generate": "批量生成", "generate-result": "生成结果", "error": "请求失败", + "market": { + "title": "模型市场", + "model-name": "模型名称", + "model-name-placeholder": "请输入模型昵称 (如:GPT-4)", + "model-id": "模型 ID", + "model-id-placeholder": "请输入模型 ID (如:gpt-4-0613)", + "model-description": "模型简介", + "model-description-placeholder": "请输入模型简介", + "model-context": "高上下文", + "model-context-tip": "模型是否为高上下文模型(高上下文模型文件解析时不会被长内容截断)", + "model-is-default": "默认模型", + "model-is-default-tip": "模型是否添加至默认模型列表(未添加至默认模型列表的模型默认不会出现在首页模型列表中)", + "model-tag": "模型标签", + "model-image": "模型图片", + "update-success": "更新成功", + "update-success-prompt": "模型市场设置已成功提交更新至服务器。", + "update-failed": "更新失败", + "update-failed-prompt": "更新请求失败,原因:{{reason}}" + }, "redeem": { "quota": "点数", "used": "已用个数", diff --git a/app/src/resources/i18n/en.json b/app/src/resources/i18n/en.json index f9f07d79..612a00f7 100644 --- a/app/src/resources/i18n/en.json +++ b/app/src/resources/i18n/en.json @@ -445,6 +445,25 @@ "quota": "Number of points", "used": "Used Count", "total": "Total" + }, + "market": { + "title": "Model Market", + "model-name": "model name", + "model-name-placeholder": "Please enter the model nickname (e.g. GPT-4)", + "model-id": "Former ID", + "model-id-placeholder": "Please enter the model ID (e.g. gpt-4-0613)", + "model-description": "Introduction to the Model", + "model-description-placeholder": "Please enter a model introduction", + "model-context": "High Context", + "model-context-tip": "Whether the model is a high context model (high context model files are not truncated by long content when parsed)", + "model-is-default": "Default Model", + "model-is-default-tip": "Whether the model is added to the default model list (models not added to the default model list will not appear in the home model list by default)", + "model-tag": "Model label", + "update-success": "Upgrade successful", + "update-success-prompt": "Model Marketplace settings were successfully submitted for update to the server.", + "update-failed": "Update failed", + "update-failed-prompt": "Update request failed for {{reason}}", + "model-image": "Model Picture" } }, "mask": { @@ -490,5 +509,6 @@ "register-success-prompt": "You have successfully registered, welcome!" }, "reset": "Reset", - "request-error": "Request failed for {{reason}}" + "request-error": "Request failed for {{reason}}", + "update": "Updated" } \ No newline at end of file diff --git a/app/src/resources/i18n/ja.json b/app/src/resources/i18n/ja.json index f3a88b98..9802432c 100644 --- a/app/src/resources/i18n/ja.json +++ b/app/src/resources/i18n/ja.json @@ -445,6 +445,25 @@ "quota": "Whirlies", "used": "使用数", "total": "合計" + }, + "market": { + "title": "モデルマーケット", + "model-name": "モデル名", + "model-name-placeholder": "モデルのニックネームを入力してください(例: GPT -4 )", + "model-id": "モデルID", + "model-id-placeholder": "モデルIDを入力してください(例: gpt -4 -0613 )", + "model-description": "モデルの紹介", + "model-description-placeholder": "モデル紹介を入力してください", + "model-context": "ハイコンテキスト", + "model-context-tip": "モデルがハイコンテキストモデルであるかどうか(ハイコンテキストモデルファイルは、解析時に長いコンテンツによって切り捨てられません)", + "model-is-default": "デフォルトモデル", + "model-is-default-tip": "モデルがデフォルトモデルリストに追加されるかどうか(デフォルトでは、デフォルトモデルリストに追加されていないモデルはホームモデルリストに表示されません)", + "model-tag": "モデルラベル", + "update-success": "正常に更新されました", + "update-success-prompt": "モデルマーケットプレイスの設定は、サーバーへの更新のために正常に送信されました。", + "update-failed": "更新に失敗", + "update-failed-prompt": "{{reason}}の更新リクエストが失敗しました", + "model-image": "モデル写真" } }, "mask": { @@ -490,5 +509,6 @@ "register-success-prompt": "登録が完了しました。ようこそ!" }, "reset": "リセット", - "request-error": "{{reason}}のためにリクエストできませんでした" + "request-error": "{{reason}}のためにリクエストできませんでした", + "update": "更新" } \ No newline at end of file diff --git a/app/src/resources/i18n/ru.json b/app/src/resources/i18n/ru.json index e6e401eb..f4740057 100644 --- a/app/src/resources/i18n/ru.json +++ b/app/src/resources/i18n/ru.json @@ -445,6 +445,25 @@ "quota": "Вихри", "used": "Количество использованных", "total": "Итого" + }, + "market": { + "title": "Модельный рынок", + "model-name": "Модели засоренности", + "model-name-placeholder": "Введите псевдоним модели (например, GPT-4)", + "model-id": "Идентификатор модели", + "model-id-placeholder": "Введите идентификатор модели (например, gpt-4-0613)", + "model-description": "Введение в модель", + "model-description-placeholder": "Пожалуйста, введите введение в модель", + "model-context": "Высокий контекст", + "model-context-tip": "Является ли модель высококонтекстной моделью (файлы высококонтекстной модели не усекаются длинным содержимым при синтаксическом анализе)", + "model-is-default": "Модель по умолчанию", + "model-is-default-tip": "Добавлена ли модель в список моделей по умолчанию (модели, не добавленные в список моделей по умолчанию, не будут отображаться в списке моделей дома по умолчанию)", + "model-tag": "Этикетка модели", + "update-success": "Успешно обновлено", + "update-success-prompt": "Настройки Model Marketplace успешно отправлены на обновление на сервер.", + "update-failed": "Ошибка обновления", + "update-failed-prompt": "Запрос на обновление не выполнен по {{reason}}", + "model-image": "Изображение модели" } }, "mask": { @@ -490,5 +509,6 @@ "register-success-prompt": "Вы успешно зарегистрировались, добро пожаловать!" }, "reset": "сброс", - "request-error": "Запрос не выполнен по {{reason}}" + "request-error": "Запрос не выполнен по {{reason}}", + "update": "Обновить" } \ No newline at end of file diff --git a/app/src/router.tsx b/app/src/router.tsx index e695b433..3f316f41 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -13,6 +13,7 @@ const Article = lazy(() => import("@/routes/Article.tsx")); const Admin = lazy(() => import("@/routes/Admin.tsx")); const Dashboard = lazy(() => import("@/routes/admin/DashBoard.tsx")); +const Market = lazy(() => import("@/routes/admin/Market.tsx")); const Channel = lazy(() => import("@/routes/admin/Channel.tsx")); const System = lazy(() => import("@/routes/admin/System.tsx")); const Charge = lazy(() => import("@/routes/admin/Charge.tsx")); @@ -104,6 +105,15 @@ const router = createBrowserRouter( ), }, + { + id: "admin-market", + path: "market", + element: ( + + + + ), + }, { id: "admin-channel", path: "channel", diff --git a/app/src/routes/admin/Market.tsx b/app/src/routes/admin/Market.tsx new file mode 100644 index 00000000..2c22a254 --- /dev/null +++ b/app/src/routes/admin/Market.tsx @@ -0,0 +1,459 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { useTranslation } from "react-i18next"; +import { Dispatch, useEffect, useMemo, useReducer, useRef } from "react"; +import { Model as RawModel } from "@/api/types.ts"; +import { supportModels } from "@/conf.ts"; +import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; +import { Input } from "@/components/ui/input.tsx"; +import { GripVertical } from "lucide-react"; +import { generateRandomChar } from "@/utils/base.ts"; +import Require from "@/components/Require.tsx"; +import { Textarea } from "@/components/ui/textarea.tsx"; +import { toast } from "sonner"; +import Tips from "@/components/Tips.tsx"; +import { Switch } from "@/components/ui/switch.tsx"; +import { Toggle } from "@/components/ui/toggle.tsx"; +import { marketEditableTags, modelImages } from "@/admin/market.ts"; + +type Model = RawModel & { + seed?: string; +}; + +type MarketForm = Model[]; + +const initialState: MarketForm = []; + +const generateSeed = () => generateRandomChar(8); + +function reducer(state: MarketForm, action: any): MarketForm { + switch (action.type) { + case "set": + return [ + ...action.payload.map((model: RawModel) => ({ + ...model, + seed: generateSeed(), + })), + ]; + case "add": + return [ + ...state, + { + ...action.payload, + seed: generateSeed(), + }, + ]; + case "remove": + let { idx } = action.payload; + return [...state.slice(0, idx), ...state.slice(idx + 1)]; + case "update": + let { index, data } = action.payload; + return [...state.slice(0, index), data, ...state.slice(index + 1)]; + case "update-id": + return [ + ...state.map((model, idx) => { + if (idx === action.payload.idx) { + return { ...model, id: action.payload.id }; + } + return model; + }), + ]; + case "update-name": + return [ + ...state.map((model, idx) => { + if (idx === action.payload.idx) { + return { ...model, name: action.payload.name }; + } + return model; + }), + ]; + case "update-description": + return [ + ...state.map((model, idx) => { + if (idx === action.payload.idx) { + return { ...model, description: action.payload.description }; + } + return model; + }), + ]; + case "update-context": + return [ + ...state.map((model, idx) => { + if (idx === action.payload.idx) { + return { ...model, high_context: action.payload.context }; + } + return model; + }), + ]; + case "update-default": + return [ + ...state.map((model, idx) => { + if (idx === action.payload.idx) { + return { ...model, default: action.payload.default }; + } + return model; + }), + ]; + case "update-tags": + return [ + ...state.map((model, idx) => { + if (idx === action.payload.idx) { + return { ...model, tag: action.payload.tags }; + } + return model; + }), + ]; + case "add-tag": + return [ + ...state.map((model, idx) => { + if (idx === action.payload.idx) { + const tag = model.tag || []; + tag.push(action.payload.tag); + return { + ...model, + tag: [...tag], + }; + } + return model; + }), + ]; + case "remove-tag": + return [ + ...state.map((model, idx) => { + if (idx === action.payload.idx) { + const tag = model.tag || []; + return { + ...model, + tag: tag.filter((t) => t !== action.payload.tag), + }; + } + return model; + }), + ]; + case "set-avatar": + return [ + ...state.map((model, idx) => { + if (idx === action.payload.idx) { + return { ...model, avatar: action.payload.avatar }; + } + return model; + }), + ]; + case "replace": + const { from, to } = action.payload; + const [removed] = state.splice(from, 1); + state.splice(to, 0, removed); + return [...state]; + default: + throw new Error(); + } +} + +type MarketTagsProps = { + tag: string[] | undefined; + idx: number; + dispatch: Dispatch; +}; + +function MarketTags({ tag, idx, dispatch }: MarketTagsProps) { + const { t } = useTranslation(); + const tags = useMemo((): Record => { + const selected = tag || []; + + return marketEditableTags.reduce( + (acc, name) => { + acc[name] = selected.includes(name); + return acc; + }, + {} as Record, + ); + }, [tag]); + + return ( +
+ {tags && + Object.keys(tags).map((name) => ( + { + dispatch({ + type: state ? "add-tag" : "remove-tag", + payload: { + idx, + tag: name, + }, + }); + }} + > + {t(`tag.${name}`)} + + ))} +
+ ); +} + +type MarketImageProps = { + image: string; + idx: number; + dispatch: Dispatch; +}; + +function MarketImage({ image, idx, dispatch }: MarketImageProps) { + const { t } = useTranslation(); + + return ( +
+ {modelImages.map((source) => ( + { + if (!state) return; + dispatch({ + type: "set-avatar", + payload: { + idx, + avatar: source, + }, + }); + }} + > + {source} + + ))} +
+ ); +} + +function Market() { + const { t } = useTranslation(); + const [form, dispatch] = useReducer(reducer, initialState); + const timer = useRef(null); + const sync = useRef(false); + + useEffect(() => { + if (form.length === 0 && supportModels.length > 0) { + dispatch({ type: "set", payload: [...supportModels] }); + } + sync.current = true; + }, [supportModels]); + + useEffect(() => { + if (timer.current) { + clearTimeout(timer.current); + } + + timer.current = Number( + setTimeout(() => { + if (sync.current) { + sync.current = false; + return; + } + + console.debug( + `[market] model market migrated, sync to server (models: ${form.length})`, + ); + + toast(t("admin.market.update-success"), { + description: t("admin.market.update-success-prompt"), + }); + }, 2000), + ); + console.debug( + `[market] model market changed, wait for sync... (triggered task id: ${timer.current})`, + ); + }, [form]); + + return ( +
+ + + {t("admin.market.title")} + + + { + const { destination, source } = result; + if ( + !destination || + destination.index === source.index || + destination.index === -1 + ) + return; + + const from = source.index; + const to = destination.index; + + dispatch({ type: "replace", payload: { from, to } }); + }} + > + + {(provided) => ( +
+ {form.map((model, index) => ( + + {(provided) => ( +
+ +
+
+ + + {t("admin.market.model-name")} + + { + dispatch({ + type: "update-name", + payload: { + idx: index, + name: e.target.value, + }, + }); + }} + /> +
+
+ + + {t("admin.market.model-id")} + + { + dispatch({ + type: "update-id", + payload: { + idx: index, + id: e.target.value, + }, + }); + }} + /> +
+
+ {t("admin.market.model-description")} +