From 157d09c02137f6e64d646ad0ae8c4d7819eeef35 Mon Sep 17 00:00:00 2001 From: viettranx Date: Sun, 8 Mar 2026 20:10:20 +0700 Subject: [PATCH] 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 --- ui/web/package.json | 3 + ui/web/pnpm-lock.yaml | 56 +++ ui/web/src/components/ui/combobox.tsx | 65 ++- .../builtin-tool-settings-dialog.tsx | 163 +----- .../media-provider-chain-form.tsx | 468 ++++++++++++++++++ .../media-provider-params-schema.ts | 143 ++++++ 6 files changed, 727 insertions(+), 171 deletions(-) create mode 100644 ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx create mode 100644 ui/web/src/pages/builtin-tools/media-provider-params-schema.ts diff --git a/ui/web/package.json b/ui/web/package.json index 7a587909..4596afd5 100644 --- a/ui/web/package.json +++ b/ui/web/package.json @@ -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", diff --git a/ui/web/pnpm-lock.yaml b/ui/web/pnpm-lock.yaml index b311bfee..d4c59519 100644 --- a/ui/web/pnpm-lock.yaml +++ b/ui/web/pnpm-lock.yaml @@ -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 diff --git a/ui/web/src/components/ui/combobox.tsx b/ui/web/src/components/ui/combobox.tsx index d0ab6ad7..d3558015 100644 --- a/ui/web/src/components/ui/combobox.tsx +++ b/ui/web/src/components/ui/combobox.tsx @@ -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; } 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(null); + const [dropdownStyle, setDropdownStyle] = React.useState({}); // 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 && ( +
+ {filtered.map((o) => ( + + ))} +
+ ); + return (
setOpen(!open)} /> )} - {open && filtered.length > 0 && ( -
- {filtered.map((o) => ( - - ))} -
- )} + {portalContainer?.current + ? dropdownContent && createPortal(dropdownContent, portalContainer.current) + : dropdownContent}
); } diff --git a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx index 2de54381..36981536 100644 --- a/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx +++ b/ui/web/src/pages/builtin-tools/builtin-tool-settings-dialog.tsx @@ -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) => Promise; } -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 ( - + {isMedia && tool ? ( - + onSave(tool.name, settings).then(() => onOpenChange(false))} + onCancel={() => onOpenChange(false)} + /> ) : ( )} @@ -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) => Promise; -}) { - 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 = {}; - if (provider) next.provider = provider; - if (model) next.model = model; - await onSave(tool.name, next); - onOpenChange(false); - } finally { - setSaving(false); - } - }; - - return ( - <> - - {tool.display_name} Settings - - Configure the LLM provider and model. Leave empty for system defaults. - - -
-
- - {enabledProviders.length > 0 ? ( - - ) : ( - - )} -
-
- -
-
- ({ value: m.id, label: m.name }))} - placeholder={modelsLoading ? "Loading models..." : "Enter or select model"} - /> -
- -
- {verifyResult && ( -

- {verifyResult.valid ? "Model verified" : verifyResult.error || "Verification failed"} -

- )} -
-
- - - - - - ); -} function JsonSettingsForm({ tool, diff --git a/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx b/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx new file mode 100644 index 00000000..33caeaad --- /dev/null +++ b/ui/web/src/pages/builtin-tools/media-provider-chain-form.tsx @@ -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; +} + +interface MediaProviderChainFormProps { + toolName: string; + initialSettings: Record; + onSave: (settings: Record) => Promise; + 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 { + const schema = MEDIA_PARAMS_SCHEMA[toolName]?.[providerType] ?? []; + const defaults: Record = {}; + for (const field of schema) { + if (field.default !== undefined) { + defaults[field.key] = field.default; + } + } + return defaults; +} + +function parseInitialEntries( + settings: Record, + providers: ReturnType["providers"], +): ProviderEntry[] { + // New format: { providers: [...] } + if (Array.isArray(settings.providers)) { + return (settings.providers as Record[]).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) ?? {}, + })); + } + + // 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["providers"]; + onUpdate: (id: string, patch: Partial) => void; + onRemove: (id: string) => void; + portalRef: React.RefObject; +} + +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 ( +
+ {/* Row 1: drag handle, index, toggle, delete */} +
+ + + + #{index + 1} + + + onUpdate(entry.id, { enabled: v })} + /> + + + {selectedProvider?.display_name || entry.provider || "New Provider"} + + + +
+ + {/* Row 2: provider + model selects */} +
+
+ + +
+
+ + 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} + /> +
+
+ + {/* Row 3: timeout, retries, expand button */} +
+
+ +
+ onUpdate(entry.id, { timeout: Number(e.target.value) })} + className="h-7 w-20 text-sm pr-5" + /> + s +
+
+
+ + onUpdate(entry.id, { max_retries: Number(e.target.value) })} + className="h-7 w-16 text-sm" + /> +
+ + {paramSchema.length > 0 && ( + + )} +
+ + {/* Collapsible params */} + {expanded && paramSchema.length > 0 && ( +
+ {paramSchema.map((field) => ( + handleParamChange(field.key, v)} + /> + ))} +
+ )} +
+ ); +} + +// Renders a single param field based on its type +function ParamFieldControl({ + field, + value, + onChange, +}: { + field: ParamField; + value: unknown; + onChange: (v: unknown) => void; +}) { + return ( +
+ + {field.type === "select" && field.options && ( + + )} + {field.type === "toggle" && ( +
+ +
+ )} + {field.type === "number" && ( + onChange(Number(e.target.value))} + className="h-8 text-sm" + /> + )} + {field.type === "text" && ( +
+ onChange(e.target.value)} + placeholder={field.description} + className="h-8 text-sm" + /> + {field.description && ( +

{field.description}

+ )} +
+ )} +
+ ); +} + +// Main form component +export function MediaProviderChainForm({ + toolName, + initialSettings, + onSave, + onCancel, +}: MediaProviderChainFormProps) { + const { providers } = useProviders(); + const enabledProviders = providers.filter((p) => p.enabled); + const portalRef = useRef(null); + + const [entries, setEntries] = useState(() => + 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) => { + 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 ( +
+ + {formatToolTitle(toolName)} — Provider Chain + + Configure and order provider fallbacks. Drag to reorder — the first enabled provider is tried first. + + + +
+ + e.id)} strategy={verticalListSortingStrategy}> + {entries.map((entry, index) => ( + + ))} + + + + {entries.length === 0 && ( +

+ No providers configured. Add one below. +

+ )} + + +
+ + + + + +
+ ); +} diff --git a/ui/web/src/pages/builtin-tools/media-provider-params-schema.ts b/ui/web/src/pages/builtin-tools/media-provider-params-schema.ts new file mode 100644 index 00000000..8618307d --- /dev/null +++ b/ui/web/src/pages/builtin-tools/media-provider-params-schema.ts @@ -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> = { + 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", +]);