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.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