mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
Uses MeshLLM built-in `mesh` collective intelligence / Mixture of Agents when Buzz Auto sees two or more distinct physical models. With zero or one model, Auto remains ordinary `auto`. This is an alternative way to improve tool responses, accuracy, and resistance to hallucination when a high-latency distributed mesh contains diverse models. Models and members may come and go: collective routing enables only after stable capacity, drops on confirmed contraction, and can recover later. Mesh-specific failures retry once through ordinary Auto. This update also pins MeshLLM to a v0.73.1-compatible backport of [MeshLLM #1074](https://github.com/Mesh-LLM/mesh-llm/pull/1074), so client-only Buzz nodes cannot enter model election or download a remote provider model. Buzz preserves the selected local sharing model and switches an existing client to sharing across a controlled app restart, retaining one runtime and one `:9337` / `:3131` pair per machine. Validation: - Full local `just ci` passes on the cleaned branch. - MeshLLM host-runtime suite: 1,568 passed, 0 failed; strict Clippy passes. - Buzz desktop Tauri suite with `mesh-llm`: 1,721 passed, 0 failed; strict feature Clippy passes. - Playwright covers client-to-share using the saved local model and no destructive stop. - Packaged two-machine testing proved single-model routing, dual-model collective routing, tool-markup fallback, and runtime reuse. - Packaged client-only recheck routed a real Mini Buzz turn through M5 while Mini stayed `is_client=true`, `is_host=false`, hosted no models, and created no Gemma cache. Builds on the recovery work merged in #2823; this PR does not duplicate it. --------- Signed-off-by: Michael Neale <michael.neale@gmail.com> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Michael Neale <michael.neale@gmail.com> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
685 lines
22 KiB
TypeScript
685 lines
22 KiB
TypeScript
/**
|
|
* Shared provider and model field components for agent dialogs.
|
|
*
|
|
* Both CreateAgentDialog (local mode) and AgentInstanceEditDialog import these
|
|
* instead of duplicating the picker logic.
|
|
*/
|
|
import * as React from "react";
|
|
import { Check, ChevronDown, Search } from "lucide-react";
|
|
|
|
import type { AcpRuntimeCatalogEntry } from "@/shared/api/types";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { Input } from "@/shared/ui/input";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
|
import {
|
|
AUTO_MODEL_DROPDOWN_VALUE,
|
|
AUTO_PROVIDER_DROPDOWN_VALUE,
|
|
CUSTOM_MODEL_DROPDOWN_VALUE,
|
|
CUSTOM_PROVIDER_DROPDOWN_VALUE,
|
|
getDefaultLlmModelLabel,
|
|
getModelSelectValue,
|
|
getPersonaProviderOptions,
|
|
hasPersonaModelOption,
|
|
PERSONA_FIELD_CONTROL_CLASS,
|
|
PERSONA_FIELD_SHELL_CLASS,
|
|
providerDisplayLabel,
|
|
type PersonaModelOption,
|
|
} from "./agentConfigOptions";
|
|
import { MODEL_DISCOVERY_LOADING_VALUE } from "./usePersonaModelDiscovery";
|
|
import type { PersonaModelDiscoveryStatus } from "./personaModelDiscoveryStatus";
|
|
|
|
export const MODEL_NO_MODELS_VALUE = "__no_models__";
|
|
|
|
export type AgentDropdownOption = {
|
|
disabled?: boolean;
|
|
label: React.ReactNode;
|
|
value: string;
|
|
};
|
|
|
|
/**
|
|
* Ensures an opened model dropdown never renders as a blank white bar.
|
|
* When `options` is empty and discovery has finished, appends a single
|
|
* disabled "No models found" sentinel row in place. Returns `options`
|
|
* for convenient chaining in tests.
|
|
*/
|
|
export function appendNoModelsSentinel(
|
|
options: AgentDropdownOption[],
|
|
loading: boolean,
|
|
): AgentDropdownOption[] {
|
|
if (options.length === 0 && !loading) {
|
|
options.push({
|
|
disabled: true,
|
|
label: "No models found",
|
|
value: MODEL_NO_MODELS_VALUE,
|
|
});
|
|
}
|
|
return options;
|
|
}
|
|
|
|
export function resolveModelFieldStatusMessage({
|
|
discoveredModelOptions,
|
|
loading,
|
|
status,
|
|
}: {
|
|
discoveredModelOptions: readonly PersonaModelOption[] | null;
|
|
loading: boolean;
|
|
status: PersonaModelDiscoveryStatus | null;
|
|
}): string | null {
|
|
if (loading) return "Loading models...";
|
|
if (status !== null) return status.message;
|
|
return discoveredModelOptions !== null
|
|
? "Saved changes take effect on the next start."
|
|
: null;
|
|
}
|
|
|
|
function optionTestId(testId: string | undefined, value: string) {
|
|
if (!testId) return undefined;
|
|
return `${testId}-option-${value || "empty"}`;
|
|
}
|
|
|
|
export function AgentConfigTextInput({
|
|
className,
|
|
usePersonaInputStyle = false,
|
|
...props
|
|
}: React.ComponentProps<typeof Input> & {
|
|
usePersonaInputStyle?: boolean;
|
|
}) {
|
|
const input = (
|
|
<Input
|
|
{...props}
|
|
className={cn(
|
|
usePersonaInputStyle && "h-8 px-0 py-0 leading-6",
|
|
usePersonaInputStyle && PERSONA_FIELD_CONTROL_CLASS,
|
|
className,
|
|
)}
|
|
/>
|
|
);
|
|
|
|
return usePersonaInputStyle ? (
|
|
<div
|
|
className={cn(
|
|
"mt-2 flex min-h-11 items-center px-3",
|
|
PERSONA_FIELD_SHELL_CLASS,
|
|
)}
|
|
>
|
|
{input}
|
|
</div>
|
|
) : (
|
|
input
|
|
);
|
|
}
|
|
|
|
export function AgentDropdownSelect({
|
|
ariaRequired,
|
|
className,
|
|
disabled = false,
|
|
emptyOptionsLabel = "No options available",
|
|
id,
|
|
onValueChange,
|
|
options,
|
|
placeholder = "Select",
|
|
placeholderClassName,
|
|
placeholderValue,
|
|
searchable = false,
|
|
selectedLabel,
|
|
testId,
|
|
value,
|
|
}: {
|
|
ariaRequired?: boolean;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
/** Shown when the option list is empty (not a search filter miss). */
|
|
emptyOptionsLabel?: string;
|
|
id: string;
|
|
onValueChange: (value: string) => void;
|
|
options: readonly AgentDropdownOption[];
|
|
placeholder?: string;
|
|
placeholderClassName?: string;
|
|
placeholderValue?: string;
|
|
/** Show a filter input above the options when the list is long. */
|
|
searchable?: boolean;
|
|
selectedLabel?: React.ReactNode;
|
|
testId?: string;
|
|
value: string;
|
|
}) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [query, setQuery] = React.useState("");
|
|
const selectedOption = options.find((option) => option.value === value);
|
|
const isPlaceholderSelection =
|
|
selectedLabel === undefined &&
|
|
(selectedOption === undefined ||
|
|
(placeholderValue !== undefined &&
|
|
selectedOption.value === placeholderValue));
|
|
|
|
const showSearch = searchable && options.length > 1;
|
|
const filteredOptions = React.useMemo(() => {
|
|
const trimmed = query.trim().toLowerCase();
|
|
if (!showSearch || trimmed === "") return options;
|
|
return options.filter((option) =>
|
|
(typeof option.label === "string" ? option.label : option.value)
|
|
.toLowerCase()
|
|
.includes(trimmed),
|
|
);
|
|
}, [options, query, showSearch]);
|
|
|
|
function handleOpenChange(next: boolean) {
|
|
setOpen(next);
|
|
if (!next) setQuery("");
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
aria-controls={`${id}-listbox`}
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
aria-required={ariaRequired}
|
|
className={cn(
|
|
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-left text-sm font-normal shadow-xs transition-colors hover:bg-background/90 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
|
|
className,
|
|
)}
|
|
data-testid={testId}
|
|
data-value={value}
|
|
disabled={disabled}
|
|
id={id}
|
|
role="combobox"
|
|
type="button"
|
|
>
|
|
<span
|
|
className={cn(
|
|
"min-w-0 truncate",
|
|
isPlaceholderSelection &&
|
|
(placeholderClassName ?? "text-foreground/45"),
|
|
)}
|
|
>
|
|
{selectedLabel ?? selectedOption?.label ?? placeholder}
|
|
</span>
|
|
<ChevronDown
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"ml-3 h-4 w-4 shrink-0 transition-transform duration-150",
|
|
disabled ? "text-foreground/30" : "text-foreground",
|
|
open && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align="start"
|
|
className="z-[100] max-h-72 overflow-y-auto rounded-2xl border-foreground/10 bg-white p-1.5 text-foreground opacity-100 data-[state=closed]:animate-none data-[state=open]:animate-none"
|
|
sideOffset={8}
|
|
style={{
|
|
boxShadow: "0 16px 36px rgb(0 0 0 / 0.12)",
|
|
width: "var(--radix-popover-trigger-width)",
|
|
}}
|
|
>
|
|
<div
|
|
aria-labelledby={id}
|
|
className="space-y-1"
|
|
id={`${id}-listbox`}
|
|
role="listbox"
|
|
>
|
|
{showSearch ? (
|
|
<div className="relative">
|
|
<Search
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-foreground/45"
|
|
/>
|
|
<Input
|
|
aria-label="Search models"
|
|
autoFocus
|
|
className="h-9 rounded-xl border-foreground/10 bg-white pl-9 text-sm"
|
|
data-testid={testId ? `${testId}-search` : undefined}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search models…"
|
|
value={query}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
{filteredOptions.length === 0 ? (
|
|
<p
|
|
className="px-3 py-2 text-sm text-foreground/55"
|
|
data-testid={testId ? `${testId}-empty` : undefined}
|
|
>
|
|
{showSearch && query.trim().length > 0
|
|
? "No matches"
|
|
: emptyOptionsLabel}
|
|
</p>
|
|
) : null}
|
|
{filteredOptions.map((option) => {
|
|
const selected = option.value === value;
|
|
return (
|
|
<button
|
|
aria-disabled={option.disabled || undefined}
|
|
aria-selected={selected}
|
|
className={cn(
|
|
"flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm leading-5 text-black opacity-100 transition-colors hover:bg-black/5 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-black/30",
|
|
selected && "bg-[var(--buzz-welcome-chartreuse)]/35",
|
|
option.disabled && "cursor-not-allowed text-black/35",
|
|
)}
|
|
data-testid={optionTestId(testId, option.value)}
|
|
data-value={option.value}
|
|
disabled={option.disabled}
|
|
key={option.value}
|
|
onClick={() => {
|
|
if (option.disabled) return;
|
|
onValueChange(option.value);
|
|
setOpen(false);
|
|
}}
|
|
role="option"
|
|
type="button"
|
|
>
|
|
<span className="min-w-0 truncate">{option.label}</span>
|
|
<Check
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"h-4 w-4 shrink-0 text-black transition-opacity",
|
|
option.disabled && "text-black/35",
|
|
selected ? "opacity-100" : "opacity-0",
|
|
)}
|
|
strokeWidth={2.5}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
export function RequiredFieldLabel({
|
|
className,
|
|
children,
|
|
htmlFor,
|
|
isRequired,
|
|
}: {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
htmlFor: string;
|
|
isRequired: boolean;
|
|
}) {
|
|
return (
|
|
<label className={cn("text-sm font-medium", className)} htmlFor={htmlFor}>
|
|
{children}
|
|
{isRequired ? (
|
|
<span className="ml-1 text-destructive" aria-hidden="true">
|
|
*
|
|
</span>
|
|
) : null}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function resolveDefaultModelLabel({
|
|
defaultModelLabel,
|
|
discoveredModelOptions,
|
|
globalModel,
|
|
isSharedCompute,
|
|
}: {
|
|
defaultModelLabel?: string;
|
|
discoveredModelOptions: readonly PersonaModelOption[] | null;
|
|
globalModel?: string;
|
|
isSharedCompute: boolean;
|
|
}) {
|
|
return (
|
|
defaultModelLabel ??
|
|
discoveredModelOptions?.find((option) => option.id.trim() === "")?.label ??
|
|
(isSharedCompute
|
|
? "Auto (collective when available)"
|
|
: getDefaultLlmModelLabel(globalModel))
|
|
);
|
|
}
|
|
|
|
export function AgentModelField({
|
|
disabled,
|
|
discoveredModelOptions,
|
|
globalModel,
|
|
allowDefaultModel = true,
|
|
defaultModelLabel,
|
|
disableSelectDuringDiscovery = true,
|
|
keepSelectedModelValueLabel = false,
|
|
id = "agent-model",
|
|
isCustomModelEditing,
|
|
isRequired,
|
|
model,
|
|
modelDiscoveryLoading,
|
|
modelDiscoveryStatus,
|
|
onIsCustomModelEditingChange,
|
|
onModelChange,
|
|
placeholder = "Select model",
|
|
placeholderClassName,
|
|
provider,
|
|
fieldClassName,
|
|
labelClassName,
|
|
selectClassName,
|
|
testId,
|
|
useCustomSelect = false,
|
|
showStatusMessage = true,
|
|
showCustomModelOption = true,
|
|
useChevronIcon = false,
|
|
usePersonaInputStyle = false,
|
|
}: {
|
|
disabled: boolean;
|
|
discoveredModelOptions: readonly PersonaModelOption[] | null;
|
|
/** Known lower-precedence model used when this field is left empty. */
|
|
globalModel?: string;
|
|
/** Hide the empty/default option when no valid lower-precedence model exists. */
|
|
allowDefaultModel?: boolean;
|
|
/** Source-aware label for the empty/default option. */
|
|
defaultModelLabel?: string;
|
|
/** Disable the trigger while live model discovery refreshes the option list. */
|
|
disableSelectDuringDiscovery?: boolean;
|
|
/** Keep the closed trigger from swapping to discovered display labels. */
|
|
keepSelectedModelValueLabel?: boolean;
|
|
/** DOM id for the model select. Defaults to `"agent-model"`. Override in
|
|
* contexts where multiple instances coexist on the same page (e.g. the
|
|
* global-config settings card) to avoid duplicate DOM ids. */
|
|
id?: string;
|
|
isCustomModelEditing: boolean;
|
|
isRequired: boolean;
|
|
model: string;
|
|
modelDiscoveryLoading: boolean;
|
|
modelDiscoveryStatus: PersonaModelDiscoveryStatus | null;
|
|
onIsCustomModelEditingChange: (value: boolean) => void;
|
|
onModelChange: (value: string) => void;
|
|
/** Trigger placeholder shown when there is no selected model option. */
|
|
placeholder?: string;
|
|
/** Optional class override for placeholder text. */
|
|
placeholderClassName?: string;
|
|
provider?: string;
|
|
/** Optional class override for the field wrapper. */
|
|
fieldClassName?: string;
|
|
/** Optional class override for the label. */
|
|
labelClassName?: string;
|
|
/** Optional class override for contexts with custom visual treatments. */
|
|
selectClassName?: string;
|
|
/** Optional test id for custom dropdown trigger/options. */
|
|
testId?: string;
|
|
/** Render the polished app dropdown instead of the native select. */
|
|
useCustomSelect?: boolean;
|
|
/** Hide the helper/status line in compact presentations. */
|
|
showStatusMessage?: boolean;
|
|
/** Hide the explicit custom-model escape hatch in compact presentations. */
|
|
showCustomModelOption?: boolean;
|
|
/** Render a controlled chevron instead of the native select indicator. */
|
|
useChevronIcon?: boolean;
|
|
/** Match custom agent text inputs when this field is shown in that flow. */
|
|
usePersonaInputStyle?: boolean;
|
|
}) {
|
|
const trimmedModel = model.trim();
|
|
const isSharedCompute = provider?.trim() === "relay-mesh";
|
|
const discoveredDefaultOption = discoveredModelOptions?.find(
|
|
(option) => option.id.trim() === "",
|
|
);
|
|
|
|
// Model discovery can report the harness's current/default model while the
|
|
// persisted value remains empty ("let the harness choose"). Treat that as a
|
|
// real, displayable option instead of a blank select state. Explicit labels
|
|
// from a lower-precedence/baked default still win when present.
|
|
const defaultOption: PersonaModelOption = {
|
|
id: "",
|
|
label: resolveDefaultModelLabel({
|
|
defaultModelLabel,
|
|
discoveredModelOptions,
|
|
globalModel,
|
|
isSharedCompute,
|
|
}),
|
|
};
|
|
const discoveredWithoutDefault = (discoveredModelOptions ?? []).filter(
|
|
(option) => option.id.trim() !== "",
|
|
);
|
|
const baseModelOptions = isSharedCompute
|
|
? [defaultOption, ...discoveredWithoutDefault]
|
|
: [
|
|
...(allowDefaultModel || discoveredDefaultOption
|
|
? [defaultOption]
|
|
: []),
|
|
...discoveredWithoutDefault,
|
|
];
|
|
const shouldShowPendingModelOption =
|
|
!isSharedCompute &&
|
|
!isCustomModelEditing &&
|
|
modelDiscoveryLoading &&
|
|
discoveredModelOptions === null &&
|
|
trimmedModel.length > 0;
|
|
const effectiveModelOptions =
|
|
shouldShowPendingModelOption &&
|
|
!baseModelOptions.some((option) => option.id === trimmedModel)
|
|
? [...baseModelOptions, { id: trimmedModel, label: trimmedModel }]
|
|
: baseModelOptions;
|
|
|
|
// isModelCustom: true when the current model isn't in any known option set.
|
|
// We check discovered options (when available) or runtime-static options so
|
|
// a previously-saved custom model stays in custom mode even before discovery.
|
|
const isModelCustom =
|
|
!isSharedCompute &&
|
|
!hasPersonaModelOption(effectiveModelOptions, trimmedModel);
|
|
|
|
const modelSelectValue =
|
|
isSharedCompute && (trimmedModel === "" || trimmedModel === "auto")
|
|
? AUTO_MODEL_DROPDOWN_VALUE
|
|
: getModelSelectValue({
|
|
isCustomModelEditing,
|
|
isModelCustom,
|
|
model,
|
|
});
|
|
|
|
// The select is only disabled for mutation pending — never for missing discovery.
|
|
// Default/custom options remain usable regardless of discovery state.
|
|
const selectDisabled =
|
|
disabled || (disableSelectDuringDiscovery && modelDiscoveryLoading);
|
|
|
|
// Show the custom model input whenever custom mode is active or the current
|
|
// model is already custom — not gated on discovery having returned.
|
|
const showCustomModelInput =
|
|
!isSharedCompute && (isCustomModelEditing || isModelCustom);
|
|
|
|
const handleModelSelectChange = (nextValue: string) => {
|
|
if (nextValue === AUTO_MODEL_DROPDOWN_VALUE) {
|
|
onIsCustomModelEditingChange(false);
|
|
onModelChange(isSharedCompute ? "auto" : "");
|
|
return;
|
|
}
|
|
if (nextValue === CUSTOM_MODEL_DROPDOWN_VALUE) {
|
|
onIsCustomModelEditingChange(true);
|
|
return;
|
|
}
|
|
onIsCustomModelEditingChange(false);
|
|
onModelChange(nextValue);
|
|
};
|
|
|
|
const modelOptions: AgentDropdownOption[] = [
|
|
...effectiveModelOptions.map((option) => ({
|
|
label: option.label,
|
|
value: option.id || AUTO_MODEL_DROPDOWN_VALUE,
|
|
})),
|
|
...(modelDiscoveryLoading && discoveredModelOptions === null
|
|
? [
|
|
{
|
|
disabled: true,
|
|
label: "Loading models...",
|
|
value: MODEL_DISCOVERY_LOADING_VALUE,
|
|
},
|
|
]
|
|
: []),
|
|
...(!isSharedCompute &&
|
|
(showCustomModelOption || modelSelectValue === CUSTOM_MODEL_DROPDOWN_VALUE)
|
|
? [{ label: "Custom model...", value: CUSTOM_MODEL_DROPDOWN_VALUE }]
|
|
: []),
|
|
];
|
|
// An opened dropdown must never show a blank popover. When all the above
|
|
// yields an empty list and discovery has finished, add a disabled sentinel
|
|
// row so the user sees "No models found" instead of a bare white bar.
|
|
appendNoModelsSentinel(modelOptions, modelDiscoveryLoading);
|
|
const stableSelectedModelLabel =
|
|
keepSelectedModelValueLabel &&
|
|
modelSelectValue === trimmedModel &&
|
|
trimmedModel.length > 0
|
|
? trimmedModel
|
|
: undefined;
|
|
// While discovery is in flight with nothing selected, the closed field
|
|
// reads "Loading models…" instead of a select-prompt — the field isn't
|
|
// waiting on the user, it's waiting on the harness.
|
|
const restingPlaceholder =
|
|
modelDiscoveryLoading &&
|
|
discoveredModelOptions === null &&
|
|
trimmedModel.length === 0 &&
|
|
!isCustomModelEditing
|
|
? "Loading models..."
|
|
: placeholder;
|
|
const statusMessage = resolveModelFieldStatusMessage({
|
|
discoveredModelOptions,
|
|
loading: modelDiscoveryLoading,
|
|
status: modelDiscoveryStatus,
|
|
});
|
|
|
|
const modelSelect = useCustomSelect ? (
|
|
<AgentDropdownSelect
|
|
ariaRequired={isRequired}
|
|
className={selectClassName}
|
|
disabled={selectDisabled}
|
|
emptyOptionsLabel="Couldn't load models"
|
|
id={id}
|
|
onValueChange={handleModelSelectChange}
|
|
options={modelOptions}
|
|
placeholder={restingPlaceholder}
|
|
placeholderClassName={placeholderClassName}
|
|
searchable
|
|
selectedLabel={stableSelectedModelLabel}
|
|
testId={testId ?? id}
|
|
value={modelSelectValue}
|
|
/>
|
|
) : (
|
|
<select
|
|
aria-required={isRequired}
|
|
className={cn(
|
|
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs disabled:cursor-not-allowed disabled:opacity-60",
|
|
useChevronIcon && "appearance-none pr-10",
|
|
selectClassName,
|
|
)}
|
|
disabled={selectDisabled}
|
|
id={id}
|
|
onChange={(event) => handleModelSelectChange(event.target.value)}
|
|
value={modelSelectValue}
|
|
>
|
|
{modelOptions.map((option) => (
|
|
<option
|
|
disabled={option.disabled}
|
|
key={option.value}
|
|
value={option.value}
|
|
>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
);
|
|
|
|
return (
|
|
<div className={cn("space-y-1.5", fieldClassName)}>
|
|
<RequiredFieldLabel
|
|
className={labelClassName}
|
|
htmlFor={id}
|
|
isRequired={isRequired}
|
|
>
|
|
Model
|
|
</RequiredFieldLabel>
|
|
{!useCustomSelect && useChevronIcon ? (
|
|
<div className="relative">
|
|
{modelSelect}
|
|
<ChevronDown
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-foreground"
|
|
/>
|
|
</div>
|
|
) : (
|
|
modelSelect
|
|
)}
|
|
{showCustomModelInput ? (
|
|
<AgentConfigTextInput
|
|
aria-label="Custom model ID"
|
|
autoCorrect="off"
|
|
disabled={disabled}
|
|
onChange={(event) => onModelChange(event.target.value)}
|
|
placeholder="Custom model ID"
|
|
usePersonaInputStyle={usePersonaInputStyle}
|
|
value={model}
|
|
/>
|
|
) : null}
|
|
{showStatusMessage && statusMessage ? (
|
|
<p className="text-xs text-muted-foreground">{statusMessage}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AgentProviderField({
|
|
disabled,
|
|
globalProvider,
|
|
isCustomProviderEditing,
|
|
isRequired,
|
|
onProviderChange,
|
|
provider,
|
|
selectedRuntime,
|
|
}: {
|
|
disabled: boolean;
|
|
globalProvider?: string;
|
|
isCustomProviderEditing: boolean;
|
|
isRequired: boolean;
|
|
onProviderChange: (value: string) => void;
|
|
provider: string;
|
|
selectedRuntime: AcpRuntimeCatalogEntry | undefined;
|
|
}) {
|
|
const trimmedProvider = provider.trim();
|
|
const providerOptions = getPersonaProviderOptions(
|
|
trimmedProvider,
|
|
selectedRuntime?.id ?? "",
|
|
globalProvider,
|
|
);
|
|
const providerSelectValue = isCustomProviderEditing
|
|
? CUSTOM_PROVIDER_DROPDOWN_VALUE
|
|
: trimmedProvider || AUTO_PROVIDER_DROPDOWN_VALUE;
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<RequiredFieldLabel htmlFor="agent-provider" isRequired={isRequired}>
|
|
LLM provider
|
|
</RequiredFieldLabel>
|
|
<select
|
|
aria-required={isRequired}
|
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs disabled:cursor-not-allowed disabled:opacity-60"
|
|
disabled={disabled}
|
|
id="agent-provider"
|
|
onChange={(event) => onProviderChange(event.target.value)}
|
|
value={providerSelectValue}
|
|
>
|
|
{providerOptions.map((option) => (
|
|
<option
|
|
key={option.id}
|
|
value={option.id || AUTO_PROVIDER_DROPDOWN_VALUE}
|
|
>
|
|
{option.id ? providerDisplayLabel(option.label) : option.label}
|
|
</option>
|
|
))}
|
|
<option value={CUSTOM_PROVIDER_DROPDOWN_VALUE}>
|
|
Custom provider...
|
|
</option>
|
|
</select>
|
|
{isCustomProviderEditing ? (
|
|
<Input
|
|
aria-label="Custom provider ID"
|
|
autoCorrect="off"
|
|
disabled={disabled}
|
|
onChange={(event) => onProviderChange(event.target.value)}
|
|
placeholder="Custom provider ID"
|
|
value={provider}
|
|
/>
|
|
) : null}
|
|
<p className="text-xs text-muted-foreground">
|
|
Changing the provider updates the available model list immediately.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|