feat: channel alpha

This commit is contained in:
Zhang Minghan 2023-12-01 17:46:50 +08:00
parent 7e7798d213
commit db7acee643
25 changed files with 1815 additions and 80 deletions

View File

@ -17,6 +17,7 @@
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
@ -31,6 +32,7 @@
"chart.js": "^4.4.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"i18next": "^23.4.6",
"localforage": "^1.10.0",
"lucide-react": "^0.289.0",

334
app/pnpm-lock.yaml generated
View File

@ -23,6 +23,9 @@ dependencies:
'@radix-ui/react-label':
specifier: ^2.0.2
version: 2.0.2(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-popover':
specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-progress':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
@ -65,6 +68,9 @@ dependencies:
clsx:
specifier: ^2.0.0
version: 2.0.0
cmdk:
specifier: ^0.2.0
version: 0.2.0(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
i18next:
specifier: ^23.4.6
version: 23.6.0
@ -549,6 +555,12 @@ packages:
'@babel/runtime': 7.23.2
dev: false
/@radix-ui/primitive@1.0.0:
resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==}
dependencies:
'@babel/runtime': 7.23.2
dev: false
/@radix-ui/primitive@1.0.1:
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
dependencies:
@ -654,6 +666,15 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-compose-refs@1.0.0(react@18.2.0):
resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
react: 18.2.0
dev: false
/@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
peerDependencies:
@ -694,6 +715,15 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-context@1.0.0(react@18.2.0):
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
react: 18.2.0
dev: false
/@radix-ui/react-context@1.0.1(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==}
peerDependencies:
@ -708,6 +738,33 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-dialog@1.0.0(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Yn9YU+QlHYLWwV1XfKiqnGVpWYWk6MeBVM6x/bcoyPvxgjQGoeT35482viLPctTMWoMw0PoHgqfSox7Ig+957Q==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-context': 1.0.0(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-focus-guards': 1.0.0(react@18.2.0)
'@radix-ui/react-focus-scope': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-id': 1.0.0(react@18.2.0)
'@radix-ui/react-portal': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.0(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.5.4(@types/react@18.2.33)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
/@radix-ui/react-dialog@1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==}
peerDependencies:
@ -756,6 +813,22 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-dismissable-layer@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-n7kDRfx+LB1zLueRDvZ1Pd0bxdJWDUZNQ/GWoxDn2prnuJKRdxsjulejX/ePkOsLi2tTm6P24mDqlMSgQpsT6g==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
'@radix-ui/react-use-escape-keydown': 1.0.0(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==}
peerDependencies:
@ -808,6 +881,15 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-focus-guards@1.0.0(react@18.2.0):
resolution: {integrity: sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
react: 18.2.0
dev: false
/@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==}
peerDependencies:
@ -822,6 +904,20 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-focus-scope@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-C4SWtsULLGf/2L4oGeIHlvWQx7Rf+7cX/vKOAD2dXW0A1b5QXwi3wWeaEgW+wn+SEVrraMUk05vLU9fZZz5HbQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==}
peerDependencies:
@ -845,6 +941,16 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-id@1.0.0(react@18.2.0):
resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/react-use-layout-effect': 1.0.0(react@18.2.0)
react: 18.2.0
dev: false
/@radix-ui/react-id@1.0.1(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==}
peerDependencies:
@ -919,6 +1025,41 @@ packages:
react-remove-scroll: 2.5.5(@types/react@18.2.33)(react@18.2.0)
dev: false
/@radix-ui/react-popover@1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-id': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot': 1.0.2(@types/react@18.2.33)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.33)(react@18.2.0)
'@types/react': 18.2.33
'@types/react-dom': 18.2.14
aria-hidden: 1.2.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll: 2.5.5(@types/react@18.2.33)(react@18.2.0)
dev: false
/@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==}
peerDependencies:
@ -949,6 +1090,18 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-portal@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-a8qyFO/Xb99d8wQdu4o7qnigNjTPG123uADNecz0eX4usnQEj7o+cG4ZX4zkqq98NYekT7UoEQIjxBNWIFuqTA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==}
peerDependencies:
@ -970,6 +1123,19 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-presence@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.0.0(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==}
peerDependencies:
@ -992,6 +1158,18 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-primitive@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/react-slot': 1.0.0(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==}
peerDependencies:
@ -1155,6 +1333,16 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-slot@1.0.0(react@18.2.0):
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
react: 18.2.0
dev: false
/@radix-ui/react-slot@1.0.2(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==}
peerDependencies:
@ -1284,6 +1472,15 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-use-callback-ref@1.0.0(react@18.2.0):
resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
react: 18.2.0
dev: false
/@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==}
peerDependencies:
@ -1298,6 +1495,16 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-controllable-state@1.0.0(react@18.2.0):
resolution: {integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
react: 18.2.0
dev: false
/@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==}
peerDependencies:
@ -1313,6 +1520,16 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-escape-keydown@1.0.0(react@18.2.0):
resolution: {integrity: sha512-JwfBCUIfhXRxKExgIqGa4CQsiMemo1Xt0W/B4ei3fpzpvPENKpMKQ8mZSB6Acj3ebrAEgi2xiQvcI1PAAodvyg==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
react: 18.2.0
dev: false
/@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==}
peerDependencies:
@ -1328,6 +1545,15 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-layout-effect@1.0.0(react@18.2.0):
resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
dependencies:
'@babel/runtime': 7.23.2
react: 18.2.0
dev: false
/@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==}
peerDependencies:
@ -1681,22 +1907,22 @@ packages:
'@types/ms': 0.7.33
dev: false
/@types/eslint-scope@3.7.6:
resolution: {integrity: sha512-zfM4ipmxVKWdxtDaJ3MP3pBurDXOCoyjvlpE3u6Qzrmw4BPbfm4/ambIeTk/r/J0iq/+2/xp0Fmt+gFvXJY2PQ==}
/@types/eslint-scope@3.7.7:
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
dependencies:
'@types/eslint': 8.44.6
'@types/estree': 1.0.3
'@types/eslint': 8.44.8
'@types/estree': 1.0.5
dev: true
/@types/eslint@8.44.6:
resolution: {integrity: sha512-P6bY56TVmX8y9J87jHNgQh43h6VVU+6H7oN7hgvivV81K2XY8qJZ5vqPy/HdUoVIelii2kChYVzQanlswPWVFw==}
/@types/eslint@8.44.8:
resolution: {integrity: sha512-4K8GavROwhrYl2QXDXm0Rv9epkA8GBFu0EI+XrrnnuCl7u8CWBRusX7fXJfanhZTDWSAL24gDI/UqXyUM0Injw==}
dependencies:
'@types/estree': 1.0.3
'@types/json-schema': 7.0.14
'@types/estree': 1.0.5
'@types/json-schema': 7.0.15
dev: true
/@types/estree@1.0.3:
resolution: {integrity: sha512-CS2rOaoQ/eAgAfcTfq6amKG7bsN+EMcgGY4FAFQdvSj2y1ixvOZTUA9mOtCai7E1SYu283XNw7urKK30nP3wkQ==}
/@types/estree@1.0.5:
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
dev: true
/@types/hast@2.3.7:
@ -1716,6 +1942,10 @@ packages:
resolution: {integrity: sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==}
dev: true
/@types/json-schema@7.0.15:
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
dev: true
/@types/katex@0.14.0:
resolution: {integrity: sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA==}
dev: false
@ -1740,6 +1970,12 @@ packages:
resolution: {integrity: sha512-AuHIyzR5Hea7ij0P9q7vx7xu4z0C28ucwjAZC0ja7JhINyCnOw8/DnvAPQQ9TfOlCtZAmCERKQX9+o1mgQhuOQ==}
dev: false
/@types/node@20.10.1:
resolution: {integrity: sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==}
dependencies:
undici-types: 5.26.5
dev: true
/@types/node@20.8.9:
resolution: {integrity: sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==}
dependencies:
@ -2050,12 +2286,12 @@ packages:
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
dev: true
/acorn-import-assertions@1.9.0(acorn@8.10.0):
/acorn-import-assertions@1.9.0(acorn@8.11.2):
resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==}
peerDependencies:
acorn: ^8
dependencies:
acorn: 8.10.0
acorn: 8.11.2
dev: true
/acorn-jsx@5.3.2(acorn@8.10.0):
@ -2072,6 +2308,12 @@ packages:
hasBin: true
dev: true
/acorn@8.11.2:
resolution: {integrity: sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==}
engines: {node: '>=0.4.0'}
hasBin: true
dev: true
/ajv-keywords@3.5.2(ajv@6.12.6):
resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==}
peerDependencies:
@ -2304,6 +2546,20 @@ packages:
engines: {node: '>=6'}
dev: false
/cmdk@0.2.0(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-JQpKvEOb86SnvMZbYaFKYhvzFntWBeSZdyii0rZPhKJj9uwJBxu4DaVYDrRN7r3mPop56oPhRw+JYWTKs66TYw==}
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
dependencies:
'@radix-ui/react-dialog': 1.0.0(@types/react@18.2.33)(react-dom@18.2.0)(react@18.2.0)
command-score: 0.1.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
/color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@ -2334,6 +2590,10 @@ packages:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
dev: false
/command-score@0.1.2:
resolution: {integrity: sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==}
dev: false
/commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
dev: true
@ -2557,8 +2817,8 @@ packages:
dev: true
optional: true
/es-module-lexer@1.3.1:
resolution: {integrity: sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==}
/es-module-lexer@1.4.1:
resolution: {integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==}
dev: true
/esbuild@0.18.20:
@ -3218,7 +3478,7 @@ packages:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
dependencies:
'@types/node': 20.8.9
'@types/node': 20.10.1
merge-stream: 2.0.0
supports-color: 8.1.1
dev: true
@ -4340,6 +4600,25 @@ packages:
tslib: 2.6.2
dev: false
/react-remove-scroll@2.5.4(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-xGVKJJr0SJGQVirVFAUZ2k1QLyO6m+2fy0l8Qawbp5Jgrv3DeLalrfMNBFSlmz5kriGGzsVBtGVnf4pTKIhhWA==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.2.33
react: 18.2.0
react-remove-scroll-bar: 2.3.4(@types/react@18.2.33)(react@18.2.0)
react-style-singleton: 2.2.1(@types/react@18.2.33)(react@18.2.0)
tslib: 2.6.2
use-callback-ref: 1.3.0(@types/react@18.2.33)(react@18.2.0)
use-sidecar: 1.1.2(@types/react@18.2.33)(react@18.2.0)
dev: false
/react-remove-scroll@2.5.5(@types/react@18.2.33)(react@18.2.0):
resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==}
engines: {node: '>=10'}
@ -4597,7 +4876,7 @@ packages:
resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==}
engines: {node: '>= 10.13.0'}
dependencies:
'@types/json-schema': 7.0.14
'@types/json-schema': 7.0.15
ajv: 6.12.6
ajv-keywords: 3.5.2(ajv@6.12.6)
dev: true
@ -4786,7 +5065,7 @@ packages:
jest-worker: 27.5.1
schema-utils: 3.3.0
serialize-javascript: 6.0.1
terser: 5.22.0
terser: 5.24.0
webpack: 5.89.0
dev: true
@ -4801,6 +5080,17 @@ packages:
source-map-support: 0.5.21
dev: true
/terser@5.24.0:
resolution: {integrity: sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==}
engines: {node: '>=10'}
hasBin: true
dependencies:
'@jridgewell/source-map': 0.3.5
acorn: 8.11.2
commander: 2.20.3
source-map-support: 0.5.21
dev: true
/text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
@ -5163,17 +5453,17 @@ packages:
webpack-cli:
optional: true
dependencies:
'@types/eslint-scope': 3.7.6
'@types/estree': 1.0.3
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.5
'@webassemblyjs/ast': 1.11.6
'@webassemblyjs/wasm-edit': 1.11.6
'@webassemblyjs/wasm-parser': 1.11.6
acorn: 8.10.0
acorn-import-assertions: 1.9.0(acorn@8.10.0)
acorn: 8.11.2
acorn-import-assertions: 1.9.0(acorn@8.11.2)
browserslist: 4.22.1
chrome-trace-event: 1.0.3
enhanced-resolve: 5.15.0
es-module-lexer: 1.3.1
es-module-lexer: 1.4.1
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1

