PT ▾
Uncensored Chatbot APIAcesso direto à API para um LLM sem censuraObter chave de API

Uncensored Chatbot APIChat Web

Vamos construir um chat web com streaming (proxy Express + JS puro)

Neste tutorial, você vai criar uma pequena página de chat em cerca de 100 linhas. Um pequeno servidor Express mantém sua chave secreta e encaminha as requisições para o Uncensored Chatbot API, enquanto um front-end em JavaScript puro lê a resposta em streaming palavra por palavra. Sem framework, sem etapa de compilação, e sua chave nunca toca o navegador.

Atualizado

Pontos principais

  • Nunca chame a API do código do navegador; um endpoint de servidor mantém a chave privada e permite validar a entrada.
  • O proxy só precisa passar os eventos enviados pelo servidor; a página analisa linhas de dados até [DONE].
  • Use textContent durante o streaming e sanitize antes de renderizar markdown.
  • Limite o tamanho do histórico e max_tokens no servidor para que um usuário não gaste todo o seu saldo.

O que estamos construindo e por que um proxy

Imagine uma única página com uma caixa de texto e um log de rolagem. Você digita uma linha, a página envia a conversa para /api/chat no seu próprio servidor, e seu servidor encaminha para o servidor upstream com a chave real anexada. A resposta vem em streaming, e cada fragmento aparece no instante em que chega. Vamos dar à bot um pouco de personalidade, um faroleiro chamado Wren, para que a demo pareça viva.

Você precisa do Node 18 ou mais recente (para o fetch nativo) e de uma chave de API. Se você ainda não tem uma, pegue o crédito de teste grátis na página de chave: novas contas recebem US$ 0,50 de crédito por 7 dias, sem necessidade de detalhes de pagamento. A URL base é https://api.uncensoredchatbotapi.com/v1 e o id do modelo é uncensored.

Por que você precisa do proxy

É tentador colar a chave no código front-end e chamar a API diretamente. Não faça isso. Tudo o que é enviado para um navegador pode ser lido por qualquer pessoa que abra as ferramentas de desenvolvedor, e uma chave vazada significa um saldo drenado. Um proxy corrige isso e oferece três benefícios extras.

  • Sigilo. A chave vive em uma variável de ambiente no servidor.
  • Controle. Você decide o prompt do sistema, o comprimento do histórico e max_tokens, então os usuários não podem substituí-los.
  • Um lugar para regras. Limites por usuário, filtros de idade e política de registro pertencem a esta camada. O guia de segurança se baseia exatamente nesta rota.

Passo 1: configurar o projeto

Crie uma pasta, inicialize-a como um projeto ES-module e instale o Express. Tudo o mais é construído nativamente.

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

Você terminará com duas coisas: server.js na raiz e uma pasta public contendo a página e seu script.

Passo 2: escrever o proxy Express

O servidor serve arquivos estáticos e expõe uma rota POST. Leia em três partes. Primeiro, ele sanitiza o histórico entrante: apenas os papéis user e assistant passam, cada mensagem é cortada para 4.000 caracteres, e apenas as últimas 30 rodadas são mantidas. Segundo, ele antecipa sua própria mensagem do sistema, que o navegador nunca pode alterar. Terceiro, ele chama o endpoint upstream com stream: true e encaminha os bytes diretamente de volta.

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"));

Duas escolhas valem a explicação. Passar os bytes crus significa que você não precisa entender o formato de evento no servidor. E retornar o status upstream para falhas permite que a página reaja de forma sensata; por exemplo, um 402 significa que seu saldo está vazio e um 429 significa que alguém está indo rápido demais.

Passo 3: a página e o leitor de streaming

Agora o front-end. Salve isso como public/index.html; é deliberadamente simples para que você possa estilizar depois.

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

Em seguida, public/chat.js. A parte interessante é o loop de leitura. Fragmentos de rede não respeitam limites de linha, então mantemos um buffer, dividimos por quebras de linha, e seguramos a última linha parcial até mais dados chegarem. Cada linha completa que começa com data: é JSON, exceto o 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 });
});

Observe a matriz history. A API não mantém memória entre chamadas, então a página reenvia toda a conversa cada vez, e o servidor a corta. Inicie o app e abra no navegador:

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

Uma nota sobre renderização de markdown

Modelos de chat adoram asteriscos, listas e a marcação de código ocasional. Nossa demo mostra texto cru, o que é seguro. Quando você quiser uma saída bonita, renderize markdown com uma biblioteca, mas siga duas regras. Execute o HTML por um sanitizador antes de inseri-lo com innerHTML, já que um modelo, ou um usuário que o engana, pode emitir tags e atributos que você não pretendia. E renderize incrementalmente com cuidado: reanalisar toda a resposta a cada fragmento é aceitável para mensagens curtas, mas markdown incompleto pode piscar, então alguns criadores mostram texto simples durante o streaming e alternam para a saída formatada quando o stream termina.

