diff --git a/ui/desktop/frontend/package.json b/ui/desktop/frontend/package.json index 1a119676..4f031c75 100644 --- a/ui/desktop/frontend/package.json +++ b/ui/desktop/frontend/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "clsx": "^2.1.1", "framer-motion": "^12.38.0", "i18next": "^25.10.10", diff --git a/ui/desktop/frontend/pnpm-lock.yaml b/ui/desktop/frontend/pnpm-lock.yaml index d9af01f0..6a91f5d1 100644 --- a/ui/desktop/frontend/pnpm-lock.yaml +++ b/ui/desktop/frontend/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@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) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -173,6 +179,12 @@ packages: 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: @@ -1493,6 +1505,13 @@ snapshots: 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 diff --git a/ui/desktop/frontend/src/components/settings/tools/MediaProviderChainForm.tsx b/ui/desktop/frontend/src/components/settings/tools/MediaProviderChainForm.tsx new file mode 100644 index 00000000..6bf2d265 --- /dev/null +++ b/ui/desktop/frontend/src/components/settings/tools/MediaProviderChainForm.tsx @@ -0,0 +1,329 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' +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 { Switch } from '../../common/Switch' +import { Combobox } from '../../common/Combobox' +import { getApiClient } from '../../../lib/api' +import type { BuiltinToolData } from '../../../types/builtin-tool' + +interface ProviderEntry { + id: string + provider: string + model: string + enabled: boolean + timeout: number + max_retries: number + params?: Record +} + +interface ModelInfo { + id: string + name?: string +} + +interface MediaProviderChainFormProps { + tool: BuiltinToolData + onSave: (name: string, settings: Record) => Promise + onClose: () => void +} + +/* ─── Sortable Card ─── */ + +function SortableProviderCard({ + entry, + index, + providerOptions, + modelOptions, + modelLoading, + onUpdate, + onRemove, + onProviderChange, +}: { + entry: ProviderEntry + index: number + providerOptions: { value: string; label: string }[] + modelOptions: { value: string; label: string }[] + modelLoading: boolean + onUpdate: (id: string, updates: Partial) => void + onRemove: (id: string) => void + onProviderChange: (id: string, providerName: string) => void +}) { + const { t } = useTranslation(['tools', 'common']) + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( +
+ {/* Header */} +
+
+ {/* Drag handle */} + + #{index + 1} + {entry.provider || t('builtin.mediaChain.newProvider')} + {entry.model && / {entry.model}} +
+
+ onUpdate(entry.id, { enabled: v })} /> + +
+
+ + {/* Provider + Model */} +
+
+ + onProviderChange(entry.id, v)} + options={providerOptions} + placeholder={t('builtin.mediaChain.selectProvider')} + allowCustom + /> +
+
+ + onUpdate(entry.id, { model: v })} + options={modelOptions} + placeholder={t('builtin.mediaChain.selectModel')} + loading={modelLoading} + allowCustom + /> +
+
+ + {/* Timeout + Retries */} +
+
+ + onUpdate(entry.id, { timeout: Math.max(1, Number(e.target.value)) })} + className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+
+ + onUpdate(entry.id, { max_retries: Math.max(0, Number(e.target.value)) })} + className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" + /> +
+
+
+ ) +} + +/* ─── Main Form ─── */ + +export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderChainFormProps) { + const { t } = useTranslation(['tools', 'common']) + const [chain, setChain] = useState([]) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const [dbProviders, setDbProviders] = useState<{ id: string; name: string }[]>([]) + const [modelsByProvider, setModelsByProvider] = useState>({}) + + // Track which providers we've already started loading models for + const loadingRef = useRef>(new Set()) + + // Parse initial chain entries with stable IDs + useEffect(() => { + const settings = tool.settings as { providers?: Omit[] } + const entries = (settings?.providers ?? []).map((p) => ({ + ...p, + id: crypto.randomUUID(), + enabled: p.enabled ?? true, + timeout: p.timeout ?? 120, + max_retries: p.max_retries ?? 2, + })) + setChain(entries) + setError('') + setSaving(false) + loadingRef.current.clear() + + // Fetch providers list + getApiClient().get<{ providers: { id: string; name: string; display_name?: string }[] | null }>('/v1/providers') + .then((res) => { + setDbProviders((res.providers ?? []).map((p) => ({ id: p.id, name: p.name }))) + }) + .catch(() => {}) + }, [tool]) + + // Once dbProviders loads, fetch models for all existing chain entries + useEffect(() => { + if (dbProviders.length === 0) return + for (const entry of chain) { + if (entry.provider && !modelsByProvider[entry.provider] && !loadingRef.current.has(entry.provider)) { + loadModelsForProvider(entry.provider) + } + } + }, [dbProviders]) // eslint-disable-line react-hooks/exhaustive-deps + + async function loadModelsForProvider(providerName: string) { + if (modelsByProvider[providerName] || loadingRef.current.has(providerName)) return + loadingRef.current.add(providerName) + const prov = dbProviders.find((p) => p.name === providerName) + if (!prov) return + try { + const res = await getApiClient().get<{ models?: ModelInfo[] }>(`/v1/providers/${prov.id}/models`) + setModelsByProvider((prev) => ({ ...prev, [providerName]: res.models ?? [] })) + } catch { + // Mark as loaded with empty list so we don't retry — user can still type manually + setModelsByProvider((prev) => ({ ...prev, [providerName]: [] })) + } + } + + const handleUpdate = useCallback((id: string, updates: Partial) => { + setChain((prev) => prev.map((p) => p.id === id ? { ...p, ...updates } : p)) + }, []) + + const handleProviderChange = useCallback((id: string, providerName: string) => { + setChain((prev) => prev.map((p) => p.id === id ? { ...p, provider: providerName, model: '' } : p)) + if (providerName) loadModelsForProvider(providerName) + }, [dbProviders, modelsByProvider]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleRemove = useCallback((id: string) => { + setChain((prev) => prev.filter((p) => p.id !== id)) + }, []) + + function addEntry() { + setChain((prev) => [...prev, { + id: crypto.randomUUID(), + provider: '', + model: '', + enabled: true, + timeout: 120, + max_retries: 2, + }]) + } + + async function handleSave() { + setSaving(true) + setError('') + try { + // Strip internal `id` field before saving + const serialized = chain.map(({ id: _id, ...rest }) => rest) + await onSave(tool.name, { providers: serialized }) + onClose() + } catch (err) { + setError((err as Error).message || 'Failed to save') + } finally { + setSaving(false) + } + } + + // Drag-and-drop + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event + if (over && active.id !== over.id) { + setChain((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 providerOptions = dbProviders.map((p) => ({ value: p.name, label: p.name })) + + // Disable save if any entry is missing provider or model + const hasIncomplete = chain.some((e) => !e.provider || !e.model) + + return ( + <> +
+ + e.id)} strategy={verticalListSortingStrategy}> + {chain.map((entry, i) => { + const models = modelsByProvider[entry.provider] ?? [] + const modelOpts = models.map((m) => ({ value: m.id, label: m.name || m.id })) + const isModelLoading = entry.provider !== '' && !modelsByProvider[entry.provider] + return ( + + ) + })} + + + + +
+ + {error &&

{error}

} +
+ + +
+ + ) +} diff --git a/ui/desktop/frontend/src/components/settings/tools/ToolSettingsDialog.tsx b/ui/desktop/frontend/src/components/settings/tools/ToolSettingsDialog.tsx index 0807c55b..4c5b67d7 100644 --- a/ui/desktop/frontend/src/components/settings/tools/ToolSettingsDialog.tsx +++ b/ui/desktop/frontend/src/components/settings/tools/ToolSettingsDialog.tsx @@ -1,8 +1,7 @@ import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Switch } from '../../common/Switch' -import { Combobox } from '../../common/Combobox' -import { getApiClient } from '../../../lib/api' +import { MediaProviderChainForm } from './MediaProviderChainForm' import type { BuiltinToolData } from '../../../types/builtin-tool' const MEDIA_TOOLS = new Set([ @@ -24,7 +23,7 @@ export function ToolSettingsDialog({ open, onOpenChange, tool, onSave }: ToolSet return (
onOpenChange(false)} /> -
+
{/* Header */}
@@ -173,183 +172,6 @@ function ExtractorChainForm({ tool, onSave, onClose }: { tool: BuiltinToolData; ) } -/* ─── Media Provider Chain Form ─── */ - -interface ProviderEntry { - provider: string - model: string - enabled: boolean - timeout: number - max_retries: number - params?: Record -} - -function MediaProviderChainForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) { - const { t } = useTranslation(['tools', 'common']) - const [chain, setChain] = useState([]) - const [saving, setSaving] = useState(false) - const [error, setError] = useState('') - - // Load configured providers for dropdown - const [dbProviders, setDbProviders] = useState<{ id: string; name: string }[]>([]) - const [modelsByProvider, setModelsByProvider] = useState>({}) - - useEffect(() => { - const settings = tool.settings as { providers?: ProviderEntry[] } - setChain(settings?.providers ?? []) - setError('') - setSaving(false) - - // Fetch providers list - getApiClient().get<{ providers: { id: string; name: string; display_name?: string }[] | null }>('/v1/providers') - .then((res) => { - const list = (res.providers ?? []).map((p) => ({ id: p.id, name: p.name })) - setDbProviders(list) - }) - .catch(() => {}) - }, [tool]) - - async function loadModels(providerName: string) { - if (modelsByProvider[providerName]) return - const prov = dbProviders.find((p) => p.name === providerName) - if (!prov) return - try { - const res = await getApiClient().get<{ models?: { id: string }[] }>(`/v1/providers/${prov.id}/models`) - setModelsByProvider((prev) => ({ ...prev, [providerName]: (res.models ?? []).map((m) => m.id) })) - } catch { - // Fail silently — user can still type manually - } - } - - function updateEntry(index: number, updates: Partial) { - setChain((prev) => prev.map((p, i) => i === index ? { ...p, ...updates } : p)) - } - - function removeEntry(index: number) { - setChain((prev) => prev.filter((_, i) => i !== index)) - } - - function addEntry() { - setChain((prev) => [...prev, { provider: '', model: '', enabled: true, timeout: 120, max_retries: 2 }]) - } - - async function handleSave() { - setSaving(true) - setError('') - try { - await onSave(tool.name, { providers: chain }) - onClose() - } catch (err) { - setError((err as Error).message || 'Failed to save') - } finally { - setSaving(false) - } - } - - const providerOptions = dbProviders.map((p) => ({ value: p.name, label: p.name })) - - return ( - <> -
- {chain.map((entry, i) => { - const modelOpts = (modelsByProvider[entry.provider] ?? []).map((m) => ({ value: m, label: m })) - return ( -
- {/* Header */} -
-
- #{i + 1} - {entry.provider || t('builtin.mediaChain.newProvider')} - {entry.model && / {entry.model}} -
-
- updateEntry(i, { enabled: v })} /> - -
-
- - {/* Provider + Model (Combobox) */} -
-
- - { - updateEntry(i, { provider: v, model: '' }) - loadModels(v) - }} - options={providerOptions} - placeholder={t('builtin.mediaChain.selectProvider')} - allowCustom - /> -
-
- - updateEntry(i, { model: v })} - options={modelOpts} - placeholder={t('builtin.mediaChain.selectModel')} - loading={entry.provider !== '' && !modelsByProvider[entry.provider]} - allowCustom - /> -
-
- - {/* Timeout + Retries */} -
-
- - updateEntry(i, { timeout: Math.max(1, Number(e.target.value)) })} - className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" - /> -
-
- - updateEntry(i, { max_retries: Math.max(0, Number(e.target.value)) })} - className="w-full bg-surface-tertiary border border-border rounded-lg px-3 py-1.5 text-base md:text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent" - /> -
-
-
- ) - })} - - -
- - {error &&

{error}

} -
- - -
- - ) -} - /* ─── Generic JSON Settings Form (fallback) ─── */ function JsonSettingsForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {