feat(ui): sortable provider chain with drag-and-drop and portal combobox

Replace simple JSON settings modal with sortable provider chain cards for
media tools. Each card supports provider/model selection, timeout, retries,
and typed provider-specific params via schema.

- Add @dnd-kit/core + @dnd-kit/sortable for drag-and-drop reordering
- New media-provider-chain-form.tsx: sortable card list with DnD
- New media-provider-params-schema.ts: typed params per tool x provider_type
- Combobox: add portalContainer prop to escape dialog overflow clipping
- Title Case formatting for dialog titles
This commit is contained in:
viettranx committed 2026-03-08 20:10:20 +07:00
1 parent 01d75ac7fe
commit 157d09c021
6 files changed
+727 -171

No files matched your search

+3
View File
@@ -10,6 +10,9 @@
"lint": "eslint ."
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-table": "^8.21.3",
+56
View File
@@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/sortable':
specifier: ^10.0.0
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@tailwindcss/typography':
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.2.0)
@@ -173,6 +182,28 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
react: '>=16.8.0'
'@dnd-kit/core@6.3.1':
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@10.0.0':
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
peerDependencies:
'@dnd-kit/core': ^6.3.0
react: '>=16.8.0'
'@dnd-kit/utilities@3.2.2':
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
peerDependencies:
react: '>=16.8.0'
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@@ -2235,6 +2266,31 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@dnd-kit/accessibility@3.1.1(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/accessibility': 3.1.1(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tslib: 2.8.1
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)':
dependencies:
'@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@dnd-kit/utilities': 3.2.2(react@19.2.4)
react: 19.2.4
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.2.4)':
dependencies:
react: 19.2.4
tslib: 2.8.1
'@esbuild/aix-ppc64@0.25.12':
optional: true
+47 -18
View File
@@ -1,4 +1,5 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { ChevronDownIcon, CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -13,6 +14,8 @@ interface ComboboxProps {
options: ComboboxOption[];
placeholder?: string;
className?: string;
/** Render dropdown into a portal container (useful inside dialogs with overflow clipping). */
portalContainer?: React.RefObject<HTMLElement | null>;
}
export function Combobox({
@@ -21,10 +24,12 @@ export function Combobox({
options,
placeholder,
className,
portalContainer,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const [dropdownStyle, setDropdownStyle] = React.useState<React.CSSProperties>({});
// Sync search text when value changes externally — show label if available
React.useEffect(() => {
@@ -44,6 +49,20 @@ export function Combobox({
return () => document.removeEventListener("mousedown", handler);
}, [open]);
// Compute dropdown position for portal mode
React.useLayoutEffect(() => {
if (!open || !portalContainer?.current || !containerRef.current) return;
const inputRect = containerRef.current.getBoundingClientRect();
const portalRect = portalContainer.current.getBoundingClientRect();
setDropdownStyle({
position: "absolute",
top: inputRect.bottom - portalRect.top + 4,
left: inputRect.left - portalRect.left,
width: inputRect.width,
zIndex: 50,
});
}, [open, search, portalContainer]);
const filtered = React.useMemo(() => {
if (!search) return options;
const q = search.toLowerCase();
@@ -68,6 +87,31 @@ export function Combobox({
if (!open && options.length > 0) setOpen(true);
};
const dropdownContent = open && filtered.length > 0 && (
<div
style={portalContainer ? dropdownStyle : undefined}
className={cn(
"bg-popover text-popover-foreground max-h-60 min-w-full overflow-y-auto rounded-md border p-1 shadow-md",
!portalContainer && "absolute top-full left-0 z-50 mt-1",
)}
>
{filtered.map((o) => (
<button
key={o.value}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(o.value)}
className="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none"
>
<span className="truncate">{o.label || o.value}</span>
{o.value === value && (
<CheckIcon className="absolute right-2 size-4" />
)}
</button>
))}
</div>
);
return (
<div ref={containerRef} className={cn("relative", className)}>
<input
@@ -86,24 +130,9 @@ export function Combobox({
onClick={() => setOpen(!open)}
/>
)}
{open && filtered.length > 0 && (
<div className="bg-popover text-popover-foreground absolute top-full left-0 z-50 mt-1 max-h-60 min-w-full overflow-y-auto rounded-md border p-1 shadow-md">
{filtered.map((o) => (
<button
key={o.value}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(o.value)}
className="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none"
>
<span className="truncate">{o.label || o.value}</span>
{o.value === value && (
<CheckIcon className="absolute right-2 size-4" />
)}
</button>
))}
</div>
)}
{portalContainer?.current
? dropdownContent && createPortal(dropdownContent, portalContainer.current)
: dropdownContent}
</div>
);
}
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -8,20 +8,10 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Combobox } from "@/components/ui/combobox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { useProviderVerify } from "@/pages/providers/hooks/use-provider-verify";
import type { BuiltinToolData } from "./hooks/use-builtin-tools";
import { MEDIA_TOOLS } from "./media-provider-params-schema";
import { MediaProviderChainForm } from "./media-provider-chain-form";
interface Props {
tool: BuiltinToolData | null;
@@ -30,19 +20,19 @@ interface Props {
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>;
}
const MEDIA_TOOLS = new Set([
"read_image", "read_document", "read_audio", "read_video",
"create_image", "create_video",
]);
export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }: Props) {
const isMedia = tool ? MEDIA_TOOLS.has(tool.name) : false;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className={isMedia ? "sm:max-w-2xl" : "sm:max-w-md"}>
{isMedia && tool ? (
<MediaSettingsForm tool={tool} onOpenChange={onOpenChange} onSave={onSave} />
<MediaProviderChainForm
toolName={tool.name}
initialSettings={tool.settings ?? {}}
onSave={(settings) => onSave(tool.name, settings).then(() => onOpenChange(false))}
onCancel={() => onOpenChange(false)}
/>
) : (
<JsonSettingsForm tool={tool} onOpenChange={onOpenChange} onSave={onSave} />
)}
@@ -51,139 +41,6 @@ export function BuiltinToolSettingsDialog({ tool, open, onOpenChange, onSave }:
);
}
function MediaSettingsForm({
tool,
onOpenChange,
onSave,
}: {
tool: BuiltinToolData;
onOpenChange: (open: boolean) => void;
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>;
}) {
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
const settings = tool.settings ?? {};
const [provider, setProvider] = useState((settings.provider as string) ?? "");
const [model, setModel] = useState((settings.model as string) ?? "");
const [saving, setSaving] = useState(false);
// Resolve provider name → id for model list and verify
const selectedProvider = useMemo(
() => enabledProviders.find((p) => p.name === provider),
[enabledProviders, provider],
);
const selectedProviderId = selectedProvider?.id;
const { models, loading: modelsLoading } = useProviderModels(selectedProviderId, selectedProvider?.provider_type);
const { verify, verifying, result: verifyResult, reset: resetVerify } = useProviderVerify();
useEffect(() => {
const s = tool.settings ?? {};
setProvider((s.provider as string) ?? "");
setModel((s.model as string) ?? "");
}, [tool]);
useEffect(() => {
resetVerify();
}, [provider, model, resetVerify]);
const handleVerify = async () => {
if (!selectedProviderId || !model.trim()) return;
await verify(selectedProviderId, model.trim());
};
const handleSave = async () => {
setSaving(true);
try {
const next: Record<string, unknown> = {};
if (provider) next.provider = provider;
if (model) next.model = model;
await onSave(tool.name, next);
onOpenChange(false);
} finally {
setSaving(false);
}
};
return (
<>
<DialogHeader>
<DialogTitle>{tool.display_name} Settings</DialogTitle>
<DialogDescription>
Configure the LLM provider and model. Leave empty for system defaults.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Provider</Label>
{enabledProviders.length > 0 ? (
<Select
value={provider}
onValueChange={(v) => {
setProvider(v);
setModel("");
}}
>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.name} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Combobox
value={provider}
onChange={setProvider}
options={[]}
placeholder="No providers configured"
/>
)}
</div>
<div className="space-y-2">
<Label>Model</Label>
<div className="flex gap-2">
<div className="flex-1">
<Combobox
value={model}
onChange={setModel}
options={models.map((m) => ({ value: m.id, label: m.name }))}
placeholder={modelsLoading ? "Loading models..." : "Enter or select model"}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 shrink-0 px-3"
disabled={!selectedProviderId || !model.trim() || verifying}
onClick={handleVerify}
>
{verifying ? "..." : "Check"}
</Button>
</div>
{verifyResult && (
<p className={`text-xs ${verifyResult.valid ? "text-success" : "text-destructive"}`}>
{verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</>
);
}
function JsonSettingsForm({
tool,
@@ -0,0 +1,468 @@
import { useState, useCallback, useRef } from "react";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2, ChevronDown, ChevronUp, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Combobox } from "@/components/ui/combobox";
import { DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { useProviderModels } from "@/pages/providers/hooks/use-provider-models";
import { MEDIA_PARAMS_SCHEMA, type ParamField } from "./media-provider-params-schema";
interface ProviderEntry {
id: string;
provider_id: string;
provider: string;
model: string;
enabled: boolean;
timeout: number;
max_retries: number;
params: Record<string, unknown>;
}
interface MediaProviderChainFormProps {
toolName: string;
initialSettings: Record<string, unknown>;
onSave: (settings: Record<string, unknown>) => Promise<void>;
onCancel: () => void;
}
function formatToolTitle(name: string): string {
return name
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function buildDefaultParams(toolName: string, providerType: string): Record<string, unknown> {
const schema = MEDIA_PARAMS_SCHEMA[toolName]?.[providerType] ?? [];
const defaults: Record<string, unknown> = {};
for (const field of schema) {
if (field.default !== undefined) {
defaults[field.key] = field.default;
}
}
return defaults;
}
function parseInitialEntries(
settings: Record<string, unknown>,
providers: ReturnType<typeof useProviders>["providers"],
): ProviderEntry[] {
// New format: { providers: [...] }
if (Array.isArray(settings.providers)) {
return (settings.providers as Record<string, unknown>[]).map((p) => ({
id: crypto.randomUUID(),
provider_id: String(p.provider_id ?? ""),
provider: String(p.provider ?? ""),
model: String(p.model ?? ""),
enabled: Boolean(p.enabled ?? true),
timeout: Number(p.timeout ?? 120),
max_retries: Number(p.max_retries ?? 2),
params: (p.params as Record<string, unknown>) ?? {},
}));
}
// Legacy format: { provider, model }
if (settings.provider || settings.model) {
const providerName = String(settings.provider ?? "");
const providerData = providers.find((p) => p.name === providerName);
return [
{
id: crypto.randomUUID(),
provider_id: providerData?.id ?? "",
provider: providerName,
model: String(settings.model ?? ""),
enabled: true,
timeout: 120,
max_retries: 2,
params: {},
},
];
}
return [];
}
// Sub-component for a single sortable provider card
interface SortableCardProps {
entry: ProviderEntry;
index: number;
toolName: string;
enabledProviders: ReturnType<typeof useProviders>["providers"];
onUpdate: (id: string, patch: Partial<ProviderEntry>) => void;
onRemove: (id: string) => void;
portalRef: React.RefObject<HTMLDivElement | null>;
}
function SortableProviderCard({ entry, index, toolName, enabledProviders, onUpdate, onRemove, portalRef }: SortableCardProps) {
const [expanded, setExpanded] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: entry.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const selectedProvider = enabledProviders.find((p) => p.id === entry.provider_id);
const { models, loading: modelsLoading } = useProviderModels(
entry.provider_id || undefined,
selectedProvider?.provider_type,
);
const paramSchema: ParamField[] =
MEDIA_PARAMS_SCHEMA[toolName]?.[selectedProvider?.provider_type ?? ""] ?? [];
const handleProviderChange = (providerName: string) => {
const pData = enabledProviders.find((p) => p.name === providerName);
const newParams = pData
? buildDefaultParams(toolName, pData.provider_type)
: {};
onUpdate(entry.id, {
provider_id: pData?.id ?? "",
provider: providerName,
model: "",
params: newParams,
});
};
const handleParamChange = (key: string, value: unknown) => {
onUpdate(entry.id, { params: { ...entry.params, [key]: value } });
};
return (
<div
ref={setNodeRef}
style={style}
className={`border rounded-lg bg-card ${!entry.enabled ? "opacity-60" : ""}`}
>
{/* Row 1: drag handle, index, toggle, delete */}
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
<button
type="button"
className="cursor-grab text-muted-foreground hover:text-foreground shrink-0"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
<span className="text-xs text-muted-foreground font-mono shrink-0">
#{index + 1}
</span>
<Switch
size="sm"
checked={entry.enabled}
onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })}
/>
<span className="text-sm font-medium truncate">
{selectedProvider?.display_name || entry.provider || "New Provider"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto h-7 w-7 p-0 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => onRemove(entry.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
{/* Row 2: provider + model selects */}
<div className="grid grid-cols-2 gap-2 px-3 py-1.5">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Provider</Label>
<Select value={entry.provider} onValueChange={handleProviderChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{enabledProviders.map((p) => (
<SelectItem key={p.id} value={p.name}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Model</Label>
<Combobox
value={entry.model}
onChange={(v) => onUpdate(entry.id, { model: v })}
options={models.map((m) => ({ value: m.id, label: m.name ?? m.id }))}
placeholder={modelsLoading ? "Loading..." : "Select model"}
className="h-8 text-sm"
portalContainer={portalRef}
/>
</div>
</div>
{/* Row 3: timeout, retries, expand button */}
<div className="flex items-center gap-3 px-3 pb-3 pt-1">
<div className="flex items-center gap-1.5">
<Label className="text-xs text-muted-foreground whitespace-nowrap">Timeout</Label>
<div className="relative">
<Input
type="number"
min={1}
max={600}
value={entry.timeout}
onChange={(e) => onUpdate(entry.id, { timeout: Number(e.target.value) })}
className="h-7 w-20 text-sm pr-5"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">s</span>
</div>
</div>
<div className="flex items-center gap-1.5">
<Label className="text-xs text-muted-foreground whitespace-nowrap">Retries</Label>
<Input
type="number"
min={0}
max={10}
value={entry.max_retries}
onChange={(e) => onUpdate(entry.id, { max_retries: Number(e.target.value) })}
className="h-7 w-16 text-sm"
/>
</div>
{paramSchema.length > 0 && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="ml-auto flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
Settings
{expanded ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
</button>
)}
</div>
{/* Collapsible params */}
{expanded && paramSchema.length > 0 && (
<div className="border-t px-3 py-3 grid grid-cols-2 gap-3">
{paramSchema.map((field) => (
<ParamFieldControl
key={field.key}
field={field}
value={entry.params[field.key] ?? field.default}
onChange={(v) => handleParamChange(field.key, v)}
/>
))}
</div>
)}
</div>
);
}
// Renders a single param field based on its type
function ParamFieldControl({
field,
value,
onChange,
}: {
field: ParamField;
value: unknown;
onChange: (v: unknown) => void;
}) {
return (
<div className="space-y-1">
<Label className="text-xs">{field.label}</Label>
{field.type === "select" && field.options && (
<Select value={String(value ?? "")} onValueChange={onChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field.type === "toggle" && (
<div className="flex items-center h-8">
<Switch
size="sm"
checked={Boolean(value)}
onCheckedChange={onChange}
/>
</div>
)}
{field.type === "number" && (
<Input
type="number"
min={field.min}
max={field.max}
step={field.step}
value={Number(value ?? 0)}
onChange={(e) => onChange(Number(e.target.value))}
className="h-8 text-sm"
/>
)}
{field.type === "text" && (
<div className="space-y-1">
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={field.description}
className="h-8 text-sm"
/>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
)}
</div>
);
}
// Main form component
export function MediaProviderChainForm({
toolName,
initialSettings,
onSave,
onCancel,
}: MediaProviderChainFormProps) {
const { providers } = useProviders();
const enabledProviders = providers.filter((p) => p.enabled);
const portalRef = useRef<HTMLDivElement | null>(null);
const [entries, setEntries] = useState<ProviderEntry[]>(() =>
parseInitialEntries(initialSettings, providers),
);
const [saving, setSaving] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setEntries((prev) => {
const oldIndex = prev.findIndex((e) => e.id === active.id);
const newIndex = prev.findIndex((e) => e.id === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
}
}, []);
const handleUpdate = useCallback((id: string, patch: Partial<ProviderEntry>) => {
setEntries((prev) => prev.map((e) => (e.id === id ? { ...e, ...patch } : e)));
}, []);
const handleRemove = useCallback((id: string) => {
setEntries((prev) => prev.filter((e) => e.id !== id));
}, []);
const handleAdd = () => {
setEntries((prev) => [
...prev,
{
id: crypto.randomUUID(),
provider_id: "",
provider: "",
model: "",
enabled: true,
timeout: 120,
max_retries: 2,
params: {},
},
]);
};
const handleSave = async () => {
setSaving(true);
try {
const serialized = entries.map(({ id: _id, ...rest }) => rest);
await onSave({ providers: serialized });
} finally {
setSaving(false);
}
};
return (
<div ref={portalRef} className="relative">
<DialogHeader>
<DialogTitle>{formatToolTitle(toolName)} — Provider Chain</DialogTitle>
<DialogDescription>
Configure and order provider fallbacks. Drag to reorder — the first enabled provider is tried first.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 max-h-[60vh] overflow-y-auto pr-1">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={entries.map((e) => e.id)} strategy={verticalListSortingStrategy}>
{entries.map((entry, index) => (
<SortableProviderCard
key={entry.id}
entry={entry}
index={index}
toolName={toolName}
enabledProviders={enabledProviders}
onUpdate={handleUpdate}
onRemove={handleRemove}
portalRef={portalRef}
/>
))}
</SortableContext>
</DndContext>
{entries.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6">
No providers configured. Add one below.
</p>
)}
<Button type="button" variant="outline" size="sm" className="w-full" onClick={handleAdd}>
<Plus className="size-3.5 mr-1.5" />
Add Provider
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</div>
);
}
@@ -0,0 +1,143 @@
export type ParamField = {
key: string;
label: string;
type: "select" | "toggle" | "number" | "text";
options?: { value: string; label: string }[];
default?: unknown;
min?: number;
max?: number;
step?: number;
description?: string;
};
export const MEDIA_PARAMS_SCHEMA: Record<string, Record<string, ParamField[]>> = {
create_image: {
minimax_native: [
{
key: "size",
label: "Size",
type: "select",
default: "1024*1024",
options: [
{ value: "1024*1024", label: "1:1" },
{ value: "1280*720", label: "16:9" },
{ value: "720*1280", label: "9:16" },
{ value: "1024*768", label: "4:3" },
{ value: "768*1024", label: "3:4" },
],
},
{ key: "prompt_optimizer", label: "Prompt Optimizer", type: "toggle", default: true },
],
bailian: [
{
key: "size",
label: "Size",
type: "select",
default: "1024*1024",
options: [
{ value: "1024*1024", label: "1K" },
{ value: "2048*2048", label: "2K" },
],
},
{ key: "prompt_extend", label: "Prompt Extend", type: "toggle", default: true },
],
dashscope: [
{
key: "size",
label: "Size",
type: "select",
default: "1024*1024",
options: [
{ value: "1024*1024", label: "1K" },
{ value: "2048*2048", label: "2K" },
],
},
{ key: "prompt_extend", label: "Prompt Extend", type: "toggle", default: true },
],
},
create_video: {
minimax_native: [
{
key: "resolution",
label: "Resolution",
type: "select",
default: "720P",
options: [
{ value: "720P", label: "720P" },
{ value: "768P", label: "768P" },
{ value: "1080P", label: "1080P" },
],
},
{ key: "prompt_optimizer", label: "Prompt Optimizer", type: "toggle", default: true },
{ key: "fast_pretreatment", label: "Fast Mode", type: "toggle", default: false },
],
gemini_native: [
{
key: "person_generation",
label: "Person Generation",
type: "select",
default: "allow_all",
options: [
{ value: "allow_all", label: "Allow All" },
{ value: "dont_allow", label: "Don't Allow" },
],
},
],
},
create_audio: {
minimax_native: [
{ key: "lyrics_optimizer", label: "Auto Lyrics", type: "toggle", default: false },
{
key: "sample_rate",
label: "Sample Rate",
type: "select",
default: "44100",
options: [
{ value: "44100", label: "44.1kHz" },
{ value: "48000", label: "48kHz" },
],
},
{
key: "bitrate",
label: "Bitrate",
type: "select",
default: "320k",
options: [
{ value: "128k", label: "128k" },
{ value: "256k", label: "256k" },
{ value: "320k", label: "320k" },
],
},
],
suno: [
{
key: "style",
label: "Style",
type: "text",
default: "",
description: "e.g. Pop, Classical, Jazz",
},
{
key: "vocal_gender",
label: "Vocal Gender",
type: "select",
default: "",
options: [
{ value: "", label: "Any" },
{ value: "m", label: "Male" },
{ value: "f", label: "Female" },
],
},
],
},
};
export const MEDIA_TOOLS = new Set([
"read_image",
"read_document",
"read_audio",
"read_video",
"create_image",
"create_video",
"create_audio",
]);