fix(desktop): fix media chain settings not editable on reopen + add drag-drop

- Fix model Combobox permanently disabled on reopen: load models for
  existing chain entries once providers list is fetched
- Add drag-and-drop reordering via @dnd-kit/sortable (matching web UI)
- Use stable UUIDs as React keys instead of array indices
- Show model display names (e.g. "Gemini 2.0 Flash") instead of raw IDs
- Disable Save button when any entry is missing provider or model
- Extract MediaProviderChainForm into its own file
This commit is contained in:
viettranx committed 2026-03-28 09:19:00 +07:00
1 parent b088144fd9
commit ea60397754
4 files changed
+352 -180

No files matched your search

+2
View File
@@ -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",
+19
View File
@@ -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
@@ -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<string, unknown>
}
interface ModelInfo {
id: string
name?: string
}
interface MediaProviderChainFormProps {
tool: BuiltinToolData
onSave: (name: string, settings: Record<string, unknown>) => Promise<void>
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<ProviderEntry>) => 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 (
<div ref={setNodeRef} style={style} className="rounded-lg border border-border p-3 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Drag handle */}
<button
type="button"
className="cursor-grab text-text-muted hover:text-text-primary shrink-0 touch-none"
{...attributes}
{...listeners}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
</svg>
</button>
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{index + 1}</span>
<span className="text-sm font-medium text-text-primary">{entry.provider || t('builtin.mediaChain.newProvider')}</span>
{entry.model && <span className="text-xs text-text-muted">/ {entry.model}</span>}
</div>
<div className="flex items-center gap-2">
<Switch checked={entry.enabled} onCheckedChange={(v) => onUpdate(entry.id, { enabled: v })} />
<button onClick={() => onRemove(entry.id)} className="p-1 text-text-muted hover:text-error transition-colors">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
{/* Provider + Model */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.provider')}</label>
<Combobox
value={entry.provider}
onChange={(v) => onProviderChange(entry.id, v)}
options={providerOptions}
placeholder={t('builtin.mediaChain.selectProvider')}
allowCustom
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.model')}</label>
<Combobox
value={entry.model}
onChange={(v) => onUpdate(entry.id, { model: v })}
options={modelOptions}
placeholder={t('builtin.mediaChain.selectModel')}
loading={modelLoading}
allowCustom
/>
</div>
</div>
{/* Timeout + Retries */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.timeout')}</label>
<input
type="number"
min={1}
max={600}
value={entry.timeout}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
<input
type="number"
min={0}
max={10}
value={entry.max_retries}
onChange={(e) => 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"
/>
</div>
</div>
</div>
)
}
/* ─── Main Form ─── */
export function MediaProviderChainForm({ tool, onSave, onClose }: MediaProviderChainFormProps) {
const { t } = useTranslation(['tools', 'common'])
const [chain, setChain] = useState<ProviderEntry[]>([])
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [dbProviders, setDbProviders] = useState<{ id: string; name: string }[]>([])
const [modelsByProvider, setModelsByProvider] = useState<Record<string, ModelInfo[]>>({})
// Track which providers we've already started loading models for
const loadingRef = useRef<Set<string>>(new Set())
// Parse initial chain entries with stable IDs
useEffect(() => {
const settings = tool.settings as { providers?: Omit<ProviderEntry, 'id'>[] }
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<ProviderEntry>) => {
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 (
<>
<div className="max-h-[60vh] overflow-y-auto p-5 space-y-3">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={chain.map((e) => 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 (
<SortableProviderCard
key={entry.id}
entry={entry}
index={i}
providerOptions={providerOptions}
modelOptions={modelOpts}
modelLoading={isModelLoading}
onUpdate={handleUpdate}
onRemove={handleRemove}
onProviderChange={handleProviderChange}
/>
)
})}
</SortableContext>
</DndContext>
<button onClick={addEntry} className="text-xs text-accent hover:text-accent-hover flex items-center gap-1 transition-colors">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
{t('builtin.mediaChain.addProvider')}
</button>
</div>
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
{t('builtin.mediaChain.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={saving || hasIncomplete} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
{saving ? t('builtin.mediaChain.saving') : t('builtin.mediaChain.save')}
</button>
</div>
</>
)
}
@@ -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 (
<div className="fixed inset-0 z-[70] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
<div className="relative w-full max-w-lg bg-surface-secondary rounded-xl border border-border overflow-hidden">
<div className="relative w-full max-w-2xl mx-4 bg-surface-secondary rounded-xl border border-border overflow-hidden flex flex-col" style={{ maxHeight: '85vh' }}>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
@@ -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<string, unknown>
}
function MediaProviderChainForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {
const { t } = useTranslation(['tools', 'common'])
const [chain, setChain] = useState<ProviderEntry[]>([])
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<Record<string, string[]>>({})
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<ProviderEntry>) {
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 (
<>
<div className="max-h-[60vh] overflow-y-auto p-5 space-y-3">
{chain.map((entry, i) => {
const modelOpts = (modelsByProvider[entry.provider] ?? []).map((m) => ({ value: m, label: m }))
return (
<div key={i} className="rounded-lg border border-border p-3 space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[11px] font-mono text-text-muted bg-surface-tertiary rounded px-1.5 py-0.5">#{i + 1}</span>
<span className="text-sm font-medium text-text-primary">{entry.provider || t('builtin.mediaChain.newProvider')}</span>
{entry.model && <span className="text-xs text-text-muted">/ {entry.model}</span>}
</div>
<div className="flex items-center gap-2">
<Switch checked={entry.enabled} onCheckedChange={(v) => updateEntry(i, { enabled: v })} />
<button onClick={() => removeEntry(i)} className="p-1 text-text-muted hover:text-error transition-colors">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</div>
{/* Provider + Model (Combobox) */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.provider')}</label>
<Combobox
value={entry.provider}
onChange={(v) => {
updateEntry(i, { provider: v, model: '' })
loadModels(v)
}}
options={providerOptions}
placeholder={t('builtin.mediaChain.selectProvider')}
allowCustom
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.model')}</label>
<Combobox
value={entry.model}
onChange={(v) => updateEntry(i, { model: v })}
options={modelOpts}
placeholder={t('builtin.mediaChain.selectModel')}
loading={entry.provider !== '' && !modelsByProvider[entry.provider]}
allowCustom
/>
</div>
</div>
{/* Timeout + Retries */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.timeout')}</label>
<input
type="number"
min={1}
max={600}
value={entry.timeout}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-text-secondary">{t('builtin.mediaChain.retries')}</label>
<input
type="number"
min={0}
max={10}
value={entry.max_retries}
onChange={(e) => 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"
/>
</div>
</div>
</div>
)
})}
<button onClick={addEntry} className="text-xs text-accent hover:text-accent-hover flex items-center gap-1 transition-colors">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
{t('builtin.mediaChain.addProvider')}
</button>
</div>
{error && <div className="px-5"><p className="text-xs text-error">{error}</p></div>}
<div className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
<button type="button" onClick={onClose} className="border border-border rounded-lg px-4 py-1.5 text-sm text-text-secondary hover:bg-surface-tertiary transition-colors">
{t('builtin.mediaChain.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={saving} className="bg-accent rounded-lg px-4 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
{saving ? t('builtin.mediaChain.saving') : t('builtin.mediaChain.save')}
</button>
</div>
</>
)
}
/* ─── Generic JSON Settings Form (fallback) ─── */
function JsonSettingsForm({ tool, onSave, onClose }: { tool: BuiltinToolData; onSave: ToolSettingsDialogProps['onSave']; onClose: () => void }) {