@dariomvg/create
openaichatnextjsstreamingintegracion

OpenAI Chat — Configuración

Módulo de chat listo para integrar en Next.js (App Router) usando el SDK de OpenAI. Cubre chat completion, chat con streaming, conversaciones persistentes con historial de mensajes, gestión de contexto, speech-to-text y text-to-speech.

Es un módulo separado e independiente del módulo OpenAI Text Generation: no importa nada de él ni depende de él de ninguna manera.

Dependencias

  • openai — SDK oficial de OpenAI

Variables de entorno

Mirá .env.local:

  • OPENAI_API_KEY — obligatoria.

Estructura de archivos

lib/openai-chat/
├── types.ts                # Types, default model/token config, rate limit config
├── store.ts                  # ChatHistoryStore interface + in-memory reference implementation
├── context.ts                # Trims message history to fit a character budget before calling OpenAI
├── service.ts                # Internal logic (SDK calls, store/context orchestration, error normalization)
├── actions.ts                 # "use server" — public API for Server Components
├── client.ts                   # Public API for Client Components (streaming chat, text-to-speech)
├── stream/
│   ├── handler.ts             # Internal — builds the chat ReadableStream
│   └── route.ts                 # Copy to app/api/openai-chat/stream/route.ts
├── audio/
│   ├── handler.ts             # Internal — builds the speech ReadableStream
│   └── route.ts                 # Copy to app/api/openai-chat/speech/route.ts
└── .env.local

⚠️ Antes de desplegar: reemplazá el store en memoria

store.ts incluye createInMemoryChatStore() solo para desarrollo local. Las conversaciones y los mensajes se pierden en cada reinicio del servidor y no se comparten entre instancias serverless. Implementá la interfaz ChatHistoryStore contra tu propia base de datos y cambiala en getChatStore(); no hace falta tocar ningún otro archivo.

Pasos de configuración

  1. Copiá la carpeta lib/openai-chat/ a tu proyecto.
  2. Copiá lib/openai-chat/stream/route.ts a app/api/openai-chat/stream/route.ts.
  3. Copiá lib/openai-chat/audio/route.ts a app/api/openai-chat/speech/route.ts.
  4. Definí OPENAI_API_KEY en el .env.local de tu proyecto.
  5. Antes de pasar a producción, reemplazá el store en memoria (mirá arriba).

Uso

Gestión de conversaciones (Server Component / server action)

import {
  createConversationAction,
  listConversationsAction,
  getConversationAction,
  renameConversationAction,
  deleteConversationAction,
} from "@/lib/openai-chat/actions";

const created = await createConversationAction("Trip planning");
if (created.ok) {
  const conversationId = created.data.id;
}

const list = await listConversationsAction();
if (list.ok) {
  console.log(list.data); // Conversation[]
}

const details = await getConversationAction(conversationId);
if (details.ok) {
  console.log(details.data.conversation, details.data.messages);
}

Enviar un mensaje (sin streaming)

import { sendMessageAction } from "@/lib/openai-chat/actions";

const result = await sendMessageAction({
  conversationId,
  message: "What's a good 3-day itinerary for Lisbon?",
  systemPrompt: "You are a helpful travel assistant.",
});

if (result.ok) {
  console.log(result.data.message.content);
} else {
  console.error(result.error.message);
}

Respuesta con streaming (Client Component)

"use client";

import { useState } from "react";
import { streamChatMessage } from "@/lib/openai-chat/client";
import type { AIActionError } from "@/lib/openai-chat/types";

export function ChatBox({ conversationId }: { conversationId: string }) {
  const [reply, setReply] = useState("");
  const [error, setError] = useState<AIActionError | null>(null);

  async function handleSend(message: string) {
    setReply("");
    setError(null);

    try {
      for await (const chunk of streamChatMessage({ conversationId, message })) {
        setReply((prev) => prev + chunk);
      }
    } catch (err) {
      setError(err as AIActionError);
    }
  }

  return (
    <div>
      {error && <p>{error.message}</p>}
      <p>{reply}</p>
    </div>
  );
}

Speech-to-text (Client Component → Server Action)

"use client";

import { transcribeAudioAction } from "@/lib/openai-chat/actions";

async function handleRecordingStop(blob: Blob) {
  const formData = new FormData();
  formData.set("audio", blob, "recording.webm");
  formData.set("language", "en");

  const result = await transcribeAudioAction(formData);
  if (result.ok) {
    console.log(result.data.text);
  }
}

Text-to-speech (Client Component)

"use client";

import { textToSpeech } from "@/lib/openai-chat/client";

async function handlePlay(text: string) {
  const blob = await textToSpeech({ text });
  const audio = new Audio(URL.createObjectURL(blob));
  audio.play();
}

Manejo de errores

Mismo formato normalizado en todo el módulo:

interface AIActionError {
  code: "RATE_LIMITED" | "INVALID_REQUEST" | "AUTH_ERROR" | "TIMEOUT" | "NOT_FOUND" | "UNKNOWN";
  message: string;
  retryable: boolean;
  retryAfter?: number; // seconds, only set for RATE_LIMITED
}
  • Las funciones de actions.ts nunca lanzan errores: devuelven { ok: false, error }.
  • streamChatMessage y textToSpeech de client.ts lanzan el AIActionError: usá try/catch.
  • NOT_FOUND se devuelve cuando un conversationId no existe en el store.

Rate limiting

Mismo enfoque que el módulo Text Generation: chatCompletion y transcribeAudio reintentan automáticamente ante un 429 con backoff exponencial (mirá RATE_LIMIT_CONFIG en types.ts). streamChatCompletion y synthesizeSpeech no reintentan automáticamente: un 429 se devuelve de inmediato como RATE_LIMITED con un retryAfter en segundos.

Gestión de contexto

context.ts recorta los mensajes más antiguos para que la conversación entre dentro de DEFAULT_CONTEXT_CHAR_BUDGET caracteres antes de cada request. Es una aproximación por cantidad de caracteres, no un conteo exacto de tokens: se dejó afuera un tokenizer real (por ejemplo tiktoken) para mantener el módulo sin dependencias. Si necesitás un presupuesto exacto de tokens, cambiá la lógica de context.ts.

Limitaciones conocidas

  • Store en memoria: mirá la advertencia de arriba; reemplazalo antes de desplegar.
  • Fallos a mitad del streaming: si un stream de chat falla después de haber empezado, la conexión simplemente se cierra y la respuesta parcial no se guarda en el historial (un mensaje incompleto del asistente podría confundir el siguiente turno). Tratá un stream inesperadamente corto como un fallo leve en la UI.
  • Recorte de contexto: basado en caracteres, no en tokens exactos; mirá arriba.