"use client";
import { useState, useEffect } from "react";
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
// 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 { copied, copy } = useCopyToClipboard();
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const [combosRes, providersRes] = await Promise.all([
fetch("/api/combos"),
fetch("/api/providers"),
]);
const combosData = await combosRes.json();
const providersData = await providersRes.json();
if (combosRes.ok) setCombos(combosData.combos || []);
if (providersRes.ok) {
const active = (providersData.connections || []).filter(
c => c.testStatus === "active" || c.testStatus === "success"
);
setActiveProviders(active);
}
} 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) => {
if (!confirm("Delete this combo?")) return;
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);
}
};
if (loading) {
return (
);
}
return (
{/* Header */}
Combos
Create model combos with fallback support
setShowCreateModal(true)}>
Create Combo
{/* Combos List */}
{combos.length === 0 ? (
layers
No combos yet
setShowCreateModal(true)}>
Create your first combo
) : (
{combos.map((combo) => (
setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
/>
))}
)}
{/* 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}
/>
);
}
function ComboCard({ combo, copied, onCopy, onEdit, onDelete }) {
return (
{/* Name + Copy */}
layers
{combo.name}
onCopy(combo.name, `combo-${combo.id}`)}
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title="Copy combo name"
>
{copied === `combo-${combo.id}` ? "check" : "content_copy"}
{/* Models list */}
{combo.models.length === 0 ? (
No models added
) : (
combo.models.map((model, index) => (
{index + 1}.
{model}
{index === 0 && (
Primary
)}
{index > 0 && (
Fallback
)}
))
)}
{/* Actions */}
edit
delete
);
}
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
// 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({});
// Fetch model aliases when modal opens
useEffect(() => {
if (isOpen) {
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
fetchModelAliases();
}
}, [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 handleRemoveModel = (index) => {
setModels(models.filter((_, i) => i !== index));
};
const handleModelChange = (index, value) => {
const newModels = [...models];
newModels[index] = value;
setModels(newModels);
};
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 */}
{/* Models */}
Models
setShowModelSelect(true)}
>
Add Model
{models.length === 0 ? (
No models added
Click "Add Model" to add
) : (
{models.map((model, index) => (
{/* Priority arrows */}
handleMoveUp(index)}
disabled={index === 0}
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/30" : "hover:bg-surface text-text-muted hover:text-primary"}`}
>
keyboard_arrow_up
handleMoveDown(index)}
disabled={index === models.length - 1}
className={`p-0.5 rounded ${index === models.length - 1 ? "text-text-muted/30" : "hover:bg-surface text-text-muted hover:text-primary"}`}
>
keyboard_arrow_down
{/* Model Input */}
handleModelChange(index, e.target.value)}
placeholder="model-name"
className="flex-1"
/>
{/* Remove */}
handleRemoveModel(index)}
className="p-2 hover:bg-red-50 rounded text-red-500"
>
close
))}
)}
{/* Actions */}
{saving ? "Saving..." : "Apply"}
Cancel
{/* Model Select Modal */}
setShowModelSelect(false)}
onSelect={handleAddModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Add Model to Combo"
/>
>
);
}