mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 12:16:54 +00:00
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:
1 parent
b088144fd9
commit
ea60397754
4 files changed
+352
-180
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+19
@@ -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 }) {
|
||||
|
||||
Reference in new issue
Block a user