mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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:
@@ -29,15 +29,36 @@ interface ModelsDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
|
||||
|
||||
function convertExtraArgsToObject(
|
||||
args: ExtraArg[],
|
||||
): Record<string, string | number | boolean> {
|
||||
const obj: Record<string, string | number | boolean> = {};
|
||||
): Record<string, ExtraArgValue> {
|
||||
const obj: Record<string, ExtraArgValue> = {};
|
||||
args.forEach((arg) => {
|
||||
if (arg.key.trim()) {
|
||||
if (arg.type === 'number') obj[arg.key] = Number(arg.value);
|
||||
else if (arg.type === 'boolean') obj[arg.key] = arg.value === 'true';
|
||||
else obj[arg.key] = arg.value;
|
||||
if (!arg.key.trim()) return;
|
||||
if (arg.type === 'number') {
|
||||
obj[arg.key] = Number(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;
|
||||
|
||||
@@ -258,11 +258,16 @@ export default function AddModelPopover({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
|
||||
@@ -437,7 +442,7 @@ export default function AddModelPopover({
|
||||
</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()}
|
||||
>
|
||||
<div className="p-3 space-y-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Plus, X, HelpCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
@@ -47,9 +48,30 @@ export default function ExtraArgsEditor({
|
||||
) => {
|
||||
const newArgs = [...args];
|
||||
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);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -90,49 +112,79 @@ export default function ExtraArgsEditor({
|
||||
{args.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('common.none')}</p>
|
||||
) : (
|
||||
args.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={arg.type}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => handleUpdate(index, 'type', value)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">{t('models.string')}</SelectItem>
|
||||
<SelectItem value="number">{t('models.number')}</SelectItem>
|
||||
<SelectItem value="boolean">{t('models.boolean')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
|
||||
/>
|
||||
{!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>
|
||||
))
|
||||
args.map((arg, index) => {
|
||||
const isObject = arg.type === 'object';
|
||||
const jsonError = isObject && isInvalidJson(arg.value);
|
||||
return (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex gap-2 items-start">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
className={isObject ? 'flex-[2]' : 'flex-1'}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={arg.type}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => handleUpdate(index, 'type', value)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">{t('models.string')}</SelectItem>
|
||||
<SelectItem value="number">{t('models.number')}</SelectItem>
|
||||
<SelectItem value="boolean">
|
||||
{t('models.boolean')}
|
||||
</SelectItem>
|
||||
<SelectItem value="object">{t('models.object')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isObject && (
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
handleUpdate(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!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>
|
||||
);
|
||||
|
||||
@@ -46,10 +46,25 @@ interface ModelItemProps {
|
||||
function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
|
||||
if (!extraArgs) return [];
|
||||
return Object.entries(extraArgs).map(([key, value]) => {
|
||||
let type: 'string' | 'number' | 'boolean' = 'string';
|
||||
if (typeof value === 'number') type = 'number';
|
||||
else if (typeof value === 'boolean') type = 'boolean';
|
||||
return { key, type, value: String(value) };
|
||||
let type: ExtraArg['type'] = 'string';
|
||||
let stringValue: string;
|
||||
if (typeof value === 'number') {
|
||||
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>
|
||||
</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-2">
|
||||
<Label>{t('models.modelName')}</Label>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
|
||||
export type ExtraArg = {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -200,6 +200,9 @@ const enUS = {
|
||||
string: 'String',
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
object: 'Object',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'Value must be a valid JSON object',
|
||||
selectModelProvider: 'Select Model Provider',
|
||||
modelProviderDescription:
|
||||
'Please fill in the model name provided by the provider',
|
||||
|
||||
@@ -205,6 +205,9 @@ const esES = {
|
||||
string: 'Cadena',
|
||||
number: 'Número',
|
||||
boolean: 'Booleano',
|
||||
object: 'Objeto',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'El valor debe ser un objeto JSON válido',
|
||||
selectModelProvider: 'Seleccionar proveedor del modelo',
|
||||
modelProviderDescription:
|
||||
'Por favor, introduce el nombre del modelo proporcionado por el proveedor',
|
||||
|
||||
@@ -203,6 +203,9 @@
|
||||
string: '文字列',
|
||||
number: '数値',
|
||||
boolean: 'ブール値',
|
||||
object: 'オブジェクト',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: '値は有効なJSONオブジェクトである必要があります',
|
||||
selectModelProvider: 'モデルプロバイダーを選択',
|
||||
modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',
|
||||
modelManufacturer: 'モデルメーカー',
|
||||
|
||||
@@ -202,6 +202,9 @@ const ruRU = {
|
||||
string: 'Строка',
|
||||
number: 'Число',
|
||||
boolean: 'Логический',
|
||||
object: 'Объект',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'Значение должно быть допустимым объектом JSON',
|
||||
selectModelProvider: 'Выберите провайдера модели',
|
||||
modelProviderDescription:
|
||||
'Пожалуйста, введите название модели, предоставленное провайдером',
|
||||
|
||||
@@ -198,6 +198,9 @@ const thTH = {
|
||||
string: 'สตริง',
|
||||
number: 'ตัวเลข',
|
||||
boolean: 'บูลีน',
|
||||
object: 'อ็อบเจกต์',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'ค่าต้องเป็นอ็อบเจกต์ JSON ที่ถูกต้อง',
|
||||
selectModelProvider: 'เลือกผู้ให้บริการโมเดล',
|
||||
modelProviderDescription: 'กรุณากรอกชื่อโมเดลที่ผู้ให้บริการจัดเตรียมไว้',
|
||||
modelManufacturer: 'ผู้ผลิตโมเดล',
|
||||
|
||||
@@ -202,6 +202,9 @@ const viVN = {
|
||||
string: 'Chuỗi',
|
||||
number: 'Số',
|
||||
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',
|
||||
modelProviderDescription:
|
||||
'Vui lòng điền tên mô hình do nhà cung cấp cung cấp',
|
||||
|
||||
@@ -192,6 +192,9 @@ const zhHans = {
|
||||
string: '字符串',
|
||||
number: '数字',
|
||||
boolean: '布尔值',
|
||||
object: '对象',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: '值必须是有效的 JSON 对象',
|
||||
selectModelProvider: '选择模型供应商',
|
||||
modelProviderDescription: '请填写供应商向您提供的模型名称',
|
||||
modelManufacturer: '模型厂商',
|
||||
|
||||
@@ -192,6 +192,9 @@ const zhHant = {
|
||||
string: '字串',
|
||||
number: '數字',
|
||||
boolean: '布林值',
|
||||
object: '物件',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: '值必須是有效的 JSON 物件',
|
||||
selectModelProvider: '選擇模型供應商',
|
||||
modelProviderDescription: '請填寫供應商向您提供的模型名稱',
|
||||
modelManufacturer: '模型廠商',
|
||||
|
||||
Reference in New Issue
Block a user