mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-21 17:57:14 +00:00
refactor(pipeline-form): swap Box banner for field-level disable_if + tooltip
The previous commit hard-coded a BoxUnavailableNotice banner above the
``local-agent`` stage card. That works, but it shouts at the user about
every field in that stage when in reality only one field —
``box-session-id-template`` — depends on the sandbox.
Use the dynamic-form schema's existing variable-injection mechanism
(``__system.*`` references via ``systemContext``) and add a sibling to
``show_if``: ``disable_if`` + ``disabled_tooltip``. The field stays
visible, becomes inert, and an info icon next to its label exposes the
reason on hover. The rest of the AI tab is left untouched.
- entities/form/dynamic.ts: extend IDynamicFormItemSchema with
``disable_if: IShowIfCondition`` and ``disabled_tooltip: I18nObject``
- DynamicFormComponent: evaluate disable_if with the same resolver as
show_if; OR the result into isFieldDisabled; render an Info tooltip
trigger next to the label when the condition matches
- ai.yaml metadata: attach disable_if (__system.box_available eq false)
and a localized disabled_tooltip to box-session-id-template
- PipelineFormComponent: drop the BoxUnavailableNotice import and the
per-stage banner; pass ``systemContext={ box_available: boxAvailable }``
only for the local-agent stage so other stages aren't paying the
re-render cost
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,22 @@ stages:
|
|||||||
th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ
|
th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ
|
||||||
es_ES: Determina cómo se comparten los entornos sandbox entre mensajes.
|
es_ES: Determina cómo se comparten los entornos sandbox entre mensajes.
|
||||||
ru_RU: Определяет, как песочницы используются совместно между сообщениями.
|
ru_RU: Определяет, как песочницы используются совместно между сообщениями.
|
||||||
|
disable_if:
|
||||||
|
field: __system.box_available
|
||||||
|
operator: eq
|
||||||
|
value: false
|
||||||
|
disabled_tooltip:
|
||||||
|
en_US: >-
|
||||||
|
Box sandbox is disabled or unavailable. Enable it in config.yaml
|
||||||
|
(box.enabled = true) and ensure the runtime is reachable to change
|
||||||
|
this setting.
|
||||||
|
zh_Hans: Box 沙箱已禁用或不可用。请在配置中启用(box.enabled = true)并确认运行时连接正常,才能修改此项。
|
||||||
|
zh_Hant: Box 沙箱已停用或無法使用。請在設定中啟用(box.enabled = true)並確認執行時連線正常,才能修改此項。
|
||||||
|
ja_JP: Box サンドボックスが無効または利用できません。設定で有効化(box.enabled = true)し、ランタイムが接続できることを確認してから変更してください。
|
||||||
|
vi_VN: Sandbox Box đã tắt hoặc không khả dụng. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime hoạt động để chỉnh sửa.
|
||||||
|
th_TH: Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่ารันไทม์เชื่อมต่อปกติก่อนปรับค่า
|
||||||
|
es_ES: El sandbox de Box está desactivado o no disponible. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime esté conectado para modificar este ajuste.
|
||||||
|
ru_RU: Песочница Box отключена или недоступна. Включите её в конфигурации (box.enabled = true) и убедитесь, что среда выполнения работает, чтобы изменить эту настройку.
|
||||||
type: select
|
type: select
|
||||||
required: false
|
required: false
|
||||||
default: "{launcher_type}_{launcher_id}"
|
default: "{launcher_type}_{launcher_id}"
|
||||||
|
|||||||
@@ -17,8 +17,14 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Copy, Check, Globe } from 'lucide-react';
|
import { Copy, Check, Globe, Info } from 'lucide-react';
|
||||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
import { systemInfo } from '@/app/infra/http';
|
import { systemInfo } from '@/app/infra/http';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -467,8 +473,53 @@ export default function DynamicFormComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All fields are disabled when editing (creation_settings are immutable)
|
// ``disable_if`` mirrors ``show_if``'s evaluator but instead of
|
||||||
const isFieldDisabled = !!isEditing;
|
// hiding the field, leaves it visible and inert. Use it when the
|
||||||
|
// operator needs to see that the field exists yet cannot edit it
|
||||||
|
// under the current runtime state (e.g. sandbox-bound fields when
|
||||||
|
// Box is disabled).
|
||||||
|
let isDisabledByCondition = false;
|
||||||
|
if (config.disable_if) {
|
||||||
|
const dependValue = resolveShowIfValue(
|
||||||
|
config.disable_if.field,
|
||||||
|
watchedValues as Record<string, unknown>,
|
||||||
|
externalDependentValues,
|
||||||
|
systemContext,
|
||||||
|
);
|
||||||
|
const cond = config.disable_if;
|
||||||
|
if (cond.operator === 'eq' && dependValue === cond.value) {
|
||||||
|
isDisabledByCondition = true;
|
||||||
|
} else if (cond.operator === 'neq' && dependValue !== cond.value) {
|
||||||
|
isDisabledByCondition = true;
|
||||||
|
} else if (
|
||||||
|
cond.operator === 'in' &&
|
||||||
|
Array.isArray(cond.value) &&
|
||||||
|
cond.value.includes(dependValue)
|
||||||
|
) {
|
||||||
|
isDisabledByCondition = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All fields are disabled when editing (creation_settings are
|
||||||
|
// immutable) or when ``disable_if`` matches.
|
||||||
|
const isFieldDisabled = !!isEditing || isDisabledByCondition;
|
||||||
|
const disabledTooltip =
|
||||||
|
isDisabledByCondition && config.disabled_tooltip
|
||||||
|
? extractI18nObject(config.disabled_tooltip)
|
||||||
|
: '';
|
||||||
|
const renderDisabledTooltipIcon = () =>
|
||||||
|
disabledTooltip ? (
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help shrink-0" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
{disabledTooltip}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null;
|
||||||
|
|
||||||
// Webhook URL fields are display-only; render outside of form binding
|
// Webhook URL fields are display-only; render outside of form binding
|
||||||
if (config.type === 'webhook-url') {
|
if (config.type === 'webhook-url') {
|
||||||
@@ -539,8 +590,9 @@ export default function DynamicFormComponent({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel className="text-base">
|
<FormLabel className="text-base flex items-center gap-1.5">
|
||||||
{extractI18nObject(config.label)}
|
{extractI18nObject(config.label)}
|
||||||
|
{renderDisabledTooltipIcon()}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
{config.description && (
|
{config.description && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -570,9 +622,14 @@ export default function DynamicFormComponent({
|
|||||||
name={config.name as keyof FormValues}
|
name={config.name as keyof FormValues}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="flex items-center gap-1.5">
|
||||||
{extractI18nObject(config.label)}{' '}
|
<span>
|
||||||
{config.required && <span className="text-red-500">*</span>}
|
{extractI18nObject(config.label)}{' '}
|
||||||
|
{config.required && (
|
||||||
|
<span className="text-red-500">*</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{renderDisabledTooltipIcon()}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '@/app/infra/entities/pipeline';
|
} from '@/app/infra/entities/pipeline';
|
||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
|
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
|
||||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
|
||||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -77,7 +76,7 @@ export default function PipelineFormComponent({
|
|||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
||||||
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
|
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
|
||||||
const { available: boxAvailable, hint: boxHint } = useBoxStatus();
|
const { available: boxAvailable } = useBoxStatus();
|
||||||
|
|
||||||
const formSchema = isEditMode
|
const formSchema = isEditMode
|
||||||
? z.object({
|
? z.object({
|
||||||
@@ -416,40 +415,40 @@ export default function PipelineFormComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The local-agent stage carries sandbox-bound fields (e.g.
|
// Box availability is exposed through ``systemContext.__system.box_available``
|
||||||
// ``box-session-id-template``). When Box is disabled / unavailable, show
|
// so individual yaml-driven fields (e.g. ``box-session-id-template``) can
|
||||||
// a banner so operators know those fields will have no effect. We render
|
// opt-in via ``disable_if`` + ``disabled_tooltip`` rather than every page
|
||||||
// the banner above the card rather than per-field because the field set
|
// hard-coding a banner. Field-level gating keeps unrelated fields
|
||||||
// is driven by yaml metadata and a single notice keeps the UI calm.
|
// untouched.
|
||||||
const showBoxNoticeForStage = stage.name === 'local-agent' && !boxAvailable;
|
const stageSystemContext =
|
||||||
|
stage.name === 'local-agent'
|
||||||
|
? { box_available: boxAvailable }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={stage.name} className="space-y-3">
|
<Card key={stage.name}>
|
||||||
{showBoxNoticeForStage && <BoxUnavailableNotice hint={boxHint} />}
|
<CardHeader>
|
||||||
<Card>
|
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||||
<CardHeader>
|
{stage.description && (
|
||||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
<CardDescription>
|
||||||
{stage.description && (
|
{extractI18nObject(stage.description)}
|
||||||
<CardDescription>
|
</CardDescription>
|
||||||
{extractI18nObject(stage.description)}
|
)}
|
||||||
</CardDescription>
|
</CardHeader>
|
||||||
)}
|
<CardContent className="space-y-6">
|
||||||
</CardHeader>
|
<DynamicFormComponent
|
||||||
<CardContent className="space-y-6">
|
itemConfigList={stage.config}
|
||||||
<DynamicFormComponent
|
initialValues={
|
||||||
itemConfigList={stage.config}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
initialValues={
|
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
}
|
||||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
onSubmit={(values) => {
|
||||||
{}
|
handleDynamicFormEmit(formName, stage.name, values);
|
||||||
}
|
}}
|
||||||
onSubmit={(values) => {
|
systemContext={stageSystemContext}
|
||||||
handleDynamicFormEmit(formName, stage.name, values);
|
/>
|
||||||
}}
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,18 @@ export interface IDynamicFormItemSchema {
|
|||||||
type: DynamicFormItemType;
|
type: DynamicFormItemType;
|
||||||
description?: I18nObject;
|
description?: I18nObject;
|
||||||
options?: IDynamicFormItemOption[];
|
options?: IDynamicFormItemOption[];
|
||||||
|
/** When the condition matches, the field is rendered. Same evaluator as
|
||||||
|
* ``disable_if`` — supports the ``__system.*`` namespace via
|
||||||
|
* ``DynamicFormComponent.systemContext``. */
|
||||||
show_if?: IShowIfCondition;
|
show_if?: IShowIfCondition;
|
||||||
|
/** When the condition matches, the field is rendered as read-only/disabled
|
||||||
|
* but stays visible. Use this when the operator needs to see that the
|
||||||
|
* field exists but can't be edited under the current runtime state (e.g.
|
||||||
|
* a sandbox-bound field when Box is disabled). Pair with
|
||||||
|
* ``disabled_tooltip`` to explain why. */
|
||||||
|
disable_if?: IShowIfCondition;
|
||||||
|
/** Tooltip shown next to the field label when ``disable_if`` is active. */
|
||||||
|
disabled_tooltip?: I18nObject;
|
||||||
|
|
||||||
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user