adk-gateway 1.0.0

Multi-channel AI gateway for adk-rust agents — Telegram, Slack, WhatsApp, Discord, Matrix + control panel
import { useState, useEffect, useRef, useMemo } from 'react';
import { fetchModelsForProvider, matchesModality, formatContext, formatPrice } from '../api/modelProviders';
import type { ModelInfo } from '../api/modelProviders';

interface Props {
  /** Provider ID to fetch models for */
  provider: string;
  /** Currently selected model ID (empty string for "add model" mode) */
  value: string;
  /** Called when a model is selected */
  onChange: (modelId: string) => void;
  /** Modality filter pattern for category-based filtering */
  modality?: string;
}

export default function ModelSelector({ provider, value, onChange, modality }: Props) {
  const [models, setModels] = useState<ModelInfo[]>([]);
  const [loading, setLoading] = useState(true);
  const [search, setSearch] = useState('');
  const [open, setOpen] = useState(false);
  const [highlighted, setHighlighted] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLDivElement>(null);

  // Fetch models when provider changes
  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetchModelsForProvider(provider).then((result) => {
      if (!cancelled) {
        setModels(result);
        setLoading(false);
      }
    });
    return () => { cancelled = true; };
  }, [provider]);

  // Filter by modality then search text
  const filtered = useMemo(() => {
    let result = models;
    if (modality) {
      result = result.filter((m) => matchesModality(m, modality));
    }
    if (search) {
      const q = search.toLowerCase();
      result = result.filter((m) =>
        m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q)
      );
    }
    return result.slice(0, 60);
  }, [models, search, modality]);

  const selected = value ? models.find((m) => m.id === value) : null;

  const handleSelect = (modelId: string) => {
    onChange(modelId);
    setSearch('');
    setOpen(false);
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setHighlighted((h) => Math.min(h + 1, filtered.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setHighlighted((h) => Math.max(h - 1, 0));
    } else if (e.key === 'Enter' && filtered[highlighted]) {
      e.preventDefault();
      handleSelect(filtered[highlighted].id);
    } else if (e.key === 'Escape') {
      setOpen(false);
    }
  };

  // Scroll highlighted into view
  useEffect(() => {
    if (open && listRef.current) {
      const el = listRef.current.children[highlighted] as HTMLElement;
      el?.scrollIntoView({ block: 'nearest' });
    }
  }, [highlighted, open]);

  // Reset highlighted when filter changes
  useEffect(() => { setHighlighted(0); }, [provider, search]);

  return (
    <div className="relative">
      <label className="block text-xs text-gray-500 mb-1">
        Model
        {loading && <span className="text-gray-400 ml-1">(loading...)</span>}
        {!loading && <span className="text-gray-400 ml-1">({filtered.length} available)</span>}
      </label>

      {/* Selected model display (only when value is set and dropdown is closed) */}
      {selected && !open && (
        <button
          type="button"
          onClick={() => { setOpen(true); setSearch(''); setTimeout(() => inputRef.current?.focus(), 0); }}
          className="w-full text-left px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white hover:border-[var(--color-accent)] transition-colors"
        >
          <div className="flex items-center justify-between">
            <div className="min-w-0">
              <span className="font-medium truncate">{selected.name}</span>
              <span className="text-gray-400 ml-2 font-mono text-xs">{selected.id}</span>
            </div>
            <div className="flex items-center gap-2 text-xs text-gray-500 shrink-0">
              {selected.context_length > 0 && <span>{formatContext(selected.context_length)} ctx</span>}
              <span>{formatPrice(selected.pricing.prompt)} in</span>
            </div>
          </div>
        </button>
      )}

      {/* Search input (shown when no value or dropdown is open) */}
      {(!selected || open) && (
        <input
          ref={inputRef}
          type="text"
          value={search}
          onChange={(e) => { setSearch(e.target.value); setOpen(true); setHighlighted(0); }}
          onFocus={() => setOpen(true)}
          onKeyDown={handleKeyDown}
          placeholder={value ? 'Search models...' : 'Search and add a model...'}
          className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:border-[var(--color-accent)]"
        />
      )}

      {/* Dropdown */}
      {open && (
        <>
          <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
          <div
            ref={listRef}
            className="absolute z-20 mt-1 w-full max-h-72 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg"
          >
            {filtered.length === 0 ? (
              <div className="px-4 py-3 text-sm text-gray-400">
                No models found{modality ? ' for this modality' : ''}
              </div>
            ) : (
              filtered.map((model, i) => (
                <button
                  key={model.id}
                  type="button"
                  onClick={() => handleSelect(model.id)}
                  className={`w-full text-left px-3 py-2 text-sm border-b border-gray-50 transition-colors ${
                    i === highlighted ? 'bg-blue-50' : 'hover:bg-gray-50'
                  } ${model.id === value ? 'bg-blue-50' : ''}`}
                >
                  <div className="flex items-center justify-between">
                    <div className="min-w-0">
                      <div className="truncate font-medium">{model.name}</div>
                      <div className="text-xs text-gray-400 font-mono truncate">{model.id}</div>
                    </div>
                    <div className="flex items-center gap-2 text-xs text-gray-500 flex-shrink-0 ml-2">
                      {model.context_length > 0 && (
                        <span className="bg-gray-100 px-1.5 py-0.5 rounded">{formatContext(model.context_length)}</span>
                      )}
                      <span>{formatPrice(model.pricing.prompt)}</span>
                    </div>
                  </div>
                </button>
              ))
            )}
          </div>
        </>
      )}

      {/* Manual entry option */}
      {!open && !value && (
        <div className="mt-1">
          <button
            type="button"
            onClick={() => {
              const custom = prompt('Enter model ID:');
              if (custom) onChange(custom);
            }}
            className="text-xs text-[var(--color-accent)] hover:underline"
          >
            Enter custom model ID
          </button>
        </div>
      )}
    </div>
  );
}