🛠️ 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:
t0ng7u
2026-05-25 02:42:22 +08:00
parent 88437a1869
commit b302be30e3
53 changed files with 599 additions and 198 deletions
@@ -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
@@ -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
View File
@@ -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>
@@ -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 = () => {
@@ -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'
@@ -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')}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
-1
View File
@@ -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') ||
@@ -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 && (
@@ -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>
)
},
@@ -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
View File
@@ -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'))
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -92,6 +92,8 @@ export interface GetUsersResponse {
export interface SearchUsersParams {
keyword?: string
group?: string
role?: string
status?: string
p?: number
page_size?: number
}