feat(models): support object type in extra parameters (#2158)

Add 'object' as a new value type for model extra parameters so users can
configure nested JSON like {"thinking": {"type": "disabled"}} required by
DeepSeek-v4 non-thinking mode (refs #2157).

UI: add 'Object' option to the type dropdown in ExtraArgsEditor; render a
full-width JSON Textarea (resize-y, monospace) with live JSON validation.
On save, JSON is parsed and rejected if not a plain object.

Also make the model edit and add-model popovers scrollable: cap height at
min(70vh, --radix-popover-content-available-height), stop wheel/touchmove
propagation so the dialog's react-remove-scroll lock doesn't swallow
events, and use overscroll-none to avoid the bottom border seam from
rubber-band overscroll.

i18n updated for all 8 locales.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Junyan Chin
2026-05-01 20:44:17 +08:00
committed by GitHub
parent b9662250a6
commit 8db55267d8
13 changed files with 184 additions and 57 deletions

View File

@@ -29,15 +29,36 @@ interface ModelsDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
function convertExtraArgsToObject( function convertExtraArgsToObject(
args: ExtraArg[], args: ExtraArg[],
): Record<string, string | number | boolean> { ): Record<string, ExtraArgValue> {
const obj: Record<string, string | number | boolean> = {}; const obj: Record<string, ExtraArgValue> = {};
args.forEach((arg) => { args.forEach((arg) => {
if (arg.key.trim()) { if (!arg.key.trim()) return;
if (arg.type === 'number') obj[arg.key] = Number(arg.value); if (arg.type === 'number') {
else if (arg.type === 'boolean') obj[arg.key] = arg.value === 'true'; obj[arg.key] = Number(arg.value);
else obj[arg.key] = arg.value; } else if (arg.type === 'boolean') {
obj[arg.key] = arg.value === 'true';
} else if (arg.type === 'object') {
const raw = arg.value.trim() || '{}';
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Invalid JSON for extra parameter "${arg.key}"`);
}
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
throw new Error(`Extra parameter "${arg.key}" must be a JSON object`);
}
obj[arg.key] = parsed as Record<string, unknown>;
} else {
obj[arg.key] = arg.value;
} }
}); });
return obj; return obj;

View File

@@ -258,11 +258,16 @@ export default function AddModelPopover({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] overflow-y-auto" className="w-[min(24rem,calc(100vw-2rem))] max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
style={{
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
}}
align="end" align="end"
side="left" side="left"
sideOffset={8} sideOffset={8}
collisionPadding={16} collisionPadding={16}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}> <Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
@@ -437,7 +442,7 @@ export default function AddModelPopover({
</div> </div>
<div <div
className="h-64 overflow-y-auto overscroll-contain rounded-md border" className="h-64 overflow-y-auto overscroll-none rounded-md border"
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
> >
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">

View File

@@ -1,6 +1,7 @@
import { Plus, X, HelpCircle } from 'lucide-react'; import { Plus, X, HelpCircle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
Select, Select,
@@ -47,9 +48,30 @@ export default function ExtraArgsEditor({
) => { ) => {
const newArgs = [...args]; const newArgs = [...args];
newArgs[index] = { ...newArgs[index], [field]: value }; newArgs[index] = { ...newArgs[index], [field]: value };
// When switching to object type, seed an empty JSON object so the textarea
// doesn't start with an unparseable empty string.
if (
field === 'type' &&
value === 'object' &&
!newArgs[index].value.trim()
) {
newArgs[index].value = '{}';
}
onChange(newArgs); onChange(newArgs);
}; };
const isInvalidJson = (raw: string) => {
if (!raw.trim()) return false;
try {
const parsed = JSON.parse(raw);
return (
parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
);
} catch {
return true;
}
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -90,49 +112,79 @@ export default function ExtraArgsEditor({
{args.length === 0 ? ( {args.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('common.none')}</p> <p className="text-sm text-muted-foreground">{t('common.none')}</p>
) : ( ) : (
args.map((arg, index) => ( args.map((arg, index) => {
<div key={index} className="flex gap-2 items-center"> const isObject = arg.type === 'object';
<Input const jsonError = isObject && isInvalidJson(arg.value);
placeholder={t('models.keyName')} return (
value={arg.key} <div key={index} className="space-y-1">
className="flex-1" <div className="flex gap-2 items-start">
disabled={disabled} <Input
onChange={(e) => handleUpdate(index, 'key', e.target.value)} placeholder={t('models.keyName')}
/> value={arg.key}
<Select className={isObject ? 'flex-[2]' : 'flex-1'}
value={arg.type} disabled={disabled}
disabled={disabled} onChange={(e) => handleUpdate(index, 'key', e.target.value)}
onValueChange={(value) => handleUpdate(index, 'type', value)} />
> <Select
<SelectTrigger className="w-24"> value={arg.type}
<SelectValue /> disabled={disabled}
</SelectTrigger> onValueChange={(value) => handleUpdate(index, 'type', value)}
<SelectContent> >
<SelectItem value="string">{t('models.string')}</SelectItem> <SelectTrigger className="w-24">
<SelectItem value="number">{t('models.number')}</SelectItem> <SelectValue />
<SelectItem value="boolean">{t('models.boolean')}</SelectItem> </SelectTrigger>
</SelectContent> <SelectContent>
</Select> <SelectItem value="string">{t('models.string')}</SelectItem>
<Input <SelectItem value="number">{t('models.number')}</SelectItem>
placeholder={t('models.value')} <SelectItem value="boolean">
value={arg.value} {t('models.boolean')}
className="flex-1" </SelectItem>
disabled={disabled} <SelectItem value="object">{t('models.object')}</SelectItem>
onChange={(e) => handleUpdate(index, 'value', e.target.value)} </SelectContent>
/> </Select>
{!disabled && ( {!isObject && (
<Button <Input
type="button" placeholder={t('models.value')}
variant="ghost" value={arg.value}
size="icon" className="flex-1"
className="h-8 w-8 flex-shrink-0" disabled={disabled}
onClick={() => handleRemove(index)} onChange={(e) =>
> handleUpdate(index, 'value', e.target.value)
<X className="h-4 w-4" /> }
</Button> />
)} )}
</div> {!disabled && (
)) <Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isObject && (
<Textarea
placeholder={t('models.objectJsonPlaceholder')}
value={arg.value}
className={`w-full font-mono text-xs min-h-[96px] resize-y ${
jsonError ? 'border-destructive' : ''
}`}
disabled={disabled}
spellCheck={false}
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
/>
)}
{jsonError && (
<p className="text-xs text-destructive pl-1">
{t('models.invalidJsonObject')}
</p>
)}
</div>
);
})
)} )}
</div> </div>
); );

View File

@@ -46,10 +46,25 @@ interface ModelItemProps {
function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] { function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
if (!extraArgs) return []; if (!extraArgs) return [];
return Object.entries(extraArgs).map(([key, value]) => { return Object.entries(extraArgs).map(([key, value]) => {
let type: 'string' | 'number' | 'boolean' = 'string'; let type: ExtraArg['type'] = 'string';
if (typeof value === 'number') type = 'number'; let stringValue: string;
else if (typeof value === 'boolean') type = 'boolean'; if (typeof value === 'number') {
return { key, type, value: String(value) }; type = 'number';
stringValue = String(value);
} else if (typeof value === 'boolean') {
type = 'boolean';
stringValue = String(value);
} else if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
) {
type = 'object';
stringValue = JSON.stringify(value, null, 2);
} else {
stringValue = String(value);
}
return { key, type, value: stringValue };
}); });
} }
@@ -209,7 +224,16 @@ export default function ModelItem({
)} )}
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-80" align="start"> <PopoverContent
className="w-80 max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
align="start"
collisionPadding={16}
style={{
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
}}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('models.modelName')}</Label> <Label>{t('models.modelName')}</Label>

View File

@@ -9,7 +9,8 @@ import {
export type ExtraArg = { export type ExtraArg = {
key: string; key: string;
type: 'string' | 'number' | 'boolean'; type: 'string' | 'number' | 'boolean' | 'object';
// For 'object' type, value holds a JSON string that will be parsed on save.
value: string; value: string;
}; };

View File

@@ -200,6 +200,9 @@ const enUS = {
string: 'String', string: 'String',
number: 'Number', number: 'Number',
boolean: 'Boolean', boolean: 'Boolean',
object: 'Object',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'Value must be a valid JSON object',
selectModelProvider: 'Select Model Provider', selectModelProvider: 'Select Model Provider',
modelProviderDescription: modelProviderDescription:
'Please fill in the model name provided by the provider', 'Please fill in the model name provided by the provider',

View File

@@ -205,6 +205,9 @@ const esES = {
string: 'Cadena', string: 'Cadena',
number: 'Número', number: 'Número',
boolean: 'Booleano', boolean: 'Booleano',
object: 'Objeto',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'El valor debe ser un objeto JSON válido',
selectModelProvider: 'Seleccionar proveedor del modelo', selectModelProvider: 'Seleccionar proveedor del modelo',
modelProviderDescription: modelProviderDescription:
'Por favor, introduce el nombre del modelo proporcionado por el proveedor', 'Por favor, introduce el nombre del modelo proporcionado por el proveedor',

View File

@@ -203,6 +203,9 @@
string: '文字列', string: '文字列',
number: '数値', number: '数値',
boolean: 'ブール値', boolean: 'ブール値',
object: 'オブジェクト',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: '値は有効なJSONオブジェクトである必要があります',
selectModelProvider: 'モデルプロバイダーを選択', selectModelProvider: 'モデルプロバイダーを選択',
modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください', modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',
modelManufacturer: 'モデルメーカー', modelManufacturer: 'モデルメーカー',

View File

@@ -202,6 +202,9 @@ const ruRU = {
string: 'Строка', string: 'Строка',
number: 'Число', number: 'Число',
boolean: 'Логический', boolean: 'Логический',
object: 'Объект',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'Значение должно быть допустимым объектом JSON',
selectModelProvider: 'Выберите провайдера модели', selectModelProvider: 'Выберите провайдера модели',
modelProviderDescription: modelProviderDescription:
'Пожалуйста, введите название модели, предоставленное провайдером', 'Пожалуйста, введите название модели, предоставленное провайдером',

View File

@@ -198,6 +198,9 @@ const thTH = {
string: 'สตริง', string: 'สตริง',
number: 'ตัวเลข', number: 'ตัวเลข',
boolean: 'บูลีน', boolean: 'บูลีน',
object: 'อ็อบเจกต์',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'ค่าต้องเป็นอ็อบเจกต์ JSON ที่ถูกต้อง',
selectModelProvider: 'เลือกผู้ให้บริการโมเดล', selectModelProvider: 'เลือกผู้ให้บริการโมเดล',
modelProviderDescription: 'กรุณากรอกชื่อโมเดลที่ผู้ให้บริการจัดเตรียมไว้', modelProviderDescription: 'กรุณากรอกชื่อโมเดลที่ผู้ให้บริการจัดเตรียมไว้',
modelManufacturer: 'ผู้ผลิตโมเดล', modelManufacturer: 'ผู้ผลิตโมเดล',

View File

@@ -202,6 +202,9 @@ const viVN = {
string: 'Chuỗi', string: 'Chuỗi',
number: 'Số', number: 'Số',
boolean: 'Boolean', boolean: 'Boolean',
object: 'Đối tượng',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'Giá trị phải là một đối tượng JSON hợp lệ',
selectModelProvider: 'Chọn nhà cung cấp mô hình', selectModelProvider: 'Chọn nhà cung cấp mô hình',
modelProviderDescription: modelProviderDescription:
'Vui lòng điền tên mô hình do nhà cung cấp cung cấp', 'Vui lòng điền tên mô hình do nhà cung cấp cung cấp',

View File

@@ -192,6 +192,9 @@ const zhHans = {
string: '字符串', string: '字符串',
number: '数字', number: '数字',
boolean: '布尔值', boolean: '布尔值',
object: '对象',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: '值必须是有效的 JSON 对象',
selectModelProvider: '选择模型供应商', selectModelProvider: '选择模型供应商',
modelProviderDescription: '请填写供应商向您提供的模型名称', modelProviderDescription: '请填写供应商向您提供的模型名称',
modelManufacturer: '模型厂商', modelManufacturer: '模型厂商',

View File

@@ -192,6 +192,9 @@ const zhHant = {
string: '字串', string: '字串',
number: '數字', number: '數字',
boolean: '布林值', boolean: '布林值',
object: '物件',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: '值必須是有效的 JSON 物件',
selectModelProvider: '選擇模型供應商', selectModelProvider: '選擇模型供應商',
modelProviderDescription: '請填寫供應商向您提供的模型名稱', modelProviderDescription: '請填寫供應商向您提供的模型名稱',
modelManufacturer: '模型廠商', modelManufacturer: '模型廠商',