Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions web/default/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ export default defineConfig(({ envMode }) => {
},
server: {
host: '0.0.0.0',
// Pinned to 17231 (uncommon, unlikely to clash with other dev servers).
// 3000/3001 routinely collide with Node/Next/CRA/Vite defaults; we own
// 17231 for DeepRouter so the URL is stable across machines.
port: 17231,
strictPort: false,
proxy: devProxy,
},
output: {
Expand Down
17 changes: 15 additions & 2 deletions web/default/src/features/keys/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,22 @@ export async function fetchTokenKeysBatch(ids: number[]): Promise<{

// Fetch Simple-mode purpose cards + price tier metadata.
// Drives the picker UI in the Create API Key drawer.
//
// Falls back to a hardcoded mirror of setting/alias_setting/seed/aliases.yaml
// when the backend hasn't shipped the endpoint yet (binary needs rebuild) or
// returns an empty payload. Production servers will override.
export async function getApiKeyPurposes(): Promise<
ApiResponse<ApiKeyPurposesResponse>
> {
const res = await api.get('/api/user/self/api-key-purposes')
return res.data
try {
const res = await api.get('/api/user/self/api-key-purposes')
const body = res.data as ApiResponse<ApiKeyPurposesResponse>
if (body?.success && body.data?.purposes?.length) return body
} catch {
/* fall through to fallback */
}
const { FALLBACK_API_KEY_PURPOSES } = await import(
'./lib/api-key-purposes-fallback'
)
return { success: true, data: FALLBACK_API_KEY_PURPOSES }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
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 { ArrowRight, Settings2, Sparkles } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { cn } from '@/lib/utils'

type ApiKeyModePickerDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onPick: (mode: 'simple' | 'advanced') => void
}

/**
* First step of the Create API Key flow — asks the user which mode to use
* before opening the drawer (PRD §3.1, refined per UX feedback). Removes
* the always-visible Simple/Advanced toggle from inside the drawer.
*/
export function ApiKeyModePickerDialog({
open,
onOpenChange,
onPick,
}: ApiKeyModePickerDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='!max-w-md sm:!max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Create API Key')}</DialogTitle>
<DialogDescription>
{t('Choose how you want to set this key up.')}
</DialogDescription>
</DialogHeader>
<div className='grid gap-3'>
<ModeCard
icon={<Sparkles className='h-5 w-5' />}
badge={t('Recommended')}
title={t('Simple')}
description={t(
'Pick what you will use the key for — chat, coding, image, video, voice, or auto. We route to the right model.'
)}
footnote={t('Best for most users. No model names to memorize.')}
onClick={() => onPick('simple')}
/>
<ModeCard
icon={<Settings2 className='h-5 w-5' />}
title={t('Advanced')}
description={t(
'Full control: model whitelist, channel group, per-key quota, expiration, IP allowlist, batch creation.'
)}
footnote={t('Best for developers and teams.')}
onClick={() => onPick('advanced')}
/>
</div>
</DialogContent>
</Dialog>
)
}