177
app/src/admin/channel.ts Normal file
View File

@ -0,0 +1,177 @@
export type Channel = {
id: number;
name: string;
type: string;
models: string[];
priority: number;
weight: number;
retry: number;
secret: string;
endpoint: string;
mapper: string;
state: boolean;
};
export type ChannelEditProps = {
type: string;
name: string;
models: string[];
priority: number;
weight: number;
retry: number;
secret: string;
endpoint: string;
mapper: string;
};
export type ChannelInfo = {
id: number;
description?: string;
endpoint: string;
format: string;
models: string[];
};
export const ChannelTypes: Record<string, string> = {
openai: "OpenAI",
claude: "Claude",
slack: "Slack",
sparkdesk: "讯飞星火",
chatglm: "智谱 ChatGLM",
qwen: "通义千问",
hunyuan: "腾讯混元",
zhinao: "360 智脑",
baichuan: "百川 AI",
skylark: "火山方舟",
bing: "New Bing",
palm: "Google PaLM2",
midjourney: "Midjourney",
oneapi: "One API",
};
export const ChannelInfos: Record<string, ChannelInfo> = {
openai: {
id: 0,
endpoint: "https://api.openai.com",
format: "<api-key>",
models: [
"gpt-3.5-turbo",
"gpt-3.5-turbo-instruct",
"gpt-3.5-turbo-0613",
"gpt-3.5-turbo-0301",
"gpt-3.5-turbo-1106",
"gpt-3.5-turbo-16k",
"gpt-3.5-turbo-16k-0613",
"gpt-3.5-turbo-16k-0301",
"gpt-4",
"gpt-4-0314",
"gpt-4-0613",
"gpt-4-1106-preview",
"gpt-4-vision-preview",
"gpt-4-32k",
"gpt-4-32k-0314",
"gpt-4-32k-0613",
"dall-e-2",
"dall-e-3",
],
},
claude: {
id: 1,
endpoint: "https://api.anthropic.com",
format: "<x-api-key>",
models: ["claude-instant-1", "claude-2"],
},
slack: {
id: 2,
endpoint: "your-channel",
format: "<bot-id>|<xoxp-token>",
models: ["claude-slack"],
},
sparkdesk: {
id: 3,
endpoint: "wss://spark-api.xf-yun.com",
format: "<app-id>|<app-secret>|<api-key>",
models: ["spark-desk-v1.5", "spark-desk-v2", "spark-desk-v3"],
},
chatglm: {
id: 4,
endpoint: "https://open.bigmodel.cn",
format: "<api-key>",
models: [
"zhipu-chatglm-turbo",
"zhipu-chatglm-pro",
"zhipu-chatglm-std",
"zhipu-chatglm-lite",
],
},
qwen: {
id: 5,
endpoint: "https://dashscope.aliyuncs.com",
format: "<api-key>",
models: ["qwen-turbo", "qwen-plus", "qwen-turbo-net", "qwen-plus-net"],
},
hunyuan: {
id: 6,
endpoint: "https://hunyuan.cloud.tencent.com",
format: "<app-id>|<secret-id>|<secret-key>",
models: ["hunyuan"],
// endpoint
},
zhinao: {
id: 7,
endpoint: "https://api.360.cn",
format: "<api-key>",
models: ["360-gpt-v9"],
},
baichuan: {
id: 8,
endpoint: "https://api.baichuan-ai.com",
format: "<api-key>",
models: ["baichuan-53b"],
},
skylark: {
id: 9,
endpoint: "https://maas-api.ml-platform-cn-beijing.volces.com",
format: "<access-key>|<secret-key>",
models: [
"skylark-lite-public",
"skylark-plus-public",
"skylark-pro-public",
"skylark-chat",
],
},
bing: {
id: 10,
endpoint: "wss://your.bing.service",
format: "<secret>",
models: ["bing-creative", "bing-balanced", "bing-precise"],
description:
"> Bing 服务需要自行搭建,详情请参考 [chatnio-bing-service](https://github.com/Deeptrain-Community/chatnio-bing-service) (如为 bing2api 可直接使用 OpenAI 格式映射)",
},
palm: {
id: 11,
endpoint: "https://generativelanguage.googleapis.com",
format: "<api-key>",
models: ["chat-bison-001"],
},
midjourney: {
id: 12,
endpoint: "https://your.midjourney.proxy",
format: "<mj-api-secret>|<white-list>",
models: ["midjourney", "midjourney-fast", "midjourney-turbo"],
description:
"> 请参考 [midjourney-proxy](https://github.com/novicezk/midjourney-proxy) 项目填入参数,可设置白名单 *white-list* 以限制回调 IP \n" +
"> 密钥举例: password|localhost,127.0.0.1,196.128.0.31\n" +
"> 注意:**请在系统设置中设置后端的公网 IP / 域名,否则无法接收回调**",
},
oneapi: {
id: 13,
endpoint: "https://openai.justsong.cn/api",
format: "<api-key>",
models: [],
},
};
export const ChannelModels: string[] = Object.values(ChannelInfos).flatMap(
(info) => info.models,
);

