"use client"; import { useState, useEffect, useCallback } from "react"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, Toggle, ConfirmModal } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; // Validate combo name: only a-z, A-Z, 0-9, -, _ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; export default function CombosPage() { const [combos, setCombos] = useState([]); const [loading, setLoading] = useState(true); const [showCreateModal, setShowCreateModal] = useState(false); const [editingCombo, setEditingCombo] = useState(null); const [activeProviders, setActiveProviders] = useState([]); const [comboStrategies, setComboStrategies] = useState({}); const [confirmState, setConfirmState] = useState(null); const { copied, copy } = useCopyToClipboard(); useEffect(() => { fetchData(); }, []); // eslint-disable-line react-hooks/exhaustive-deps const fetchData = async () => { try { const [combosRes, providersRes, settingsRes] = await Promise.all([ fetch("/api/combos"), fetch("/api/providers"), fetch("/api/settings"), ]); const combosData = await combosRes.json(); const providersData = await providersRes.json(); const settingsData = settingsRes.ok ? await settingsRes.json() : {}; // Only LLM combos here - webSearch/webFetch combos belong to media-providers/web if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind || c.kind === "llm")); if (providersRes.ok) { setActiveProviders(providersData.connections || []); } setComboStrategies(settingsData.comboStrategies || {}); } catch (error) { console.log("Error fetching data:", error); } finally { setLoading(false); } }; const handleCreate = async (data) => { try { const res = await fetch("/api/combos", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (res.ok) { await fetchData(); setShowCreateModal(false); } else { const err = await res.json(); alert(err.error || "Failed to create combo"); } } catch (error) { console.log("Error creating combo:", error); } }; const handleUpdate = async (id, data) => { try { const res = await fetch(`/api/combos/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); if (res.ok) { await fetchData(); setEditingCombo(null); } else { const err = await res.json(); alert(err.error || "Failed to update combo"); } } catch (error) { console.log("Error updating combo:", error); } }; const handleDelete = async (id) => { setConfirmState({ title: "Delete Combo", message: "Delete this combo?", onConfirm: async () => { setConfirmState(null); try { const res = await fetch(`/api/combos/${id}`, { method: "DELETE" }); if (res.ok) { setCombos(combos.filter(c => c.id !== id)); } } catch (error) { console.log("Error deleting combo:", error); } } }); }; const handleToggleRoundRobin = async (comboName, enabled) => { try { const updated = { ...comboStrategies }; if (enabled) { updated[comboName] = { fallbackStrategy: "round-robin" }; } else { delete updated[comboName]; } await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comboStrategies: updated }), }); setComboStrategies(updated); } catch (error) { console.log("Error updating combo strategy:", error); } }; if (loading) { return (
); } return (
{/* Header */}

Combos

Create model combos with fallback support

{/* Combos List */} {combos.length === 0 ? (
layers

No combos yet

Create model combos with fallback support

) : (
{combos.map((combo) => ( setEditingCombo(combo)} onDelete={() => handleDelete(combo.id)} roundRobinEnabled={comboStrategies[combo.name]?.fallbackStrategy === "round-robin"} onToggleRoundRobin={(enabled) => handleToggleRoundRobin(combo.name, enabled)} /> ))}
)} {/* Create Modal - Use key to force remount and reset state */} setShowCreateModal(false)} onSave={handleCreate} activeProviders={activeProviders} /> {/* Edit Modal - Use key to force remount and reset state */} setEditingCombo(null)} onSave={(data) => handleUpdate(editingCombo.id, data)} activeProviders={activeProviders} /> {/* Confirm Delete Modal */} setConfirmState(null)} onConfirm={confirmState?.onConfirm} title={confirmState?.title || "Confirm"} message={confirmState?.message} variant="danger" />
); } function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled, onToggleRoundRobin }) { return (
layers
{combo.name}
{combo.models.length === 0 ? ( No models ) : ( combo.models.slice(0, 3).map((model, index) => ( {model} )) )} {combo.models.length > 3 && ( +{combo.models.length - 3} more )}
{/* Actions */}
{/* Round Robin Toggle — always visible */}
Round Robin
); } function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), // no transition — prevents the CSS settle animation fighting React's re-render on drop opacity: isDragging ? 0.4 : 1, zIndex: isDragging ? 999 : undefined, }; const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(model); const commit = () => { const trimmed = draft.trim(); if (trimmed && trimmed !== model) onEdit(trimmed); else setDraft(model); setEditing(false); }; const handleKeyDown = (e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") { setDraft(model); setEditing(false); } }; return (
{/* Drag handle */} {/* Index badge */} {index + 1} {/* Inline editable model value */} {editing ? ( setDraft(e.target.value)} onBlur={commit} onKeyDown={handleKeyDown} className="min-w-0 flex-1 rounded border border-primary/40 bg-white px-1.5 py-0.5 font-mono text-xs text-text-main outline-none dark:bg-black/20" /> ) : (
setEditing(true)} title="Click to edit" > {model}
)} {/* Priority arrows */}
{/* Remove */}
); } function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) { // Initialize state with combo values - key prop on parent handles reset on remount const [name, setName] = useState(combo?.name || ""); const [models, setModels] = useState(combo?.models || []); const [showModelSelect, setShowModelSelect] = useState(false); const [saving, setSaving] = useState(false); const [nameError, setNameError] = useState(""); const [modelAliases, setModelAliases] = useState({}); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ); // Use stable index-based IDs so duplicates and similar names are handled correctly const modelItems = models.map((model, i) => ({ uid: `item-${i}`, model })); const handleDragEnd = (event) => { const { active, over } = event; if (over && active.id !== over.id) { const oldIndex = modelItems.findIndex((m) => m.uid === active.id); const newIndex = modelItems.findIndex((m) => m.uid === over.id); if (oldIndex !== -1 && newIndex !== -1) { setModels((prev) => arrayMove(prev, oldIndex, newIndex)); } } }; const fetchModalData = async () => { try { const aliasesRes = await fetch("/api/models/alias"); if (!aliasesRes.ok) return; const aliasesData = await aliasesRes.json(); setModelAliases(aliasesData.aliases || {}); } catch (error) { console.error("Error fetching modal data:", error); } }; useEffect(() => { if (isOpen) fetchModalData(); }, [isOpen]); const validateName = (value) => { if (!value.trim()) { setNameError("Name is required"); return false; } if (!VALID_NAME_REGEX.test(value)) { setNameError("Only letters, numbers, -, _ and . allowed"); return false; } setNameError(""); return true; }; const handleNameChange = (e) => { const value = e.target.value; setName(value); if (value) validateName(value); else setNameError(""); }; const handleAddModel = (model) => { if (!models.includes(model.value)) { setModels([...models, model.value]); } }; const handleDeselectModel = (model) => { setModels(models.filter((m) => m !== model.value)); }; const handleRemoveModel = (index) => { setModels(models.filter((_, i) => i !== index)); }; const handleMoveUp = (index) => { if (index === 0) return; const newModels = [...models]; [newModels[index - 1], newModels[index]] = [newModels[index], newModels[index - 1]]; setModels(newModels); }; const handleMoveDown = (index) => { if (index === models.length - 1) return; const newModels = [...models]; [newModels[index], newModels[index + 1]] = [newModels[index + 1], newModels[index]]; setModels(newModels); }; const handleSave = async () => { if (!validateName(name)) return; setSaving(true); await onSave({ name: name.trim(), models }); setSaving(false); }; const isEdit = !!combo; return ( <>
{/* Name */}

Only letters, numbers, -, _ and . allowed

{/* Models */}
{models.length === 0 ? (
layers

No models added yet

) : ( m.uid)} strategy={verticalListSortingStrategy}>
{modelItems.map(({ uid, model }, index) => ( { const updated = [...models]; updated[index] = newVal; setModels(updated); }} onMoveUp={() => handleMoveUp(index)} onMoveDown={() => handleMoveDown(index)} onRemove={() => handleRemoveModel(index)} /> ))}
)} {/* Add Model button */}
{/* Actions */}
{/* Model Select Modal */} setShowModelSelect(false)} onSelect={handleAddModel} onDeselect={handleDeselectModel} activeProviders={activeProviders} modelAliases={modelAliases} title="Add Model to Combo" kindFilter={kindFilter} addedModelValues={models} closeOnSelect={false} /> ); }