function ModeCard({
icon,
title,
description,
footnote,
badge,
onClick,
}: {
icon: React.ReactNode
title: string
description: string
footnote?: string
badge?: string
onClick: () => void
}) {
return (
<button
type='button'
onClick={onClick}
className={cn(
'group border-border bg-background hover:border-foreground/40 relative flex w-full items-start gap-3 rounded-lg border p-4 text-left transition-all hover:shadow-sm',
'focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-[3px] focus-visible:outline-none'
)}
>
<span className='bg-muted text-muted-foreground group-hover:bg-foreground/10 group-hover:text-foreground flex h-10 w-10 shrink-0 items-center justify-center rounded-md border'>
{icon}
</span>
<span className='flex min-w-0 flex-1 flex-col gap-1'>
<span className='flex items-baseline justify-between gap-2'>
<span className='flex items-center gap-2'>
<span className='text-sm font-semibold'>{title}</span>
{badge && (
<span className='bg-foreground/10 text-foreground rounded-full px-2 py-0.5 text-[10px] font-medium'>
{badge}
</span>
)}
</span>
<ArrowRight className='text-muted-foreground group-hover:text-foreground h-4 w-4 shrink-0 transition-transform group-hover:translate-x-0.5' />
</span>
<span className='text-muted-foreground text-xs leading-snug'>
{description}
</span>
{footnote && (
<span className='text-muted-foreground/80 mt-0.5 text-[11px]'>
{footnote}
</span>
)}
</span>
</button>
)
}
93 changes: 52 additions & 41 deletions web/default/src/features/keys/components/api-keys-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ function useGroupRatios(): Record<string, number> {
export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
const { t } = useTranslation()
const groupRatios = useGroupRatios()
const isAdmin = useAuthStore((s) =>
Boolean(s.auth.user?.role && s.auth.user.role >= 10)
)
return [
{
id: 'select',
Expand Down Expand Up @@ -220,48 +223,56 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
},
meta: { label: t('Quota') },
},
{
accessorKey: 'group',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Group')} />
),
cell: ({ row }) => {
const apiKey = row.original
const group = row.getValue('group') as string
const ratio = group && group !== 'auto' ? groupRatios[group] : undefined
// Group column is admin-only. End users should never see "1.2x" markup
// multipliers next to their keys (PRD — group + ratio belong in the
// operator surface, not the customer surface).
...(isAdmin
? [
{
accessorKey: 'group',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Group')} />
),
cell: ({ row }) => {
const apiKey = row.original
const group = row.getValue('group') as string
const ratio =
group && group !== 'auto' ? groupRatios[group] : undefined

if (group === 'auto') {
return (
<Tooltip>
<TooltipTrigger
render={
<span className='inline-flex items-center gap-1.5 text-xs' />
}
>
<GroupBadge group='auto' />
{apiKey.cross_group_retry && (
<>
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground/60'>
{t('Cross-group')}
</span>
</>
)}
</TooltipTrigger>
<TooltipContent>
<span className='text-xs'>
{t(
'Automatically selects the best available group with circuit breaker mechanism'
)}
</span>
</TooltipContent>
</Tooltip>
)
}
return <GroupBadge group={group} ratio={ratio} />
},
meta: { label: t('Group'), mobileHidden: true },
},
if (group === 'auto') {
return (
<Tooltip>
<TooltipTrigger
render={
<span className='inline-flex items-center gap-1.5 text-xs' />
}
>
<GroupBadge group='auto' />
{apiKey.cross_group_retry && (
<>
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground/60'>
{t('Cross-group')}
</span>
</>
)}
</TooltipTrigger>
<TooltipContent>
<span className='text-xs'>
{t(
'Automatically selects the best available group with circuit breaker mechanism'
)}
</span>
</TooltipContent>
</Tooltip>
)
}
return <GroupBadge group={group} ratio={ratio} />
},
meta: { label: t('Group'), mobileHidden: true },
} satisfies ColumnDef<ApiKey>,
]
: []),
{
id: 'model_limits',
accessorKey: 'model_limits',
Expand Down
15 changes: 15 additions & 0 deletions web/default/src/features/keys/components/api-keys-dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import { savePreferredMode } from '../lib'
import { ApiKeyModePickerDialog } from './api-key-mode-picker-dialog'
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
import { useApiKeys } from './api-keys-provider'
Expand All @@ -38,8 +40,21 @@ export function ApiKeysDialogs() {
}
}, [open])

// User picked Simple/Advanced in the mode-picker dialog → persist the
// preference and open the create drawer (which reads loadPreferredMode()
// on open).
const handlePickMode = (mode: 'simple' | 'advanced') => {
savePreferredMode(mode)
setOpen('create')
}

return (
<>
<ApiKeyModePickerDialog
open={open === 'mode-picker'}
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
onPick={handlePickMode}
/>
<ApiKeysMutateDrawer
open={open === 'create' || open === 'update'}
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
Expand Down
Loading