View File

@ -2,6 +2,7 @@
@import "dashboard";
@import "management";
@import "broadcast";
@import "channel";
.admin-page {
position: relative;

View File

@ -0,0 +1,98 @@
.channel {
width: 100%;
height: 100%;
padding: 2rem;
display: flex;
flex-direction: column;
.channel-card {
width: 100%;
height: 100%;
min-height: 20vh;
}
}
.channel-wrapper {
display: flex;
flex-direction: column;
margin-top: 0.5rem;
margin-bottom: 2rem;
& > * {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.channel-row {
display: flex;
flex-direction: column;
user-select: none;
white-space: nowrap;
.channel-content {
display: flex;
flex-direction: row;
align-items: center;
margin-left: 0.25rem;
margin-bottom: 0.5rem;
}
}
}
.channel-model-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.5rem;
height: max-content;
width: 100%;
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
padding: 1rem;
min-height: 5rem;
.channel-model-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
transition: .25s;
height: max-content;
&:hover {
border-color: hsl(var(--border-hover));
}
.remove-action {
width: 0.75rem;
height: 0.75rem;
cursor: pointer;
margin-left: 0.5rem;
color: hsl(var(--text-secondary));
transition: .25s;
&:hover {
color: hsl(var(--text-primary));
}
}
}
}
.channel-model-action {
display: flex;
flex-direction: row;
width: 100%;
flex-wrap: wrap;
gap: 0.5rem;
}
.channel-description {
white-space: break-spaces;
line-height: 1em;
}

