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
73 changes: 73 additions & 0 deletions web/default/src/components/model-group-selector-utils.ts
Original file line number Diff line number Diff line change
@@ -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)
})
}
59 changes: 59 additions & 0 deletions web/default/src/components/model-group-selector.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
50 changes: 40 additions & 10 deletions web/default/src/components/model-group-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -181,7 +183,14 @@ export const ModelSelector: React.FC<ModelSelectorProps> = 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<string, ModelOption[]>
}

const query = searchQuery.toLowerCase()
const filtered: Record<string, ModelOption[]> = {}
Expand All @@ -194,7 +203,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
m.description?.toLowerCase().includes(query)
)
if (matches.length > 0) {
filtered[category] = matches
filtered[category] = sortModelOptionsForSearch(matches)
}
})

Expand Down Expand Up @@ -267,11 +276,32 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
<div className='flex min-w-0 flex-1 items-center gap-1'>
<div
className={cn(
'truncate font-medium',
'flex min-w-0 flex-wrap items-center gap-1 font-medium',
isMobile ? 'text-sm' : 'text-[11px]'
)}
>
<span className='inline'>{model.label}</span>
<span className='min-w-0 truncate'>
{model.label}
</span>
{model.promotions?.map((promotion) => (
<Badge
key={promotion}
variant='outline'
className={cn(
'shrink-0 border px-1.5 py-0 text-[9px] leading-4 font-semibold whitespace-nowrap',
promotion === 'free' &&
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-400/10 dark:text-emerald-300',
promotion === 'limited' &&
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-300',
promotion === 'hot' &&
'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-400/30 dark:bg-rose-400/10 dark:text-rose-300',
promotion === 'new' &&
'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-400/30 dark:bg-sky-400/10 dark:text-sky-300'
)}
>
{getModelPromotionLabel(promotion, t)}
</Badge>
))}
</div>
<Check
className={cn(
Expand Down
32 changes: 32 additions & 0 deletions web/default/src/features/playground/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,38 @@ export async function getUserModels(group?: string): Promise<string[]> {
.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<string, { plg?: number | string } | undefined>
}
}

type PlaygroundPricingResponse = {
success?: boolean
data?: PlaygroundModelPricing[]
display_pricing?: Record<string, PlaygroundModelPricing['display_pricing']>
}

export async function getPlaygroundModelPricing(): Promise<
PlaygroundModelPricing[]
> {
const res = await api.get<PlaygroundPricingResponse>('/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
*/
Expand Down
62 changes: 59 additions & 3 deletions web/default/src/features/playground/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
() =>
Expand Down
5 changes: 5 additions & 0 deletions web/default/src/features/playground/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

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'

Expand Down Expand Up @@ -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 {
Expand Down