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 { 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}>246 ‹247 </button>248 <span>249 {index + 1}/{count}250 </span>251 <button aria-label="Next branch" disabled={index >= count - 1} onClick={onNext}>252 ›253 </button>254 </div>255 );256}257