9router/src/app/(dashboard)/dashboard/combos/page.js

470 lines
16 KiB
JavaScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal } 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 { copied, copy } = useCopyToClipboard();
useEffect(() => {
fetchData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
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) {
setActiveProviders(providersData.connections || []);
}
} 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 (
<div className="flex flex-col gap-6">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Combos</h1>
<p className="text-sm text-text-muted mt-1">
Create model combos with fallback support
</p>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)}>
Create Combo
</Button>
</div>
{/* Combos List */}
{combos.length === 0 ? (
<Card>
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<span className="material-symbols-outlined text-[32px]">layers</span>
</div>
<p className="text-text-main font-medium mb-1">No combos yet</p>
<p className="text-sm text-text-muted mb-4">Create model combos with fallback support</p>
<Button icon="add" onClick={() => setShowCreateModal(true)}>
Create Combo
</Button>
</div>
</Card>
) : (
<div className="flex flex-col gap-4">
{combos.map((combo) => (
<ComboCard
key={combo.id}
combo={combo}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
/>
))}
</div>
)}
{/* Create Modal - Use key to force remount and reset state */}
<ComboFormModal
key="create"
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSave={handleCreate}
activeProviders={activeProviders}
/>
{/* Edit Modal - Use key to force remount and reset state */}
<ComboFormModal
key={editingCombo?.id || "new"}
isOpen={!!editingCombo}
combo={editingCombo}
onClose={() => setEditingCombo(null)}
onSave={(data) => handleUpdate(editingCombo.id, data)}
activeProviders={activeProviders}
/>
</div>
);
}
function ComboCard({ combo, copied, onCopy, onEdit, onDelete }) {
return (
<Card padding="sm" className="group">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<code className="text-sm font-medium font-mono truncate">{combo.name}</code>
<button
onClick={(e) => { e.stopPropagation(); onCopy(combo.name, `combo-${combo.id}`); }}
className="p-0.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors opacity-0 group-hover:opacity-100"
title="Copy combo name"
>
<span className="material-symbols-outlined text-[14px]">
{copied === `combo-${combo.id}` ? "check" : "content_copy"}
</span>
</button>
</div>
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
{combo.models.length === 0 ? (
<span className="text-xs text-text-muted italic">No models</span>
) : (
combo.models.slice(0, 3).map((model, index) => (
<code key={index} className="text-[10px] font-mono bg-black/5 dark:bg-white/5 px-1.5 py-0.5 rounded text-text-muted">
{model}
</code>
))
)}
{combo.models.length > 3 && (
<span className="text-[10px] text-text-muted">+{combo.models.length - 3} more</span>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={onEdit}
className="p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors"
title="Edit"
>
<span className="material-symbols-outlined text-[16px]">edit</span>
</button>
<button
onClick={onDelete}
className="p-1.5 hover:bg-red-500/10 rounded text-red-500 transition-colors"
title="Delete"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
</div>
</div>
</Card>
);
}
// Inline editable model item
function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) {
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); // revert if empty or unchanged
setEditing(false);
};
const handleKeyDown = (e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") { setDraft(model); setEditing(false); }
};
return (
<div className="group flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/[0.02] dark:bg-white/[0.02] hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-colors">
{/* Index badge */}
<span className="text-[10px] font-medium text-text-muted w-3 text-center shrink-0">{index + 1}</span>
{/* Inline editable model value */}
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={handleKeyDown}
className="flex-1 min-w-0 px-1.5 py-0.5 text-xs font-mono bg-white dark:bg-black/20 border border-primary/40 rounded outline-none text-text-main"
/>
) : (
<div
className="flex-1 min-w-0 px-1.5 py-0.5 text-xs font-mono text-text-main truncate cursor-text hover:bg-black/5 dark:hover:bg-white/5 rounded"
onClick={() => setEditing(true)}
title="Click to edit"
>
{model}
</div>
)}
{/* Priority arrows */}
<div className="flex items-center gap-0.5">
<button
onClick={onMoveUp}
disabled={isFirst}
className={`p-0.5 rounded ${isFirst ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move up"
>
<span className="material-symbols-outlined text-[12px]">arrow_upward</span>
</button>
<button
onClick={onMoveDown}
disabled={isLast}
className={`p-0.5 rounded ${isLast ? "text-text-muted/20 cursor-not-allowed" : "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"}`}
title="Move down"
>
<span className="material-symbols-outlined text-[12px]">arrow_downward</span>
</button>
</div>
{/* Remove */}
<button
onClick={onRemove}
className="p-0.5 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 transition-all"
title="Remove"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</div>
);
}
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({});
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 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 (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEdit ? "Edit Combo" : "Create Combo"}
>
<div className="flex flex-col gap-3">
{/* Name */}
<div>
<Input
label="Combo Name"
value={name}
onChange={handleNameChange}
placeholder="my-combo"
error={nameError}
/>
<p className="text-[10px] text-text-muted mt-0.5">
Only letters, numbers, - and _ allowed
</p>
</div>
{/* Models */}
<div>
<label className="text-sm font-medium mb-1.5 block">Models</label>
{models.length === 0 ? (
<div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]">
<span className="material-symbols-outlined text-text-muted text-xl mb-1">layers</span>
<p className="text-xs text-text-muted">No models added yet</p>
</div>
) : (
<div className="flex flex-col gap-1 max-h-[200px] overflow-y-auto">
{models.map((model, index) => (
<ModelItem
key={index}
index={index}
model={model}
isFirst={index === 0}
isLast={index === models.length - 1}
onEdit={(newVal) => {
const updated = [...models];
updated[index] = newVal;
setModels(updated);
}}
onMoveUp={() => handleMoveUp(index)}
onMoveDown={() => handleMoveDown(index)}
onRemove={() => handleRemoveModel(index)}
/>
))}
</div>
)}
{/* Add Model button */}
<button
onClick={() => setShowModelSelect(true)}
className="w-full mt-2 py-2 border border-dashed border-black/10 dark:border-white/10 rounded-lg text-xs text-text-muted hover:text-primary hover:border-primary/30 transition-colors flex items-center justify-center gap-1"
>
<span className="material-symbols-outlined text-[16px]">add</span>
Add Model
</button>
</div>
{/* Actions */}
<div className="flex gap-2 pt-1">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
Cancel
</Button>
<Button
onClick={handleSave}
fullWidth
size="sm"
disabled={!name.trim() || !!nameError || saving}
>
{saving ? "Saving..." : isEdit ? "Save" : "Create"}
</Button>
</div>
</div>
</Modal>
{/* Model Select Modal */}
<ModelSelectModal
isOpen={showModelSelect}
onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Add Model to Combo"
/>
</>
);
}