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%
3.9 KB · 126 lines typescript
Raw Blame History
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45import { NextResponse, type NextRequest } from "next/server";6import { requireSession } from "@/lib/auth/guard";7import { ensureCatalogFresh, getModel } from "@/lib/catalog";8import {9  createConversation,10  getConversation,11  getMessage,12  insertMessage,13  updateConversation,14} from "@/lib/conversations";15import { startGeneration } from "@/lib/generate";1617export const runtime = "nodejs";18export const dynamic = "force-dynamic";1920interface ChatRequestBody {21  conversationId?: string;22  content?: string;23  modelId?: string;24  /** Assistant message ID to regenerate: creates a sibling branch, never destroys the original. */25  regenerateOf?: string;26  /** Parent message for the new user message (branch editing); defaults to the current leaf. */27  parentId?: string | null;28}2930export async function POST(req: NextRequest) {31  const { unauthorized } = await requireSession();32  if (unauthorized) return unauthorized;3334  let body: ChatRequestBody;35  try {36    body = await req.json();37  } catch {38    return NextResponse.json({ error: "Invalid request." }, { status: 400 });39  }4041  if (!body.modelId) {42    return NextResponse.json({ error: "Pick a model first." }, { status: 400 });43  }44  await ensureCatalogFresh().catch(() => {});45  const model = getModel(body.modelId);46  if (!model) {47    return NextResponse.json({ error: "Unknown model. Refresh the catalog." }, { status: 400 });48  }4950  let parentMessage = null;5152  if (body.regenerateOf) {53    // Regeneration: new assistant sibling under the same user message.54    const original = getMessage(body.regenerateOf);55    if (!original || original.role !== "assistant") {56      return NextResponse.json({ error: "Message to regenerate was not found." }, { status: 404 });57    }58    if (!getConversation(original.conversation_id)) {59      return NextResponse.json({ error: "Conversation not found." }, { status: 404 });60    }61    parentMessage = original.parent_id ? getMessage(original.parent_id) : null;62    const started = startGeneration({63      conversationId: original.conversation_id,64      parentMessage,65      model,66    });67    return sseResponse(started.stream);68  }6970  const content = (body.content ?? "").trim();71  if (!content) {72    return NextResponse.json({ error: "Write a message first." }, { status: 400 });73  }7475  // New message flow: resolve or create the conversation.76  let conversation = body.conversationId ? getConversation(body.conversationId) : null;77  if (body.conversationId && !conversation) {78    return NextResponse.json({ error: "Conversation not found." }, { status: 404 });79  }80  if (!conversation) {81    conversation = createConversation(content);82  } else if (conversation.title === "New conversation") {83    updateConversation(conversation.id, { title: content.slice(0, 80) });84  }8586  const parentId =87    body.parentId !== undefined ? body.parentId : conversation.current_leaf_id;88  if (parentId) {89    const parent = getMessage(parentId);90    if (!parent || parent.conversation_id !== conversation.id) {91      return NextResponse.json({ error: "Invalid parent message." }, { status: 400 });92    }93  }9495  const userMessage = insertMessage({96    conversationId: conversation.id,97    parentId: parentId ?? null,98    role: "user",99    content,100    status: "completed",101  });102103  const started = startGeneration({104    conversationId: conversation.id,105    parentMessage: userMessage,106    model,107  });108109  return sseResponse(started.stream, {110    "X-Conversation-Id": conversation.id,111    "X-User-Message-Id": userMessage.id,112  });113}114115function sseResponse(stream: ReadableStream<Uint8Array>, extraHeaders?: Record<string, string>) {116  return new Response(stream, {117    headers: {118      "Content-Type": "text/event-stream; charset=utf-8",119      "Cache-Control": "no-cache, no-transform",120      Connection: "keep-alive",121      "X-Accel-Buffering": "no",122      ...extraHeaders,123    },124  });125}126