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// Client-side view types mirroring the API payloads.67export interface ApiConversation {8 id: string;9 title: string;10 pinned: number;11 current_leaf_id: string | null;12 created_at: number;13 updated_at: number;14}1516export interface ApiMessage {17 id: string;18 conversation_id: string;19 parent_id: string | null;20 role: "user" | "assistant" | "system";21 content: string;22 reasoning: string | null;23 model_id: string | null;24 model_name: string | null;25 provider: string | null;26 generation_id: string | null;27 status: "pending" | "streaming" | "completed" | "cancelled" | "failed";28 error_message: string | null;29 created_at: number;30}3132export interface ApiModel {33 id: string;34 name: string;35 provider?: string;36 description?: string;37 contextLength?: number;38 pricing?: { prompt?: number; completion?: number; image?: number; request?: number };39 capabilities: {40 text: boolean;41 vision: boolean;42 reasoning: boolean;43 tools: boolean;44 structuredOutput: boolean;45 };46 available: boolean;47 favorite: boolean;48 pinned: boolean;49 lastUsedAt: number | null;50 useCount: number;51}5253/** Compute the active thread through the message tree, honoring branch choices. */54export function computeThread(55 messages: ApiMessage[],56 branchChoice: Map<string, string> // parentId ("root" for roots) → chosen child id57): ApiMessage[] {58 const children = new Map<string, ApiMessage[]>();59 for (const m of messages) {60 const key = m.parent_id ?? "root";61 const arr = children.get(key) ?? [];62 arr.push(m);63 children.set(key, arr);64 }65 for (const arr of children.values()) arr.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));6667 const thread: ApiMessage[] = [];68 let key = "root";69 for (;;) {70 const siblings = children.get(key);71 if (!siblings || siblings.length === 0) break;72 const chosenId = branchChoice.get(key);73 const chosen = siblings.find((s) => s.id === chosenId) ?? siblings[siblings.length - 1];74 thread.push(chosen);75 key = chosen.id;76 }77 return thread;78}7980/** Sibling info for branch navigation on a message. */81export function siblingInfo(82 messages: ApiMessage[],83 message: ApiMessage84): { index: number; count: number; siblings: ApiMessage[] } {85 const siblings = messages86 .filter((m) => (m.parent_id ?? "root") === (message.parent_id ?? "root"))87 .sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));88 return { index: siblings.findIndex((s) => s.id === message.id), count: siblings.length, siblings };89}9091/** Branch choices that select the path from root to the given leaf. */92export function choicesForLeaf(messages: ApiMessage[], leafId: string): Map<string, string> {93 const byId = new Map(messages.map((m) => [m.id, m]));94 const choices = new Map<string, string>();95 let cursor = byId.get(leafId);96 while (cursor) {97 choices.set(cursor.parent_id ?? "root", cursor.id);98 cursor = cursor.parent_id ? byId.get(cursor.parent_id) : undefined;99 }100 return choices;101}102103export function formatTokens(n: number | undefined | null): string {104 if (n === undefined || n === null) return "—";105 if (n < 1000) return String(n);106 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;107 return `${(n / 1_000_000).toFixed(1)}M`;108}109110export function formatUsd(v: number | undefined | null): string {111 if (v === undefined || v === null) return "—";112 if (v === 0) return "$0";113 if (v < 0.01) return `$${v.toFixed(4)}`;114 if (v < 1) return `$${v.toFixed(3)}`;115 return `$${v.toFixed(2)}`;116}117118export function perMillion(perToken: number | undefined): string {119 if (perToken === undefined || perToken === null) return "—";120 const v = perToken * 1_000_000;121 if (v === 0) return "free";122 if (v < 1) return `$${v.toFixed(2)}/M`;123 return `$${v.toFixed(v < 10 ? 2 : 0)}/M`;124}125126/** Provider glyph: first two letters, monochrome. Never brand colors. */127export function providerGlyph(provider: string | undefined | null): string {128 if (!provider) return "··";129 return provider.slice(0, 2).toUpperCase();130}131132export function estimateTokensClient(text: string): number {133 return Math.ceil(text.length / 3.6);134}135