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%
7.8 KB · 257 lines tsx
Raw Blame History
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45"use client";67import { useEffect, useRef, useState } from "react";8import { Markdown } from "./Markdown";9import { siblingInfo, type ApiMessage, type ApiModel } from "./types";1011interface StreamingView {12  assistantMessageId: string;13  generationId: string;14  content: string;15  reasoning: string;16  error: string | null;17}1819interface MessageListProps {20  thread: ApiMessage[];21  allMessages: ApiMessage[];22  pendingUser: ApiMessage | null;23  streaming: StreamingView | null;24  models: ApiModel[];25  currentModelName: string | null;26  onSelectBranch: (parentKey: string, childId: string) => void;27  onRegenerate: (assistantMessageId: string, withModelId?: string) => void;28}2930export function MessageList({31  thread,32  allMessages,33  pendingUser,34  streaming,35  models,36  currentModelName,37  onSelectBranch,38  onRegenerate,39}: MessageListProps) {40  const scrollRef = useRef<HTMLDivElement>(null);41  const [autoFollow, setAutoFollow] = useState(true);4243  // Streaming text auto-follows the bottom; scrolling up pauses it.44  useEffect(() => {45    const el = scrollRef.current;46    if (!el) return;47    const onScroll = () => {48      const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;49      setAutoFollow(nearBottom);50    };51    el.addEventListener("scroll", onScroll, { passive: true });52    return () => el.removeEventListener("scroll", onScroll);53  }, []);5455  useEffect(() => {56    const el = scrollRef.current;57    if (el && autoFollow) el.scrollTop = el.scrollHeight;58  }, [thread, pendingUser, streaming?.content, streaming?.reasoning, autoFollow]);5960  const jumpToLatest = () => {61    const el = scrollRef.current;62    if (el) el.scrollTop = el.scrollHeight;63    setAutoFollow(true);64  };6566  const isEmpty = thread.length === 0 && !pendingUser && !streaming;6768  return (69    <div className="message-scroll" ref={scrollRef}>70      {isEmpty ? (71        <div className="empty-state" style={{ display: "flex", flexDirection: "column", justifyContent: "center", minHeight: "60dvh" }}>72          <div>73            <div className="glyph">▮▯▮</div>74            <h2>Pick a model and ask something</h2>75            <p>{currentModelName ? `${currentModelName} is loaded and ready.` : "Loading the model catalog…"}</p>76          </div>77        </div>78      ) : (79        <div className="message-column">80          {thread.map((m) => (81            <MessageItem82              key={m.id}83              message={m}84              allMessages={allMessages}85              streaming={streaming}86              models={models}87              onSelectBranch={onSelectBranch}88              onRegenerate={onRegenerate}89            />90          ))}91          {pendingUser && <div className="msg-user pending">{pendingUser.content}</div>}92          {streaming && !thread.some((m) => m.id === streaming.assistantMessageId) && (93            <StreamingMessage streaming={streaming} modelName={currentModelName} />94          )}95        </div>96      )}97      {!autoFollow && (streaming || thread.length > 0) && (98        <button className="jump-pill" onClick={jumpToLatest}>99          ↓ jump to latest100        </button>101      )}102    </div>103  );104}105106function StreamingMessage({ streaming, modelName }: { streaming: StreamingView; modelName: string | null }) {107  return (108    <div className="msg-assistant">109      <div className="msg-attribution">110        <span className="live-dot" aria-hidden />111        <span className="model-name">{modelName ?? "model"}</span>112        <span>streaming</span>113      </div>114      {streaming.reasoning && (115        <details className="reasoning-block" open>116          <summary>reasoning</summary>117          <div>{streaming.reasoning}</div>118        </details>119      )}120      <div className={`msg-body${streaming.error ? "" : " streaming-caret"}`}>121        <Markdown content={streaming.content} />122      </div>123      {streaming.error && <div className="msg-error">{streaming.error}</div>}124    </div>125  );126}127128function MessageItem({129  message,130  allMessages,131  streaming,132  models,133  onSelectBranch,134  onRegenerate,135}: {136  message: ApiMessage;137  allMessages: ApiMessage[];138  streaming: StreamingView | null;139  models: ApiModel[];140  onSelectBranch: (parentKey: string, childId: string) => void;141  onRegenerate: (assistantMessageId: string, withModelId?: string) => void;142}) {143  const [copied, setCopied] = useState(false);144  const isStreamingThis = streaming?.assistantMessageId === message.id;145  const content = isStreamingThis ? streaming.content : message.content;146  const reasoning = isStreamingThis ? streaming.reasoning : message.reasoning;147  const { index, count, siblings } = siblingInfo(allMessages, message);148  const parentKey = message.parent_id ?? "root";149  const modelGone = message.model_id !== null && !models.some((m) => m.id === message.model_id && m.available);150151  const copy = async () => {152    try {153      await navigator.clipboard.writeText(content);154      setCopied(true);155      setTimeout(() => setCopied(false), 1500);156    } catch {157      /* clipboard unavailable */158    }159  };160161  if (message.role === "user") {162    return (163      <>164        <div className="msg-user">{content}</div>165        {count > 1 && (166          <BranchNav167            index={index}168            count={count}169            onPrev={() => onSelectBranch(parentKey, siblings[index - 1].id)}170            onNext={() => onSelectBranch(parentKey, siblings[index + 1].id)}171          />172        )}173      </>174    );175  }176177  return (178    <div className="msg-assistant">179      <div className="msg-attribution">180        {isStreamingThis && <span className="live-dot" aria-hidden />}181        <span className="model-name">{message.model_name ?? message.model_id ?? "model"}</span>182        {modelGone && !isStreamingThis && <span title="This model is no longer in the catalog">· Unavailable</span>}183        <span>184          ·{" "}185          {new Date(message.created_at).toLocaleTimeString([], {186            hour: "2-digit",187            minute: "2-digit",188          })}189        </span>190        {message.status === "cancelled" && <span>· stopped</span>}191      </div>192193      {reasoning && (194        <details className="reasoning-block" open={isStreamingThis && !content}>195          <summary>reasoning</summary>196          <div>{reasoning}</div>197        </details>198      )}199200      <div className={`msg-body${isStreamingThis && !streaming?.error ? " streaming-caret" : ""}`}>201        <Markdown content={content} />202      </div>203204      {(message.status === "failed" || (isStreamingThis && streaming?.error)) && (205        <div className="msg-error">206          {isStreamingThis ? streaming?.error : message.error_message ?? "This generation failed."}207        </div>208      )}209210      {!isStreamingThis && (211        <div className="msg-actions">212          <button className="msg-action-btn" onClick={copy}>213            {copied ? "Copied" : "Copy"}214          </button>215          <button className="msg-action-btn" onClick={() => onRegenerate(message.id)} title="Regenerate with the current model — creates a branch">216            ⟳ Regenerate217          </button>218          {count > 1 && (219            <BranchNav220              index={index}221              count={count}222              onPrev={() => onSelectBranch(parentKey, siblings[index - 1].id)}223              onNext={() => onSelectBranch(parentKey, siblings[index + 1].id)}224            />225          )}226        </div>227      )}228    </div>229  );230}231232function BranchNav({233  index,234  count,235  onPrev,236  onNext,237}: {238  index: number;239  count: number;240  onPrev: () => void;241  onNext: () => void;242}) {243  return (244    <div className="branch-nav" style={{ alignSelf: "flex-end" }}>245      <button aria-label="Previous branch" disabled={index <= 0} onClick={onPrev}>246247      </button>248      <span>249        {index + 1}/{count}250      </span>251      <button aria-label="Next branch" disabled={index >= count - 1} onClick={onNext}>252253      </button>254    </div>255  );256}257