View File

@ -62,7 +62,7 @@
input {
text-align: center;
max-width: 3.5rem;
max-width: 4rem;
max-height: 1.75rem;
}

View File

@ -72,7 +72,54 @@
}
}
.model-select-group {
transform: translateX(-26px) !important;
width: calc(100vw - 72px) !important;
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: transparent;
}
}
.thin-scrollbar {
scrollbar-width: thin;
-ms-overflow-style: none;
&::-webkit-scrollbar {
width: 6px;
}
}
input[type="number"] {
-webkit-appearance: textfield;
margin: 0;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
}
&::after {
content: '>';
position: absolute;
right: 5px;
top: 2px;
transform: rotate(-45deg);
}
&::before {
content: '<';
position: absolute;
right: 5px;
top: 20px;
transform: rotate(135deg);
}
}

View File

@ -12,7 +12,14 @@ import { useDispatch } from "react-redux";
import { openDialog as openQuotaDialog } from "@/store/quota.ts";
import { openDialog as openSubscriptionDialog } from "@/store/subscription.ts";
import { AppDispatch } from "@/store";
import { Copy } from "lucide-react";
import {
Codepen,
Codesandbox,
Copy,
Github,
Twitter,
Youtube,
} from "lucide-react";
import { copyClipboard } from "@/utils/dom.ts";
import { useToast } from "./ui/use-toast.ts";
import { useTranslation } from "react-i18next";
@ -43,6 +50,21 @@ const LanguageMap: Record<string, string> = {
rs: "rust",
};
function getSocialIcon(url: string) {
const { hostname } = new URL(url);
if (hostname.includes("github.com"))
return <Github className="h-4 w-4 inline-block mr-0.5" />;
if (hostname.includes("twitter.com"))
return <Twitter className="h-4 w-4 inline-block mr-0.5" />;
if (hostname.includes("youtube.com"))
return <Youtube className="h-4 w-4 inline-block mr-0.5" />;
if (hostname.includes("codepen.io"))
return <Codepen className="h-4 w-4 inline-block mr-0.5" />;
if (hostname.includes("codesandbox.io"))
return <Codesandbox className="h-4 w-4 inline-block mr-0.5" />;
}
function MarkdownContent({ children, className }: MarkdownProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
@ -75,6 +97,7 @@ function MarkdownContent({ children, className }: MarkdownProps) {
if (doAction(dispatch, url)) e.preventDefault();
}}
>
{getSocialIcon(url)}
{children}
</a>
);

View File

