ES ▾
Uncensored Chatbot APIAcceso directo a la API para un LLM sin censuraObtener clave de API

Uncensored Chatbot APIChat web

Construyamos un chat web con streaming (proxy Express + JS puro)

En este tutorial crearás una página de chat pequeña en unas 100 líneas. Un pequeño servidor Express guarda tu clave secreta y retransmite peticiones a la Uncensored Chatbot API, mientras que un front-end en JavaScript puro lee la respuesta en streaming palabra por palabra. Sin framework, sin paso de compilación y tu clave nunca toca el navegador.

Actualizado

Puntos clave

  • Nunca llames a la API desde el código del navegador; una ruta de servidor mantiene la clave privada y te permite validar la entrada.
  • El proxy solo necesita pasar los eventos enviados por el servidor; la página analiza las líneas de datos hasta [DONE].
  • Usa textContent durante el streaming y sanitiza antes de renderizar markdown.
  • Limita la longitud del historial y max_tokens en el servidor para que un usuario no pueda gastar todo tu saldo.

Lo que estamos construyendo y por qué un proxy

Imagina una sola página con un cuadro de texto y un registro con desplazamiento. Escribes una línea, la página envía la conversación a /api/chat en tu propio servidor, y tu servidor la reenvía al upstream con la clave real adjunta. La respuesta llega en streaming y cada fragmento aparece en el instante en que llega. Le daremos al bot un poco de personalidad, un guardián de faro llamado Wren, para que la demo se sienta viva.

Necesitas Node 18 o más reciente (para fetch integrado) y una clave de API. Si aún no tienes una, obtén la prueba gratis en la página de clave: las cuentas nuevas reciben $0,50 de crédito por 7 días, sin necesidad de datos de pago. La URL base es https://api.uncensoredchatbotapi.com/v1 y el id. del modelo es uncensored.

Por qué necesitas el proxy

Es tentador pegar la clave en el código front-end y llamar a la API directamente. Por favor, no lo hagas. Cualquier cosa enviada a un navegador puede ser leída por cualquiera que abra las herramientas de desarrollo, y una clave filtrada significa un saldo agotado. Un proxy soluciona esto y te da tres beneficios adicionales.

  • Confidencialidad. La clave vive en una variable de entorno en el servidor.
  • Control. Tú decides el prompt del sistema, la longitud del historial y max_tokens, para que los usuarios no puedan anularlos.
  • Un lugar para las reglas. Los límites por usuario, los filtros de edad y la política de registro pertenecen a esta capa. La guía de seguridad se basa en esta ruta.

Paso 1: configurar el proyecto

Crea una carpeta, inicialízala como un proyecto de módulo ES e instala Express. Todo lo demás está integrado.

mkdir lantern-chat && cd lantern-chat
npm init -y
npm pkg set type=module
npm install express
mkdir public

Terminarás con dos cosas: server.js en la raíz y una carpeta public que contiene la página y su script.

Paso 2: escribir el proxy Express

El servidor sirve archivos estáticos y expone una sola ruta POST. Léela en tres partes. Primero sanitiza el historial entrante: solo pasan los roles user y assistant, cada mensaje se recorta a 4.000 caracteres y solo se conservan los últimos 30 turnos. Segundo, antepone tu propio mensaje del sistema, que el navegador nunca puede cambiar. Tercero, llama al endpoint upstream con stream: true y canaliza los bytes directamente de vuelta.

import express from "express";

const app = express();
app.use(express.json({ limit: "256kb" }));
app.use(express.static("public"));

const UPSTREAM = "https://api.uncensoredchatbotapi.com/v1/chat/completions";
const SYSTEM = "You are Wren, a dry-witted night-shift lighthouse keeper. Stay in character.";

app.post("/api/chat", async (req, res) => {
  const history = Array.isArray(req.body.messages) ? req.body.messages : [];
  // Keep only well-formed turns and cap the history we forward.
  const turns = history
    .filter((m) => ["user", "assistant"].includes(m.role) && typeof m.content === "string")
    .slice(-30)
    .map((m) => ({ role: m.role, content: m.content.slice(0, 4000) }));

  if (turns.length === 0) return res.status(400).json({ error: "no messages" });

  const upstream = await fetch(UPSTREAM, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "uncensored",
      messages: [{ role: "system", content: SYSTEM }, ...turns],
      stream: true,
      max_tokens: 500,
      temperature: 0.8,
    }),
  });

  if (!upstream.ok) {
    const info = await upstream.json().catch(() => ({}));
    return res.status(upstream.status).json(info);
  }

  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  for await (const chunk of upstream.body) res.write(chunk);
  res.end();
});

