diff --git a/web/default/rsbuild.config.ts b/web/default/rsbuild.config.ts index e9d604175360..bc3b9099ae2e 100644 --- a/web/default/rsbuild.config.ts +++ b/web/default/rsbuild.config.ts @@ -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: { diff --git a/web/default/src/features/keys/api.ts b/web/default/src/features/keys/api.ts index 5a53b987d475..19f57ad24953 100644 --- a/web/default/src/features/keys/api.ts +++ b/web/default/src/features/keys/api.ts @@ -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 > { - 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 + 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 } } diff --git a/web/default/src/features/keys/components/api-key-mode-picker-dialog.tsx b/web/default/src/features/keys/components/api-key-mode-picker-dialog.tsx new file mode 100644 index 000000000000..ebf7539ffd48 --- /dev/null +++ b/web/default/src/features/keys/components/api-key-mode-picker-dialog.tsx @@ -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 . + +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 ( + + + + {t('Create API Key')} + + {t('Choose how you want to set this key up.')} + + +
+ } + 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')} + /> + } + 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')} + /> +
+
+
+ ) +} + +function ModeCard({ + icon, + title, + description, + footnote, + badge, + onClick, +}: { + icon: React.ReactNode + title: string + description: string + footnote?: string + badge?: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/web/default/src/features/keys/components/api-keys-columns.tsx b/web/default/src/features/keys/components/api-keys-columns.tsx index cddbea22ef89..dbfe8c56373a 100644 --- a/web/default/src/features/keys/components/api-keys-columns.tsx +++ b/web/default/src/features/keys/components/api-keys-columns.tsx @@ -98,6 +98,9 @@ function useGroupRatios(): Record { export function useApiKeysColumns(): ColumnDef[] { const { t } = useTranslation() const groupRatios = useGroupRatios() + const isAdmin = useAuthStore((s) => + Boolean(s.auth.user?.role && s.auth.user.role >= 10) + ) return [ { id: 'select', @@ -220,48 +223,56 @@ export function useApiKeysColumns(): ColumnDef[] { }, meta: { label: t('Quota') }, }, - { - accessorKey: 'group', - header: ({ column }) => ( - - ), - 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 }) => ( + + ), + 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 ( - - - } - > - - {apiKey.cross_group_retry && ( - <> - · - - {t('Cross-group')} - - - )} - - - - {t( - 'Automatically selects the best available group with circuit breaker mechanism' - )} - - - - ) - } - return - }, - meta: { label: t('Group'), mobileHidden: true }, - }, + if (group === 'auto') { + return ( + + + } + > + + {apiKey.cross_group_retry && ( + <> + · + + {t('Cross-group')} + + + )} + + + + {t( + 'Automatically selects the best available group with circuit breaker mechanism' + )} + + + + ) + } + return + }, + meta: { label: t('Group'), mobileHidden: true }, + } satisfies ColumnDef, + ] + : []), { id: 'model_limits', accessorKey: 'model_limits', diff --git a/web/default/src/features/keys/components/api-keys-dialogs.tsx b/web/default/src/features/keys/components/api-keys-dialogs.tsx index b3b8bb8b4e6f..fddc9a9e68a0 100644 --- a/web/default/src/features/keys/components/api-keys-dialogs.tsx +++ b/web/default/src/features/keys/components/api-keys-dialogs.tsx @@ -17,6 +17,8 @@ along with this program. If not, see . 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' @@ -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 ( <> + !isOpen && setOpen(null)} + onPick={handlePickMode} + /> !isOpen && setOpen(null)} diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx index 3dc51c4a6db3..7a0943085bcc 100644 --- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx +++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx @@ -59,11 +59,10 @@ import { SheetTitle, } from '@/components/ui/sheet' import { Switch } from '@/components/ui/switch' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Textarea } from '@/components/ui/textarea' import { DateTimePicker } from '@/components/datetime-picker' import { MultiSelect } from '@/components/multi-select' -import { createApiKey, updateApiKey, getApiKey } from '../api' +import { createApiKey, updateApiKey, getApiKey, getApiKeyPurposes } from '../api' import { ERROR_MESSAGES, SUCCESS_MESSAGES, DEFAULT_GROUP } from '../constants' import { apiKeyFormSchema, @@ -72,15 +71,23 @@ import { detectAdvancedMode, getApiKeyFormDefaultValues, loadPreferredMode, - savePreferredMode, transformFormDataToPayload, transformApiKeyToFormDefaults, } from '../lib' -import { type ApiKey } from '../types' +import { + type ApiKey, + type SimpleBrand, + type SimplePriceTierId, + type SimplePurposeId, +} from '../types' +import { ApiKeyBrandFilter } from './api-key-brand-filter' import { ApiKeyGroupCombobox, type ApiKeyGroupOption, } from './api-key-group-combobox' +import { ApiKeyPriceTier } from './api-key-price-tier' +import { ApiKeyPurposePicker } from './api-key-purpose-picker' +import { ApiKeySuccessDialog } from './api-key-success-dialog' import { useApiKeys } from './api-keys-provider' type ApiKeyMutateDrawerProps = { @@ -126,7 +133,7 @@ export function ApiKeysMutateDrawer({ }: ApiKeyMutateDrawerProps) { const { t } = useTranslation() const isUpdate = !!currentRow - const { triggerRefresh } = useApiKeys() + const { triggerRefresh, setOpen: setApiKeysDialog } = useApiKeys() const { status } = useStatus() const [isSubmitting, setIsSubmitting] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) @@ -156,6 +163,24 @@ export function ApiKeysMutateDrawer({ staleTime: 5 * 60 * 1000, }) + // Simple-mode picker metadata (6 purpose cards + 4 price tiers). + const { data: purposesData, isLoading: purposesLoading } = useQuery({ + queryKey: ['api-key-purposes'], + queryFn: getApiKeyPurposes, + staleTime: 10 * 60 * 1000, + }) + const purposes = purposesData?.data?.purposes ?? [] + const priceTiers = purposesData?.data?.price_tiers ?? [] + const defaultPriceTier = purposesData?.data?.default_price_tier ?? 'standard' + + // Once a Simple-mode key is created, surface key + base URL + client links + // in the success dialog (PRD §4.2). Lives outside the drawer so it + // survives the drawer closing. + const [createdKey, setCreatedKey] = useState(null) + const [createdPurpose, setCreatedPurpose] = useState< + SimplePurposeId | undefined + >(undefined) + const models = modelsData?.data || [] const groupsRaw = groupsData?.data || {} const groups: ApiKeyGroupOption[] = Object.entries(groupsRaw).map( @@ -205,12 +230,12 @@ export function ApiKeysMutateDrawer({ } }, [open, isUpdate, currentRow, form, defaultUseAutoGroup, userDefaultGroup]) - const handleModeChange = (next: string) => { - const nextMode: CreateMode = next === 'advanced' ? 'advanced' : 'simple' - setMode(nextMode) - // Only persist the preference for the create flow — edits inherit the - // smart-detected mode from the token's actual state. - if (!isUpdate) savePreferredMode(nextMode) + // Re-open the mode-picker dialog so the user can switch modes mid-create. + // The drawer stays open underneath; when the user picks a mode the picker + // closes itself and our form-init useEffect re-runs against the new + // preferred mode (since we close + reopen 'create'). + const handleChangeMode = () => { + setApiKeysDialog('mode-picker') } const onSubmit = async (data: ApiKeyFormValues) => { @@ -234,17 +259,19 @@ export function ApiKeysMutateDrawer({ // Create mode - handle batch creation const count = data.tokenCount || 1 let successCount = 0 + let firstKey: string | null = null for (let i = 0; i < count; i++) { const result = await createApiKey({ ...basePayload, name: - i === 0 && data.name - ? data.name - : `${data.name || 'default'}-${Math.random().toString(36).slice(2, 8)}`, + i === 0 && basePayload.name + ? basePayload.name + : `${basePayload.name || 'default'}-${Math.random().toString(36).slice(2, 8)}`, }) if (result.success) { successCount++ + if (i === 0 && result.data?.key) firstKey = result.data.key } else { toast.error(result.message || t(ERROR_MESSAGES.CREATE_FAILED)) break @@ -252,13 +279,22 @@ export function ApiKeysMutateDrawer({ } if (successCount > 0) { - toast.success( - t('Successfully created {{count}} API Key(s)', { - count: successCount, - }) - ) - onOpenChange(false) triggerRefresh() + // Simple + single-key create → reveal key + base url + client + // tutorial chips in the success dialog (PRD §4.2). Batch creates + // skip the dialog because power users don't need it. + if (mode === 'simple' && count === 1 && firstKey) { + setCreatedKey(firstKey) + setCreatedPurpose(data.simple_purpose as SimplePurposeId | undefined) + onOpenChange(false) + } else { + toast.success( + t('Successfully created {{count}} API Key(s)', { + count: successCount, + }) + ) + onOpenChange(false) + } } } } catch (_error) { @@ -291,6 +327,7 @@ export function ApiKeysMutateDrawer({ : t('Enter quota in {{currency}}', { currency: currencyLabel }) const selectedGroup = form.watch('group') const unlimitedQuota = form.watch('unlimited_quota') + const simplePurpose = form.watch('simple_purpose') return ( - - {isUpdate ? t('Update API Key') : t('Create API Key')} - - - {isUpdate - ? t('Update the API key by providing necessary info.') - : t('Add a new API key by providing necessary info.')}{' '} - {t("Click save when you're done.")} - +
+
+ + {isUpdate ? t('Update API Key') : t('Create API Key')} + + + {isUpdate + ? t('Update the API key by providing necessary info.') + : mode === 'simple' + ? t( + 'Simple mode — defaults to unlimited models and any IP.' + ) + : t('Add a new API key by providing necessary info.')} + +
+ {!isUpdate && ( + + )} +
- {/* DeepRouter Simple ↔ Advanced mode toggle. Simple hides the - * group / cross_group_retry / tokenCount / model_limits / - * allow_ips fields so a first-time user sees just "name + - * expiry + quota" — closer to OpenAI's 2-field create flow. - * Hidden values are still kept in react-hook-form state, so a - * mode toggle is purely visual (no silent data loss). */} - - - {t('Simple')} - {t('Advanced')} - - {mode === 'simple' && ( -

- {t( - 'Simple key creation — defaults to unlimited models and any IP. Switch to Advanced to restrict.' + {/* Mode is chosen via the mode-picker Dialog before the drawer + * opens (see ApiKeysDialogs). The "Switch to ..." link in the + * SheetHeader re-opens the picker mid-flow. Simple shows a + * 6-card purpose picker (PRD §4.1); Advanced shows the full + * form. Hidden values stay in react-hook-form state, so toggle + * is purely visual (no silent data loss). */} + {mode === 'simple' && ( + <> + - )} - + icon={KeyRound} + > + ( + + + + + + + )} + /> + + + ( + + + { + field.onChange(v) + if ( + v === 'all' && + !form.getValues('simple_price_tier') + ) { + form.setValue( + 'simple_price_tier', + defaultPriceTier as SimplePriceTierId + ) + } + const next = purposes.find((p) => p.id === v) + const currentBrand = + form.getValues('simple_brand') + if ( + currentBrand && + next && + !next.available_brands.includes( + currentBrand as SimpleBrand + ) + ) { + form.setValue('simple_brand', undefined) + } + }} + /> + + + + )} + /> + + {simplePurpose && + simplePurpose !== 'all' && + (() => { + const card = purposes.find((p) => p.id === simplePurpose) + if (!card || card.available_brands.length === 0) + return null + return ( + ( + + + {t('Prefer a provider? (optional)')} + + + + + + + )} + /> + ) + })()} + + {simplePurpose === 'all' && ( + ( + + + + + + + )} + /> + )} + + + )} + + {mode === 'advanced' && ( + <> - {mode === 'advanced' && ( - ( @@ -381,9 +550,8 @@ export function ApiKeysMutateDrawer({ )} /> - )} - {mode === 'advanced' && selectedGroup === 'auto' && ( + {selectedGroup === 'auto' && ( - {!isUpdate && mode === 'advanced' && ( + {!isUpdate && ( - {mode === 'advanced' && ( + + ( + + + ({ label: m, value: m }))} + selected={field.value} + onChange={field.onChange} + placeholder={t( + 'Search and pick models (e.g. deepseek-v3, gpt-4o, claude-sonnet-4-7) — empty = all' + )} + /> + + + {field.value.length > 0 + ? t('{{count}} model(s) selected', { + count: field.value.length, + }) + : t( + 'All available models. Clients can call any model your channels expose.' + )} + + + + )} + /> + +

- ( - - {t('Model Limits')} - - ({ - label: m, - value: m, - }))} - selected={field.value} - onChange={field.onChange} - placeholder={t( - 'Select models (empty for allow all)' - )} - /> - - - {t('Limit which models can be used with this key')} - - - - )} - /> -
+ )} @@ -663,10 +841,23 @@ export function ApiKeysMutateDrawer({ disabled={isSubmitting} className='w-full sm:w-auto' > - {isSubmitting ? t('Saving...') : t('Save changes')} + {isSubmitting + ? t('Saving...') + : mode === 'simple' && !isUpdate + ? t('Create key') + : t('Save changes')} + { + setCreatedKey(null) + setCreatedPurpose(undefined) + }} + />
) } diff --git a/web/default/src/features/keys/components/api-keys-primary-buttons.tsx b/web/default/src/features/keys/components/api-keys-primary-buttons.tsx index 25cd2ab301a8..a245419ac206 100644 --- a/web/default/src/features/keys/components/api-keys-primary-buttons.tsx +++ b/web/default/src/features/keys/components/api-keys-primary-buttons.tsx @@ -26,7 +26,7 @@ export function ApiKeysPrimaryButtons() { const { setOpen } = useApiKeys() return (
- diff --git a/web/default/src/features/keys/components/api-keys-table.tsx b/web/default/src/features/keys/components/api-keys-table.tsx index 1cfb49cd755d..477e8214e277 100644 --- a/web/default/src/features/keys/components/api-keys-table.tsx +++ b/web/default/src/features/keys/components/api-keys-table.tsx @@ -173,7 +173,7 @@ function ApiKeysMobileList({ export function ApiKeysTable() { const { t } = useTranslation() const { refreshTrigger, setOpen } = useApiKeys() - const handleCreate = () => setOpen('create') + const handleCreate = () => setOpen('mode-picker') const columns = useApiKeysColumns() const [rowSelection, setRowSelection] = useState({}) const [sorting, setSorting] = useState([]) diff --git a/web/default/src/features/keys/lib/api-key-purposes-fallback.ts b/web/default/src/features/keys/lib/api-key-purposes-fallback.ts new file mode 100644 index 000000000000..b43ef30f796c --- /dev/null +++ b/web/default/src/features/keys/lib/api-key-purposes-fallback.ts @@ -0,0 +1,160 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { + ApiKeyPurposesResponse, + PriceTierSummary, + PurposeSummary, +} from '../types' + +// Hardcoded mirror of setting/alias_setting/seed/aliases.yaml. Used by the +// Create API Key drawer when the backend hasn't shipped the new endpoint +// yet (binary needs rebuild for /api/user/self/api-key-purposes to exist). +// Once the backend is live this falls through; in production the YAML is +// the authoritative source. + +const ZH = (() => { + if (typeof navigator === 'undefined') return false + return navigator.language?.toLowerCase().startsWith('zh') +})() + +function pick(en: T, zh: T): T { + return ZH ? zh : en +} + +export const FALLBACK_PURPOSES: PurposeSummary[] = [ + { + id: 'chat', + label: pick('Chat / Writing', '聊天 / 写作'), + icon: '💬', + desc: pick('Translation, creation, dialogue', '翻译、创作、对话'), + human_estimate: pick('≈ ¥1 for 100 chats', '约 ¥1 聊 100 句'), + price_range: '¥0.01 – 0.10 / 1K tokens', + recommended_brand: 'claude', + available_brands: ['claude', 'openai', 'gemini', 'deepseek'], + }, + { + id: 'coding', + label: pick('Coding', '编程 / Coding'), + icon: '💻', + desc: pick( + 'Code completion, AI pair programming, refactor', + '代码补全、AI 编程、重构' + ), + human_estimate: pick('≈ ¥1 for 50 code edits', '约 ¥1 改 50 段代码'), + price_range: '¥0.02 – 0.15 / 1K tokens', + recommended_brand: 'claude', + available_brands: ['claude', 'openai', 'deepseek'], + }, + { + id: 'image', + label: pick('Image generation', '图像生成'), + icon: '🎨', + desc: pick('Text-to-image, image edit, image variation', '文生图、改图、变体'), + human_estimate: pick('≈ ¥10 for 20 images', '约 ¥10 生成 20 张'), + price_range: '¥0.3 – 1.0 / image', + recommended_brand: 'openai', + available_brands: ['openai'], + }, + { + id: 'video', + label: pick('Video generation', '视频生成'), + icon: '🎬', + desc: pick('Text-to-video, video edit', '文生视频、改视频'), + human_estimate: pick('≈ ¥50 for 10 short clips', '约 ¥50 生成 10 段短视频'), + price_range: '¥3 – 10 / clip', + recommended_brand: 'openai', + available_brands: [], + }, + { + id: 'voice', + label: pick('Voice / TTS / Transcription', '语音 / TTS / 转写'), + icon: '🎙️', + desc: pick('Transcription, voice cloning, text-to-speech', '转写、配音、克隆'), + human_estimate: pick('≈ ¥10 for 200 minutes', '约 ¥10 转写 200 分钟'), + price_range: '¥0.05 / minute', + recommended_brand: 'openai', + available_brands: [], + }, + { + id: 'all', + label: pick('Everything (Auto)', '全部 (Auto)'), + icon: '⚡', + desc: pick( + 'Auto-route by task. Set a price cap below.', + '按任务自动路由,可设置价格上限' + ), + human_estimate: pick('Billed per actual model', '按实际使用模型计费'), + price_range: 'Variable', + recommended_brand: '', + available_brands: ['claude', 'openai', 'gemini', 'deepseek'], + }, +] + +export const FALLBACK_PRICE_TIERS: PriceTierSummary[] = [ + { + id: 'economy', + label: pick('Economy', '经济档'), + desc: pick( + 'Cheap & fast only, never Opus / o1 / Ultra', + '只走便宜模型,绝不上 Opus/o1' + ), + price_range: '¥0.001 – 0.02 / 1K', + is_default: false, + requires_confirm: false, + }, + { + id: 'standard', + label: pick('Standard', '标准档'), + desc: pick( + 'Default. Covers most tasks, avoids ultra-premium models.', + '默认,覆盖大部分场景,避免顶配' + ), + price_range: '¥0.001 – 0.10 / 1K', + is_default: true, + requires_confirm: false, + }, + { + id: 'premium', + label: pick('Premium', '高级档'), + desc: pick( + 'Includes Claude Opus and GPT-4 family', + '含 Claude Opus、GPT-4 系列' + ), + price_range: '¥0.001 – 0.30 / 1K', + is_default: false, + requires_confirm: false, + }, + { + id: 'ultra', + label: pick('Ultra', '顶配档'), + desc: pick( + 'No cap. Includes o1 / Opus / Gemini Ultra. Confirm required.', + '无上限,含 o1 / Opus / Gemini Ultra,需确认' + ), + price_range: 'Uncapped', + is_default: false, + requires_confirm: true, + }, +] + +export const FALLBACK_API_KEY_PURPOSES: ApiKeyPurposesResponse = { + purposes: FALLBACK_PURPOSES, + price_tiers: FALLBACK_PRICE_TIERS, + default_price_tier: 'standard', +} diff --git a/web/default/src/features/keys/types.ts b/web/default/src/features/keys/types.ts index 81bed2cedfaf..cb7620f25479 100644 --- a/web/default/src/features/keys/types.ts +++ b/web/default/src/features/keys/types.ts @@ -150,6 +150,7 @@ export interface ApiKeyPurposesResponse { // ============================================================================ export type ApiKeysDialogType = + | 'mode-picker' | 'create' | 'update' | 'delete' diff --git a/web/default/src/hooks/use-api-key-form-mode.ts b/web/default/src/hooks/use-api-key-form-mode.ts deleted file mode 100644 index aae8628a61ff..000000000000 --- a/web/default/src/hooks/use-api-key-form-mode.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* -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 . - -For commercial licensing, please contact support@quantumnous.com -*/ -import { useCallback, useEffect, useState } from 'react' - -export type ApiKeyFormMode = 'simple' | 'advanced' - -const STORAGE_KEY = 'api_key_form_mode' -const DEFAULT_MODE: ApiKeyFormMode = 'simple' - -function readStoredMode(): ApiKeyFormMode { - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (raw === 'simple' || raw === 'advanced') return raw - } catch { - /* ignore */ - } - return DEFAULT_MODE -} - -function writeStoredMode(mode: ApiKeyFormMode) { - try { - localStorage.setItem(STORAGE_KEY, mode) - } catch { - /* ignore */ - } -} - -/** - * API Key create-drawer mode toggle persisted in localStorage. Defaults to - * 'simple' for first-time users (PRD docs/tasks/api-key-simple-advanced-prd.md - * §3.1) and stays in sync across tabs via the storage event. - */ -export function useApiKeyFormMode(): [ - ApiKeyFormMode, - (mode: ApiKeyFormMode) => void, -] { - const [mode, setModeState] = useState(() => readStoredMode()) - - const setMode = useCallback((next: ApiKeyFormMode) => { - setModeState(next) - writeStoredMode(next) - }, []) - - useEffect(() => { - const handleStorage = (e: StorageEvent) => { - if (e.key !== STORAGE_KEY) return - const next = e.newValue - if (next === 'simple' || next === 'advanced') setModeState(next) - } - window.addEventListener('storage', handleStorage) - return () => window.removeEventListener('storage', handleStorage) - }, []) - - return [mode, setMode] -} diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index b5b1ed8168f5..f2f7c268623f 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -251,6 +251,7 @@ "All Types": "All Types", "All Vendors": "All Vendors", "All Your AI Models": "All Your AI Models", + "All available models. Clients can call any model your channels expose.": "All available models. Clients can call any model your channels expose.", "All categories": "All categories", "All conditions must match before this tier is used.": "All conditions must match before this tier is used.", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "All edits are overwrite operations. Leave fields empty to keep current values unchanged.", @@ -293,6 +294,7 @@ "Allowed": "Allowed", "Allowed Origins": "Allowed Origins", "Allowed Ports": "Allowed Ports", + "Allowed models": "Allowed models", "Already have an account?": "Already have an account?", "Always matches (default tier).": "Always matches (default tier).", "Amount": "Amount", @@ -2732,6 +2734,7 @@ "Pick a date": "Pick a date", "Pick the task — we route to the best model for you.": "Pick the task — we route to the best model for you.", "Pick what you will use this key for. We handle the rest.": "Pick what you will use this key for. We handle the rest.", + "Pick which models this key can call. Leave empty to allow every model your account has access to.": "Pick which models this key can call. Leave empty to allow every model your account has access to.", "Pick your client and follow the setup guide:": "Pick your client and follow the setup guide:", "Ping Interval (seconds)": "Ping Interval (seconds)", "Plan": "Plan", @@ -3269,6 +3272,7 @@ "Scope": "Scope", "Scopes": "Scopes", "Search": "Search", + "Search and pick models (e.g. deepseek-v3, gpt-4o, claude-sonnet-4-7) — empty = all": "Search and pick models (e.g. deepseek-v3, gpt-4o, claude-sonnet-4-7) — empty = all", "Search by name or URL...": "Search by name or URL...", "Search by order number...": "Search by order number...", "Search channel type...": "Search channel type...", @@ -4460,6 +4464,7 @@ "{{count}} log entries removed.": "{{count}} log entries removed.", "{{count}} minutes ago": "{{count}} minutes ago", "{{count}} model(s)": "{{count}} model(s)", + "{{count}} model(s) selected": "{{count}} model(s) selected", "{{count}} models": "{{count}} models", "{{count}} months ago": "{{count}} months ago", "{{count}} override": "{{count}} override", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 58a0cba74f52..8e2590c31a51 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -251,6 +251,7 @@ "All Types": "所有类型", "All Vendors": "所有供应商", "All Your AI Models": "所有 AI 模型", + "All available models. Clients can call any model your channels expose.": "全部可用模型。客户端可调用账号下任意已接入的模型。", "All categories": "全部分类", "All conditions must match before this tier is used.": "所有条件都满足后才会使用该档位。", "All edits are overwrite operations. Leave fields empty to keep current values unchanged.": "所有编辑都是覆盖操作。留空字段将保持当前值不变。", @@ -293,6 +294,7 @@ "Allowed": "允许", "Allowed Origins": "允许的 Origins", "Allowed Ports": "允许的端口", + "Allowed models": "允许的模型", "Already have an account?": "已有账户?", "Always matches (default tier).": "始终匹配(默认档位)。", "Amount": "金额", @@ -2732,6 +2734,7 @@ "Pick a date": "选择日期", "Pick the task — we route to the best model for you.": "选择任务类型,我们自动路由到最合适的模型。", "Pick what you will use this key for. We handle the rest.": "选择你要用这把 Key 做什么,剩下的我们来处理。", + "Pick which models this key can call. Leave empty to allow every model your account has access to.": "选择这把 Key 可以调用的模型。留空 = 允许账号能用的全部模型。", "Pick your client and follow the setup guide:": "选择你的客户端,跟随设置指引:", "Ping Interval (seconds)": "Ping 间隔(秒)", "Plan": "套餐", @@ -3269,6 +3272,7 @@ "Scope": "作用域", "Scopes": "作用域", "Search": "搜索", + "Search and pick models (e.g. deepseek-v3, gpt-4o, claude-sonnet-4-7) — empty = all": "搜索并选择模型(例如 deepseek-v3、gpt-4o、claude-sonnet-4-7),留空则全部允许", "Search by name or URL...": "按名称或 URL 搜索...", "Search by order number...": "按订单号搜索...", "Search channel type...": "搜索渠道类型...", @@ -4460,6 +4464,7 @@ "{{count}} log entries removed.": "已删除 {{count}} 条日志。", "{{count}} minutes ago": "{{count}} 分钟前", "{{count}} model(s)": "{{count}} 个模型", + "{{count}} model(s) selected": "已选 {{count}} 个模型", "{{count}} models": "{{count}} 个模型", "{{count}} months ago": "{{count}} 个月前", "{{count}} override": "{{count}} 个覆盖",