mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 10:15:48 +00:00
fix(ui): use provider model selectors for delivery behavior
Use shared provider/model selector UX for Delivery Behavior settings across workspace, agent, and channel configuration. Also closes disabled combobox dropdowns.
This commit is contained in:
1 parent
bac8f511e6
commit
fe705ef47f
7 files changed
+148
-35
No files matched your search
@@ -43,6 +43,7 @@ interface ProviderModelSelectProps {
|
||||
modelFilter?: string;
|
||||
/** Extra models to prepend to the dropdown (e.g. curated embedding models not returned by API). */
|
||||
extraModels?: { id: string; name: string }[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderModelSelect({
|
||||
@@ -64,6 +65,7 @@ export function ProviderModelSelect({
|
||||
filterEmbedding,
|
||||
modelFilter,
|
||||
extraModels,
|
||||
disabled,
|
||||
}: ProviderModelSelectProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { providers } = useProviders();
|
||||
@@ -92,10 +94,10 @@ export function ProviderModelSelect({
|
||||
// Auto-select first enabled provider when none is set (unless allowEmpty).
|
||||
// Uses ref for callback so this only re-runs when provider or providers actually change.
|
||||
useEffect(() => {
|
||||
if (!allowEmpty && !provider && enabledProviders.length > 0) {
|
||||
if (!disabled && !allowEmpty && !provider && enabledProviders.length > 0) {
|
||||
onProviderChangeRef.current(enabledProviders[0]!.name);
|
||||
}
|
||||
}, [allowEmpty, provider, enabledProviders]);
|
||||
}, [allowEmpty, disabled, provider, enabledProviders]);
|
||||
|
||||
const selectedProvider = useMemo(
|
||||
() => enabledProviders.find((p) => p.name === provider),
|
||||
@@ -118,6 +120,7 @@ export function ProviderModelSelect({
|
||||
}, [llmChanged, verifyResult, onSaveBlockedChange]);
|
||||
|
||||
const handleProviderChange = (v: string) => {
|
||||
if (disabled) return;
|
||||
onProviderChange(v);
|
||||
// Only clear model when NOT in allowEmpty mode.
|
||||
// In allowEmpty mode (embedding config), both callbacks update the same
|
||||
@@ -129,7 +132,7 @@ export function ProviderModelSelect({
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!selectedProviderId || !model.trim()) return;
|
||||
if (disabled || !selectedProviderId || !model.trim()) return;
|
||||
await verify(selectedProviderId, model.trim());
|
||||
};
|
||||
|
||||
@@ -138,7 +141,7 @@ export function ProviderModelSelect({
|
||||
<div className="grid gap-1.5">
|
||||
<InfoLabel tip={providerTip ?? t("providerTip")}>{providerLabel ?? t("provider")}</InfoLabel>
|
||||
{enabledProviders.length > 0 ? (
|
||||
<Select value={provider || "__empty__"} onValueChange={(v) => handleProviderChange(v === "__empty__" ? "" : v)}>
|
||||
<Select value={provider || "__empty__"} onValueChange={(v) => handleProviderChange(v === "__empty__" ? "" : v)} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={providerPlaceholder ?? t("selectProvider")} />
|
||||
</SelectTrigger>
|
||||
@@ -165,6 +168,7 @@ export function ProviderModelSelect({
|
||||
value={provider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
placeholder={t("noProvidersConfigured")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -195,6 +199,7 @@ export function ProviderModelSelect({
|
||||
placeholder={modelsLoading ? t("loadingModels") : (modelPlaceholder ?? t("enterOrSelectModel"))}
|
||||
allowCustom
|
||||
customLabel={t("useCustomModel")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{shouldShowVerify && (
|
||||
@@ -203,7 +208,7 @@ export function ProviderModelSelect({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 shrink-0 px-3"
|
||||
disabled={!selectedProviderId || !model.trim() || verifying}
|
||||
disabled={disabled || !selectedProviderId || !model.trim() || verifying}
|
||||
onClick={handleVerify}
|
||||
>
|
||||
{verifying ? "..." : t("check")}
|
||||
|
||||
@@ -24,6 +24,7 @@ interface ComboboxProps {
|
||||
allowCustom?: boolean;
|
||||
/** Label for the custom value hint (default: "Use custom:"). */
|
||||
customLabel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
@@ -36,6 +37,7 @@ export function Combobox({
|
||||
portalContainer,
|
||||
allowCustom,
|
||||
customLabel = "Use custom:",
|
||||
disabled,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
@@ -50,6 +52,13 @@ export function Combobox({
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const [dropdownStyle, setDropdownStyle] = React.useState<React.CSSProperties>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!disabled) return;
|
||||
setOpen(false);
|
||||
setInputDirty(false);
|
||||
inputDirtyRef.current = false;
|
||||
}, [disabled]);
|
||||
|
||||
// Sync search text when value changes externally — show label if available
|
||||
React.useEffect(() => {
|
||||
// After handleSelect, skip sync while value is still the selected value.
|
||||
@@ -163,6 +172,7 @@ export function Combobox({
|
||||
}, [options, search]);
|
||||
|
||||
const handleSelect = (val: string) => {
|
||||
if (disabled) return;
|
||||
selectedValueRef.current = val; // suppress value-sync until user types again
|
||||
onChange(val);
|
||||
onSelect?.(val);
|
||||
@@ -174,6 +184,7 @@ export function Combobox({
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
const val = e.target.value;
|
||||
selectedValueRef.current = null; // user is typing — resume normal value sync
|
||||
setSearch(val);
|
||||
@@ -186,6 +197,7 @@ export function Combobox({
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent) => {
|
||||
if (disabled) return;
|
||||
// Only open dropdown on user-initiated focus (click/tab), not programmatic.
|
||||
// relatedTarget is null for programmatic focus or first tab into page.
|
||||
if (!e.relatedTarget && document.hasFocus()) return;
|
||||
@@ -237,13 +249,15 @@ export function Combobox({
|
||||
value={search}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleFocus}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground dark:bg-input/30 h-9 w-full rounded-md border bg-transparent px-3 py-1 pr-8 text-base md:text-sm shadow-xs outline-none transition-[color,box-shadow]",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-1",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
/>
|
||||
{options.length > 0 && (
|
||||
{options.length > 0 && !disabled && (
|
||||
<ChevronDownIcon
|
||||
className="text-muted-foreground absolute top-1/2 right-2.5 size-4 -translate-y-1/2 cursor-pointer opacity-50"
|
||||
onClick={() => setOpen(!open)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
@@ -334,7 +335,7 @@ function DeliveryBehaviorSection({
|
||||
disabled={disabled}
|
||||
provider={value.quick_ack?.provider ?? ""}
|
||||
model={value.quick_ack?.model ?? ""}
|
||||
onProvider={(provider) => patchQuick({ provider })}
|
||||
onProvider={(provider) => patchQuick({ provider, model: "" })}
|
||||
onModel={(model) => patchQuick({ model })}
|
||||
/>
|
||||
<NumberField label={t("delivery.quickAckDelay")} value={value.quick_ack?.min_delay_ms ?? 1000} disabled={disabled} onChange={(min_delay_ms) => patchQuick({ min_delay_ms })} />
|
||||
@@ -348,7 +349,7 @@ function DeliveryBehaviorSection({
|
||||
disabled={disabled}
|
||||
provider={value.intermediate_replies?.provider ?? ""}
|
||||
model={value.intermediate_replies?.model ?? ""}
|
||||
onProvider={(provider) => patchIntermediate({ provider })}
|
||||
onProvider={(provider) => patchIntermediate({ provider, model: "" })}
|
||||
onModel={(model) => patchIntermediate({ model })}
|
||||
/>
|
||||
<NumberField label={t("delivery.timeoutMs")} value={value.intermediate_replies?.timeout_ms ?? 2500} disabled={disabled} onChange={(timeout_ms) => patchIntermediate({ timeout_ms })} />
|
||||
@@ -361,16 +362,18 @@ function DeliveryBehaviorSection({
|
||||
function DeliveryProviderFields({ disabled, provider, model, onProvider, onModel }: { disabled: boolean; provider: string; model: string; onProvider: (value: string) => void; onModel: (value: string) => void }) {
|
||||
const { t } = useTranslation("agents");
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("delivery.provider")}</Label>
|
||||
<Input className="text-base md:text-sm" value={provider} disabled={disabled} placeholder={t("delivery.providerPlaceholder")} onChange={(e) => onProvider(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("delivery.model")}</Label>
|
||||
<Input className="text-base md:text-sm" value={model} disabled={disabled} placeholder={t("delivery.modelPlaceholder")} onChange={(e) => onModel(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<ProviderModelSelect
|
||||
provider={provider}
|
||||
onProviderChange={onProvider}
|
||||
model={model}
|
||||
onModelChange={onModel}
|
||||
disabled={disabled}
|
||||
allowEmpty
|
||||
providerLabel={t("delivery.provider")}
|
||||
modelLabel={t("delivery.model")}
|
||||
providerPlaceholder={t("delivery.providerPlaceholder")}
|
||||
modelPlaceholder={t("delivery.modelPlaceholder")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function isDeliveryProviderKey(key: string) {
|
||||
return /^chat_behavior\.(quick_ack|intermediate_replies)\.provider$/.test(key);
|
||||
}
|
||||
|
||||
export function isDeliveryModelKey(key: string) {
|
||||
return /^chat_behavior\.(quick_ack|intermediate_replies)\.model$/.test(key);
|
||||
}
|
||||
|
||||
export function deliveryModelKey(providerKey: string) {
|
||||
return providerKey.replace(/\.provider$/, ".model");
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { ToolNameSelect } from "@/components/shared/tool-name-select";
|
||||
import { SkillNameSelect } from "@/components/shared/skill-name-select";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
import { BitrixPortalSelect } from "./bitrix24/bitrix-portal-select";
|
||||
import { deliveryModelKey, isDeliveryModelKey, isDeliveryProviderKey } from "./channel-delivery-provider-fields";
|
||||
import type { FieldDef } from "./channel-schemas";
|
||||
|
||||
const INHERIT = "__inherit__";
|
||||
@@ -41,6 +43,7 @@ export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, cont
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{fields.map((field) => {
|
||||
if (isDeliveryModelKey(field.key)) return null;
|
||||
// Conditional visibility: skip field if showWhen condition is not met
|
||||
if (field.showWhen) {
|
||||
const depValue = allValues[field.showWhen.key] ?? fields.find((f) => f.key === field.showWhen!.key)?.defaultValue;
|
||||
@@ -60,6 +63,25 @@ export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, cont
|
||||
disabledHint = field.disabledWhen.hint;
|
||||
}
|
||||
}
|
||||
if (isDeliveryProviderKey(field.key)) {
|
||||
const modelKey = deliveryModelKey(field.key);
|
||||
const modelField = fields.find((f) => f.key === modelKey);
|
||||
return (
|
||||
<DeliveryProviderModelField
|
||||
key={field.key}
|
||||
providerField={field}
|
||||
modelField={modelField}
|
||||
providerValue={(values[field.key] as string) ?? ""}
|
||||
modelValue={(values[modelKey] as string) ?? ""}
|
||||
disabled={disabled}
|
||||
onProviderChange={(provider) => {
|
||||
onChange(field.key, provider || undefined);
|
||||
onChange(modelKey, undefined);
|
||||
}}
|
||||
onModelChange={(model) => onChange(modelKey, model || undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FieldRenderer
|
||||
key={field.key}
|
||||
@@ -80,6 +102,47 @@ export function ChannelFields({ fields, values, onChange, idPrefix, isEdit, cont
|
||||
);
|
||||
}
|
||||
|
||||
function DeliveryProviderModelField({
|
||||
providerField,
|
||||
modelField,
|
||||
providerValue,
|
||||
modelValue,
|
||||
disabled,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
}: {
|
||||
providerField: FieldDef;
|
||||
modelField?: FieldDef;
|
||||
providerValue: string;
|
||||
modelValue: string;
|
||||
disabled?: boolean;
|
||||
onProviderChange: (value: string) => void;
|
||||
onModelChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("channels");
|
||||
const providerLabel = t(`fieldConfig.${providerField.key}.label`, { defaultValue: providerField.label });
|
||||
const providerTip = providerField.help ? t(`fieldConfig.${providerField.key}.help`, { defaultValue: providerField.help }) : undefined;
|
||||
const modelLabel = modelField ? t(`fieldConfig.${modelField.key}.label`, { defaultValue: modelField.label }) : t("fieldConfig.model.label", { defaultValue: "Model" });
|
||||
const modelTip = modelField?.help ? t(`fieldConfig.${modelField.key}.help`, { defaultValue: modelField.help }) : undefined;
|
||||
|
||||
return (
|
||||
<ProviderModelSelect
|
||||
provider={providerValue}
|
||||
onProviderChange={onProviderChange}
|
||||
model={modelValue}
|
||||
onModelChange={onModelChange}
|
||||
allowEmpty
|
||||
disabled={disabled}
|
||||
providerLabel={providerLabel}
|
||||
modelLabel={modelLabel}
|
||||
providerTip={providerTip}
|
||||
modelTip={modelTip}
|
||||
providerPlaceholder={providerField.placeholder}
|
||||
modelPlaceholder={modelField?.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRenderer({
|
||||
field,
|
||||
value,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { configSchema } from "./channel-schemas";
|
||||
import { deliveryModelKey, isDeliveryModelKey, isDeliveryProviderKey } from "./channel-delivery-provider-fields";
|
||||
import { normalizeReasoningDeliveryConfig, resolveReasoningDeliveryValue } from "./reasoning-delivery-config";
|
||||
|
||||
describe("telegram configSchema", () => {
|
||||
@@ -34,6 +35,14 @@ describe("telegram configSchema", () => {
|
||||
expect(quickMode.help).not.toMatch(/main LLM block reply/i);
|
||||
expect(quickMode.options!.map((option) => option.value)).toContain("sidecar_generated");
|
||||
});
|
||||
|
||||
it("groups delivery provider and model fields for dropdown rendering", () => {
|
||||
expect(isDeliveryProviderKey("chat_behavior.quick_ack.provider")).toBe(true);
|
||||
expect(isDeliveryProviderKey("chat_behavior.intermediate_replies.provider")).toBe(true);
|
||||
expect(isDeliveryModelKey("chat_behavior.quick_ack.model")).toBe(true);
|
||||
expect(isDeliveryModelKey("chat_behavior.intermediate_replies.model")).toBe(true);
|
||||
expect(deliveryModelKey("chat_behavior.intermediate_replies.provider")).toBe("chat_behavior.intermediate_replies.model");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pancake configSchema", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { InfoLabel } from "@/components/shared/info-label";
|
||||
import { ProviderModelSelect } from "@/components/shared/provider-model-select";
|
||||
|
||||
const quickAckModeValues = ["sidecar_generated", "llm_generated", "fixed_template", "off"] as const;
|
||||
type QuickAckMode = (typeof quickAckModeValues)[number];
|
||||
@@ -154,10 +155,18 @@ export function BehaviorChatCard({ value, onChange }: Props) {
|
||||
disabled={!value.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<TextField label={t("behavior.provider")} value={value.quick_ack?.provider ?? ""} disabled={!value.enabled} placeholder={t("behavior.providerPlaceholder")} onChange={(provider) => patchAck({ provider })} />
|
||||
<TextField label={t("behavior.model")} value={value.quick_ack?.model ?? ""} disabled={!value.enabled} placeholder={t("behavior.modelPlaceholder")} onChange={(model) => patchAck({ model })} />
|
||||
</div>
|
||||
<ProviderModelSelect
|
||||
provider={value.quick_ack?.provider ?? ""}
|
||||
onProviderChange={(provider) => patchAck({ provider, model: "" })}
|
||||
model={value.quick_ack?.model ?? ""}
|
||||
onModelChange={(model) => patchAck({ model })}
|
||||
disabled={!value.enabled}
|
||||
allowEmpty
|
||||
providerLabel={t("behavior.provider")}
|
||||
modelLabel={t("behavior.model")}
|
||||
providerPlaceholder={t("behavior.providerPlaceholder")}
|
||||
modelPlaceholder={t("behavior.modelPlaceholder")}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<NumberField label={t("behavior.timeoutMs")} value={value.quick_ack?.timeout_ms ?? 2500} disabled={!value.enabled} onChange={(timeout_ms) => patchAck({ timeout_ms })} />
|
||||
<NumberField label={t("behavior.maxTokens")} value={value.quick_ack?.max_tokens ?? 40} disabled={!value.enabled} onChange={(max_tokens) => patchAck({ max_tokens })} />
|
||||
@@ -205,10 +214,18 @@ export function BehaviorChatCard({ value, onChange }: Props) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<TextField label={t("behavior.provider")} value={value.intermediate_replies?.provider ?? ""} disabled={!value.enabled} placeholder={t("behavior.providerPlaceholder")} onChange={(provider) => patchIntermediate({ provider })} />
|
||||
<TextField label={t("behavior.model")} value={value.intermediate_replies?.model ?? ""} disabled={!value.enabled} placeholder={t("behavior.modelPlaceholder")} onChange={(model) => patchIntermediate({ model })} />
|
||||
</div>
|
||||
<ProviderModelSelect
|
||||
provider={value.intermediate_replies?.provider ?? ""}
|
||||
onProviderChange={(provider) => patchIntermediate({ provider, model: "" })}
|
||||
model={value.intermediate_replies?.model ?? ""}
|
||||
onModelChange={(model) => patchIntermediate({ model })}
|
||||
disabled={!value.enabled}
|
||||
allowEmpty
|
||||
providerLabel={t("behavior.provider")}
|
||||
modelLabel={t("behavior.model")}
|
||||
providerPlaceholder={t("behavior.providerPlaceholder")}
|
||||
modelPlaceholder={t("behavior.modelPlaceholder")}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<NumberField label={t("behavior.timeoutMs")} value={value.intermediate_replies?.timeout_ms ?? 2500} disabled={!value.enabled} onChange={(timeout_ms) => patchIntermediate({ timeout_ms })} />
|
||||
<NumberField label={t("behavior.maxTokens")} value={value.intermediate_replies?.max_tokens ?? 60} disabled={!value.enabled} onChange={(max_tokens) => patchIntermediate({ max_tokens })} />
|
||||
@@ -260,15 +277,6 @@ function formatAckPreview(preview: PreviewResponse | null, t: (key: string, opti
|
||||
return `${t("behavior.previewAck")}: ${ack.content ?? ""}`;
|
||||
}
|
||||
|
||||
function TextField({ label, value, disabled, placeholder, onChange }: { label: string; value: string; disabled: boolean; placeholder?: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input className="text-base md:text-sm" value={value} disabled={disabled} placeholder={placeholder} onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ label, value, disabled, onChange }: { label: string; value: number; disabled: boolean; onChange: (v: number) => void }) {
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
|
||||
Reference in new issue
Block a user