mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 00:15:05 +00:00
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:
1 parent
01d75ac7fe
commit
157d09c021
6 files changed
+727
-171
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+56
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
Reference in new issue
Block a user