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%
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