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%
4.2 KB · 135 lines typescript
Raw Blame History
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