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%
4.0 KB · 116 lines tsx
Raw Blame History
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45"use client";67import { useRef, useState } from "react";8import { estimateTokensClient, formatTokens, perMillion, providerGlyph, type ApiModel } from "./types";910interface ComposerProps {11  model: ApiModel | null;12  contextTokens: number;13  streaming: boolean;14  onSend: (content: string) => void;15  onStop: () => void;16  onOpenModelSheet: () => void;17}1819export function Composer({ model, contextTokens, streaming, onSend, onStop, onOpenModelSheet }: ComposerProps) {20  const [text, setText] = useState("");21  const taRef = useRef<HTMLTextAreaElement>(null);2223  const totalTokens = contextTokens + estimateTokensClient(text);24  const ctxLimit = model?.contextLength;25  const ctxPct = ctxLimit ? Math.min((totalTokens / ctxLimit) * 100, 100) : 0;2627  const send = () => {28    const content = text.trim();29    if (!content || streaming || !model) return;30    setText("");31    if (taRef.current) taRef.current.style.height = "auto";32    onSend(content);33  };3435  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {36    // Enter sends on desktop; on touch keyboards Enter makes a newline.37    if (e.key === "Enter" && !e.shiftKey && !isTouchDevice()) {38      e.preventDefault();39      send();40    }41  };4243  const autoGrow = () => {44    const ta = taRef.current;45    if (!ta) return;46    ta.style.height = "auto";47    ta.style.height = `${Math.min(ta.scrollHeight, window.innerHeight * 0.4)}px`;48  };4950  return (51    <div className="composer-wrap">52      <div className="composer">53        {/* Model Rail — the machined cartridge. Tap to open the model sheet. */}54        <button55          type="button"56          className={`model-rail${streaming ? " live" : ""}`}57          onClick={onOpenModelSheet}58          aria-label="Change model"59        >60          <span className="rail-glyph">{providerGlyph(model?.provider)}</span>61          <span className="rail-main">62            <span className="rail-model-id">{model?.id ?? "select a model"}</span>63            <span className="rail-meta">64              <span>65                {formatTokens(totalTokens)}66                {ctxLimit ? ` / ${formatTokens(ctxLimit)}` : ""}67              </span>68              <span className="ctx-meter" aria-hidden>69                <span className="fill" style={{ width: `${ctxPct}%` }} />70              </span>71              <span className="price-chip">{perMillion(model?.pricing?.completion)}</span>72            </span>73          </span>74          <span className="rail-chevron" aria-hidden>75            <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">76              <path d="M4 5.5 7 8.5l3-3" strokeLinecap="round" strokeLinejoin="round" />77            </svg>78          </span>79        </button>8081        <div className="composer-box">82          <textarea83            ref={taRef}84            rows={1}85            placeholder={model ? "Message…" : "Pick a model first…"}86            value={text}87            onChange={(e) => {88              setText(e.target.value);89              autoGrow();90            }}91            onKeyDown={onKeyDown}92            aria-label="Message"93          />94          {streaming ? (95            <button className="send-btn stop" onClick={onStop} aria-label="Stop generation">96              <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">97                <rect x="4" y="4" width="8" height="8" rx="1.5" />98              </svg>99            </button>100          ) : (101            <button className="send-btn" onClick={send} disabled={!text.trim() || !model} aria-label="Send message">102              <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.8">103                <path d="M9 15V3M4 8l5-5 5 5" strokeLinecap="round" strokeLinejoin="round" />104              </svg>105            </button>106          )}107        </div>108      </div>109    </div>110  );111}112113function isTouchDevice(): boolean {114  return typeof window !== "undefined" && window.matchMedia("(pointer: coarse)").matches;115}116