| "use client";
|
|
|
| import { useState, useEffect } from "react";
|
| import { useTranslations } from "next-intl";
|
|
|
| export interface ModelMapping {
|
| id: string;
|
| pattern: string;
|
| comboId: string;
|
| comboName?: string;
|
| priority: number;
|
| enabled: boolean;
|
| description: string;
|
| }
|
|
|
| interface Combo {
|
| id: string;
|
| name: string;
|
| }
|
|
|
| export default function ModelRoutingSection({ combos: externalCombos }: { combos?: Combo[] } = {}) {
|
| const t = useTranslations("settings");
|
| const [mappings, setMappings] = useState<ModelMapping[]>([]);
|
| const [internalCombos, setInternalCombos] = useState<Combo[]>([]);
|
| const [loading, setLoading] = useState(true);
|
| const [adding, setAdding] = useState(false);
|
| const [editingId, setEditingId] = useState<string | null>(null);
|
| const combos = externalCombos || internalCombos;
|
|
|
|
|
| const [pattern, setPattern] = useState("");
|
| const [comboId, setComboId] = useState("");
|
| const [priority, setPriority] = useState(0);
|
| const [description, setDescription] = useState("");
|
|
|
| const loadMappings = async () => {
|
| try {
|
| const res = await fetch("/api/model-combo-mappings");
|
| if (res.ok) {
|
| const data = await res.json();
|
| return data.mappings || [];
|
| }
|
| } catch {}
|
| return [];
|
| };
|
|
|
| useEffect(() => {
|
| let cancelled = false;
|
| loadMappings().then((data) => {
|
| if (!cancelled) {
|
| setMappings(data);
|
| setLoading(false);
|
| }
|
| });
|
| return () => {
|
| cancelled = true;
|
| };
|
| }, []);
|
|
|
| useEffect(() => {
|
| if (externalCombos !== undefined) return;
|
| let cancelled = false;
|
| fetch("/api/combos")
|
| .then((res) => (res.ok ? res.json() : { combos: [] }))
|
| .then((data) => {
|
| if (!cancelled) {
|
| setInternalCombos(Array.isArray(data?.combos) ? data.combos : []);
|
| }
|
| })
|
| .catch(() => {});
|
| return () => {
|
| cancelled = true;
|
| };
|
| }, [externalCombos]);
|
|
|
| const refetchMappings = async () => {
|
| const data = await loadMappings();
|
| setMappings(data);
|
| };
|
|
|
| const resetForm = () => {
|
| setPattern("");
|
| setComboId("");
|
| setPriority(0);
|
| setDescription("");
|
| setAdding(false);
|
| setEditingId(null);
|
| };
|
|
|
| const handleSave = async () => {
|
| if (!pattern.trim() || !comboId) return;
|
|
|
| try {
|
| if (editingId) {
|
| const res = await fetch(`/api/model-combo-mappings/${editingId}`, {
|
| method: "PUT",
|
| headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({ pattern: pattern.trim(), comboId, priority, description }),
|
| });
|
| if (res.ok) await refetchMappings();
|
| } else {
|
| const res = await fetch("/api/model-combo-mappings", {
|
| method: "POST",
|
| headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({ pattern: pattern.trim(), comboId, priority, description }),
|
| });
|
| if (res.ok) await refetchMappings();
|
| }
|
| } catch {}
|
| resetForm();
|
| };
|
|
|
| const handleEdit = (m: ModelMapping) => {
|
| setPattern(m.pattern);
|
| setComboId(m.comboId);
|
| setPriority(m.priority);
|
| setDescription(m.description);
|
| setEditingId(m.id);
|
| setAdding(true);
|
| };
|
|
|
| const handleDelete = async (id: string) => {
|
| if (!confirm(t("deleteRoutingRule"))) return;
|
| try {
|
| await fetch(`/api/model-combo-mappings/${id}`, { method: "DELETE" });
|
| setMappings((prev) => prev.filter((m) => m.id !== id));
|
| } catch {}
|
| };
|
|
|
| const handleToggle = async (m: ModelMapping) => {
|
| try {
|
| const res = await fetch(`/api/model-combo-mappings/${m.id}`, {
|
| method: "PUT",
|
| headers: { "Content-Type": "application/json" },
|
| body: JSON.stringify({ enabled: !m.enabled }),
|
| });
|
| if (res.ok) {
|
| setMappings((prev) => prev.map((x) => (x.id === m.id ? { ...x, enabled: !x.enabled } : x)));
|
| }
|
| } catch {}
|
| };
|
|
|
| return (
|
| <div className="rounded-xl border border-black/10 dark:border-white/10 bg-white/50 dark:bg-white/[0.02] p-4">
|
| <div className="flex items-center justify-between">
|
| <div className="flex items-center gap-2">
|
| <span className="material-symbols-outlined text-primary text-[18px]">route</span>
|
| <div>
|
| <h3 className="text-sm font-semibold">{t("modelRoutingTitle")}</h3>
|
| <p className="text-[11px] text-text-muted">{t("modelRoutingDesc")}</p>
|
| </div>
|
| </div>
|
| {!adding && (
|
| <button
|
| onClick={() => setAdding(true)}
|
| className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg
|
| bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
| >
|
| <span className="material-symbols-outlined text-[14px]">add</span>
|
| {t("addRule")}
|
| </button>
|
| )}
|
| </div>
|
|
|
| {/* Inline form */}
|
| {adding && (
|
| <div className="mt-3 p-3 rounded-lg border border-primary/20 bg-primary/[0.03] dark:bg-primary/[0.06]">
|
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
| <div>
|
| <label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
| {t("pattern")}
|
| </label>
|
| <input
|
| value={pattern}
|
| onChange={(e) => setPattern(e.target.value)}
|
| placeholder="claude-sonnet*"
|
| className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
|
| bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
| />
|
| <p className="text-[9px] text-text-muted mt-0.5">{t("patternHint")}</p>
|
| </div>
|
| <div>
|
| <label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
| {t("routeToCombo")}
|
| </label>
|
| <select
|
| value={comboId}
|
| onChange={(e) => setComboId(e.target.value)}
|
| className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
|
| bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
| >
|
| <option value="">{t("selectCombo")}</option>
|
| {combos.map((c) => (
|
| <option key={c.id} value={c.id}>
|
| {c.name}
|
| </option>
|
| ))}
|
| </select>
|
| </div>
|
| <div>
|
| <label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
| {t("priority")}
|
| </label>
|
| <input
|
| type="number"
|
| value={priority}
|
| onChange={(e) => setPriority(Number(e.target.value))}
|
| className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
|
| bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
| />
|
| <p className="text-[9px] text-text-muted mt-0.5">{t("priorityHint")}</p>
|
| </div>
|
| <div>
|
| <label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
| {t("description")}
|
| </label>
|
| <input
|
| value={description}
|
| onChange={(e) => setDescription(e.target.value)}
|
| placeholder="Route Opus models to frontier combo"
|
| className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
|
| bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
| />
|
| </div>
|
| </div>
|
| <div className="flex items-center gap-2 mt-2.5">
|
| <button
|
| onClick={handleSave}
|
| disabled={!pattern.trim() || !comboId}
|
| className="px-3 py-1 text-xs font-medium rounded-lg bg-primary text-white
|
| hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
| >
|
| {editingId ? t("update") : t("save")}
|
| </button>
|
| <button
|
| onClick={resetForm}
|
| className="px-3 py-1 text-xs font-medium rounded-lg
|
| bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
| >
|
| {t("cancel")}
|
| </button>
|
| </div>
|
| </div>
|
| )}
|
|
|
| {/* Mappings list */}
|
| {loading ? (
|
| <div className="mt-3 text-xs text-text-muted">{t("loading")}</div>
|
| ) : mappings.length === 0 ? (
|
| <div className="mt-3 text-center py-4">
|
| <p className="text-xs text-text-muted">{t("noRoutingRules")}</p>
|
| <p className="text-[10px] text-text-muted mt-1">{t("routingRuleHint")}</p>
|
| </div>
|
| ) : (
|
| <div className="mt-3 flex flex-col gap-1.5">
|
| {mappings.map((m) => (
|
| <div
|
| key={m.id}
|
| className={`flex items-center justify-between px-3 py-2 rounded-lg border transition-colors
|
| ${
|
| m.enabled
|
| ? "border-black/10 dark:border-white/10 bg-white/70 dark:bg-white/[0.02]"
|
| : "border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-white/[0.01] opacity-50"
|
| }`}
|
| >
|
| <div className="flex items-center gap-3 min-w-0">
|
| <code className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-700 dark:text-amber-300 font-mono shrink-0">
|
| {m.pattern}
|
| </code>
|
| <span className="text-text-muted text-[10px]">→</span>
|
| <span className="text-xs font-medium text-primary truncate">
|
| {m.comboName || m.comboId.slice(0, 8)}
|
| </span>
|
| {m.description && (
|
| <span className="text-[10px] text-text-muted truncate hidden sm:inline">
|
| {m.description}
|
| </span>
|
| )}
|
| <span className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted shrink-0">
|
| P{m.priority}
|
| </span>
|
| </div>
|
| <div className="flex items-center gap-1 shrink-0">
|
| <button
|
| onClick={() => handleToggle(m)}
|
| className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
| title={m.enabled ? t("disable") : t("enable")}
|
| >
|
| <span
|
| className={`material-symbols-outlined text-[14px] ${m.enabled ? "text-emerald-500" : "text-text-muted"}`}
|
| >
|
| {m.enabled ? "toggle_on" : "toggle_off"}
|
| </span>
|
| </button>
|
| <button
|
| onClick={() => handleEdit(m)}
|
| className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
| title="Edit"
|
| >
|
| <span className="material-symbols-outlined text-[14px] text-text-muted">
|
| edit
|
| </span>
|
| </button>
|
| <button
|
| onClick={() => handleDelete(m.id)}
|
| className="p-1 rounded hover:bg-red-500/10 transition-colors"
|
| title="Delete"
|
| >
|
| <span className="material-symbols-outlined text-[14px] text-red-500">delete</span>
|
| </button>
|
| </div>
|
| </div>
|
| ))}
|
| </div>
|
| )}
|
| </div>
|
| );
|
| }
|
|
|