app.listen(3000, () => console.log("http://localhost:3000"));

Dos opciones valen la pena explicarse. Pasar los bytes sin procesar significa que no necesitas entender el formato de eventos en el servidor en absoluto. Y devolver el estado del upstream para errores permite que la página reaccione de forma sensata; por ejemplo, un 402 significa que tu saldo está vacío y un 429 significa que alguien va demasiado rápido.

Paso 3: la página y el lector de stream

Ahora el front-end. Guarda esto como public/index.html; está deliberadamente desnudo para que puedas estilizarlo más tarde.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Lantern Chat</title>
  <style>
    body { font: 16px system-ui; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
    #log { min-height: 320px; border: 1px solid #ccc; padding: 1rem; white-space: pre-wrap; }
    .me { color: #234; font-weight: 600; }
    form { display: flex; gap: .5rem; margin-top: 1rem; }
    input { flex: 1; padding: .6rem; }
  </style>
</head>
<body>
  <h1>Lantern Chat</h1>
  <div id="log"></div>
  <form id="form">
    <input id="text" autocomplete="off" placeholder="Say something...">
    <button>Send</button>
  </form>
  <script src="chat.js"></script>
</body>
</html>

A continuación, public/chat.js. La parte interesante es el bucle de lectura. Los fragmentos de red no respetan los límites de línea, así que mantenemos un buffer, dividimos por saltos de línea y retenemos la última línea parcial hasta que llegue más datos. Cada línea completa que comienza con data: es JSON, excepto el marcador final [DONE].

const log = document.getElementById("log");
const form = document.getElementById("form");
const input = document.getElementById("text");
const history = [];

function addLine(cls, text) {
  const div = document.createElement("div");
  div.className = cls;
  div.textContent = text;          // textContent, never innerHTML, for untrusted text
  log.appendChild(div);
  return div;
}

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const text = input.value.trim();
  if (!text) return;
  input.value = "";
  history.push({ role: "user", content: text });
  addLine("me", "You: " + text);
  const bubble = addLine("bot", "");

  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages: history }),
  });
  if (!res.ok) {
    bubble.textContent = "(the chat is unavailable right now)";
    history.pop();
    return;
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "", reply = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop();            // keep a partial line for the next read
    for (const line of lines) {
      if (!line.startsWith("data: ")) continue;
      const data = line.slice(6).trim();
      if (data === "[DONE]") continue;
      const json = JSON.parse(data);
      const piece = json.choices?.[0]?.delta?.content;
      if (piece) {
        reply += piece;
        bubble.textContent = reply;
      }
    }
  }
  history.push({ role: "assistant", content: reply });
});

Observa la matriz history. La API no mantiene memoria entre llamadas, así que la página reenvía toda la conversación cada vez y el servidor la recorta. Inicia la aplicación y ábrela en un navegador:

export API_KEY="paste-your-key-here"
node server.js

Una nota sobre el renderizado de markdown

Los modelos de chat aman los asteriscos, las listas y los bloques de código ocasionales. Nuestra demo muestra texto sin formato, que es seguro. Cuando quieras una salida bonita, renderiza markdown con una biblioteca, pero sigue dos reglas. Ejecuta el HTML a través de un sanitizador antes de insertarlo con innerHTML, ya que un modelo, o un usuario que lo engañe, puede emitir etiquetas y atributos que no esperabas. Y renderiza de forma incremental con cuidado: volver a analizar toda la respuesta en cada fragmento está bien para mensajes cortos, pero el markdown a medio terminar puede parpadear, por lo que algunos creadores muestran texto sin formato mientras transmiten y cambian a una salida formateada cuando termina la transmisión.

El formato de juego de roles es una peculiaridad aparte. Muchos personajes envuelven acciones en asteriscos, como *ajusta la lámpara*. Decide si tu aplicación da estilo a esos como cursivas, y dile al personaje en el prompt del sistema qué convención seguir. Nuestra guía de diseño de personas muestra el wording del prompt para eso.

Paso 4: prueba de humo de la ruta

Antes de culpar al navegador, prueba el proxy directamente. Si la terminal funciona, el servidor está bien y cualquier otro error está en la página.