A formatação de roleplay é uma peculiaridade separada. Muitos personagens envolvem ações em asteriscos, como *ajusta a lâmpada*. Decida se seu aplicativo estiliza isso como itálico, e diga ao personagem no prompt do sistema qual convenção seguir. Nosso guia de design de persona mostra a redação do prompt para isso.

Passo 4: teste de fumaça na rota

Antes de culpar o navegador, teste o proxy diretamente. Se o terminal funcionar, o servidor está OK e qualquer bug restante está na 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?"}]}'

A flag -N desativa o buffer próprio do curl, então você deve ver as linhas data: aparecerem gradualmente, terminando com data: [DONE]. Se você receber um erro JSON em vez disso, leia seu status: 401 significa que a chave no seu ambiente está errada, 402 significa que o saldo está vazio, e 404 significa que a URL upstream tem um erro de digitação.

SintomaCausa provávelCorreção
A página mostra a mensagem de indisponívelServidor retornou status diferente de 200Execute o teste curl e leia o status
O texto aparece apenas no finalUma camada de proxy está fazendo buffer da respostaDesative o buffer para o endpoint
Caracteres corrompidosDecoder usado sem stream: trueMantenha a opção definida em TextDecoder.decode
A resposta corta no meio da frasemax_tokens atingidoAumente o limite no servidor

Com esses quatro ajustes, você pode diagnosticar quase todos os problemas de primeira execução em menos de um minuto. Mantenha o comando curl em suas anotações; ele também serve como um teste de integridade útil depois que você alterar o proxy mais tarde.

Pequenos detalhes que dão a sensação de acabamento

Um chat com streaming já é agradável, mas alguns detalhes separam uma demonstração de um produto ao qual as pessoas voltam.

  • Desative o botão de enviar enquanto uma resposta está chegando. Envios duplos criam históricos entrelaçados que confundem tanto o usuário quanto o modelo.
  • Adicione um botão de parada. Crie um AbortController, passe seu signal para o fetch e chame abort() no clique. Salve qualquer texto que tenha chegado até então como a rodada do assistente.
  • Armazene o log. Mantenha o histórico no sessionStorage para que uma atualização não apague a conversa, e ofereça um botão de limpar chat que o esvazia.
  • Rolação automática sensata. Siga o fundo apenas quando o usuário já estiver perto dele; caso contrário, deixe-o ler linhas mais antigas em paz.
  • Mostre uma falha suave. Substitua a bolha vazia por um link de nova tentativa que reenvia a última mensagem do usuário.

Cada uma dessas é uma dúzia de linhas de JavaScript puro, então resista a buscar um framework até que sua interface realmente precise de componentes, roteamento ou estado compartilhado.

Consolidação e próximos passos

Agora você tem um chat funcional. Antes que usuários reais cheguem, adicione algumas salvaguardas no server.js. Limite as requisições por IP ou sessão, porque uma única chave tem permissão para 300 requisições por minuto no total, e um usuário ansioso poderia usar todas. Lidar explicitamente com erros upstream: um 503 com upstream_busy merece um botão de nova tentativa amigável, e um 403 content_blocked merece uma mensagem clara em vez de uma bolha em branco. Mantenha max_tokens modesto; 500 é mais do que suficiente para chat, enquanto o máximo permitido é 32.000 por requisição.

Então pense sobre o custo. Como ilustração, assuma que cada rodada envia 1.200 tokens de prompt e recebe 250 tokens de conclusão. Isso é cerca de $0,0003 para entrada e $0,00025 para saída, totalizando aproximadamente $0,00055 por rodada. Essas contagens de tokens são suposições, então meça as suas com o fragmento usage. A documentação cobre esse campo, e o guia para LLMs hospedados sem censura explica o que esperar desse tipo de serviço.

Perguntas e respostas

Posso chamar a API diretamente do navegador?

Tecnicamente sim, mas você exporia sua chave para todos os visitantes. Use uma rota de servidor como a deste tutorial para que a chave permaneça em uma variável de ambiente.

O servidor precisa analisar o streaming?

Não. Ele pode encaminhar os bytes inalterados, e a página lê as linhas de dados. Analise no servidor apenas se quiser registrar texto ou filtrar a saída.

Por que minha resposta chega de uma vez só?

Algo no meio está fazendo buffer. Verifique se a requisição define stream como true e se algum proxy reverso ou camada de compressão não está segurando a resposta.

Como dou memória ao bot?

Reenvie a conversa em mensagens a cada chamada, cortando as rodadas mais antigas quando ela crescer. A janela de contexto de 100.000 tokens é compartilhada com a resposta.

Sua chave está a um formulário de distância

Crie uma conta, copie a chave, altere a base URL. Essa é toda a configuração.

Obter chave de APILer a documentação