SPB Git

spb/chat-spboucher Public

Private universal chat interface over the OpenRouter ecosystem — 400+ models, branching, streaming, usage tracking. Next.js 16 + SQLite, PWA, deployed on m4m64a at chat.spboucher.ai

TypeScript 78.8% CSS 15.1% JavaScript 4.9% Shell 1.2%
9.6 KB · 271 lines tsx
Raw Blame History
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45"use client";67import { useEffect, useMemo, useRef, useState } from "react";8import { formatTokens, perMillion, providerGlyph, type ApiModel } from "./types";910interface ModelSheetProps {11  models: ApiModel[];12  selectedId: string | null;13  onSelect: (id: string) => void;14  onToggleFavorite: (id: string, favorite: boolean) => void;15  onClose: () => void;16}1718type CapFilter = "reasoning" | "vision" | "tools" | "free";19type SortKey = "name" | "context" | "price" | "newest";2021const PAGE = 80;2223/** Subsequence fuzzy match with a crude score (lower = better). */24function fuzzyScore(query: string, target: string): number | null {25  const q = query.toLowerCase();26  const t = target.toLowerCase();27  const direct = t.indexOf(q);28  if (direct !== -1) return direct;29  let qi = 0;30  let gaps = 0;31  let last = -1;32  for (let ti = 0; ti < t.length && qi < q.length; ti++) {33    if (t[ti] === q[qi]) {34      if (last !== -1) gaps += ti - last - 1;35      last = ti;36      qi++;37    }38  }39  return qi === q.length ? 1000 + gaps : null;40}4142export function ModelSheet({ models, selectedId, onSelect, onToggleFavorite, onClose }: ModelSheetProps) {43  const [query, setQuery] = useState("");44  const [caps, setCaps] = useState<Set<CapFilter>>(new Set());45  const [provider, setProvider] = useState<string | null>(null);46  const [sort, setSort] = useState<SortKey>("name");47  const [limit, setLimit] = useState(PAGE);48  const listRef = useRef<HTMLDivElement>(null);49  const inputRef = useRef<HTMLInputElement>(null);5051  useEffect(() => {52    // Focus search on desktop only — on mobile the keyboard would cover the list.53    if (!window.matchMedia("(pointer: coarse)").matches) inputRef.current?.focus();54    const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();55    window.addEventListener("keydown", onKey);56    return () => window.removeEventListener("keydown", onKey);57  }, [onClose]);5859  const providers = useMemo(() => {60    const counts = new Map<string, number>();61    for (const m of models) {62      if (!m.available || !m.provider) continue;63      counts.set(m.provider, (counts.get(m.provider) ?? 0) + 1);64    }65    return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([p]) => p);66  }, [models]);6768  const filtered = useMemo(() => {69    let list = models.filter((m) => m.available);70    if (provider) list = list.filter((m) => m.provider === provider);71    if (caps.has("reasoning")) list = list.filter((m) => m.capabilities.reasoning);72    if (caps.has("vision")) list = list.filter((m) => m.capabilities.vision);73    if (caps.has("tools")) list = list.filter((m) => m.capabilities.tools);74    if (caps.has("free")) list = list.filter((m) => (m.pricing?.completion ?? 0) === 0 && (m.pricing?.prompt ?? 0) === 0);7576    if (query.trim()) {77      const scored: Array<{ m: ApiModel; s: number }> = [];78      for (const m of list) {79        const s = fuzzyScore(query.trim(), `${m.name} ${m.id} ${m.provider ?? ""}`);80        if (s !== null) scored.push({ m, s });81      }82      scored.sort((a, b) => a.s - b.s);83      return scored.map((x) => x.m);84    }8586    const sorted = [...list];87    switch (sort) {88      case "context":89        sorted.sort((a, b) => (b.contextLength ?? 0) - (a.contextLength ?? 0));90        break;91      case "price":92        sorted.sort((a, b) => (a.pricing?.completion ?? 0) - (b.pricing?.completion ?? 0));93        break;94      case "newest":95        sorted.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0));96        break;97      default:98        sorted.sort((a, b) => a.name.localeCompare(b.name));99    }100    return sorted;101  }, [models, query, caps, provider, sort]);102103  const favorites = useMemo(104    () => (query ? [] : filtered.filter((m) => m.favorite || m.pinned)),105    [filtered, query]106  );107  const recents = useMemo(108    () =>109      query110        ? []111        : filtered112            .filter((m) => m.lastUsedAt && !m.favorite && !m.pinned)113            .sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))114            .slice(0, 5),115    [filtered, query]116  );117  const rest = useMemo(() => {118    const shown = new Set([...favorites, ...recents].map((m) => m.id));119    return filtered.filter((m) => !shown.has(m.id));120  }, [filtered, favorites, recents]);121122  // Incremental rendering keeps the list fast with 300+ entries.123  useEffect(() => setLimit(PAGE), [query, caps, provider, sort]);124  const onScroll = () => {125    const el = listRef.current;126    if (el && el.scrollHeight - el.scrollTop - el.clientHeight < 600 && limit < rest.length) {127      setLimit((l) => l + PAGE);128    }129  };130131  const toggleCap = (c: CapFilter) => {132    const next = new Set(caps);133    if (next.has(c)) next.delete(c);134    else next.add(c);135    setCaps(next);136  };137138  return (139    <>140      <div className="sheet-scrim" onClick={onClose} aria-hidden />141      <div className="sheet" role="dialog" aria-modal="true" aria-label="Choose a model">142        <div className="sheet-handle" aria-hidden />143        <div className="sheet-search">144          <input145            ref={inputRef}146            placeholder={`Search ${models.filter((m) => m.available).length} models…`}147            value={query}148            onChange={(e) => setQuery(e.target.value)}149            aria-label="Search models"150          />151          <button className="icon-btn" onClick={onClose} aria-label="Close">152            <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.6">153              <path d="m4.5 4.5 9 9m0-9-9 9" strokeLinecap="round" />154            </svg>155          </button>156        </div>157158        <div className="filter-row">159          {(["reasoning", "vision", "tools", "free"] as CapFilter[]).map((c) => (160            <button key={c} className={`filter-chip${caps.has(c) ? " on" : ""}`} onClick={() => toggleCap(c)}>161              {c}162            </button>163          ))}164          <span style={{ width: 1, background: "var(--border)", flex: "none", margin: "4px 2px" }} />165          {providers.map((p) => (166            <button167              key={p}168              className={`filter-chip${provider === p ? " on" : ""}`}169              onClick={() => setProvider(provider === p ? null : p)}170            >171              {p}172            </button>173          ))}174        </div>175176        <div className="filter-row" style={{ paddingTop: 0 }}>177          {(178            [179              ["name", "A–Z"],180              ["context", "context ↓"],181              ["price", "price ↑"],182              ["newest", "recent"],183            ] as [SortKey, string][]184          ).map(([k, label]) => (185            <button key={k} className={`filter-chip${sort === k ? " on" : ""}`} onClick={() => setSort(k)}>186              {label}187            </button>188          ))}189        </div>190191        <div className="model-list" ref={listRef} onScroll={onScroll}>192          {favorites.length > 0 && (193            <>194              <div className="model-section-label">Favorites</div>195              {favorites.map((m) => (196                <ModelRow key={m.id} m={m} selected={m.id === selectedId} onSelect={onSelect} onToggleFavorite={onToggleFavorite} />197              ))}198            </>199          )}200          {recents.length > 0 && (201            <>202              <div className="model-section-label">Recent</div>203              {recents.map((m) => (204                <ModelRow key={m.id} m={m} selected={m.id === selectedId} onSelect={onSelect} onToggleFavorite={onToggleFavorite} />205              ))}206            </>207          )}208          <div className="model-section-label">{query ? "Results" : "All models"}</div>209          {rest.slice(0, limit).map((m) => (210            <ModelRow key={m.id} m={m} selected={m.id === selectedId} onSelect={onSelect} onToggleFavorite={onToggleFavorite} />211          ))}212          {rest.length === 0 && favorites.length === 0 && recents.length === 0 && (213            <p style={{ padding: 16, color: "var(--text-dim)", fontSize: 14 }}>214              No models match. Clear a filter or try another search.215            </p>216          )}217        </div>218      </div>219    </>220  );221}222223function ModelRow({224  m,225  selected,226  onSelect,227  onToggleFavorite,228}: {229  m: ApiModel;230  selected: boolean;231  onSelect: (id: string) => void;232  onToggleFavorite: (id: string, favorite: boolean) => void;233}) {234  return (235    <div236      className={`model-row${selected ? " selected" : ""}`}237      role="button"238      tabIndex={0}239      onClick={() => onSelect(m.id)}240      onKeyDown={(e) => e.key === "Enter" && onSelect(m.id)}241    >242      <span className="prov-glyph">{providerGlyph(m.provider)}</span>243      <span className="m-main">244        <div className="m-name">{m.name}</div>245        <div className="m-sub">246          <span>{m.id}</span>247          <span>{m.contextLength ? formatTokens(m.contextLength) : "—"}</span>248          <span style={{ color: "var(--amber-500)" }}>{perMillion(m.pricing?.completion)}</span>249        </div>250      </span>251      <span className="cap-badges" aria-hidden>252        {m.capabilities.reasoning && <span className="cap-badge">R</span>}253        {m.capabilities.vision && <span className="cap-badge">V</span>}254        {m.capabilities.tools && <span className="cap-badge">T</span>}255      </span>256      <button257        className={`fav-btn${m.favorite ? " on" : ""}`}258        aria-label={m.favorite ? "Remove from favorites" : "Add to favorites"}259        onClick={(e) => {260          e.stopPropagation();261          onToggleFavorite(m.id, !m.favorite);262        }}263      >264        <svg width="16" height="16" viewBox="0 0 16 16" fill={m.favorite ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.3">265          <path d="M8 1.8l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.6l-3.8 2 .7-4.3-3.1-3 4.3-.6z" strokeLinejoin="round" />266        </svg>267      </button>268    </div>269  );270}271