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%
5.0 KB · 173 lines tsx
Raw Blame History
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