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