@ -0,0 +1,73 @@
import React from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import { Button } from "@/components/ui/button.tsx";
type ActionProps = {
tooltip?: string;
children: React.ReactNode;
onClick?: () => any;
variant?:
| "secondary"
| "default"
| "destructive"
| "outline"
| "ghost"
| "link"
| null
| undefined;
};
function OperationAction({ tooltip, children, onClick, variant }: ActionProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{variant === "destructive" ? (
<Popover>
<PopoverTrigger asChild>
<Button
size={`icon`}
className={`mx-1 w-8 h-8`}
variant={variant}
>
{children}
</Button>
</PopoverTrigger>
<PopoverContent className={`w-max`}>
<Button
className={`flex flex-row items-center mx-1`}
onClick={onClick}
variant={variant}
>
{children}
<p className={`ml-1 translate-y-[-1px]`}>{tooltip}</p>
</Button>
</PopoverContent>
</Popover>
) : (
<Button
size={`icon`}
className={`mx-1 w-8 h-8`}
onClick={onClick}
variant={variant}
>
{children}
</Button>
)}
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export default OperationAction;

View File

@ -0,0 +1,5 @@
function Required() {
return <span className={`text-red-500 mr-0.5`}>*</span>;
}
export default Required;

View File

@ -114,8 +114,6 @@ function SelectGroupMobile(props: SelectGroupProps) {
<SelectValue placeholder={props.current.value} />
</SelectTrigger>
<SelectContent
position={`item-aligned`}
side={props.side}
className={`${props.className} ${props.classNameMobile}`}
>
{props.list.map((select: SelectItemProps, idx: number) => (

View File

@ -0,0 +1,15 @@
import { useState } from "react";
import ChannelTable from "@/components/admin/assemblies/ChannelTable.tsx";
import ChannelEditor from "@/components/admin/assemblies/ChannelEditor.tsx";
function ChannelSettings() {
const [enabled, setEnabled] = useState<boolean>(false);
return !enabled ? (
<ChannelTable setEnabled={setEnabled} />
) : (
<ChannelEditor setEnabled={setEnabled} />
);
}
export default ChannelSettings;

View File

@ -1,7 +1,13 @@
import { useDispatch, useSelector } from "react-redux";
import { closeMenu, selectMenu } from "@/store/menu.ts";
import React, { useMemo } from "react";
import {CandlestickChart, LayoutDashboard, Radio, Settings, Users} from "lucide-react";
import {
CandlestickChart,
LayoutDashboard,
Radio,
Settings,
Users,
} from "lucide-react";
import router from "@/router.tsx";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";

View File

@ -0,0 +1,384 @@
import Tips from "@/components/Tips.tsx";
import { Input } from "@/components/ui/input.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectGroup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
ChannelEditProps,
ChannelInfos,
ChannelModels,
ChannelTypes,
} from "@/admin/channel.ts";
import { Textarea } from "@/components/ui/textarea.tsx";
import { NumberInput } from "@/components/ui/number-input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { useTranslation } from "react-i18next";
import { useMemo, useReducer, useState } from "react";
import Required from "@/components/Require.tsx";
import { X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import {
Command,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import Markdown from "@/components/Markdown.tsx";
const initialState: ChannelEditProps = {
type: "openai",
name: "",
models: [],
priority: 0,
weight: 1,
retry: 3,
secret: "",
endpoint: ChannelInfos["openai"].endpoint,
mapper: "",
};
type CustomActionProps = {
onPost: (model: string) => void;
};
function CustomAction({ onPost }: CustomActionProps) {
const { t } = useTranslation();
const [model, setModel] = useState("");
function post() {
const data = model.trim();
if (data === "") return;
onPost(data);
setModel("");
}
return (
<div className={`flex flex-row grow gap-0 custom-action`}>
<Input
value={model}
placeholder={t("admin.channels.add-custom-model")}
className={`rounded-r-none`}
onChange={(e) => setModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") post();
}}
/>
<Button className={`rounded-l-none`} onClick={post}>
{t("add")}
</Button>
</div>
);
}
function reducer(state: ChannelEditProps, action: any) {
switch (action.type) {
case "type":
const isChanged = ChannelInfos[state.type].endpoint !== state.endpoint;
const endpoint = isChanged
? state.endpoint
: ChannelInfos[action.value].endpoint;
return { ...state, endpoint, type: action.value };
case "name":
return { ...state, name: action.value };
case "models":
return { ...state, models: action.value };
case "add-model":
if (state.models.includes(action.value) || action.value === "") {
return state;
}
return { ...state, models: [...state.models, action.value] };
case "add-models":
const models = action.value.filter(
(model: string) => !state.models.includes(model) && model !== "",
);
return { ...state, models: [...state.models, ...models] };
case "remove-model":
return {
...state,
models: state.models.filter((model) => model !== action.value),
};
case "clear-models":
return { ...state, models: [] };
case "priority":
return { ...state, priority: action.value };
case "weight":
return { ...state, weight: action.value };
case "secret":
return { ...state, secret: action.value };
case "endpoint":
return { ...state, endpoint: action.value };
case "mapper":
return { ...state, mapper: action.value };
case "retry":
return { ...state, retry: action.value };
case "clear":
return { ...initialState };
default:
return state;
}
}
function validator(state: ChannelEditProps): boolean {
return (
state.name.trim() !== "" &&
state.models.length > 0 &&
state.secret.trim() !== "" &&
state.endpoint.trim() !== ""
);
}
function handler(data: ChannelEditProps): ChannelEditProps {
data.models = data.models.filter((model) => model.trim() !== "");
data.name = data.name.trim();
data.secret = data.secret
.trim()
.split("\n")
.filter((line) => line.trim() !== "")
.join("\n");
data.endpoint = data.endpoint.trim();
data.mapper = data.mapper
.trim()
.split("\n")
.filter((line) => {
if (line.trim() === "") return false;
const values = line.split(">");
return (
values.length === 2 &&
values[0].trim() !== "" &&
values[1].trim() !== ""
);
})
.join("\n");
return data;
}
type ChannelEditorProps = {
setEnabled: (enabled: boolean) => void;
};
function ChannelEditor({ setEnabled }: ChannelEditorProps) {
const { t } = useTranslation();
const [edit, dispatch] = useReducer(reducer, { ...initialState });
const info = useMemo(() => {
return ChannelInfos[edit.type];
}, [edit.type]);
const unusedModels = useMemo(() => {
return ChannelModels.filter(
(model) => !edit.models.includes(model) && model !== "",
);
}, [edit.models]);
const enabled = useMemo(() => validator(edit), [edit]);
function post() {
const data = handler(edit);
console.debug(`[channel] preflight channel data`, data);
// setEnabled(false);
}
return (
<div className={`channel-editor`}>
<div className={`channel-wrapper w-full h-max`}>
<div className={`channel-row`}>
<div className={`channel-content`}>
<Required />
{t("admin.channels.name")}
<Tips content={t("admin.channels.name-tip")} />
</div>
<Input
value={edit.name}
placeholder={t("admin.channels.name-placeholder")}
onChange={(e) => dispatch({ type: "name", value: e.target.value })}
/>
</div>
<div className={`channel-row`}>
<div className={`channel-content`}>
<Required />
{t("admin.channels.type")}
</div>
<Select
value={edit.type}
onValueChange={(value) => dispatch({ type: "type", value })}
>
<SelectTrigger>
<SelectValue placeholder={t("admin.channels.type")} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(ChannelTypes).map(([key, value], idx) => (
<SelectItem key={idx} value={key}>
{value}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{info.description && (
<Markdown className={`channel-description mt-4 mb-1`}>
{info.description}
</Markdown>
)}
</div>
<div className={`channel-row`}>
<div className={`channel-content`}>
<Required />
{t("admin.channels.model")}
</div>
<div className={`channel-model-wrapper`}>
{edit.models.map((model: string, idx: number) => (
<div className={`channel-model-item`} key={idx}>
{model}
<X
className={`remove-action`}
onClick={() =>
dispatch({ type: "remove-model", value: model })
}
/>
</div>
))}
</div>
<div className={`channel-model-action mt-4`}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>{t("admin.channels.add-model")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent asChild>
<Command>
<CommandInput
placeholder={t("admin.channels.search-model")}
/>
<CommandList className={`thin-scrollbar`}>
{unusedModels.map((model, idx) => (
<CommandItem
key={idx}
value={model}
onSelect={() =>
dispatch({ type: "add-model", value: model })
}
className={`px-2`}
>
{model}
</CommandItem>
))}
</CommandList>
</Command>
</DropdownMenuContent>
</DropdownMenu>
<CustomAction
onPost={(model) => {
dispatch({ type: "add-model", value: model });
}}
/>
<Button
onClick={() =>
dispatch({ type: "add-models", value: info.models })
}
>
{t("admin.channels.fill-template-models", {
number: info.models.length,
})}
</Button>
<Button
variant={`outline`}
onClick={() => dispatch({ type: "clear-models" })}
>
{t("admin.channels.clear-models")}
</Button>
</div>
</div>
<div className={`channel-row`}>
<div className={`channel-content`}>
<Required />
{t("admin.channels.secret")}
</div>
<Textarea
value={edit.secret}
placeholder={t("admin.channels.secret-placeholder", {
format: info.format,
})}
onChange={(e) =>
dispatch({ type: "secret", value: e.target.value })
}
/>
</div>
<div className={`channel-row`}>
<div className={`channel-content`}>
<Required />
{t("admin.channels.endpoint")}
</div>
<Input
value={edit.endpoint}
placeholder={t("admin.channels.endpoint-placeholder")}
onChange={(e) =>
dispatch({ type: "endpoint", value: e.target.value })
}
/>
</div>
<div className={`channel-row`}>
<div className={`channel-content`}>
{t("admin.channels.priority")}
<Tips content={t("admin.channels.priority-tip")} />
</div>
<NumberInput
value={edit.priority}
acceptNegative={true}
onValueChange={(value) => dispatch({ type: "priority", value })}
/>
</div>
<div className={`channel-row`}>
<div className={`channel-content`}>
{t("admin.channels.weight")}
<Tips content={t("admin.channels.weight-tip")} />
</div>
<NumberInput
value={edit.weight}
min={1}
onValueChange={(value) => dispatch({ type: "weight", value })}
/>
</div>
<div className={`channel-row`}>
<div className={`channel-content`}>
{t("admin.channels.retry")}
<Tips content={t("admin.channels.retry-tip")} />
</div>
<NumberInput
value={edit.retry}
min={1}
onValueChange={(value) => dispatch({ type: "retry", value })}
/>
</div>
<div className={`channel-row`}>
<div className={`channel-content`}>
{t("admin.channels.mapper")}
<Tips content={t("admin.channels.mapper-tip")} />
</div>
<Textarea
value={edit.mapper}
placeholder={t("admin.channels.mapper-placeholder")}
onChange={(e) =>
dispatch({ type: "mapper", value: e.target.value })
}
/>
</div>
</div>
<div className={`mt-4 flex flex-row w-full h-max pr-2`}>
<div className={`grow`} />
<Button variant={`outline`} onClick={() => setEnabled(false)}>
{t("cancel")}
</Button>
<Button className={`ml-2`} onClick={post} disabled={!enabled}>
{t("confirm")}
</Button>
</div>
</div>
);
}
export default ChannelEditor;

View File

@ -0,0 +1,96 @@
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import { Check, Plus, RotateCw, Settings2, Trash, X } from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import OperationAction from "@/components/OperationAction.tsx";
import { useState } from "react";
import { Channel } from "@/admin/channel.ts";
import { useTranslation } from "react-i18next";
type ChannelTableProps = {
setEnabled: (enabled: boolean) => void;
};
function ChannelTable({ setEnabled }: ChannelTableProps) {
const { t } = useTranslation();
const [data, setData] = useState<Channel[]>([]);
return (
<div className={`channel-table`}>
<Table>
<TableHeader>
<TableRow className={`select-none whitespace-nowrap`}>
<TableCell>{t("admin.channels.id")}</TableCell>
<TableCell>{t("admin.channels.name")}</TableCell>
<TableCell>{t("admin.channels.type")}</TableCell>
<TableCell>{t("admin.channels.priority")}</TableCell>
<TableCell>{t("admin.channels.weight")}</TableCell>
<TableCell>{t("admin.channels.state")}</TableCell>
<TableCell>{t("admin.channels.action")}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{(data || []).map((chan, idx) => (
<TableRow key={idx}>
<TableCell>{chan.id}</TableCell>
<TableCell>{chan.name}</TableCell>
<TableCell>
<Badge className={`select-none w-max`}>{chan.type}</Badge>
</TableCell>
<TableCell>{chan.priority}</TableCell>
<TableCell>{chan.weight}</TableCell>
<TableCell>
{chan.state ? (
<Check className={`h-4 w-4 text-green-500`} />
) : (
<X className={`h-4 w-4 text-red-500`} />
)}
</TableCell>
<TableCell>
<OperationAction tooltip={t("admin.channels.edit")}>
<Settings2 className={`h-4 w-4`} />
</OperationAction>
{chan.state ? (
<OperationAction
tooltip={t("admin.channels.disable")}
variant={`destructive`}
>
<X className={`h-4 w-4`} />
</OperationAction>
) : (
<OperationAction tooltip={t("admin.channels.enable")}>
<Check className={`h-4 w-4`} />
</OperationAction>
)}
<OperationAction
tooltip={t("admin.channels.delete")}
variant={`destructive`}
>
<Trash className={`h-4 w-4`} />
</OperationAction>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className={`mt-6 pr-2 flex flex-row w-full h-max`}>
<div className={`grow`} />
<Button variant={`outline`} size={`icon`} className={`mr-2`}>
<RotateCw className={`h-4 w-4`} />
</Button>
<Button onClick={() => setEnabled(true)}>
<Plus className={`h-4 w-4 mr-1`} />
{t("admin.channels.create")}
</Button>
</div>
</div>
);
}
export default ChannelTable;

View File

@ -0,0 +1,153 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/components/ui/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -22,6 +22,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
return (
<Input
ref={ref}
type={"number"}
className={`number-input ${className}`}
id={props.id}
value={value}
@ -34,6 +35,11 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
value = props.min;
props.onValueChange(value);
}}
min={props.min}
max={props.max}
onWheel={(e) => {
e.stopPropagation();
}}
/>
);
},

View File

@ -0,0 +1,29 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/components/ui/lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -1,12 +1,12 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronUp, ChevronDown } from "lucide-react";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "./lib/utils";
import { cn } from "@/components/ui/lib/utils";
const Select = SelectPrimitive.Root;
const SelectList = SelectPrimitive.Group;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
@ -30,52 +30,84 @@ const SelectTrigger = React.forwardRef<
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
avoidCollisions={true}
ref={(ref) => {
if (!ref) return;
ref.ontouchend = (e) => {
e.preventDefault();
e.stopPropagation();
};
}}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper"
? "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
: "w-[80vw] max-w-[30rem] max-h-[80vh]",
className,
)}
position={position}
{...props}
>
<SelectPrimitive.ScrollUpButton
className={`flex items-center justify-center h-[25px] cursor-pointer`}
>
<ChevronUp />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton
className={`flex items-center justify-center h-[25px] cursor-pointer`}
>
<ChevronDown />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
>(
(
{ className, children, position = "popper", ...props },
ref: React.ForwardedRef<HTMLDivElement>,
) => {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={(ref) => {
if (!ref) return;
ref.ontouchend = (e) => {
e.preventDefault();
e.stopPropagation();
};
}}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
},
);
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
@ -127,11 +159,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectList,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@ -34,7 +34,7 @@ import {
SelectValue,
} from "@/components/ui/select.tsx";
import { langs, setLanguage } from "@/i18n.ts";
import {cn} from "@/components/ui/lib/utils.ts";
import { cn } from "@/components/ui/lib/utils.ts";
function SettingsDialog() {
const { t, i18n } = useTranslation();

View File

@ -15,6 +15,7 @@ const resources = {
cn: {
translation: {
end: "",
add: "添加",
"not-found": "页面未找到",
home: "首页",
login: "登录",
@ -362,6 +363,42 @@ const resources = {
generate: "批量生成",
"generate-result": "生成结果",
error: "请求失败",
channels: {
id: "渠道 ID",
name: "名称",
"name-tip": "渠道名称,用于标识渠道",
"name-placeholder": "请输入渠道名称",
type: "类型",
priority: "优先级",
"priority-tip": "多渠道时,根据优先级顺序请求,越大优先级越高",
weight: "权重",
"weight-tip": "同优先级时,根据权重比例进行均衡负载调用",
retry: "最大重试次数",
"retry-tip": "当渠道请求失败时,最多重试的次数",
model: "模型",
secret: "密钥",
"secret-placeholder":
"请输入密钥,格式:{{format}}\n多个密钥时一行一个请求时随机选取负载",
endpoint: "接入点",
"endpoint-placeholder": "请输入接入点(即代理)",
mapper: "模型映射",
"mapper-tip": "模型名转换,实现非对称的模型请求",
"mapper-placeholder":
"请输入模型映射,一行一个,格式: model>model\n" +
"前者为请求的模型,后者为映射的模型(需要在模型中存在),中间用 > 分隔",
state: "状态",
action: "操作",
edit: "编辑渠道",
enable: "启用渠道",
disable: "禁用渠道",
delete: "删除渠道",
create: "创建渠道",
"search-model": "搜索模型",
"fill-template-models": "填入模板模型 ({{number}} 个)",
"add-custom-model": "添加自定义模型",
"add-model": "添加模型",
"clear-models": "清空全部模型",
},
},
mask: {
title: "预设设置",
@ -373,6 +410,7 @@ const resources = {
en: {
translation: {
end: ".", // end of sentence
add: "Add",
"not-found": "Page not found",
home: "Home",
login: "Login",
@ -738,6 +776,46 @@ const resources = {
generate: "Generate",
"generate-result": "Generate Result",
error: "Request Failed",
channels: {
id: "Channel ID",
name: "Name",
"name-tip": "Channel name, used to identify the channel",
"name-placeholder": "Please enter the channel name",
type: "Type",
priority: "Priority",
"priority-tip":
"When there are multiple channels, the request is made according to the priority order, the higher the priority, the higher the priority",
weight: "Weight",
"weight-tip":
"When the priority is the same, the load balancing call is performed according to the weight ratio",
retry: "Max Retry",
"retry-tip":
"When the channel request fails, the maximum number of retries",
model: "Model",
secret: "Secret",
"secret-placeholder":
"Please enter the secret, format: {{format}}\nWhen there are multiple secrets, one line is selected randomly when requesting the load",
endpoint: "Endpoint",
"endpoint-placeholder": "Please enter the endpoint (ie proxy)",
mapper: "Model Mapper",
"mapper-tip":
"Model name conversion to achieve asymmetric model request",
"mapper-placeholder":
"Please enter the model mapper, one line each, format: model>model\n" +
"The former is the requested model, and the latter is the mapped model (which needs to exist in the model), separated by > in the middle",
state: "State",
action: "Action",
edit: "Edit Channel",
enable: "Enable Channel",
disable: "Disable Channel",
delete: "Delete Channel",
create: "Create Channel",
"search-model": "Search Model",
"fill-template-models": "Fill Template Models ({{number}})",
"add-custom-model": "Add Custom Model",
"add-model": "Add Model",
"clear-models": "Clear All Models",
},
},
mask: {
title: "Mask Settings",
@ -749,6 +827,7 @@ const resources = {
ru: {
translation: {
end: "",
add: "Добавить",
"not-found": "Страница не найдена",
home: "Главная",
login: "Войти",
@ -1117,6 +1196,46 @@ const resources = {
generate: "Генерировать",
"generate-result": "Результат",
error: "Ошибка запроса",
channels: {
id: "ID канала",
name: "Название",
"name-tip": "Название канала, используется для идентификации канала",
"name-placeholder": "Введите название канала",
type: "Тип",
priority: "Приоритет",
"priority-tip":
"При наличии нескольких каналов запрос выполняется в порядке приоритета, чем выше приоритет, тем выше приоритет",
weight: "Вес",
"weight-tip":
"При равном приоритете вызов балансировки нагрузки выполняется в соответствии с весовым соотношением",
retry: "Максимальное количество попыток",
"retry-tip":
"При сбое запроса канала максимальное количество повторных попыток",
model: "Модель",
secret: "Секрет",
"secret-placeholder":
"Введите секрет, формат: {{format}}\nПри наличии нескольких секретов при запросе загрузки выбирается одна строка случайным образом",
endpoint: "Конечная точка",
"endpoint-placeholder": "Введите конечную точку (т.е. прокси)",
mapper: "Модельный маппер",
"mapper-tip":
"Преобразование имени модели для достижения асимметричного запроса модели",
"mapper-placeholder":
"Введите модельный маппер, по одной строке, формат: model>model\n" +
"Первая модель - запрошенная модель, вторая модель - отображаемая модель (которая должна существовать в модели), разделенная > посередине",
state: "Статус",
action: "Действие",
edit: "Редактировать канал",
enable: "Включить канал",
disable: "Отключить канал",
delete: "Удалить канал",
create: "Создать канал",
"search-model": "Поиск по имени модели",
"fill-template-models": "Заполнить шаблонные модели ({{number}})",
"add-custom-model": "Добавить пользовательскую модель",
"add-model": "Добавить модель",
"clear-models": "Очистить все модели",
},
},
mask: {
title: "Настройки маски",

View File

@ -1,5 +1,27 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { useTranslation } from "react-i18next";
import ChannelSettings from "@/components/admin/ChannelSettings.tsx";
function Channel() {
return <></>;
const { t } = useTranslation();
return (
<div className={`channel`}>
<Card className={`channel-card`}>
<CardHeader className={`select-none`}>
<CardTitle>{t("admin.channel")}</CardTitle>
</CardHeader>
<CardContent>
<ChannelSettings />
</CardContent>
</Card>
</div>
);
}
export default Channel;

116
channel/channel.go Normal file
View File

@ -0,0 +1,116 @@
package channel
import (
"chat/utils"
"math/rand"
"strings"
)
var defaultMaxRetries = 1
func (c *Channel) GetId() int {
return c.Id
}
func (c *Channel) GetName() string {
return c.Name
}
func (c *Channel) GetType() string {
return c.Type
}
func (c *Channel) GetPriority() int {
return c.Priority
}
func (c *Channel) GetWeight() int {
if c.Weight <= 0 {
return 1
}
return c.Weight
}
func (c *Channel) GetModels() []string {
return c.Models
}
func (c *Channel) GetRetry() int {
if c.Retry <= 0 {
return defaultMaxRetries
}
return c.Retry
}
func (c *Channel) GetSecret() string {
return c.Secret
}
func (c *Channel) GetRandomSecret() string {
arr := strings.Split(c.GetSecret(), "\n")
idx := rand.Intn(len(arr))
return arr[idx]
}
func (c *Channel) GetEndpoint() string {
return c.Endpoint
}
func (c *Channel) GetMapper() string {
return c.Mapper
}
func (c *Channel) GetReflect() map[string]string {
if c.Reflect == nil {
var reflect map[string]string
arr := strings.Split(c.GetMapper(), "\n")
for _, item := range arr {
pair := strings.Split(item, ">")
if len(pair) == 2 {
reflect[pair[0]] = pair[1]
}
}
c.Reflect = &reflect
}
return *c.Reflect
}
func (c *Channel) GetModelReflect(model string) string {
ref := c.GetReflect()
if reflect, ok := ref[model]; ok && len(reflect) > 0 {
return reflect
}
return model
}
func (c *Channel) GetHitModels() []string {
if c.HitModels == nil {
var res []string
models := c.GetModels()
ref := c.GetReflect()
for _, model := range models {
if !utils.Contains(model, res) {
res = append(res, model)
}
}
for model := range ref {
if !utils.Contains(model, res) {
res = append(res, model)
}
}
c.HitModels = &res
}
return *c.HitModels
}
func (c *Channel) GetState() bool {
return c.State
}

24
channel/types.go Normal file
View File

@ -0,0 +1,24 @@
package channel
type Channel struct {
Id int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Priority int `json:"priority"`
Weight int `json:"weight"`
Models []string `json:"models"`
Retry int `json:"retry"`
Secret string `json:"secret"`
Endpoint string `json:"endpoint"`
Mapper string `json:"mapper"`
State bool `json:"state"`
Reflect *map[string]string `json:"reflect"`
HitModels *[]string `json:"hit_models"`
}
type Sequence []*Channel
type Manager struct {
Sequence Sequence `json:"sequence"`
}

View File

@ -6,3 +6,20 @@ const (
Assistant = "assistant"
Tool = "tool"
)
const (
OpenAIChannelType = iota
ClaudeChannelType
SlackChannelType
SparkdeskChannelType
ChatGLMChannelType
DashscopeChannelType
HunyuanChannelType
ZhinaoChannelType
BaichuanChannelType
SkylarkChannelType
BingChannelType
PalmChannelType
MidjourneyChannelType
OneAPIChannelType
)