File size: 8,805 Bytes
88c4c60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | "use client";
import { useState, useEffect } from "react";
import Modal from "./Modal";
import Input from "./Input";
import Button from "./Button";
import ModelSelectModal from "./ModelSelectModal";
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
// 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);
setEditing(false);
};
const handleKeyDown = (e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") { setDraft(model); setEditing(false); }
};
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]">
<span className="text-[10px] font-medium text-text-muted w-3 text-center shrink-0">{index + 1}</span>
{editing ? (
<input autoFocus value={draft} onChange={(e) => 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" />
) : (
<div className="min-w-0 flex-1 cursor-text truncate rounded px-1.5 py-0.5 font-mono text-xs text-text-main hover:bg-black/5 dark:hover:bg-white/5"
onClick={() => setEditing(true)} title="Click to edit">{model}</div>
)}
<div className="flex shrink-0 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>
<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>
);
}
// Reusable Combo create/edit modal. forcePrefix auto-prepends to name.
export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null, forcePrefix = "", title }) {
// Strip prefix when editing existing combo so user only edits suffix
const initialName = combo?.name
? (forcePrefix && combo.name.startsWith(forcePrefix) ? combo.name.slice(forcePrefix.length) : combo.name)
: "";
const [name, setName] = useState(initialName);
const [models, setModels] = useState(combo?.models || []);
const [showModelSelect, setShowModelSelect] = useState(false);
const [saving, setSaving] = useState(false);
const [nameError, setNameError] = useState("");
const [modelAliases, setModelAliases] = useState({});
useEffect(() => {
if (!isOpen) return;
fetch("/api/models/alias").then((r) => r.ok ? r.json() : null).then((d) => d && setModelAliases(d.aliases || {})).catch(() => {});
}, [isOpen]);
const validateName = (value) => {
if (!value.trim()) { setNameError("Name is required"); return false; }
const full = forcePrefix + value;
if (!VALID_NAME_REGEX.test(full)) { setNameError("Only letters, numbers, -, _ and . allowed"); return false; }
setNameError("");
return true;
};
const handleNameChange = (e) => {
let value = e.target.value;
// If user types prefix manually, strip it (we always prepend)
if (forcePrefix && value.startsWith(forcePrefix)) value = value.slice(forcePrefix.length);
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 = (i) => setModels(models.filter((_, idx) => idx !== i));
const handleMoveUp = (i) => {
if (i === 0) return;
const a = [...models]; [a[i - 1], a[i]] = [a[i], a[i - 1]]; setModels(a);
};
const handleMoveDown = (i) => {
if (i === models.length - 1) return;
const a = [...models]; [a[i], a[i + 1]] = [a[i + 1], a[i]]; setModels(a);
};
const handleSave = async () => {
if (!validateName(name)) return;
setSaving(true);
await onSave({ name: forcePrefix + name.trim(), models });
setSaving(false);
};
const isEdit = !!combo;
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title={title || (isEdit ? "Edit Combo" : "Create Combo")}>
<div className="flex flex-col gap-3">
<div>
{forcePrefix ? (
<>
<label className="text-sm font-medium mb-1 block">Combo Name</label>
<div className="flex items-stretch">
<span className="inline-flex items-center px-2 rounded-l border border-r-0 border-black/10 dark:border-white/10 bg-black/[0.04] dark:bg-white/[0.04] text-text-muted font-mono text-sm">{forcePrefix}</span>
<input value={name} onChange={handleNameChange} placeholder="my-combo"
className="flex-1 min-w-0 rounded-r border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 px-2 py-1.5 font-mono text-sm outline-none focus:border-primary" />
</div>
{nameError && <p className="text-[11px] text-red-500 mt-0.5">{nameError}</p>}
</>
) : (
<Input label="Combo Name" value={name} onChange={handleNameChange} placeholder="my-combo" error={nameError} />
)}
<p className="text-[10px] text-text-muted mt-0.5">
{forcePrefix ? `Auto-prefixed with "${forcePrefix}". ` : ""}Only letters, numbers, -, _ and . allowed
</p>
</div>
<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 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={(v) => { const a = [...models]; a[index] = v; setModels(a); }}
onMoveUp={() => handleMoveUp(index)}
onMoveDown={() => handleMoveDown(index)}
onRemove={() => handleRemoveModel(index)} />
))}
</div>
)}
<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-primary font-medium hover:text-primary hover:border-primary/50 transition-colors flex items-center justify-center gap-1">
<span className="material-symbols-outlined text-[16px]">add</span>
Add Model
</button>
</div>
<div className="flex flex-col gap-2 pt-1 sm:flex-row">
<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>
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel} onDeselect={handleDeselectModel}
activeProviders={activeProviders} modelAliases={modelAliases}
title="Add Model to Combo" kindFilter={kindFilter}
addedModelValues={models} closeOnSelect={false} />
</>
);
}
|