diff --git a/web/default/src/components/model-group-selector-utils.ts b/web/default/src/components/model-group-selector-utils.ts new file mode 100644 index 000000000000..15a975fcc8e2 --- /dev/null +++ b/web/default/src/components/model-group-selector-utils.ts @@ -0,0 +1,73 @@ +/* +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. +*/ +import type { ModelPromotion } from '@/features/available-models/lib/model-promotions' + +export type ModelSelectorOption = { + label: string + value: string + category?: string + description?: string + promotions?: ModelPromotion[] + price?: number + releaseDate?: string + featuredOrder?: number +} + +function promotionPriority( + model: ModelSelectorOption, + order: readonly ModelPromotion[] +) { + const priority = order.findIndex((promotion) => + model.promotions?.includes(promotion) + ) + return priority === -1 ? order.length : priority +} + +function compareSearchMetadata(a: ModelSelectorOption, b: ModelSelectorOption) { + const aRelease = a.releaseDate ? Date.parse(a.releaseDate) : Number.NaN + const bRelease = b.releaseDate ? Date.parse(b.releaseDate) : Number.NaN + const aHasRelease = Number.isFinite(aRelease) + const bHasRelease = Number.isFinite(bRelease) + if (aHasRelease || bHasRelease) { + if (!aHasRelease) return 1 + if (!bHasRelease) return -1 + if (aRelease !== bRelease) return bRelease - aRelease + } + + const aPrice = a.price ?? Number.POSITIVE_INFINITY + const bPrice = b.price ?? Number.POSITIVE_INFINITY + if (aPrice !== bPrice) return aPrice - bPrice + + const aFeatured = a.featuredOrder ?? Number.POSITIVE_INFINITY + const bFeatured = b.featuredOrder ?? Number.POSITIVE_INFINITY + if (aFeatured !== bFeatured) return aFeatured - bFeatured + + return a.label.localeCompare(b.label, undefined, { numeric: true }) +} + +/** Match the Available Models list: free, discounted, then hot campaigns. */ +export function sortModelOptionsForDefault( + models: readonly ModelSelectorOption[] +): ModelSelectorOption[] { + const order: ModelPromotion[] = ['free', 'limited', 'hot'] + return [...models].sort( + (a, b) => promotionPriority(a, order) - promotionPriority(b, order) + ) +} + +/** Search ordering: new, free, discounted, hot, then the remaining models. */ +export function sortModelOptionsForSearch( + models: readonly ModelSelectorOption[] +): ModelSelectorOption[] { + const order: ModelPromotion[] = ['new', 'free', 'limited', 'hot'] + return [...models].sort((a, b) => { + const rankDiff = promotionPriority(a, order) - promotionPriority(b, order) + return rankDiff || compareSearchMetadata(a, b) + }) +} diff --git a/web/default/src/components/model-group-selector.test.ts b/web/default/src/components/model-group-selector.test.ts new file mode 100644 index 000000000000..0f74e52c07ae --- /dev/null +++ b/web/default/src/components/model-group-selector.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test' +import { + sortModelOptionsForDefault, + sortModelOptionsForSearch, +} from './model-group-selector-utils' + +test('default ordering matches the model list campaign order', () => { + const models = [ + { label: 'plain', value: 'plain' }, + { label: 'hot', value: 'hot', promotions: ['hot' as const] }, + { label: 'discount', value: 'discount', promotions: ['limited' as const] }, + { label: 'free', value: 'free', promotions: ['free' as const] }, + ] + + expect( + sortModelOptionsForDefault(models).map((model) => model.value) + ).toEqual(['free', 'discount', 'hot', 'plain']) +}) + +describe('sortModelOptionsForSearch', () => { + test('sorts newest releases first, then cheapest prices', () => { + const models = [ + { + label: 'expensive-new', + value: 'expensive-new', + releaseDate: '2026-08-20', + price: 1, + }, + { + label: 'cheap-old', + value: 'cheap-old', + releaseDate: '2026-01-01', + price: 0.01, + }, + { + label: 'cheap-new', + value: 'cheap-new', + releaseDate: '2026-08-20', + price: 0.01, + }, + ] + + expect( + sortModelOptionsForSearch(models).map((model) => model.value) + ).toEqual(['cheap-new', 'expensive-new', 'cheap-old']) + }) + + test('puts new promotions first when release metadata is missing', () => { + const models = [ + { label: 'zeta', value: 'zeta', price: 0.1 }, + { label: 'alpha', value: 'alpha', price: 0.1 }, + { label: 'new', value: 'new', promotions: ['new' as const], price: 10 }, + ] + + expect( + sortModelOptionsForSearch(models).map((model) => model.value) + ).toEqual(['new', 'alpha', 'zeta']) + }) +}) diff --git a/web/default/src/components/model-group-selector.tsx b/web/default/src/components/model-group-selector.tsx index cbead6511c36..e19765e48076 100644 --- a/web/default/src/components/model-group-selector.tsx +++ b/web/default/src/components/model-group-selector.tsx @@ -21,6 +21,7 @@ import { ChevronsUpDown, Check, CpuIcon, LayersIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' import { useIsMobile } from '@/hooks/use-mobile' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Command, @@ -42,13 +43,14 @@ import { PopoverContent, PopoverTrigger, } from '@/components/ui/popover' +import { getModelPromotionLabel } from '@/features/available-models/lib/model-promotions' +import { + sortModelOptionsForDefault, + sortModelOptionsForSearch, + type ModelSelectorOption, +} from './model-group-selector-utils' -interface ModelOption { - label: string - value: string - category?: string - description?: string -} +type ModelOption = ModelSelectorOption interface GroupOption { label: string @@ -181,7 +183,14 @@ export const ModelSelector: React.FC = React.memo( // Filter models by search query const filteredModels = useMemo(() => { - if (!searchQuery.trim()) return groupedModels + if (!searchQuery.trim()) { + return Object.fromEntries( + Object.entries(groupedModels).map(([category, categoryModels]) => [ + category, + sortModelOptionsForDefault(categoryModels), + ]) + ) as Record + } const query = searchQuery.toLowerCase() const filtered: Record = {} @@ -194,7 +203,7 @@ export const ModelSelector: React.FC = React.memo( m.description?.toLowerCase().includes(query) ) if (matches.length > 0) { - filtered[category] = matches + filtered[category] = sortModelOptionsForSearch(matches) } }) @@ -267,11 +276,32 @@ export const ModelSelector: React.FC = React.memo(
- {model.label} + + {model.label} + + {model.promotions?.map((promotion) => ( + + {getModelPromotionLabel(promotion, t)} + + ))}
{ .map((model: string) => model.trim()) .filter(Boolean) } + +export type PlaygroundModelPricing = { + model_name: string + model_price?: number + featured_order?: number + release_date?: string + directory_metadata?: { released_at?: string } + display_pricing?: { + prices?: Record + } +} + +type PlaygroundPricingResponse = { + success?: boolean + data?: PlaygroundModelPricing[] + display_pricing?: Record +} + +export async function getPlaygroundModelPricing(): Promise< + PlaygroundModelPricing[] +> { + const res = await api.get('/api/website/pricing', { + params: { group: 'plg' }, + }) + const payload = res.data + if (!payload?.success || !Array.isArray(payload.data)) return [] + return payload.data.map((model) => ({ + ...model, + display_pricing: + model.display_pricing ?? payload.display_pricing?.[model.model_name], + })) +} /** * Get user groups */ diff --git a/web/default/src/features/playground/index.tsx b/web/default/src/features/playground/index.tsx index 5e09a7cb11f3..c0d9ac44ccce 100644 --- a/web/default/src/features/playground/index.tsx +++ b/web/default/src/features/playground/index.tsx @@ -43,7 +43,14 @@ import { import { trackAdsFunnelEvent } from '@/lib/analytics/gtag' import { useCanUseGroups } from '@/hooks/use-enterprise' import { useSystemConfig } from '@/hooks/use-system-config' -import { getPlaygroundConversation, getUserModels, getUserGroups } from './api' +import { getModelPromotions } from '@/features/available-models/lib/model-promotions' +import { + getPlaygroundConversation, + getPlaygroundModelPricing, + getUserModels, + getUserGroups, + type PlaygroundModelPricing, +} from './api' import { PlaygroundChat } from './components/playground-chat' import { PlaygroundConversationList } from './components/playground-conversation-list' import { FirstRunWelcome, GetKeyCard } from './components/playground-first-run' @@ -89,6 +96,29 @@ import type { Message as MessageType, PlaygroundAttachment } from './types' // PLG users are always pinned to the single `plg` group. const PLG_GROUP = 'plg' +function getPublicModelPrice(model: PlaygroundModelPricing | undefined) { + if (!model) return undefined + const prices = model.display_pricing?.prices ?? {} + for (const dimension of [ + 'input', + 'request', + 'second', + 'image', + 'audio_input', + 'output', + ]) { + const price = Number(prices[dimension]?.plg) + if (Number.isFinite(price) && price >= 0) return price + } + if ( + typeof model.model_price === 'number' && + Number.isFinite(model.model_price) + ) { + return model.model_price + } + return undefined +} + export function Playground({ firstRun: firstRunFromUrl = false, initialModel, @@ -252,12 +282,38 @@ export function Playground({ }, }) + const { data: publicModelPricingData } = useQuery({ + queryKey: ['playground-public-model-pricing'], + queryFn: getPlaygroundModelPricing, + staleTime: 5 * 60 * 1000, + retry: false, + }) + + const publicModelPricing = useMemo( + () => + new Map( + (publicModelPricingData ?? []).map((model) => [model.model_name, model]) + ), + [publicModelPricingData] + ) + const playgroundModelsData = useMemo( () => (availableModelsData ?? []) .filter(isSupportedPlaygroundModelName) - .map((model) => ({ label: model, value: model })), - [availableModelsData] + .map((model) => { + const pricing = publicModelPricing.get(model) + return { + label: model, + value: model, + promotions: getModelPromotions(model), + price: getPublicModelPrice(pricing), + releaseDate: + pricing?.directory_metadata?.released_at ?? pricing?.release_date, + featuredOrder: pricing?.featured_order, + } + }), + [availableModelsData, publicModelPricing] ) const chatModelsData = useMemo( () => diff --git a/web/default/src/features/playground/types.ts b/web/default/src/features/playground/types.ts index 740fd416a173..7ef9c2871265 100644 --- a/web/default/src/features/playground/types.ts +++ b/web/default/src/features/playground/types.ts @@ -16,6 +16,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ +import type { ModelPromotion } from '@/features/available-models/lib/model-promotions' // Message types export type MessageRole = 'user' | 'assistant' | 'system' @@ -235,6 +236,10 @@ export interface PlaygroundRecordPayload { export interface ModelOption { label: string value: string + promotions?: ModelPromotion[] + price?: number + releaseDate?: string + featuredOrder?: number } export interface GroupOption {