SPB Git

spb/zyquo-cloud-web Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

TypeScript 81.9% CSS 8.9% JavaScript 7.5% Shell 1.1% HTML 0.6%
11.8 KB · 332 lines tsx
Raw Blame History
1/*2 *  ModelMenu.tsx3 *  Zyquo Cloud Web4 *5 *  Author: Simon-Pierre Boucher6 *  Mail: contact@spboucher.ai7 *8 *  The Model Menu — command-palette-style picker (also opened with ⌘K via the9 *  command palette's model section). Instant fuzzy search, capability filter10 *  chips, Favorites + Recents groups, provider sections (keyed providers11 *  first), capability/context/price badges, per-row star, "not configured"12 *  affordance, keyboard navigation.13 */1415import { useEffect, useMemo, useRef, useState } from 'react'16import { CATALOG, priceBadge } from '../features/catalogHelpers'17import { PROVIDER_META } from '../providers/registry'18import { useStore } from '../state/store'19import { contextBadge, type AIModel, type Provider } from '../types'20import {21  IconBrain,22  IconEye,23  IconSearch,24  IconStar,25  IconWrench,26  ProviderGlyph,27} from './icons'2829const FILTERS = [30  { key: 'vision', label: 'Vision' },31  { key: 'tools', label: 'Tools' },32  { key: 'reasoning', label: 'Reasoning' },33  { key: 'jsonMode', label: 'JSON' },34  { key: 'long', label: 'Long context' },35  { key: 'cheap', label: 'Cheapest' },36] as const3738type FilterKey = (typeof FILTERS)[number]['key']3940/** Simple subsequence fuzzy match; returns a score (lower = better) or null. */41function fuzzyScore(query: string, target: string): number | null {42  const q = query.toLowerCase()43  const t = target.toLowerCase()44  if (q === '') return 045  const direct = t.indexOf(q)46  if (direct !== -1) return direct47  let qi = 048  let gaps = 049  let last = -150  for (let ti = 0; ti < t.length && qi < q.length; ti++) {51    if (t[ti] === q[qi]) {52      if (last !== -1) gaps += ti - last - 153      last = ti54      qi++55    }56  }57  return qi === q.length ? 100 + gaps : null58}5960interface Row {61  model: AIModel62  section: string63}6465export default function ModelMenu({66  onPick,67  onClose,68  currentID,69  title = 'Switch model',70}: {71  onPick: (model: AIModel, scope: 'conversation' | 'default' | 'message') => void72  onClose: () => void73  currentID?: string74  title?: string75}) {76  const settings = useStore((s) => s.settings)77  const updateSettings = useStore((s) => s.updateSettings)78  const keyedProviders = useStore((s) => s.keyedProviders)79  const openSettings = useStore((s) => s.openSettings)80  const [query, setQuery] = useState('')81  const [filters, setFilters] = useState<Set<FilterKey>>(new Set())82  const [highlight, setHighlight] = useState(0)83  const inputRef = useRef<HTMLInputElement>(null)84  const listRef = useRef<HTMLDivElement>(null)8586  useEffect(() => inputRef.current?.focus(), [])8788  const favoriteSet = useMemo(() => new Set(settings.favoriteModelIDs), [settings.favoriteModelIDs])8990  const rows: Row[] = useMemo(() => {91    let models = [...CATALOG]92    // Capability filter chips93    if (filters.has('vision')) models = models.filter((m) => m.capabilities.vision)94    if (filters.has('tools')) models = models.filter((m) => m.capabilities.tools)95    if (filters.has('reasoning')) models = models.filter((m) => m.capabilities.reasoning)96    if (filters.has('jsonMode')) models = models.filter((m) => m.capabilities.jsonMode)97    if (filters.has('long')) models = models.filter((m) => m.contextWindow >= 400_000)98    if (filters.has('cheap')) {99      models = models100        .filter((m) => m.pricing)101        .sort((a, b) => (a.pricing?.outputPerMTok ?? 0) - (b.pricing?.outputPerMTok ?? 0))102        .slice(0, 20)103    }104    // Fuzzy search over name + id + provider105    if (query !== '') {106      const scored = models107        .map((m) => {108          const scores = [109            fuzzyScore(query, m.displayName),110            fuzzyScore(query, m.id),111            fuzzyScore(query, PROVIDER_META[m.provider].displayName),112          ].filter((s): s is number => s !== null)113          return scores.length > 0 ? { m, score: Math.min(...scores) } : null114        })115        .filter((x): x is { m: AIModel; score: number } => x !== null)116        .sort((a, b) => a.score - b.score)117      return scored.map(({ m }) => ({ model: m, section: 'Results' }))118    }119    // Grouped: aliases → favorites → recents → providers (keyed first)120    const out: Row[] = []121    for (const [alias, ref] of Object.entries(settings.aliases)) {122      const model = models.find((m) => m.provider === ref.provider && m.id === ref.modelID)123      if (model) out.push({ model, section: `Alias: ${alias}` })124    }125    const favorites = models.filter((m) => favoriteSet.has(m.id))126    for (const model of favorites) out.push({ model, section: 'Favorites' })127    const recents = settings.recentModelIDs128      .map((id) => models.find((m) => m.id === id))129      .filter((m): m is AIModel => m !== undefined && !favoriteSet.has(m.id))130      .slice(0, 5)131    for (const model of recents) out.push({ model, section: 'Recents' })132    const providerOrder: Provider[] = [...new Set(models.map((m) => m.provider))].sort((a, b) => {133      const ak = keyedProviders.includes(a) ? 0 : 1134      const bk = keyedProviders.includes(b) ? 0 : 1135      if (ak !== bk) return ak - bk136      return PROVIDER_META[a].displayName.localeCompare(PROVIDER_META[b].displayName)137    })138    for (const provider of providerOrder) {139      const list = models140        .filter((m) => m.provider === provider)141        .sort((a, b) => rankOf(a) - rankOf(b))142      for (const model of list) {143        out.push({ model, section: PROVIDER_META[provider].displayName })144      }145    }146    return out147148    function rankOf(m: AIModel): number {149      if (favoriteSet.has(m.id)) return 0150      if (m.isRecommended) return 1151      if (m.isLegacy) return 3152      return 2153    }154  }, [query, filters, settings.aliases, settings.recentModelIDs, favoriteSet, keyedProviders])155156  useEffect(() => setHighlight(0), [query, filters])157158  const pick = (model: AIModel, scope: 'conversation' | 'default' | 'message' = 'conversation') => {159    updateSettings({160      recentModelIDs: [model.id, ...settings.recentModelIDs.filter((id) => id !== model.id)].slice(161        0,162        8163      ),164    })165    onPick(model, scope)166    onClose()167  }168169  const onKeyDown = (e: React.KeyboardEvent) => {170    if (e.key === 'ArrowDown') {171      e.preventDefault()172      setHighlight((h) => Math.min(h + 1, rows.length - 1))173      scrollToHighlight(1)174    } else if (e.key === 'ArrowUp') {175      e.preventDefault()176      setHighlight((h) => Math.max(h - 1, 0))177      scrollToHighlight(-1)178    } else if (e.key === 'Enter') {179      e.preventDefault()180      const row = rows[highlight]181      if (row) pick(row.model, e.metaKey || e.ctrlKey ? 'default' : 'conversation')182    } else if (e.key === 'Escape') {183      onClose()184    }185  }186187  const scrollToHighlight = (dir: number) => {188    requestAnimationFrame(() => {189      const el = listRef.current?.querySelector('.model-row.highlighted')190      el?.scrollIntoView({ block: dir > 0 ? 'nearest' : 'nearest' })191    })192  }193194  const toggleFavorite = (e: React.MouseEvent, model: AIModel) => {195    e.stopPropagation()196    const next = favoriteSet.has(model.id)197      ? settings.favoriteModelIDs.filter((id) => id !== model.id)198      : [...settings.favoriteModelIDs, model.id]199    updateSettings({ favoriteModelIDs: next })200  }201202  let lastSection = ''203  let rowIndex = -1204205  return (206    <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>207      <div className="panel" role="dialog" aria-label={title} onKeyDown={onKeyDown}>208        <div className="panel-search">209          <IconSearch size={14} />210          <input211            ref={inputRef}212            placeholder="Search models, providers, capabilities…"213            value={query}214            onChange={(e) => setQuery(e.target.value)}215          />216        </div>217        <div className="panel-filters">218          {FILTERS.map((f) => (219            <button220              key={f.key}221              className={`filter-chip${filters.has(f.key) ? ' active' : ''}`}222              onClick={() =>223                setFilters((prev) => {224                  const next = new Set(prev)225                  if (next.has(f.key)) next.delete(f.key)226                  else next.add(f.key)227                  return next228                })229              }230            >231              {f.label}232            </button>233          ))}234        </div>235        <div className="panel-list" ref={listRef}>236          {rows.length === 0 && <div className="panel-empty">No models match.</div>}237          {rows.map((row) => {238            rowIndex++239            const index = rowIndex240            const header =241              row.section !== lastSection ? (242                <div className="panel-section-header" key={`h-${row.section}`}>243                  {row.section}244                </div>245              ) : null246            lastSection = row.section247            const model = row.model248            const keyed = keyedProviders.includes(model.provider)249            return (250              <div key={`${row.section}-${model.provider}-${model.id}`}>251                {header}252                <button253                  className={`model-row${index === highlight ? ' highlighted' : ''}${254                    model.id === currentID ? ' current' : ''255                  }`}256                  onClick={(e) =>257                    pick(model, e.metaKey || e.ctrlKey ? 'default' : 'conversation')258                  }259                  onMouseMove={() => setHighlight(index)}260                >261                  <span className="glyph">262                    <ProviderGlyph provider={model.provider} />263                  </span>264                  <span className="model-row-name">{model.displayName}</span>265                  <span className="model-row-provider">266                    {PROVIDER_META[model.provider].displayName}267                  </span>268                  {model.isRecommended && <span className="badge accent">Featured</span>}269                  {model.isLegacy && <span className="badge">Legacy</span>}270                  {!keyed && (271                    <button272                      className="badge warn"273                      title="No API key configured — add one"274                      onClick={(e) => {275                        e.stopPropagation()276                        onClose()277                        openSettings('providers')278                      }}279                    >280                      Add key281                    </button>282                  )}283                  <span className="model-row-badges">284                    {model.capabilities.vision && (285                      <span className="capability-badge" title="Vision">286                        <IconEye />287                      </span>288                    )}289                    {model.capabilities.reasoning && (290                      <span className="capability-badge" title="Reasoning">291                        <IconBrain />292                      </span>293                    )}294                    {model.capabilities.tools && (295                      <span className="capability-badge" title="Tools">296                        <IconWrench />297                      </span>298                    )}299                    <span>{contextBadge(model)}</span>300                    <span title="USD per 1M tokens in / out">{priceBadge(model)}</span>301                    <button302                      className={`star${favoriteSet.has(model.id) ? ' active' : ''}`}303                      title="Favorite"304                      onClick={(e) => toggleFavorite(e, model)}305                    >306                      <IconStar filled={favoriteSet.has(model.id)} />307                    </button>308                  </span>309                </button>310              </div>311            )312          })}313        </div>314        <div className="panel-footer">315          <span>316            <kbd>↑↓</kbd> navigate317          </span>318          <span>319            <kbd>↵</kbd> use for this chat320          </span>321          <span>322            <kbd>⌘↵</kbd> set as default323          </span>324          <span>325            <kbd>esc</kbd> close326          </span>327        </div>328      </div>329    </div>330  )331}332