curl -N http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Wren, is the fog coming in?"}]}'

La bandera -N desactiva el buffering propio de curl, así que deberías ver las líneas data: aparecer gradualmente, terminando con data: [DONE]. Si obtienes un error JSON en su lugar, lee su estado: 401 significa que la clave en tu entorno es incorrecta, 402 significa que el saldo está vacío y un 404 significa que la URL del upstream tiene un error tipográfico.

SíntomaCausa probableSolución
La página muestra el mensaje de no disponibleEl servidor devolvió un estado distinto a 200Ejecuta la prueba de curl y lee el estado
El texto aparece solo al finalUna capa de proxy almacena la respuestaDesactiva el almacenamiento para la ruta
Caracteres corruptosDecodificador usado sin stream: trueMantén la opción activada en TextDecoder.decode
La respuesta se corta a mitad de fraseSe alcanzó max_tokensAumenta el límite en el servidor

Con esas cuatro correcciones puedes diagnosticar casi todos los problemas de la primera ejecución en menos de un minuto. Guarda el comando curl en tus notas; también es una prueba de salud útil después de cambiar el proxy más adelante.

Pequeños detalles que lo hacen sentir completo

Un chat con streaming ya es agradable, pero algunos detalles separan una demostración de un producto al que los usuarios vuelven.

  • Desactiva el botón de enviar mientras llega una respuesta. Los envíos dobles crean historiales entrelazados que confunden tanto al usuario como al modelo.
  • Añade un botón de detención. Crea un AbortController, pasa su señal a fetch y llama a abort() al hacer clic. Guarda todo el texto que haya llegado hasta ese momento como el turno del asistente.
  • Guarda el registro. Mantén el historial en sessionStorage para que una actualización no borre la conversación, y ofrece un botón de limpiar chat que lo vacíe.
  • Desplazamiento automático sensato. Sigue el final solo cuando el usuario ya esté cerca de él; de lo contrario, déjalo leer líneas antiguas en paz.
  • Muestra un fallo suave. Reemplaza la burbuja vacía con un enlace de reintento que reenvía el último mensaje del usuario.

Cada una de estas son una decena de líneas de JavaScript puro, así que resiste la tentación de usar un framework hasta que tu interfaz realmente necesite componentes, enrutamiento o estado compartido.

Endurecimiento y próximos pasos

Ahora tienes un chat funcional. Antes de que lleguen usuarios reales, añade algunas medidas de seguridad en server.js. Limita las peticiones por IP o sesión, porque una clave está permitida 300 peticiones por minuto en total, y un usuario ansioso podría usarlas todas. Maneja errores del upstream explícitamente: un 503 con upstream_busy merece un botón de reintento amigable, y un 403 content_blocked merece un mensaje claro en lugar de una burbuja en blanco. Mantén max_tokens modesto; 500 es suficiente para chat, mientras que el máximo permitido es 32.000 por petición.

Luego piensa en el coste. Como ilustración, supongamos que cada turno envía 1.200 tokens de prompt y recibe 250 tokens de finalización. Eso es aproximadamente $0.0003 para entrada y $0.00025 para salida, aproximadamente $0.00055 por turno. Esas cantidades de tokens son suposiciones, así que mide las tuyas con el fragmento usage. La documentación cubre ese campo, y la guía de LLM alojados sin censura explica qué esperar de este tipo de servicio.

Preguntas y respuestas

¿Puedo llamar a la API directamente desde el navegador?

Técnicamente sí, pero expondrías tu clave a todos los visitantes. Usa una ruta de servidor como la de este tutorial para que la clave permanezca en una variable de entorno.

¿Necesita el servidor analizar el streaming?

No. Puede reenviar los bytes sin cambios y la página lee las líneas de datos. Analiza en el servidor solo si quieres registrar texto o filtrar la salida.

¿Por qué llega mi respuesta de golpe?

Algo en el medio está almacenando. Comprueba que la petición establece stream a true y que cualquier proxy inverso o capa de compresión no esté reteniendo la respuesta.

¿Cómo le doy memoria al bot?

Reenvía la conversación en mensajes en cada llamada, recortando los turnos más antiguos cuando crezca. La ventana de contexto de 100.000 tokens se comparte con la respuesta.

Tu clave está a un formulario de distancia

Crea una cuenta, copia la clave y cambia la URL base. Esa es toda la configuración.

Obtener clave de APILeer la documentación