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.0 KB · 135 lines typescript
Raw Blame History
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45import crypto from "node:crypto";6import { getDb } from "@/lib/db/database";78export interface ConversationRow {9  id: string;10  title: string;11  pinned: number;12  current_leaf_id: string | null;13  created_at: number;14  updated_at: number;15}1617export interface MessageRow {18  id: string;19  conversation_id: string;20  parent_id: string | null;21  role: "user" | "assistant" | "system";22  content: string;23  reasoning: string | null;24  model_id: string | null;25  model_name: string | null;26  provider: string | null;27  generation_id: string | null;28  status: "pending" | "streaming" | "completed" | "cancelled" | "failed";29  error_message: string | null;30  created_at: number;31}3233export function listConversations(): ConversationRow[] {34  return getDb()35    .prepare("SELECT * FROM conversations ORDER BY pinned DESC, updated_at DESC")36    .all() as ConversationRow[];37}3839export function getConversation(id: string): ConversationRow | null {40  return (getDb().prepare("SELECT * FROM conversations WHERE id = ?").get(id) as ConversationRow) ?? null;41}4243export function createConversation(title?: string): ConversationRow {44  const db = getDb();45  const id = crypto.randomUUID();46  const now = Date.now();47  db.prepare(48    "INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)"49  ).run(id, title?.slice(0, 80) || "New conversation", now, now);50  return getConversation(id)!;51}5253export function updateConversation(54  id: string,55  patch: { title?: string; pinned?: boolean; currentLeafId?: string | null }56): void {57  const db = getDb();58  if (patch.title !== undefined) {59    db.prepare("UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?").run(60      patch.title.slice(0, 120),61      Date.now(),62      id63    );64  }65  if (patch.pinned !== undefined) {66    db.prepare("UPDATE conversations SET pinned = ? WHERE id = ?").run(patch.pinned ? 1 : 0, id);67  }68  if (patch.currentLeafId !== undefined) {69    db.prepare("UPDATE conversations SET current_leaf_id = ? WHERE id = ?").run(patch.currentLeafId, id);70  }71}7273export function deleteConversation(id: string): void {74  getDb().prepare("DELETE FROM conversations WHERE id = ?").run(id);75}7677export function touchConversation(id: string): void {78  getDb().prepare("UPDATE conversations SET updated_at = ? WHERE id = ?").run(Date.now(), id);79}8081export function listMessages(conversationId: string): MessageRow[] {82  return getDb()83    .prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at, id")84    .all(conversationId) as MessageRow[];85}8687export function getMessage(id: string): MessageRow | null {88  return (getDb().prepare("SELECT * FROM messages WHERE id = ?").get(id) as MessageRow) ?? null;89}9091export function insertMessage(m: {92  conversationId: string;93  parentId: string | null;94  role: "user" | "assistant" | "system";95  content?: string;96  modelId?: string;97  modelName?: string;98  provider?: string;99  generationId?: string;100  status?: MessageRow["status"];101}): MessageRow {102  const db = getDb();103  const id = crypto.randomUUID();104  db.prepare(105    `INSERT INTO messages (id, conversation_id, parent_id, role, content, model_id, model_name, provider, generation_id, status, created_at)106     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`107  ).run(108    id,109    m.conversationId,110    m.parentId,111    m.role,112    m.content ?? "",113    m.modelId ?? null,114    m.modelName ?? null,115    m.provider ?? null,116    m.generationId ?? null,117    m.status ?? "completed",118    Date.now()119  );120  return getMessage(id)!;121}122123/** Walk parent pointers from a leaf to the root: the active thread, oldest first. */124export function threadToLeaf(conversationId: string, leafId: string | null): MessageRow[] {125  if (!leafId) return [];126  const byId = new Map(listMessages(conversationId).map((m) => [m.id, m]));127  const thread: MessageRow[] = [];128  let cursor = byId.get(leafId);129  while (cursor) {130    thread.push(cursor);131    cursor = cursor.parent_id ? byId.get(cursor.parent_id) : undefined;132  }133  return thread.reverse();134}135