🛠️ fix: v1 interface feedback regressions
Resolve verified V1 frontend feedback by improving channel workflows, auth behavior, API key interactions, user filtering, layout persistence, subscription quota handling, i18n text, pricing metadata, and stale frontend cache recovery. - Add a global frontend cache version cleanup to prevent old frontend localStorage from causing page errors after upgrades. - Fix channel copy refresh, model mapping input focus loss, create-channel fetch-model title state, upstream model update confirmation, and batch test toast behavior. - Respect password login settings and improve Turnstile, forgot-password, registration, and invite-link flows. - Make user role/status filtering server-side and preserve table page size in URLs. - Improve API key edit validation feedback and prefetch real keys for reliable copy actions. - Fix rankings access fail-open behavior, double scrollbars, subscription received amount conversion/display, token i18n wording, model deletion confirmation grammar, and Claude pricing context inference. - Add clearer Playground model/group loading errors. Validation: - bun run typecheck - bun run i18n:sync - gofmt on modified Go files - go test ./controller ./model -run '^$'
This commit is contained in:
@@ -1218,7 +1218,7 @@ func CopyChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
// insert
|
||||
if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil {
|
||||
if err := clone.Insert(); err != nil {
|
||||
common.SysError("failed to clone channel: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"})
|
||||
return
|
||||
|
||||
@@ -88,6 +88,7 @@ func GetStatus(c *gin.Context) {
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"register_enabled": common.RegisterEnabled,
|
||||
"password_login_enabled": common.PasswordLoginEnabled,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
|
||||
|
||||
+13
-1
@@ -251,8 +251,20 @@ func GetAllUsers(c *gin.Context) {
|
||||
func SearchUsers(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
group := c.Query("group")
|
||||
var role *int
|
||||
if roleStr := c.Query("role"); roleStr != "" {
|
||||
if parsed, err := strconv.Atoi(roleStr); err == nil {
|
||||
role = &parsed
|
||||
}
|
||||
}
|
||||
var status *int
|
||||
if statusStr := c.Query("status"); statusStr != "" {
|
||||
if parsed, err := strconv.Atoi(statusStr); err == nil {
|
||||
status = &parsed
|
||||
}
|
||||
}
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
users, total, err := model.SearchUsers(keyword, group, role, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
+14
-17
@@ -225,7 +225,7 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) {
|
||||
func SearchUsers(keyword string, group string, role *int, status *int, startIdx int, num int) ([]*User, int64, error) {
|
||||
var users []*User
|
||||
var total int64
|
||||
var err error
|
||||
@@ -246,28 +246,25 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
|
||||
|
||||
// 构建搜索条件
|
||||
likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?"
|
||||
likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"}
|
||||
|
||||
// 尝试将关键字转换为整数ID
|
||||
keywordInt, err := strconv.Atoi(keyword)
|
||||
if err == nil {
|
||||
// 如果是数字,同时搜索ID和其他字段
|
||||
likeCondition = "id = ? OR " + likeCondition
|
||||
if group != "" {
|
||||
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
|
||||
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
|
||||
} else {
|
||||
query = query.Where(likeCondition,
|
||||
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
} else {
|
||||
// 非数字关键字,只搜索字符串字段
|
||||
if group != "" {
|
||||
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
|
||||
} else {
|
||||
query = query.Where(likeCondition,
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
likeArgs = append([]interface{}{keywordInt}, likeArgs...)
|
||||
}
|
||||
|
||||
query = query.Where("("+likeCondition+")", likeArgs...)
|
||||
if group != "" {
|
||||
query = query.Where(commonGroupCol+" = ?", group)
|
||||
}
|
||||
if role != nil {
|
||||
query = query.Where("role = ?", *role)
|
||||
}
|
||||
if status != nil {
|
||||
query = query.Where("status = ?", *status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
|
||||
Vendored
+6
-5
@@ -24,9 +24,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.40.0",
|
||||
@@ -38,7 +38,7 @@
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
@@ -47,8 +47,8 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sse.js": "^2.7.2",
|
||||
"streamdown": "^2.0.1",
|
||||
"sse.js": "^2.8.0",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
@@ -94,6 +94,7 @@
|
||||
"js-cookie": "3.0.7",
|
||||
"mermaid": "11.15.0",
|
||||
"minimist": "1.2.8",
|
||||
"postcss": "8.5.15",
|
||||
"qs": "6.15.2",
|
||||
"uuid": "14.0.0",
|
||||
},
|
||||
|
||||
Vendored
+6
-5
@@ -38,9 +38,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.20",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.40.0",
|
||||
@@ -52,7 +52,7 @@
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
@@ -61,8 +61,8 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sse.js": "^2.7.2",
|
||||
"streamdown": "^2.0.1",
|
||||
"sse.js": "^2.8.0",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
@@ -106,6 +106,7 @@
|
||||
"js-cookie": "3.0.7",
|
||||
"mermaid": "11.15.0",
|
||||
"minimist": "1.2.8",
|
||||
"postcss": "8.5.15",
|
||||
"qs": "6.15.2",
|
||||
"uuid": "14.0.0"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) {
|
||||
className={cn(
|
||||
'@container/content',
|
||||
'h-[calc(100svh-var(--app-header-height,0px))]',
|
||||
'min-h-0 overflow-hidden',
|
||||
'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]'
|
||||
)}
|
||||
>
|
||||
|
||||
+11
-2
@@ -67,6 +67,7 @@ export function ForgotPasswordForm({
|
||||
resolver: zodResolver(forgotPasswordFormSchema),
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken)
|
||||
|
||||
async function onSubmit(data: z.infer<typeof forgotPasswordFormSchema>) {
|
||||
if (!validateTurnstile()) return
|
||||
@@ -78,6 +79,8 @@ export function ForgotPasswordForm({
|
||||
form.reset()
|
||||
startCountdown()
|
||||
toast.success(t('Reset email sent, please check your inbox'))
|
||||
} else {
|
||||
toast.error(res?.message || t('Failed to send reset email'))
|
||||
}
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
@@ -107,8 +110,14 @@ export function ForgotPasswordForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit' className='mt-2' disabled={isLoading || isActive}>
|
||||
{isActive ? `Resend (${secondsLeft}s)` : 'Send reset email'}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2'
|
||||
disabled={isLoading || isActive || !turnstileReady}
|
||||
>
|
||||
{isActive
|
||||
? t('Resend ({{seconds}}s)', { seconds: secondsLeft })
|
||||
: t('Send reset email')}
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ export function useEmailVerification(options?: UseEmailVerificationOptions) {
|
||||
toast.success(i18next.t('Verification email sent'))
|
||||
return true
|
||||
}
|
||||
toast.error(
|
||||
res?.message || i18next.t('Failed to send verification email')
|
||||
)
|
||||
return false
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
|
||||
+115
-86
@@ -81,6 +81,10 @@ export function UserAuthForm({
|
||||
const passkeyLoginEnabled = Boolean(
|
||||
status?.passkey_login ?? status?.data?.passkey_login
|
||||
)
|
||||
const passwordLoginEnabled =
|
||||
(status?.password_login_enabled ??
|
||||
status?.data?.password_login_enabled ??
|
||||
true) !== false
|
||||
const {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
@@ -98,6 +102,16 @@ export function UserAuthForm({
|
||||
!passkeySupported ||
|
||||
(requiresLegalConsent && !agreedToLegal)
|
||||
const hasWeChatLogin = Boolean(status?.wechat_login)
|
||||
const hasOAuthLogin = Boolean(
|
||||
status?.github_oauth ||
|
||||
status?.discord_oauth ||
|
||||
status?.oidc_enabled ||
|
||||
status?.linuxdo_oauth ||
|
||||
status?.telegram_oauth ||
|
||||
(status?.custom_oauth_providers?.length ?? 0) > 0
|
||||
)
|
||||
const hasAlternativeLogin =
|
||||
passkeyLoginEnabled || hasWeChatLogin || hasOAuthLogin
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresLegalConsent) {
|
||||
@@ -275,6 +289,42 @@ export function UserAuthForm({
|
||||
}
|
||||
}
|
||||
|
||||
const alternativeLoginMethods = (
|
||||
<>
|
||||
{passkeyLoginEnabled && (
|
||||
<div className='mt-2 space-y-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={passkeyButtonDisabled}
|
||||
onClick={handlePasskeyLogin}
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{isPasskeyLoading ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<KeyRound className='h-4 w-4' />
|
||||
)}
|
||||
{t('Sign in with Passkey')}
|
||||
</Button>
|
||||
{!passkeySupported && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Passkey is not supported on this device.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Providers */}
|
||||
<OAuthProviders
|
||||
status={status}
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
|
||||
isWeChatLoading={isWeChatSubmitting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -282,63 +332,72 @@ export function UserAuthForm({
|
||||
className={cn('grid gap-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username or Email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Enter your username or email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{hasAlternativeLogin && alternativeLoginMethods}
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='relative'>
|
||||
<FormLabel>{t('Password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={t('Enter password')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 z-10 text-sm font-medium hover:opacity-75'
|
||||
>
|
||||
{t('Forgot password?')}
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
|
||||
{/* Turnstile */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
{passwordLoginEnabled && (
|
||||
<>
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username or Email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Enter your username or email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='relative'>
|
||||
<FormLabel>{t('Password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t('Enter password')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 z-10 text-sm font-medium hover:opacity-75'
|
||||
>
|
||||
{t('Forgot password?')}
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
|
||||
{/* Turnstile */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LegalConsent
|
||||
@@ -348,37 +407,7 @@ export function UserAuthForm({
|
||||
className='mt-1'
|
||||
/>
|
||||
|
||||
{passkeyLoginEnabled && (
|
||||
<div className='mt-2 space-y-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={passkeyButtonDisabled}
|
||||
onClick={handlePasskeyLogin}
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{isPasskeyLoading ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<KeyRound className='h-4 w-4' />
|
||||
)}
|
||||
{t('Sign in with Passkey')}
|
||||
</Button>
|
||||
{!passkeySupported && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Passkey is not supported on this device.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Providers */}
|
||||
<OAuthProviders
|
||||
status={status}
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
|
||||
isWeChatLoading={isWeChatSubmitting}
|
||||
/>
|
||||
{!hasAlternativeLogin && alternativeLoginMethods}
|
||||
</form>
|
||||
|
||||
{hasWeChatLogin && (
|
||||
|
||||
@@ -53,7 +53,10 @@ import { registerFormSchema } from '@/features/auth/constants'
|
||||
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
|
||||
import { useEmailVerification } from '@/features/auth/hooks/use-email-verification'
|
||||
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
import { getAffiliateCode } from '@/features/auth/lib/storage'
|
||||
import {
|
||||
getAffiliateCode,
|
||||
saveAffiliateCode,
|
||||
} from '@/features/auth/lib/storage'
|
||||
|
||||
export function SignUpForm({
|
||||
className,
|
||||
@@ -107,6 +110,7 @@ export function SignUpForm({
|
||||
status?.data?.oauth_register_enabled ??
|
||||
true
|
||||
const hasWeChatLogin = Boolean(status?.wechat_login)
|
||||
const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken)
|
||||
|
||||
const wechatQrCodeUrl = useMemo(() => {
|
||||
return (
|
||||
@@ -130,6 +134,13 @@ export function SignUpForm({
|
||||
}
|
||||
}, [requiresLegalConsent])
|
||||
|
||||
useEffect(() => {
|
||||
const aff = new URLSearchParams(window.location.search).get('aff')?.trim()
|
||||
if (aff) {
|
||||
saveAffiliateCode(aff)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function onSubmit(data: z.infer<typeof registerFormSchema>) {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
@@ -164,6 +175,8 @@ export function SignUpForm({
|
||||
if (res?.success) {
|
||||
toast.success(t('Account created! Please sign in'))
|
||||
redirectToLogin()
|
||||
} else {
|
||||
toast.error(res?.message || t('Failed to create account'))
|
||||
}
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
@@ -307,7 +320,13 @@ export function SignUpForm({
|
||||
<Button
|
||||
variant='outline'
|
||||
type='button'
|
||||
disabled={isLoading || isSendingCode || isActive || !emailValue}
|
||||
disabled={
|
||||
isLoading ||
|
||||
isSendingCode ||
|
||||
isActive ||
|
||||
!emailValue ||
|
||||
!turnstileReady
|
||||
}
|
||||
onClick={handleSendVerificationCode}
|
||||
>
|
||||
{isActive ? (
|
||||
@@ -343,7 +362,11 @@ export function SignUpForm({
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
(requiresLegalConsent && !agreedToLegal) ||
|
||||
!turnstileReady
|
||||
}
|
||||
>
|
||||
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
|
||||
{t('Create account')}
|
||||
|
||||
+2
@@ -126,6 +126,7 @@ export interface SystemStatus {
|
||||
privacy_policy_enabled?: boolean
|
||||
oauth_register_enabled?: boolean
|
||||
register_enabled?: boolean
|
||||
password_login_enabled?: boolean
|
||||
password_register_enabled?: boolean
|
||||
custom_oauth_providers?: CustomOAuthProviderInfo[]
|
||||
[key: string]: unknown
|
||||
@@ -168,6 +169,7 @@ export interface SystemStatus {
|
||||
privacy_policy_enabled?: boolean
|
||||
oauth_register_enabled?: boolean
|
||||
register_enabled?: boolean
|
||||
password_login_enabled?: boolean
|
||||
password_register_enabled?: boolean
|
||||
custom_oauth_providers?: CustomOAuthProviderInfo[]
|
||||
[key: string]: unknown
|
||||
|
||||
@@ -56,6 +56,7 @@ export function ChannelsPrimaryButtons() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
setOpen,
|
||||
setCurrentRow,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
idSort,
|
||||
@@ -104,7 +105,13 @@ export function ChannelsPrimaryButtons() {
|
||||
</div>
|
||||
|
||||
{/* Create Channel */}
|
||||
<Button onClick={() => setOpen('create-channel')} size='sm'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCurrentRow(null)
|
||||
setOpen('create-channel')
|
||||
}}
|
||||
size='sm'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
<span className='max-sm:hidden'>{t('Create Channel')}</span>
|
||||
<span className='sm:hidden'>{t('Create')}</span>
|
||||
|
||||
+40
-8
@@ -28,6 +28,7 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { Check, Copy, Info, Loader2, Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -302,11 +303,12 @@ export function ChannelTestDialog({
|
||||
}, [])
|
||||
|
||||
const testSingleModel = useCallback(
|
||||
async (model: string) => {
|
||||
async (model: string, silent = false): Promise<TestResult | undefined> => {
|
||||
if (!currentRow) return
|
||||
|
||||
markModelTesting(model, true)
|
||||
updateTestResult(model, { status: 'testing' })
|
||||
let finalResult: TestResult | undefined
|
||||
|
||||
try {
|
||||
await handleTestChannel(
|
||||
@@ -315,24 +317,28 @@ export function ChannelTestDialog({
|
||||
testModel: model,
|
||||
endpointType: endpointType === 'auto' ? undefined : endpointType,
|
||||
stream: isStreamTest || undefined,
|
||||
silent,
|
||||
},
|
||||
(success, responseTime, error, errorCode) => {
|
||||
updateTestResult(model, {
|
||||
finalResult = {
|
||||
status: success ? 'success' : 'error',
|
||||
responseTime,
|
||||
error,
|
||||
errorCode,
|
||||
})
|
||||
}
|
||||
updateTestResult(model, finalResult)
|
||||
}
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
updateTestResult(model, {
|
||||
finalResult = {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : t('Test failed'),
|
||||
})
|
||||
}
|
||||
updateTestResult(model, finalResult)
|
||||
} finally {
|
||||
markModelTesting(model, false)
|
||||
}
|
||||
return finalResult
|
||||
},
|
||||
[
|
||||
currentRow,
|
||||
@@ -350,15 +356,41 @@ export function ChannelTestDialog({
|
||||
|
||||
setIsBatchTesting(true)
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
modelsToTest.map((modelName) => testSingleModel(modelName))
|
||||
const settled = await Promise.allSettled(
|
||||
modelsToTest.map((modelName) => testSingleModel(modelName, true))
|
||||
)
|
||||
const results = settled
|
||||
.map((result) =>
|
||||
result.status === 'fulfilled' ? result.value : undefined
|
||||
)
|
||||
.filter((result): result is TestResult => Boolean(result))
|
||||
const successCount = results.filter(
|
||||
(result) => result.status === 'success'
|
||||
).length
|
||||
const failedCount = modelsToTest.length - successCount
|
||||
if (failedCount > 0) {
|
||||
toast.error(
|
||||
t(
|
||||
'Batch test completed: {{success}} succeeded, {{failed}} failed',
|
||||
{
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
toast.success(
|
||||
t('Batch test completed: {{count}} succeeded', {
|
||||
count: successCount,
|
||||
})
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
setIsBatchTesting(false)
|
||||
setRowSelection({})
|
||||
}
|
||||
},
|
||||
[testSingleModel]
|
||||
[t, testSingleModel]
|
||||
)
|
||||
|
||||
const handleClose = () => {
|
||||
|
||||
+21
-12
@@ -67,6 +67,7 @@ type FetchModelsDialogProps = {
|
||||
redirectSourceModels?: string[]
|
||||
customFetcher?: () => Promise<string[]>
|
||||
existingModelsOverride?: string[]
|
||||
channelName?: string | null
|
||||
}
|
||||
|
||||
export function FetchModelsDialog({
|
||||
@@ -77,9 +78,11 @@ export function FetchModelsDialog({
|
||||
redirectSourceModels = [],
|
||||
customFetcher,
|
||||
existingModelsOverride,
|
||||
channelName,
|
||||
}: FetchModelsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const activeChannel = customFetcher ? null : currentRow
|
||||
const queryClient = useQueryClient()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
@@ -89,8 +92,9 @@ export function FetchModelsDialog({
|
||||
|
||||
// Parse existing models
|
||||
const existingModels = useMemo(
|
||||
() => existingModelsOverride ?? parseModelsString(currentRow?.models || ''),
|
||||
[existingModelsOverride, currentRow?.models]
|
||||
() =>
|
||||
existingModelsOverride ?? parseModelsString(activeChannel?.models || ''),
|
||||
[existingModelsOverride, activeChannel?.models]
|
||||
)
|
||||
|
||||
// Categorize models with redirect models
|
||||
@@ -125,14 +129,14 @@ export function FetchModelsDialog({
|
||||
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && (currentRow || customFetcher)) {
|
||||
if (open && (activeChannel || customFetcher)) {
|
||||
handleFetchModels()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentRow?.id, customFetcher])
|
||||
}, [open, activeChannel?.id, customFetcher])
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
if (!currentRow && !customFetcher) return
|
||||
if (!activeChannel && !customFetcher) return
|
||||
|
||||
setIsFetching(true)
|
||||
try {
|
||||
@@ -142,7 +146,7 @@ export function FetchModelsDialog({
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else {
|
||||
const response = await fetchUpstreamModels(currentRow!.id)
|
||||
const response = await fetchUpstreamModels(activeChannel!.id)
|
||||
if (response.success) {
|
||||
const list = Array.isArray(response.data) ? response.data : []
|
||||
setFetchedModels(list)
|
||||
@@ -173,11 +177,11 @@ export function FetchModelsDialog({
|
||||
}
|
||||
|
||||
// Otherwise, directly save to API (standalone mode)
|
||||
if (!currentRow) return
|
||||
if (!activeChannel) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const modelsString = selectedModels.join(',')
|
||||
const response = await updateChannel(currentRow.id, {
|
||||
const response = await updateChannel(activeChannel.id, {
|
||||
models: modelsString,
|
||||
})
|
||||
if (response.success) {
|
||||
@@ -367,10 +371,15 @@ export function FetchModelsDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Fetch Models')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{currentRow ? (
|
||||
{activeChannel ? (
|
||||
<>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{currentRow.name}</strong>
|
||||
<strong>{activeChannel.name}</strong>
|
||||
</>
|
||||
) : channelName ? (
|
||||
<>
|
||||
{t('Fetch available models for:')}{' '}
|
||||
<strong>{channelName}</strong>
|
||||
</>
|
||||
) : (
|
||||
t('Fetch available models from upstream')
|
||||
@@ -378,7 +387,7 @@ export function FetchModelsDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!currentRow && !customFetcher ? (
|
||||
{!activeChannel && !customFetcher ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No channel selected')}
|
||||
</div>
|
||||
@@ -413,7 +422,7 @@ export function FetchModelsDialog({
|
||||
|
||||
{/* Tabs for New vs Existing vs Removed */}
|
||||
<Tabs
|
||||
key={`${currentRow?.id}-${fetchedModels.length}-${removedModels.length}`}
|
||||
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
|
||||
defaultValue={
|
||||
newModels.length > 0
|
||||
? 'new'
|
||||
|
||||
+3
-2
@@ -107,7 +107,7 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
const anyAdd = selectedAddArr.length > 0
|
||||
const anyRemove = selectedRemoveArr.length > 0
|
||||
|
||||
if (hasAdd && hasRemove && (!anyAdd || !anyRemove)) {
|
||||
if (hasAdd && hasRemove && anyAdd !== anyRemove) {
|
||||
setPartialConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
@@ -278,7 +278,8 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
props.confirmLoading ||
|
||||
(selectedAdd.size === 0 && selectedRemove.size === 0)
|
||||
(props.addModels.length === 0 &&
|
||||
props.removeModels.length === 0)
|
||||
}
|
||||
>
|
||||
{t('Confirm')}
|
||||
|
||||
+2
@@ -393,6 +393,7 @@ export function ChannelMutateDrawer({
|
||||
const currentType = form.watch('type')
|
||||
const currentBaseUrl = form.watch('base_url')
|
||||
const currentModels = form.watch('models')
|
||||
const currentName = form.watch('name')
|
||||
const currentModelMapping = form.watch('model_mapping')
|
||||
const awsKeyType = form.watch('aws_key_type')
|
||||
const upstreamModelUpdateCheckEnabled = form.watch(
|
||||
@@ -3380,6 +3381,7 @@ export function ChannelMutateDrawer({
|
||||
redirectModels={redirectModelList}
|
||||
redirectSourceModels={redirectModelKeyList}
|
||||
customFetcher={!isEditing ? createModeFetcher : undefined}
|
||||
channelName={!isEditing ? currentName?.trim() : undefined}
|
||||
existingModelsOverride={
|
||||
!isEditing
|
||||
? parseModelsString(form.getValues('models') || '')
|
||||
|
||||
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Code, Table, Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -45,6 +45,12 @@ export function ModelMappingEditor({
|
||||
const [mode, setMode] = useState<'visual' | 'json'>('visual')
|
||||
const [rows, setRows] = useState<MappingRow[]>([])
|
||||
const [jsonValue, setJsonValue] = useState(value)
|
||||
const nextRowIdRef = useRef(0)
|
||||
|
||||
const createRowId = () => {
|
||||
nextRowIdRef.current += 1
|
||||
return `mapping-${nextRowIdRef.current}`
|
||||
}
|
||||
|
||||
const parseJsonToRows = (json: string) => {
|
||||
try {
|
||||
@@ -53,14 +59,32 @@ export function ModelMappingEditor({
|
||||
return
|
||||
}
|
||||
const parsed = JSON.parse(json)
|
||||
const newRows: MappingRow[] = Object.entries(parsed).map(
|
||||
([from, to], index) => ({
|
||||
id: `${Date.now()}-${index}`,
|
||||
from,
|
||||
to: String(to),
|
||||
const entries = Object.entries(parsed)
|
||||
setRows((previousRows) => {
|
||||
const remainingRows = [...previousRows]
|
||||
return entries.map(([from, to], index) => {
|
||||
const toString = String(to)
|
||||
const existingIndex = remainingRows.findIndex(
|
||||
(row) =>
|
||||
row.from === from ||
|
||||
(row.from === from && row.to === toString) ||
|
||||
previousRows[index]?.id === row.id
|
||||
)
|
||||
if (existingIndex >= 0) {
|
||||
const [existing] = remainingRows.splice(existingIndex, 1)
|
||||
return {
|
||||
id: existing.id,
|
||||
from,
|
||||
to: toString,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: createRowId(),
|
||||
from,
|
||||
to: toString,
|
||||
}
|
||||
})
|
||||
)
|
||||
setRows(newRows)
|
||||
})
|
||||
} catch (_error) {
|
||||
// Invalid JSON, keep current rows
|
||||
}
|
||||
@@ -88,7 +112,7 @@ export function ModelMappingEditor({
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newRow: MappingRow = {
|
||||
id: `${Date.now()}`,
|
||||
id: createRowId(),
|
||||
from: '',
|
||||
to: '',
|
||||
}
|
||||
|
||||
+19
-6
@@ -205,7 +205,12 @@ export async function handleUpdateTagField(
|
||||
*/
|
||||
export async function handleTestChannel(
|
||||
id: number,
|
||||
options?: { testModel?: string; endpointType?: string; stream?: boolean },
|
||||
options?: {
|
||||
testModel?: string
|
||||
endpointType?: string
|
||||
stream?: boolean
|
||||
silent?: boolean
|
||||
},
|
||||
onTestComplete?: (
|
||||
success: boolean,
|
||||
responseTime?: number,
|
||||
@@ -227,17 +232,23 @@ export async function handleTestChannel(
|
||||
try {
|
||||
const response = await testChannel(id, payload)
|
||||
if (response.success) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.TESTED))
|
||||
if (!options?.silent) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.TESTED))
|
||||
}
|
||||
onTestComplete?.(true, response.data?.response_time)
|
||||
} else {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED))
|
||||
if (!options?.silent) {
|
||||
toast.error(response.message || i18next.t(ERROR_MESSAGES.TEST_FAILED))
|
||||
}
|
||||
onTestComplete?.(false, undefined, response.message, response.error_code)
|
||||
}
|
||||
} catch (_error: unknown) {
|
||||
const err = _error as { response?: { data?: { message?: string } } }
|
||||
const errorMsg =
|
||||
err?.response?.data?.message || i18next.t(ERROR_MESSAGES.TEST_FAILED)
|
||||
toast.error(errorMsg)
|
||||
if (!options?.silent) {
|
||||
toast.error(errorMsg)
|
||||
}
|
||||
onTestComplete?.(false, undefined, errorMsg)
|
||||
}
|
||||
}
|
||||
@@ -253,10 +264,12 @@ export async function handleCopyChannel(
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await copyChannel(id, params)
|
||||
if (response.success && response.data?.id) {
|
||||
if (response.success) {
|
||||
toast.success(i18next.t(SUCCESS_MESSAGES.COPIED))
|
||||
queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onSuccess?.(response.data.id)
|
||||
onSuccess?.(response.data?.id ?? 0)
|
||||
} else {
|
||||
toast.error(response.message || i18next.t('Failed to copy channel'))
|
||||
}
|
||||
} catch (_error) {
|
||||
toast.error(i18next.t('Failed to copy channel'))
|
||||
|
||||
+14
-2
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Check, Copy, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -62,12 +63,17 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
)
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const realKey = resolvedFullKey || (await resolveRealKey(apiKey.id))
|
||||
const realKey = resolvedFullKey
|
||||
if (!realKey) {
|
||||
void resolveRealKey(apiKey.id)
|
||||
toast.info(t('API key is loading, please try again in a moment'))
|
||||
return
|
||||
}
|
||||
if (realKey) {
|
||||
const ok = await copyToClipboard(realKey)
|
||||
if (ok) markKeyCopied(apiKey.id)
|
||||
}
|
||||
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied])
|
||||
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t])
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
@@ -116,6 +122,12 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
size='icon'
|
||||
className='size-7 shrink-0'
|
||||
onClick={handleCopy}
|
||||
onFocus={() => {
|
||||
if (!resolvedFullKey) void resolveRealKey(apiKey.id)
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
if (!resolvedFullKey) void resolveRealKey(apiKey.id)
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -616,7 +616,7 @@ export function ApiKeysMutateDrawer({
|
||||
</SheetClose>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
onClick={form.handleSubmit(onSubmit, onInvalid)}
|
||||
disabled={isSubmitting}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
|
||||
@@ -94,13 +94,33 @@ export function DataTableRowActions<TData>({
|
||||
triggerRefresh,
|
||||
setResolvedKey,
|
||||
resolveRealKey,
|
||||
resolvedKeys,
|
||||
loadingKeys,
|
||||
} = useApiKeys()
|
||||
const isEnabled = apiKey.status === API_KEY_STATUS.ENABLED
|
||||
const { chatPresets, serverAddress } = useChatPresets()
|
||||
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
|
||||
const resolvedRealKey = resolvedKeys[apiKey.id]
|
||||
const isRealKeyLoading = Boolean(loadingKeys[apiKey.id])
|
||||
|
||||
const hasChatPresets = chatPresets.length > 0
|
||||
|
||||
const handleMenuOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open && !resolvedRealKey && !isRealKeyLoading) {
|
||||
void resolveRealKey(apiKey.id)
|
||||
}
|
||||
},
|
||||
[apiKey.id, isRealKeyLoading, resolvedRealKey, resolveRealKey]
|
||||
)
|
||||
|
||||
const getCachedRealKey = useCallback(() => {
|
||||
if (resolvedRealKey) return resolvedRealKey
|
||||
void resolveRealKey(apiKey.id)
|
||||
toast.info(t('API key is loading, please try again in a moment'))
|
||||
return null
|
||||
}, [apiKey.id, resolvedRealKey, resolveRealKey, t])
|
||||
|
||||
const handleOpenChatPreset = useCallback(
|
||||
async (preset: ChatPreset) => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
@@ -201,7 +221,7 @@ export function DataTableRowActions<TData>({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu modal={false} onOpenChange={handleMenuOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
@@ -216,7 +236,7 @@ export function DataTableRowActions<TData>({
|
||||
<DropdownMenuContent align='end' className='w-[200px]'>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
const realKey = getCachedRealKey()
|
||||
if (!realKey) return
|
||||
const ok = await copyToClipboard(realKey)
|
||||
if (ok) toast.success(t('Copied'))
|
||||
@@ -229,7 +249,7 @@ export function DataTableRowActions<TData>({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const realKey = await resolveRealKey(apiKey.id)
|
||||
const realKey = getCachedRealKey()
|
||||
if (!realKey) return
|
||||
const connStr = encodeConnectionString(
|
||||
realKey,
|
||||
|
||||
@@ -192,8 +192,10 @@ export function DataTableBulkActions<TData>({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Delete Models?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Are you sure you want to delete')} {selectedIds.length}{' '}
|
||||
{t('model(s)? This action cannot be undone.')}
|
||||
{t(
|
||||
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
|
||||
{ count: selectedIds.length }
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
+27
-2
@@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { getUserModels, getUserGroups } from './api'
|
||||
import { PlaygroundChat } from './components/playground-chat'
|
||||
import { PlaygroundInput } from './components/playground-input'
|
||||
@@ -26,6 +28,7 @@ import { createUserMessage, createLoadingAssistantMessage } from './lib'
|
||||
import type { Message as MessageType } from './types'
|
||||
|
||||
export function Playground() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
config,
|
||||
parameterEnabled,
|
||||
@@ -52,13 +55,35 @@ export function Playground() {
|
||||
// Load models
|
||||
const { data: modelsData, isLoading: isLoadingModels } = useQuery({
|
||||
queryKey: ['playground-models'],
|
||||
queryFn: getUserModels,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await getUserModels()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('Failed to load playground models')
|
||||
)
|
||||
return []
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Load groups
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['playground-groups'],
|
||||
queryFn: getUserGroups,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await getUserGroups()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('Failed to load playground groups')
|
||||
)
|
||||
return []
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Update models when data changes
|
||||
|
||||
@@ -90,7 +90,6 @@ export function loadMessages(): Message[] | null {
|
||||
if (saved) {
|
||||
const parsed: unknown = JSON.parse(saved)
|
||||
if (!Array.isArray(parsed)) {
|
||||
localStorage.removeItem(STORAGE_KEYS.MESSAGES)
|
||||
return null
|
||||
}
|
||||
const sanitized = sanitizeMessagesOnLoad(parsed as Message[])
|
||||
|
||||
@@ -268,6 +268,9 @@ function inferContextAndOutputs(
|
||||
if (lower.includes('1m') || lower.includes('-long')) {
|
||||
return { context: 1_000_000, maxOutput: 65_536 }
|
||||
}
|
||||
if (/claude.*(?:4|opus|sonnet)/.test(lower)) {
|
||||
return { context: 1_000_000, maxOutput: 65_536 }
|
||||
}
|
||||
if (
|
||||
lower.includes('200k') ||
|
||||
lower.includes('claude-3') ||
|
||||
|
||||
+3
-2
@@ -20,6 +20,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Crown, CalendarClock, Package } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -253,11 +254,11 @@ export function SubscriptionPurchaseDialog(props: Props) {
|
||||
)}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Total Quota')}
|
||||
{t('Received amount')}
|
||||
</span>
|
||||
<span className='flex items-center gap-1 text-sm'>
|
||||
<Package className='h-3.5 w-3.5' />
|
||||
{totalAmount > 0 ? totalAmount : t('Unlimited')}
|
||||
{totalAmount > 0 ? formatQuota(totalAmount) : t('Unlimited')}
|
||||
</span>
|
||||
</div>
|
||||
{plan.upgrade_group && (
|
||||
|
||||
+4
-3
@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { useMemo } from 'react'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -176,15 +177,15 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
||||
},
|
||||
{
|
||||
id: 'total_amount',
|
||||
meta: { label: t('Total Quota'), mobileHidden: true },
|
||||
meta: { label: t('Received amount'), mobileHidden: true },
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t('Total Quota')} />
|
||||
<DataTableColumnHeader column={column} title={t('Received amount')} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const total = Number(row.original.plan.total_amount || 0)
|
||||
return (
|
||||
<span className='text-muted-foreground'>
|
||||
{total > 0 ? total : t('Unlimited')}
|
||||
{total > 0 ? formatQuota(total) : t('Unlimited')}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
|
||||
+4
-2
@@ -328,7 +328,7 @@ export function SubscriptionsMutateDrawer({
|
||||
name='total_amount'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Total Quota')}</FormLabel>
|
||||
<FormLabel>{t('Received amount')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
@@ -340,7 +340,9 @@ export function SubscriptionsMutateDrawer({
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('0 means unlimited')}
|
||||
{t(
|
||||
'0 means unlimited. The value is converted to quota units when saved.'
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
+3
-2
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { parseQuotaFromDollars, quotaUnitsToDollars } from '@/lib/format'
|
||||
import type { SubscriptionPlan, PlanPayload } from '../types'
|
||||
|
||||
export function getPlanFormSchema(t: TFunction) {
|
||||
@@ -81,7 +82,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
|
||||
enabled: plan.enabled !== false,
|
||||
sort_order: Number(plan.sort_order || 0),
|
||||
max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
|
||||
total_amount: Number(plan.total_amount || 0),
|
||||
total_amount: quotaUnitsToDollars(Number(plan.total_amount || 0)),
|
||||
upgrade_group: plan.upgrade_group || '',
|
||||
stripe_price_id: plan.stripe_price_id || '',
|
||||
creem_product_id: plan.creem_product_id || '',
|
||||
@@ -104,7 +105,7 @@ export function formValuesToPlanPayload(values: PlanFormValues): PlanPayload {
|
||||
: 0,
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||
total_amount: Number(values.total_amount || 0),
|
||||
total_amount: parseQuotaFromDollars(Number(values.total_amount || 0)),
|
||||
upgrade_group: values.upgrade_group || '',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ export function useUpdateOption() {
|
||||
// If updating frontend-display-related config, also refresh status
|
||||
if (STATUS_RELATED_KEYS.includes(variables.key)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['status'] })
|
||||
try {
|
||||
window.localStorage.removeItem('status')
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(i18next.t('Setting updated successfully'))
|
||||
|
||||
+6
-1
@@ -841,8 +841,13 @@ export const ModelRatioVisualEditor = memo(
|
||||
persistPricingData(data)
|
||||
setEditData(data)
|
||||
setEditorOpen(true)
|
||||
toast.success(
|
||||
t(
|
||||
'Pricing changes saved to draft. Click "Save model prices" to apply.'
|
||||
)
|
||||
)
|
||||
},
|
||||
[persistPricingData]
|
||||
[persistPricingData, t]
|
||||
)
|
||||
|
||||
const handleBatchCopy = useCallback(() => {
|
||||
|
||||
@@ -386,12 +386,17 @@ export function RatioSettingsCard({
|
||||
(key) => normalized[key] !== modelNormalizedDefaults.current[key]
|
||||
)
|
||||
|
||||
if (updates.length === 0) {
|
||||
toast.info(t('No model price changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of updates) {
|
||||
const apiKey = apiKeyMap[key as string] || (key as string)
|
||||
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
|
||||
}
|
||||
},
|
||||
[updateOption]
|
||||
[t, updateOption]
|
||||
)
|
||||
|
||||
const saveGroupRatios = useCallback(
|
||||
|
||||
@@ -171,7 +171,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
'No usage logs available. Logs will appear here once API calls are made.'
|
||||
)}
|
||||
skeletonKeyPrefix='usage-log-skeleton'
|
||||
tableClassName='max-h-[calc(100dvh-13rem)] overflow-auto sm:max-h-[calc(100dvh-14rem)]'
|
||||
tableClassName='overflow-x-auto'
|
||||
tableHeaderClassName='bg-muted/30 sticky top-0 z-10'
|
||||
toolbar={
|
||||
isCommon ? (
|
||||
|
||||
+16
-4
@@ -49,10 +49,22 @@ export async function getUsers(
|
||||
export async function searchUsers(
|
||||
params: SearchUsersParams
|
||||
): Promise<GetUsersResponse> {
|
||||
const { keyword = '', group = '', p = 1, page_size = 10 } = params
|
||||
const res = await api.get(
|
||||
`/api/user/search?keyword=${keyword}&group=${group}&p=${p}&page_size=${page_size}`
|
||||
)
|
||||
const {
|
||||
keyword = '',
|
||||
group = '',
|
||||
role = '',
|
||||
status = '',
|
||||
p = 1,
|
||||
page_size = 10,
|
||||
} = params
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.set('keyword', keyword)
|
||||
queryParams.set('group', group)
|
||||
if (role) queryParams.set('role', role)
|
||||
if (status) queryParams.set('status', status)
|
||||
queryParams.set('p', String(p))
|
||||
queryParams.set('page_size', String(page_size))
|
||||
const res = await api.get(`/api/user/search?${queryParams.toString()}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
||||
+27
-4
@@ -85,6 +85,17 @@ export function UsersTable() {
|
||||
{ columnId: 'group', searchKey: 'group', type: 'string' },
|
||||
],
|
||||
})
|
||||
const statusFilter =
|
||||
(columnFilters.find((filter) => filter.id === 'status')?.value as
|
||||
| string[]
|
||||
| undefined) ?? []
|
||||
const roleFilter =
|
||||
(columnFilters.find((filter) => filter.id === 'role')?.value as
|
||||
| string[]
|
||||
| undefined) ?? []
|
||||
const groupFilter =
|
||||
(columnFilters.find((filter) => filter.id === 'group')?.value as string) ??
|
||||
''
|
||||
|
||||
// Fetch data with React Query
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
@@ -93,18 +104,30 @@ export function UsersTable() {
|
||||
pagination.pageIndex + 1,
|
||||
pagination.pageSize,
|
||||
globalFilter,
|
||||
statusFilter,
|
||||
roleFilter,
|
||||
groupFilter,
|
||||
refreshTrigger,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const hasFilter = globalFilter?.trim()
|
||||
const hasColumnFilter =
|
||||
statusFilter.length > 0 || roleFilter.length > 0 || Boolean(groupFilter)
|
||||
const params = {
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
|
||||
const result = hasFilter
|
||||
? await searchUsers({ ...params, keyword: globalFilter })
|
||||
: await getUsers(params)
|
||||
const result =
|
||||
hasFilter || hasColumnFilter
|
||||
? await searchUsers({
|
||||
...params,
|
||||
keyword: globalFilter,
|
||||
status: statusFilter[0] ?? '',
|
||||
role: roleFilter[0] ?? '',
|
||||
group: groupFilter,
|
||||
})
|
||||
: await getUsers(params)
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(
|
||||
@@ -160,7 +183,7 @@ export function UsersTable() {
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
manualPagination: !globalFilter,
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
})
|
||||
|
||||
|
||||
+2
@@ -92,6 +92,8 @@ export interface GetUsersResponse {
|
||||
export interface SearchUsersParams {
|
||||
keyword?: string
|
||||
group?: string
|
||||
role?: string
|
||||
status?: string
|
||||
p?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
+1
-2
@@ -151,8 +151,7 @@ export function useTableUrlState(
|
||||
search: (prev) => ({
|
||||
...(prev as SearchRecord),
|
||||
[pageKey]: nextPage <= defaultPage ? undefined : nextPage,
|
||||
[pageSizeKey]:
|
||||
nextPageSize === defaultPageSize ? undefined : nextPageSize,
|
||||
[pageSizeKey]: nextPageSize,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -382,6 +382,7 @@
|
||||
"Apps using the most tokens through new-api": "Apps using the most tokens through new-api",
|
||||
"are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.": "are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.",
|
||||
"Are you sure you want to delete": "Are you sure you want to delete",
|
||||
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "Are you sure you want to delete {{count}} model(s)? This action cannot be undone.",
|
||||
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "Are you sure you want to delete all auto-disabled keys? This action cannot be undone.",
|
||||
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.",
|
||||
"Are you sure you want to delete this key? This action cannot be undone.": "Are you sure you want to delete this key? This action cannot be undone.",
|
||||
|
||||
Vendored
+1
@@ -382,6 +382,7 @@
|
||||
"Apps using the most tokens through new-api": "Applications consommant le plus de jetons via new-api",
|
||||
"are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.": "sont également listés ici. Supprimez-les des Modèles pour que la réponse `/v1/models` reste conviviale et pour masquer les noms spécifiques aux fournisseurs.",
|
||||
"Are you sure you want to delete": "Êtes-vous sûr de vouloir supprimer",
|
||||
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer {{count}} modèle(s) ? Cette action ne peut pas être annulée.",
|
||||
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer toutes les clés automatiquement désactivées ? Cette action ne peut pas être annulée.",
|
||||
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer le déploiement \"{{name}}\" ? Cette action est irréversible.",
|
||||
"Are you sure you want to delete this key? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer cette clé ? Cette action ne peut pas être annulée.",
|
||||
|
||||
Vendored
+1
@@ -382,6 +382,7 @@
|
||||
"Apps using the most tokens through new-api": "new-api 経由で最も多くのトークンを利用しているアプリ",
|
||||
"are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.": "もここにリストされています。`/v1/models` レスポンスをユーザーフレンドリーに保ち、ベンダー固有の名前を隠すために、Models からこれらを削除します。",
|
||||
"Are you sure you want to delete": "削除してもよろしいですか",
|
||||
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "{{count}} 個のモデルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "すべての自動無効化されたキーを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "デプロイ \"{{name}}\" を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"Are you sure you want to delete this key? This action cannot be undone.": "このキーを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
|
||||
Vendored
+1
@@ -382,6 +382,7 @@
|
||||
"Apps using the most tokens through new-api": "Приложения, использующие больше всего токенов через new-api",
|
||||
"are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.": "также перечислены здесь. Удалите их из Моделей, чтобы ответ `/v1/models` был удобным для пользователя и скрывал имена, специфичные для поставщиков.",
|
||||
"Are you sure you want to delete": "Вы уверены, что хотите удалить",
|
||||
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "Вы уверены, что хотите удалить {{count}} модел(ей)? Это действие нельзя отменить.",
|
||||
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "Вы уверены, что хотите удалить все автоматически отключённые ключи? Это действие нельзя отменить.",
|
||||
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Вы уверены, что хотите удалить развертывание \"{{name}}\"? Это действие нельзя отменить.",
|
||||
"Are you sure you want to delete this key? This action cannot be undone.": "Вы уверены, что хотите удалить этот ключ? Это действие нельзя отменить.",
|
||||
|
||||
Vendored
+1
@@ -382,6 +382,7 @@
|
||||
"Apps using the most tokens through new-api": "Ứng dụng dùng nhiều token nhất qua new-api",
|
||||
"are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.": "cũng được liệt kê ở đây. Xóa chúng khỏi Models để giữ cho phản hồi `/v1/models` thân thiện với người dùng và ẩn các tên dành riêng cho nhà cung cấp.",
|
||||
"Are you sure you want to delete": "Bạn có chắc chắn muốn xóa ",
|
||||
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "Bạn có chắc muốn xóa {{count}} mô hình không? Hành động này không thể hoàn tác.",
|
||||
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "Bạn có chắc chắn muốn xóa tất cả các khóa bị tắt tự động? Hành động này không thể hoàn tác.",
|
||||
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Bạn có chắc muốn xóa triển khai \"{{name}}\" không? Hành động này không thể hoàn tác.",
|
||||
"Are you sure you want to delete this key? This action cannot be undone.": "Bạn có chắc chắn muốn xóa khóa này? Hành động này không thể hoàn tác.",
|
||||
|
||||
Vendored
+3
-2
@@ -382,6 +382,7 @@
|
||||
"Apps using the most tokens through new-api": "通过 new-api 消耗 Token 最多的应用",
|
||||
"are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.": "也在此处列出。将它们从模型中移除,以保持 `/v1/models` 响应对用户友好并隐藏供应商特定的名称。",
|
||||
"Are you sure you want to delete": "您确定要删除吗",
|
||||
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "您确定要删除 {{count}} 个模型吗?此操作无法撤销。",
|
||||
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "您确定要删除所有自动禁用的密钥吗?此操作无法撤销。",
|
||||
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "确定要删除部署 \"{{name}}\" 吗?此操作不可撤销。",
|
||||
"Are you sure you want to delete this key? This action cannot be undone.": "您确定要删除此密钥吗?此操作无法撤销。",
|
||||
@@ -3996,8 +3997,8 @@
|
||||
"Token usage by model since launch": "自上线以来各模型的 Token 用量",
|
||||
"Token-based": "按量计费",
|
||||
"Tokenizer": "分词器",
|
||||
"tokens": "令牌",
|
||||
"Tokens": "令牌",
|
||||
"tokens": "Token",
|
||||
"Tokens": "Token",
|
||||
"tokens / mo": "token / 月",
|
||||
"Tokens by category": "分类 Token 占比",
|
||||
"Tokens Only": "仅限 Token",
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
const FRONTEND_CACHE_VERSION = 'default-v1'
|
||||
const FRONTEND_CACHE_VERSION_KEY = 'newapi:default:cache-version'
|
||||
const PRESERVED_LOCAL_STORAGE_KEYS = new Set([
|
||||
FRONTEND_CACHE_VERSION_KEY,
|
||||
'user',
|
||||
'uid',
|
||||
'aff',
|
||||
'oauth:binding:result',
|
||||
])
|
||||
|
||||
export function initializeFrontendCache(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
const currentVersion = window.localStorage.getItem(
|
||||
FRONTEND_CACHE_VERSION_KEY
|
||||
)
|
||||
if (currentVersion === FRONTEND_CACHE_VERSION) return
|
||||
|
||||
clearLocalUiCache()
|
||||
window.localStorage.setItem(
|
||||
FRONTEND_CACHE_VERSION_KEY,
|
||||
FRONTEND_CACHE_VERSION
|
||||
)
|
||||
} catch {
|
||||
// Storage can be unavailable in private mode; the app should still boot.
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocalUiCache(): void {
|
||||
const keysToRemove: string[] = []
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index)
|
||||
if (key && !PRESERVED_LOCAL_STORAGE_KEYS.has(key)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => window.localStorage.removeItem(key))
|
||||
}
|
||||
Vendored
+1
-1
@@ -181,7 +181,7 @@ export async function getFreshModuleAccess(
|
||||
cacheStatus(status)
|
||||
return getModuleAccessFromStatus(status, module)
|
||||
} catch {
|
||||
return getModuleAccess(module)
|
||||
return { enabled: false, requireAuth: true }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
@@ -31,6 +31,7 @@ import { useAuthStore } from '@/stores/auth-store'
|
||||
import { getStatus } from '@/lib/api'
|
||||
import '@/lib/dayjs'
|
||||
import { applyFaviconToDom } from '@/lib/dom-utils'
|
||||
import { initializeFrontendCache } from '@/lib/frontend-cache'
|
||||
import { handleServerError } from '@/lib/handle-server-error'
|
||||
import { DirectionProvider } from './context/direction-provider'
|
||||
import { FontProvider } from './context/font-provider'
|
||||
@@ -43,6 +44,7 @@ import './styles/index.css'
|
||||
|
||||
// Ensure VChart theme is initialized before any chart mounts (prevents white default theme flash)
|
||||
// VChart theme is driven by our ThemeProvider (html.light/html.dark) via per-chart `theme` prop.
|
||||
initializeFrontendCache()
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
Vendored
+21
@@ -30,6 +30,7 @@ import { Route as errors401RouteImport } from './routes/(errors)/401'
|
||||
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
|
||||
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
|
||||
import { Route as authResetRouteImport } from './routes/(auth)/reset'
|
||||
import { Route as authRegisterRouteImport } from './routes/(auth)/register'
|
||||
import { Route as authOtpRouteImport } from './routes/(auth)/otp'
|
||||
import { Route as authOauthRouteImport } from './routes/(auth)/oauth'
|
||||
import { Route as authForgotPasswordRouteImport } from './routes/(auth)/forgot-password'
|
||||
@@ -171,6 +172,11 @@ const authResetRoute = authResetRouteImport.update({
|
||||
path: '/reset',
|
||||
getParentRoute: () => authRouteRoute,
|
||||
} as any)
|
||||
const authRegisterRoute = authRegisterRouteImport.update({
|
||||
id: '/register',
|
||||
path: '/register',
|
||||
getParentRoute: () => authRouteRoute,
|
||||
} as any)
|
||||
const authOtpRoute = authOtpRouteImport.update({
|
||||
id: '/otp',
|
||||
path: '/otp',
|
||||
@@ -394,6 +400,7 @@ export interface FileRoutesByFullPath {
|
||||
'/forgot-password': typeof authForgotPasswordRoute
|
||||
'/oauth': typeof authOauthRoute
|
||||
'/otp': typeof authOtpRoute
|
||||
'/register': typeof authRegisterRoute
|
||||
'/reset': typeof authResetRoute
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
@@ -451,6 +458,7 @@ export interface FileRoutesByTo {
|
||||
'/forgot-password': typeof authForgotPasswordRoute
|
||||
'/oauth': typeof authOauthRoute
|
||||
'/otp': typeof authOtpRoute
|
||||
'/register': typeof authRegisterRoute
|
||||
'/reset': typeof authResetRoute
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
@@ -512,6 +520,7 @@ export interface FileRoutesById {
|
||||
'/(auth)/forgot-password': typeof authForgotPasswordRoute
|
||||
'/(auth)/oauth': typeof authOauthRoute
|
||||
'/(auth)/otp': typeof authOtpRoute
|
||||
'/(auth)/register': typeof authRegisterRoute
|
||||
'/(auth)/reset': typeof authResetRoute
|
||||
'/(auth)/sign-in': typeof authSignInRoute
|
||||
'/(auth)/sign-up': typeof authSignUpRoute
|
||||
@@ -572,6 +581,7 @@ export interface FileRouteTypes {
|
||||
| '/forgot-password'
|
||||
| '/oauth'
|
||||
| '/otp'
|
||||
| '/register'
|
||||
| '/reset'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
@@ -629,6 +639,7 @@ export interface FileRouteTypes {
|
||||
| '/forgot-password'
|
||||
| '/oauth'
|
||||
| '/otp'
|
||||
| '/register'
|
||||
| '/reset'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
@@ -689,6 +700,7 @@ export interface FileRouteTypes {
|
||||
| '/(auth)/forgot-password'
|
||||
| '/(auth)/oauth'
|
||||
| '/(auth)/otp'
|
||||
| '/(auth)/register'
|
||||
| '/(auth)/reset'
|
||||
| '/(auth)/sign-in'
|
||||
| '/(auth)/sign-up'
|
||||
@@ -910,6 +922,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof authResetRouteImport
|
||||
parentRoute: typeof authRouteRoute
|
||||
}
|
||||
'/(auth)/register': {
|
||||
id: '/(auth)/register'
|
||||
path: '/register'
|
||||
fullPath: '/register'
|
||||
preLoaderRoute: typeof authRegisterRouteImport
|
||||
parentRoute: typeof authRouteRoute
|
||||
}
|
||||
'/(auth)/otp': {
|
||||
id: '/(auth)/otp'
|
||||
path: '/otp'
|
||||
@@ -1176,6 +1195,7 @@ interface authRouteRouteChildren {
|
||||
authForgotPasswordRoute: typeof authForgotPasswordRoute
|
||||
authOauthRoute: typeof authOauthRoute
|
||||
authOtpRoute: typeof authOtpRoute
|
||||
authRegisterRoute: typeof authRegisterRoute
|
||||
authResetRoute: typeof authResetRoute
|
||||
authSignInRoute: typeof authSignInRoute
|
||||
authSignUpRoute: typeof authSignUpRoute
|
||||
@@ -1186,6 +1206,7 @@ const authRouteRouteChildren: authRouteRouteChildren = {
|
||||
authForgotPasswordRoute: authForgotPasswordRoute,
|
||||
authOauthRoute: authOauthRoute,
|
||||
authOtpRoute: authOtpRoute,
|
||||
authRegisterRoute: authRegisterRoute,
|
||||
authResetRoute: authResetRoute,
|
||||
authSignInRoute: authSignInRoute,
|
||||
authSignUpRoute: authSignUpRoute,
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/(auth)/register')({
|
||||
beforeLoad: ({ location }) => {
|
||||
throw redirect({
|
||||
to: '/sign-up',
|
||||
search: location.search,
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -24,7 +24,7 @@ import { Channels } from '@/features/channels'
|
||||
|
||||
const channelsSearchSchema = z.object({
|
||||
page: z.number().optional().catch(1),
|
||||
pageSize: z.number().optional().catch(10),
|
||||
pageSize: z.number().optional().catch(undefined),
|
||||
filter: z.string().optional().catch(''),
|
||||
status: z.array(z.string()).optional().catch([]),
|
||||
type: z.array(z.string()).optional().catch([]),
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import { API_KEY_STATUS_OPTIONS } from '@/features/keys/constants'
|
||||
|
||||
const apiKeySearchSchema = z.object({
|
||||
page: z.number().optional().catch(1),
|
||||
pageSize: z.number().optional().catch(10),
|
||||
pageSize: z.number().optional().catch(undefined),
|
||||
status: z
|
||||
.array(z.enum(API_KEY_STATUS_OPTIONS.map((s) => s.value as `${number}`)))
|
||||
.optional()
|
||||
|
||||
@@ -28,7 +28,7 @@ const logTypeValues = ['0', '1', '2', '3', '4', '5', '6'] as const
|
||||
|
||||
const usageLogsSearchSchema = z.object({
|
||||
page: z.number().optional().catch(1),
|
||||
pageSize: z.number().optional().catch(20),
|
||||
pageSize: z.number().optional().catch(undefined),
|
||||
type: z.array(z.enum(logTypeValues)).optional().catch([]),
|
||||
filter: z.string().optional().catch(''),
|
||||
model: z.string().optional().catch(''),
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import { Users } from '@/features/users'
|
||||
|
||||
const usersSearchSchema = z.object({
|
||||
page: z.number().optional().catch(1),
|
||||
pageSize: z.number().optional().catch(10),
|
||||
pageSize: z.number().optional().catch(undefined),
|
||||
filter: z.string().optional().catch(''),
|
||||
status: z
|
||||
.array(z.enum(['1', '2']))
|
||||
|
||||
Reference in New Issue
Block a user