feat: add drag-and-drop reordering for combo models (#1056) (#1108)

This commit is contained in:
Zanuar Tri Romadon 2026-05-14 09:54:52 +07:00 committed by GitHub
parent a8100e9444
commit 0a05285973
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 80 additions and 24 deletions

View file

@ -12,6 +12,10 @@
"start:bun": "NODE_ENV=production bun ./.next/standalone/server.js"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@xyflow/react": "^12.10.1",
"bcryptjs": "^3.0.3",

View file

@ -1,6 +1,10 @@
"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";
@ -283,15 +287,20 @@ function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled,
);
}
// Inline editable model item
function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown, onRemove }) {
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); // revert if empty or unchanged
else setDraft(model);
setEditing(false);
};
@ -301,7 +310,26 @@ function ModelItem({ index, model, isFirst, isLast, onEdit, onMoveUp, onMoveDown
};
return (
<div className="group flex min-w-0 items-center gap-1.5 rounded-md bg-black/[0.02] px-2 py-1 transition-colors hover:bg-black/[0.04] dark:bg-white/[0.02] dark:hover:bg-white/[0.04]">
<div
ref={setNodeRef}
style={style}
className={`group flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 bg-black/[0.02] hover:bg-black/[0.04] dark:bg-white/[0.02] dark:hover:bg-white/[0.04] transition-colors ${isDragging ? "shadow-md ring-1 ring-primary/30" : ""}`}
>
{/* Drag handle */}
<button
{...attributes}
{...listeners}
type="button"
className="cursor-grab touch-none p-0.5 rounded text-text-muted hover:text-primary active:cursor-grabbing shrink-0"
title="Drag to reorder"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="4" r="2"/><circle cx="15" cy="4" r="2"/>
<circle cx="9" cy="12" r="2"/><circle cx="15" cy="12" r="2"/>
<circle cx="9" cy="20" r="2"/><circle cx="15" cy="20" r="2"/>
</svg>
</button>
{/* Index badge */}
<span className="text-[10px] font-medium text-text-muted w-3 text-center shrink-0">{index + 1}</span>
@ -366,6 +394,25 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
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");
@ -470,25 +517,30 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF
<p className="text-xs text-text-muted">No models added yet</p>
</div>
) : (
<div className="flex max-h-[55vh] min-w-0 flex-col gap-1 overflow-y-auto sm:max-h-[350px]">
{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>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis, restrictToParentElement]}>
<SortableContext items={modelItems.map((m) => m.uid)} strategy={verticalListSortingStrategy}>
<div className="flex max-h-[55vh] min-w-0 flex-col gap-1 overflow-y-auto sm:max-h-[350px]">
{modelItems.map(({ uid, model }, index) => (
<ModelItem
key={uid}
id={uid}
index={index}
model={model}
isFirst={index === 0}
isLast={index === modelItems.length - 1}
onEdit={(newVal) => {
const updated = [...models];
updated[index] = newVal;
setModels(updated);
}}
onMoveUp={() => handleMoveUp(index)}
onMoveDown={() => handleMoveDown(index)}
onRemove={() => handleRemoveModel(index)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* Add Model button */}