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, useMemo, useRef, useState } from "react";8import {9 type ApiConversation,10 type ApiMessage,11 type ApiModel,12 computeThread,13 choicesForLeaf,14 estimateTokensClient,15} from "./types";16import { Sidebar } from "./Sidebar";17import { MessageList } from "./MessageList";18import { Composer } from "./Composer";19import { ModelSheet } from "./ModelSheet";2021interface StreamingState {22 assistantMessageId: string;23 generationId: string;24 content: string;25 reasoning: string;26 error: string | null;27}2829export function ChatApp() {30 const [conversations, setConversations] = useState<ApiConversation[]>([]);31 const [activeId, setActiveId] = useState<string | null>(null);32 const [messages, setMessages] = useState<ApiMessage[]>([]);33 const [branchChoice, setBranchChoice] = useState<Map<string, string>>(new Map());34 const [models, setModels] = useState<ApiModel[]>([]);35 const [modelId, setModelId] = useState<string | null>(null);36 const [sheetOpen, setSheetOpen] = useState(false);37 const [drawerOpen, setDrawerOpen] = useState(false);38 const [streaming, setStreaming] = useState<StreamingState | null>(null);39 const [pendingUser, setPendingUser] = useState<ApiMessage | null>(null);40 const abortRef = useRef<AbortController | null>(null);41 const activeIdRef = useRef<string | null>(null);42 activeIdRef.current = activeId;4344 const model = useMemo(() => models.find((m) => m.id === modelId) ?? null, [models, modelId]);4546 const refreshConversations = useCallback(async () => {47 const res = await fetch("/api/conversations");48 if (!res.ok) return;49 const json = await res.json();50 setConversations(json.conversations);51 }, []);5253 const loadConversation = useCallback(async (id: string) => {54 const res = await fetch(`/api/conversations/${id}`);55 if (!res.ok) return;56 const json = await res.json();57 if (activeIdRef.current !== id) return; // user already moved on58 setMessages(json.messages);59 const leaf = json.conversation.current_leaf_id;60 setBranchChoice(leaf ? choicesForLeaf(json.messages, leaf) : new Map());61 }, []);6263 // Initial load: conversations + model catalog.64 useEffect(() => {65 refreshConversations();66 (async () => {67 const res = await fetch("/api/models");68 if (!res.ok) return;69 const json = await res.json();70 const all: ApiModel[] = json.models;71 setModels(all);72 const saved = localStorage.getItem("spb-model");73 const avail = all.filter((m) => m.available);74 const pick =75 (saved && avail.find((m) => m.id === saved)) ||76 avail.find((m) => m.favorite) ||77 avail.filter((m) => m.lastUsedAt).sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))[0] ||78 avail.find((m) => m.id === "anthropic/claude-sonnet-4.5") ||79 avail.find((m) => m.id === "openai/gpt-4o-mini") ||80 avail[0];81 if (pick) setModelId(pick.id);82 })();83 }, [refreshConversations]);8485 useEffect(() => {86 if (modelId) localStorage.setItem("spb-model", modelId);87 }, [modelId]);8889 useEffect(() => {90 if (activeId) loadConversation(activeId);91 else {92 setMessages([]);93 setBranchChoice(new Map());94 }95 }, [activeId, loadConversation]);9697 const thread = useMemo(() => computeThread(messages, branchChoice), [messages, branchChoice]);9899 const contextTokens = useMemo(100 () => thread.reduce((acc, m) => acc + estimateTokensClient(m.content), 0),101 [thread]102 );103104 /** Core streaming loop shared by send + regenerate. */105 const runStream = useCallback(106 async (body: Record<string, unknown>) => {107 const ctrl = new AbortController();108 abortRef.current = ctrl;109 let convId = (body.conversationId as string) ?? null;110 try {111 const res = await fetch("/api/chat", {112 method: "POST",113 headers: { "Content-Type": "application/json" },114 body: JSON.stringify(body),115 signal: ctrl.signal,116 });117 if (!res.ok || !res.body) {118 const json = await res.json().catch(() => ({}));119 setStreaming((s) =>120 s ? { ...s, error: json.error ?? "The request failed." } : {121 assistantMessageId: "",122 generationId: "",123 content: "",124 reasoning: "",125 error: json.error ?? "The request failed.",126 }127 );128 return;129 }130131 convId = res.headers.get("X-Conversation-Id") ?? convId;132 if (convId && activeIdRef.current !== convId) {133 setActiveId(convId);134 refreshConversations();135 }136137 const reader = res.body.getReader();138 const decoder = new TextDecoder();139 let buffer = "";140 for (;;) {141 const { done, value } = await reader.read();142 if (done) break;143 buffer += decoder.decode(value, { stream: true });144 let idx: number;145 while ((idx = buffer.indexOf("\n")) !== -1) {146 const line = buffer.slice(0, idx).trim();147 buffer = buffer.slice(idx + 1);148 if (!line.startsWith("data:")) continue;149 const payload = line.slice(5).trim();150 if (!payload) continue;151 let event: Record<string, unknown>;152 try {153 event = JSON.parse(payload);154 } catch {155 continue;156 }157 switch (event.type) {158 case "meta":159 setStreaming({160 assistantMessageId: event.assistantMessageId as string,161 generationId: event.generationId as string,162 content: "",163 reasoning: "",164 error: null,165 });166 break;167 case "content.delta":168 setStreaming((s) => (s ? { ...s, content: s.content + (event.text as string) } : s));169 break;170 case "reasoning.delta":171 setStreaming((s) => (s ? { ...s, reasoning: s.reasoning + (event.text as string) } : s));172 break;173 case "generation.error":174 setStreaming((s) => (s ? { ...s, error: event.message as string } : s));175 break;176 }177 }178 }179 } catch {180 // network drop or user abort — server state is canonical; resync below181 } finally {182 abortRef.current = null;183 setPendingUser(null);184 setStreaming(null);185 const target = convId ?? activeIdRef.current;186 if (target) {187 if (activeIdRef.current === null) setActiveId(target);188 await loadConversation(target);189 }190 refreshConversations();191 }192 },193 [loadConversation, refreshConversations]194 );195196 const sendMessage = useCallback(197 async (content: string) => {198 if (!modelId || streaming) return;199 const parentId = thread.length ? thread[thread.length - 1].id : null;200 // Optimistic: show the user message instantly, marked pending.201 setPendingUser({202 id: `pending-${Date.now()}`,203 conversation_id: activeId ?? "",204 parent_id: parentId,205 role: "user",206 content,207 reasoning: null,208 model_id: null,209 model_name: null,210 provider: null,211 generation_id: null,212 status: "pending",213 error_message: null,214 created_at: Date.now(),215 });216 await runStream({217 conversationId: activeId ?? undefined,218 parentId,219 content,220 modelId,221 });222 },223 [modelId, streaming, thread, activeId, runStream]224 );225226 const regenerate = useCallback(227 async (assistantMessageId: string, withModelId?: string) => {228 if (streaming) return;229 await runStream({230 regenerateOf: assistantMessageId,231 modelId: withModelId ?? modelId,232 });233 },234 [streaming, modelId, runStream]235 );236237 const stopGeneration = useCallback(async () => {238 const genId = streaming?.generationId;239 if (genId) {240 await fetch(`/api/generations/${genId}/cancel`, { method: "POST" }).catch(() => {});241 }242 abortRef.current?.abort();243 }, [streaming]);244245 const selectBranch = useCallback(246 (parentKey: string, childId: string) => {247 const next = new Map(branchChoice);248 next.set(parentKey, childId);249 setBranchChoice(next);250 // Persist the new active leaf so other devices resume the same branch.251 const newThread = computeThread(messages, next);252 const leaf = newThread[newThread.length - 1];253 if (leaf && activeId) {254 fetch(`/api/conversations/${activeId}`, {255 method: "PATCH",256 headers: { "Content-Type": "application/json" },257 body: JSON.stringify({ currentLeafId: leaf.id }),258 }).catch(() => {});259 }260 },261 [branchChoice, messages, activeId]262 );263264 const newConversation = useCallback(() => {265 setActiveId(null);266 setDrawerOpen(false);267 }, []);268269 const deleteConversation = useCallback(270 async (id: string) => {271 await fetch(`/api/conversations/${id}`, { method: "DELETE" });272 if (activeIdRef.current === id) setActiveId(null);273 refreshConversations();274 },275 [refreshConversations]276 );277278 const toggleFavorite = useCallback(async (id: string, favorite: boolean) => {279 setModels((ms) => ms.map((m) => (m.id === id ? { ...m, favorite } : m)));280 await fetch("/api/models/prefs", {281 method: "POST",282 headers: { "Content-Type": "application/json" },283 body: JSON.stringify({ modelId: id, favorite }),284 }).catch(() => {});285 }, []);286287 const activeConv = conversations.find((c) => c.id === activeId) ?? null;288289 return (290 <div className="app">291 <Sidebar292 conversations={conversations}293 activeId={activeId}294 open={drawerOpen}295 onSelect={(id) => {296 setActiveId(id);297 setDrawerOpen(false);298 }}299 onNew={newConversation}300 onDelete={deleteConversation}301 onClose={() => setDrawerOpen(false)}302 />303 <div className="main">304 <header className="topbar">305 <button306 className="icon-btn menu-btn"307 aria-label="Open conversations"308 onClick={() => setDrawerOpen(true)}309 >310 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6">311 <path d="M3 5h14M3 10h14M3 15h14" strokeLinecap="round" />312 </svg>313 </button>314 <span className="topbar-title">{activeConv?.title ?? "New conversation"}</span>315 <button className="icon-btn" aria-label="New conversation" onClick={newConversation}>316 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6">317 <path d="M10 4v12M4 10h12" strokeLinecap="round" />318 </svg>319 </button>320 </header>321322 <MessageList323 thread={thread}324 allMessages={messages}325 pendingUser={pendingUser}326 streaming={streaming}327 onSelectBranch={selectBranch}328 onRegenerate={regenerate}329 models={models}330 currentModelName={model?.name ?? null}331 />332333 <Composer334 model={model}335 contextTokens={contextTokens}336 streaming={Boolean(streaming)}337 onSend={sendMessage}338 onStop={stopGeneration}339 onOpenModelSheet={() => setSheetOpen(true)}340 />341 </div>342343 {sheetOpen && (344 <ModelSheet345 models={models}346 selectedId={modelId}347 onSelect={(id) => {348 setModelId(id);349 setSheetOpen(false);350 }}351 onToggleFavorite={toggleFavorite}352 onClose={() => setSheetOpen(false)}353 />354 )}355 </div>356 );357}358