// Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: chat.spboucher.ai // Client-side view types mirroring the API payloads. export interface ApiConversation { id: string; title: string; pinned: number; current_leaf_id: string | null; created_at: number; updated_at: number; } export interface ApiMessage { id: string; conversation_id: string; parent_id: string | null; role: "user" | "assistant" | "system"; content: string; reasoning: string | null; model_id: string | null; model_name: string | null; provider: string | null; generation_id: string | null; status: "pending" | "streaming" | "completed" | "cancelled" | "failed"; error_message: string | null; created_at: number; } export interface ApiModel { id: string; name: string; provider?: string; description?: string; contextLength?: number; pricing?: { prompt?: number; completion?: number; image?: number; request?: number }; capabilities: { text: boolean; vision: boolean; reasoning: boolean; tools: boolean; structuredOutput: boolean; }; available: boolean; favorite: boolean; pinned: boolean; lastUsedAt: number | null; useCount: number; } /** Compute the active thread through the message tree, honoring branch choices. */ export function computeThread( messages: ApiMessage[], branchChoice: Map // parentId ("root" for roots) → chosen child id ): ApiMessage[] { const children = new Map(); for (const m of messages) { const key = m.parent_id ?? "root"; const arr = children.get(key) ?? []; arr.push(m); children.set(key, arr); } for (const arr of children.values()) arr.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id)); const thread: ApiMessage[] = []; let key = "root"; for (;;) { const siblings = children.get(key); if (!siblings || siblings.length === 0) break; const chosenId = branchChoice.get(key); const chosen = siblings.find((s) => s.id === chosenId) ?? siblings[siblings.length - 1]; thread.push(chosen); key = chosen.id; } return thread; } /** Sibling info for branch navigation on a message. */ export function siblingInfo( messages: ApiMessage[], message: ApiMessage ): { index: number; count: number; siblings: ApiMessage[] } { const siblings = messages .filter((m) => (m.parent_id ?? "root") === (message.parent_id ?? "root")) .sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id)); return { index: siblings.findIndex((s) => s.id === message.id), count: siblings.length, siblings }; } /** Branch choices that select the path from root to the given leaf. */ export function choicesForLeaf(messages: ApiMessage[], leafId: string): Map { const byId = new Map(messages.map((m) => [m.id, m])); const choices = new Map(); let cursor = byId.get(leafId); while (cursor) { choices.set(cursor.parent_id ?? "root", cursor.id); cursor = cursor.parent_id ? byId.get(cursor.parent_id) : undefined; } return choices; } export function formatTokens(n: number | undefined | null): string { if (n === undefined || n === null) return "—"; if (n < 1000) return String(n); if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`; return `${(n / 1_000_000).toFixed(1)}M`; } export function formatUsd(v: number | undefined | null): string { if (v === undefined || v === null) return "—"; if (v === 0) return "$0"; if (v < 0.01) return `$${v.toFixed(4)}`; if (v < 1) return `$${v.toFixed(3)}`; return `$${v.toFixed(2)}`; } export function perMillion(perToken: number | undefined): string { if (perToken === undefined || perToken === null) return "—"; const v = perToken * 1_000_000; if (v === 0) return "free"; if (v < 1) return `$${v.toFixed(2)}/M`; return `$${v.toFixed(v < 10 ? 2 : 0)}/M`; } /** Provider glyph: first two letters, monochrome. Never brand colors. */ export function providerGlyph(provider: string | undefined | null): string { if (!provider) return "··"; return provider.slice(0, 2).toUpperCase(); } export function estimateTokensClient(text: string): number { return Math.ceil(text.length / 3.6); }