feat(playground): add chat parameter settings panel

- expose playground controls for sampling, penalty, token, and seed parameters.
- reuse the existing persisted config and parameter-enabled state so requests only send enabled values.
- add localized parameter copy and keep the panel scrollable on short screens.
This commit is contained in:
QuentinHsu
2026-07-09 22:10:03 +08:00
parent 4645ad9df5
commit 928b475075
14 changed files with 569 additions and 2 deletions
@@ -43,17 +43,33 @@ import {
getAttachmentActionNotice,
getSearchActionNotice,
} from '../../lib'
import type { ParameterEnabled, PlaygroundConfig } from '../../types'
import { PlaygroundParameterPanel } from './playground-parameter-panel'
type PlaygroundInputToolsProps = {
config: PlaygroundConfig
disabled?: boolean
hasMessages?: boolean
onClearMessages?: () => void
onConfigChange: <K extends keyof PlaygroundConfig>(
key: K,
value: PlaygroundConfig[K]
) => void
onParameterEnabledChange: (
key: keyof ParameterEnabled,
value: boolean
) => void
parameterEnabled: ParameterEnabled
}
export function PlaygroundInputTools({
config,
disabled,
hasMessages = false,
onClearMessages,
onConfigChange,
onParameterEnabledChange,
parameterEnabled,
}: PlaygroundInputToolsProps) {
const { t } = useTranslation()
const [clearConfirmOpen, setClearConfirmOpen] = useState(false)
@@ -133,6 +149,14 @@ export function PlaygroundInputTools({
</TooltipContent>
</Tooltip>
<PlaygroundParameterPanel
config={config}
disabled={disabled}
onConfigChange={onConfigChange}
onParameterEnabledChange={onParameterEnabledChange}
parameterEnabled={parameterEnabled}
/>
<Tooltip>
<TooltipTrigger
render={
@@ -27,11 +27,17 @@ import {
} from '@/components/ai-elements/prompt-input'
import { getSubmittableInputText } from '../../lib'
import type { ModelOption, GroupOption } from '../../types'
import type {
ModelOption,
GroupOption,
ParameterEnabled,
PlaygroundConfig,
} from '../../types'
import { PlaygroundInputControls } from './playground-input-controls'
import { PlaygroundInputTools } from './playground-input-tools'
interface PlaygroundInputProps {
config: PlaygroundConfig
onSubmit: (text: string) => void
onStop?: () => void
disabled?: boolean
@@ -44,10 +50,20 @@ interface PlaygroundInputProps {
groupValue: string
onGroupChange: (value: string) => void
hasMessages?: boolean
onConfigChange: <K extends keyof PlaygroundConfig>(
key: K,
value: PlaygroundConfig[K]
) => void
onClearMessages?: () => void
onParameterEnabledChange: (
key: keyof ParameterEnabled,
value: boolean
) => void
parameterEnabled: ParameterEnabled
}
export function PlaygroundInput({
config,
onSubmit,
onStop,
disabled,
@@ -60,7 +76,10 @@ export function PlaygroundInput({
groupValue,
onGroupChange,
hasMessages = false,
onConfigChange,
onClearMessages,
onParameterEnabledChange,
parameterEnabled,
}: PlaygroundInputProps) {
const { t } = useTranslation()
const [text, setText] = useState('')
@@ -107,9 +126,13 @@ export function PlaygroundInput({
text={text}
tools={
<PlaygroundInputTools
config={config}
disabled={disabled}
hasMessages={hasMessages}
onConfigChange={onConfigChange}
onClearMessages={onClearMessages}
onParameterEnabledChange={onParameterEnabledChange}
parameterEnabled={parameterEnabled}
/>
}
/>
@@ -0,0 +1,266 @@
/*
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 { SlidersHorizontalIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PromptInputButton } from '@/components/ai-elements/prompt-input'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import {
getParameterControlValueText,
normalizeParameterNumberValue,
PLAYGROUND_PARAMETER_CONTROLS,
PLAYGROUND_PARAMETER_PANEL_SCROLL_CLASS,
type PlaygroundParameterKey,
} from '../../lib/parameters/playground-parameters'
import type { ParameterEnabled, PlaygroundConfig } from '../../types'
type PlaygroundParameterPanelProps = {
config: PlaygroundConfig
disabled?: boolean
onConfigChange: <K extends keyof PlaygroundConfig>(
key: K,
value: PlaygroundConfig[K]
) => void
onParameterEnabledChange: (
key: PlaygroundParameterKey,
value: boolean
) => void
parameterEnabled: ParameterEnabled
}
type PlaygroundParameterContentProps = PlaygroundParameterPanelProps & {
compact?: boolean
}
function PlaygroundParameterContent({
compact = false,
config,
disabled,
onConfigChange,
onParameterEnabledChange,
parameterEnabled,
}: PlaygroundParameterContentProps) {
const { t } = useTranslation()
const updateParameterConfig = (
key: PlaygroundParameterKey,
value: number | null
) => {
if (key === 'seed') {
onConfigChange('seed', value)
return
}
onConfigChange(key, value ?? 0)
}
return (
<div
className={cn(
'grid gap-3',
PLAYGROUND_PARAMETER_PANEL_SCROLL_CLASS,
compact ? 'px-4 pb-4' : 'p-1'
)}
>
{PLAYGROUND_PARAMETER_CONTROLS.map((control) => {
const enabled = parameterEnabled[control.key]
const value = config[control.key]
const controlId = `playground-${control.key}`
return (
<div
className={cn(
'border-border/70 bg-background/60 grid gap-2 rounded-lg border p-3 transition-opacity',
(!enabled || disabled) && 'opacity-55'
)}
key={control.key}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0 space-y-1'>
<div className='flex min-w-0 items-center gap-2'>
<label
className='truncate text-sm leading-5 font-medium'
htmlFor={controlId}
>
{t(control.labelKey)}
</label>
<Badge
className='h-5 max-w-24 shrink-0 px-1.5 font-mono text-[11px]'
variant='outline'
>
{t(getParameterControlValueText(control.key, value))}
</Badge>
</div>
<p className='text-muted-foreground text-xs leading-4'>
{t(control.descriptionKey)}
</p>
</div>
<Switch
aria-label={t('Enable {{parameter}}', {
parameter: t(control.labelKey),
})}
checked={enabled}
disabled={disabled}
onCheckedChange={(checked) =>
onParameterEnabledChange(control.key, checked)
}
size='sm'
/>
</div>
{control.valueType === 'slider' ? (
<Slider
className='py-1.5'
disabled={disabled || !enabled}
id={controlId}
max={control.max}
min={control.min}
onValueChange={(nextValue) => {
const firstValue = Array.isArray(nextValue)
? nextValue[0]
: nextValue
updateParameterConfig(
control.key,
normalizeParameterNumberValue(control.key, firstValue)
)
}}
step={control.step}
value={[Number(value)]}
/>
) : (
<Input
disabled={disabled || !enabled}
id={controlId}
inputMode='numeric'
max={control.max}
min={control.min}
onChange={(event) => {
updateParameterConfig(
control.key,
normalizeParameterNumberValue(
control.key,
event.target.value
)
)
}}
step={control.step}
type='number'
value={value ?? ''}
/>
)}
</div>
)
})}
</div>
)
}
export function PlaygroundParameterPanel(props: PlaygroundParameterPanelProps) {
const { t } = useTranslation()
const isMobile = useIsMobile()
const activeCount = PLAYGROUND_PARAMETER_CONTROLS.filter(
(control) => props.parameterEnabled[control.key]
).length
const trigger = (
<PromptInputButton
aria-label={t('Parameters')}
className='text-muted-foreground hover:text-foreground hover:bg-muted/70 relative font-medium'
disabled={props.disabled}
variant='ghost'
>
<SlidersHorizontalIcon size={16} />
<span className='bg-primary text-primary-foreground absolute -top-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full px-1 text-[9px] leading-none font-semibold'>
{activeCount}
</span>
</PromptInputButton>
)
if (isMobile) {
return (
<Sheet>
<Tooltip>
<TooltipTrigger render={<SheetTrigger render={trigger} />} />
<TooltipContent>
<p>{t('Parameters')}</p>
</TooltipContent>
</Tooltip>
<SheetContent
className='max-h-[85vh] overflow-hidden rounded-t-xl'
side='bottom'
>
<SheetHeader>
<SheetTitle>{t('Parameter settings')}</SheetTitle>
</SheetHeader>
<PlaygroundParameterContent {...props} compact />
</SheetContent>
</Sheet>
)
}
return (
<Popover>
<Tooltip>
<TooltipTrigger render={<PopoverTrigger render={trigger} />} />
<TooltipContent>
<p>{t('Parameters')}</p>
</TooltipContent>
</Tooltip>
<PopoverContent
align='start'
className='w-[22rem] max-w-[calc(100vw-2rem)] gap-3 p-3'
collisionPadding={8}
side='top'
sideOffset={8}
>
<div className='space-y-1 px-1'>
<div className='text-sm font-semibold'>{t('Parameter settings')}</div>
<div className='text-muted-foreground text-xs leading-4'>
{t('Only enabled parameters are sent with the request.')}
</div>
</div>
<PlaygroundParameterContent {...props} />
</PopoverContent>
</Popover>
)
}
+5
View File
@@ -37,6 +37,7 @@ export function Playground() {
setModels,
setGroups,
updateConfig,
updateParameterEnabled,
clearMessages,
} = usePlaygroundState()
@@ -95,6 +96,7 @@ export function Playground() {
{/* Input area: center content and constrain to the same container width */}
<div className='mx-auto w-full max-w-4xl'>
<PlaygroundInput
config={config}
disabled={isGenerating}
groups={groups}
groupValue={config.group}
@@ -103,10 +105,13 @@ export function Playground() {
modelValue={config.model}
models={models}
onGroupChange={(value) => updateConfig('group', value)}
onConfigChange={updateConfig}
onClearMessages={handleClearMessages}
onModelChange={(value) => updateConfig('model', value)}
onParameterEnabledChange={updateParameterEnabled}
onStop={stopGeneration}
onSubmit={handleSendMessage}
parameterEnabled={parameterEnabled}
hasMessages={messages.length > 0}
/>
</div>
+1
View File
@@ -31,6 +31,7 @@ export * from './message/message-timing-utils'
export * from './message/message-update-utils'
export * from './message/message-utils'
export * from './options/playground-option-utils'
export * from './parameters/playground-parameters'
export * from './state/playground-state-utils'
export * from './storage/storage'
export * from './streaming/payload-builder'
@@ -0,0 +1,129 @@
/*
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 type { PlaygroundConfig, ParameterEnabled } from '../../types'
type ParameterValue = PlaygroundConfig[keyof PlaygroundConfig]
export type PlaygroundParameterKey = keyof ParameterEnabled
export type PlaygroundParameterControl = {
key: PlaygroundParameterKey
labelKey: string
descriptionKey: string
valueType: 'slider' | 'number'
min: number
max: number
step: number
}
export const PLAYGROUND_PARAMETER_CONTROLS = [
{
key: 'temperature',
labelKey: 'Temperature',
descriptionKey: 'Controls randomness and creativity',
valueType: 'slider',
min: 0.1,
max: 1,
step: 0.1,
},
{
key: 'top_p',
labelKey: 'Top P',
descriptionKey: 'Limits token selection to a probability mass',
valueType: 'slider',
min: 0.1,
max: 1,
step: 0.1,
},
{
key: 'frequency_penalty',
labelKey: 'Frequency Penalty',
descriptionKey: 'Reduces repeated wording',
valueType: 'slider',
min: -2,
max: 2,
step: 0.1,
},
{
key: 'presence_penalty',
labelKey: 'Presence Penalty',
descriptionKey: 'Encourages new topics',
valueType: 'slider',
min: -2,
max: 2,
step: 0.1,
},
{
key: 'max_tokens',
labelKey: 'Max Tokens',
descriptionKey: 'Caps the response length',
valueType: 'number',
min: 0,
max: 200000,
step: 1,
},
{
key: 'seed',
labelKey: 'Seed',
descriptionKey: 'Keeps compatible responses more repeatable',
valueType: 'number',
min: 0,
max: 2147483647,
step: 1,
},
] as const satisfies readonly PlaygroundParameterControl[]
export const PLAYGROUND_PARAMETER_PANEL_SCROLL_CLASS =
'max-h-[min(28rem,calc(100vh-10rem))] overflow-y-auto pr-1'
export function normalizeParameterNumberValue(
key: PlaygroundParameterKey,
value: string | number
): number | null {
if (value === '') {
return key === 'seed' ? null : 0
}
const control = PLAYGROUND_PARAMETER_CONTROLS.find((item) => item.key === key)
const parsed = typeof value === 'number' ? value : Number.parseFloat(value)
if (!control || Number.isNaN(parsed)) {
return key === 'seed' ? null : 0
}
const clamped = Math.min(control.max, Math.max(control.min, parsed))
if (control.step >= 1) {
return Math.trunc(clamped)
}
const precision = Math.max(0, String(control.step).split('.')[1]?.length ?? 0)
return Number(clamped.toFixed(precision))
}
export function getParameterControlValueText(
key: PlaygroundParameterKey,
value: ParameterValue
): string {
if (key === 'seed' && value === null) {
return 'Not set'
}
return String(value)
}