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 { useCallback, useEffect, useState } from "react";8import { formatTokens, formatUsd } from "./types";910interface UsageTotals {11 requests: number;12 prompt_tokens: number;13 completion_tokens: number;14 reasoning_tokens: number;15 total_tokens: number;16 cost_usd: number;17}1819interface UsageByModel {20 model_id: string;21 model_name: string;22 provider: string | null;23 requests: number;24 total_tokens: number;25 cost_usd: number;26}2728const PERIODS = [29 ["today", "Today"],30 ["7d", "7 days"],31 ["30d", "30 days"],32 ["all", "All time"],33] as const;3435export function SettingsView() {36 const [period, setPeriod] = useState<string>("7d");37 const [totals, setTotals] = useState<UsageTotals | null>(null);38 const [byModel, setByModel] = useState<UsageByModel[]>([]);39 const [syncing, setSyncing] = useState(false);40 const [syncMsg, setSyncMsg] = useState<string | null>(null);41 const [theme, setTheme] = useState<string>("auto");4243 useEffect(() => {44 setTheme(localStorage.getItem("spb-theme") ?? "auto");45 }, []);4647 const loadUsage = useCallback(async (p: string) => {48 const res = await fetch(`/api/usage?period=${p}`);49 if (!res.ok) return;50 const json = await res.json();51 setTotals(json.totals);52 setByModel(json.byModel);53 }, []);5455 useEffect(() => {56 loadUsage(period);57 }, [period, loadUsage]);5859 async function syncCatalog() {60 setSyncing(true);61 setSyncMsg(null);62 try {63 const res = await fetch("/api/models/sync", { method: "POST" });64 const json = await res.json();65 setSyncMsg(res.ok ? `Synced ${json.total} models (${json.added} new).` : json.error ?? "Sync failed.");66 } catch {67 setSyncMsg("Sync failed. Check connectivity.");68 }69 setSyncing(false);70 }7172 function applyTheme(next: string) {73 setTheme(next);74 if (next === "auto") {75 localStorage.removeItem("spb-theme");76 delete document.documentElement.dataset.theme;77 } else {78 localStorage.setItem("spb-theme", next);79 document.documentElement.dataset.theme = next;80 }81 }8283 return (84 <div className="settings-page">85 <a href="/" style={{ fontSize: 14, textDecoration: "none" }}>86 ← Back to chat87 </a>88 <h1>Settings</h1>8990 <h2>Usage</h2>91 <div className="period-tabs">92 {PERIODS.map(([k, label]) => (93 <button key={k} className={`filter-chip${period === k ? " on" : ""}`} onClick={() => setPeriod(k)}>94 {label}95 </button>96 ))}97 </div>98 <div className="stat-grid">99 <div className="stat-tile">100 <div className="v">{totals?.requests ?? "—"}</div>101 <div className="k">Requests</div>102 </div>103 <div className="stat-tile">104 <div className="v">{formatTokens(totals?.prompt_tokens)}</div>105 <div className="k">Input tokens</div>106 </div>107 <div className="stat-tile">108 <div className="v">{formatTokens(totals?.completion_tokens)}</div>109 <div className="k">Output tokens</div>110 </div>111 <div className="stat-tile">112 <div className="v cost">{formatUsd(totals?.cost_usd)}</div>113 <div className="k">Total cost</div>114 </div>115 </div>116117 {byModel.length > 0 && (118 <>119 <h2>By model</h2>120 <table className="usage-table">121 <thead>122 <tr>123 <th>Model</th>124 <th className="num">Requests</th>125 <th className="num">Tokens</th>126 <th className="num">Cost</th>127 </tr>128 </thead>129 <tbody>130 {byModel.map((m) => (131 <tr key={m.model_id}>132 <td>{m.model_name}</td>133 <td className="num">{m.requests}</td>134 <td className="num">{formatTokens(m.total_tokens)}</td>135 <td className="num" style={{ color: "var(--amber-500)" }}>136 {formatUsd(m.cost_usd)}137 </td>138 </tr>139 ))}140 </tbody>141 </table>142 </>143 )}144145 <h2>Appearance</h2>146 <div className="settings-row">147 <div>148 <div className="r-label">Theme</div>149 <div className="r-sub">Dark is the native face of the instrument.</div>150 </div>151 <div style={{ display: "flex", gap: 6 }}>152 {["auto", "dark", "light"].map((t) => (153 <button key={t} className={`filter-chip${theme === t ? " on" : ""}`} onClick={() => applyTheme(t)}>154 {t}155 </button>156 ))}157 </div>158 </div>159160 <h2>Model catalog</h2>161 <div className="settings-row">162 <div>163 <div className="r-label">Refresh catalog</div>164 <div className="r-sub">{syncMsg ?? "Pull the latest models from OpenRouter."}</div>165 </div>166 <button className="btn" onClick={syncCatalog} disabled={syncing}>167 {syncing ? "Syncing…" : "Sync now"}168 </button>169 </div>170 </div>171 );172}173