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