"use client" /** * One draggable adapter row plus its collapsible option form. * * Split out of the page so the sortable wiring stays legible: the row owns its * drag transform and disclosure state, the page owns the list and the policy. */ import * as React from 'react' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { ChevronDown, ChevronRight, GripVertical } from 'lucide-react' import { Button } from '@open-mercato/ui/primitives/button' import { Input } from '@open-mercato/ui/primitives/input' import { Label } from '@open-mercato/ui/primitives/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select' import { Switch } from '@open-mercato/ui/primitives/switch' import { useT } from '@open-mercato/shared/lib/i18n/context' import { HealthStateBadge } from '../../../components/health/HealthStateBadge' export type OptionField = { name: string kind: 'string' | 'number' | 'boolean' | 'enum' | 'stringList' required: boolean secret: boolean choices?: string[] format?: string } export type InstalledAdapter = { id: string kind: string packageName: string fields: OptionField[] options: Record configured: boolean configurationHint: string | null } export type AdapterHealth = { id: string enabled: boolean ready: boolean ok: boolean detail: string | null latencyMs: number | null /** False when the row reports configuration only, with no call made. */ probed?: boolean /** What calling this adapter's health check costs. */ probeCost?: 'free' | 'heavy' | 'billable' /** When the row was last actually called, when it was reused from a cache. */ checkedAt?: string | null } export const SECRET_PLACEHOLDER = '__om_secret_unchanged__' const ACRONYMS: Readonly> = { url: 'URL', api: 'API', ua: 'UA', id: 'ID', ttl: 'TTL', ms: '(ms)', } /** `baseUrl` -> `Base URL`, `timeoutMs` -> `Timeout (ms)`. Schema keys are not labels. */ export function humanizeFieldName(name: string): string { return name .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .split(/[\s_-]+/) .map((word, index) => { const lower = word.toLowerCase() if (ACRONYMS[lower]) return ACRONYMS[lower] return index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : lower }) .join(' ') .trim() } function OptionInput({ field, value, onChange, savedHint, }: { field: OptionField value: unknown onChange: (next: unknown) => void savedHint: string }) { if (field.kind === 'boolean') { return } if (field.kind === 'enum') { return ( ) } if (field.kind === 'number') { return ( onChange(event.target.value === '' ? undefined : Number(event.target.value))} /> ) } if (field.kind === 'stringList') { return ( { const parts = event.target.value .split(',') .map((part) => part.trim()) .filter((part) => part.length > 0) onChange(parts.length > 0 ? parts : undefined) }} /> ) } // A stored secret arrives as a placeholder token. Render an empty field with an // "already saved" hint rather than dots that look like a real value. const isStoredSecret = field.secret && value === SECRET_PLACEHOLDER return ( onChange( event.target.value === '' ? (field.secret ? SECRET_PLACEHOLDER : undefined) : event.target.value, ) } /> ) } export type AdapterRowProps = { id: string enabled: boolean weight: number /** Per-adapter budget; falls back to the policy-wide one when unset. */ timeoutMs?: number /** Hourly call ceiling for this tenant; empty means no ceiling. */ maxCallsPerHour?: number meta?: InstalledAdapter health?: AdapterHealth options: Record onToggle: (enabled: boolean) => void onWeight: (weight: number) => void onTimeout: (timeoutMs: number | undefined) => void onMaxCalls: (maxCallsPerHour: number | undefined) => void onOption: (field: string, value: unknown) => void /** True when this adapter's options differ from what is stored. */ } export function AdapterRow({ id, enabled, weight, timeoutMs, maxCallsPerHour, meta, health, options, onToggle, onWeight, onTimeout, onMaxCalls, onOption, }: AdapterRowProps) { const t = useT() const [open, setOpen] = React.useState(false) const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }) const fields = meta?.fields ?? [] // Health is shown per row rather than in a separate card: a standalone summary // goes stale the moment an adapter is toggled or reordered, and it separated a // fact from the control that changes it. const needsConfig = meta ? !meta.configured : false const statusDetail = (needsConfig ? meta?.configurationHint : null) ?? health?.detail ?? (health?.ok && health.latencyMs !== null ? `${health.latencyMs}ms` : null) return (
{id} {needsConfig ? ( {t('agent_orchestrator.settings.webSearch.needsConfig', 'Configuration required')} ) : enabled ? ( // Configured is not the same claim as working. Saying "Healthy" for // an adapter nobody called would be a guess dressed as a fact — so // an unprobed row reads "Not checked", in the same words the // overview uses for the same fact. ) : null}

{meta?.packageName ?? '—'} {statusDetail ? {statusDetail} : null}

{/* Always available: an adapter has engine-level knobs even when its package exposes no options of its own. */}
{open ? (
onWeight(Number(event.target.value) || 0)} />

{t( 'agent_orchestrator.settings.webSearch.weightHint', 'How much this source pulls in the merged ranking.', )}

{ const raw = event.target.value.trim() onTimeout(raw === '' ? undefined : Number(raw)) }} /> {/* Latency is not uniform across kinds: a SERP read finishes in about a second while a model running its own web search takes tens. */}

{t( 'agent_orchestrator.settings.webSearch.timeoutHint', 'Milliseconds this adapter alone may take. Leave empty to use the policy-wide budget.', )}

{ const raw = event.target.value.trim() onMaxCalls(raw === '' ? undefined : Number(raw)) }} />

{t( 'agent_orchestrator.settings.webSearch.maxCallsHint', 'Most calls this adapter may make per hour for this tenant. Once reached it sits searches out, including as last resort. Leave empty for no ceiling.', )}

{fields.map((field) => (
onOption(field.name, next)} />
))}
{/* Committed by the page's single Save, not here: a card of its own was the second button operators could not tell apart from it. */}
) : null}
) }