RU ▾
Uncensored Chatbot APIПрямой доступ к API одной модели без цензуры LLMПолучить API-ключ

Uncensored Chatbot APIВеб-чат

Создадим потоковый веб-чат (прокси Express + чистый JS)

В этом руководстве вы создадите небольшую страницу чата примерно за 100 строк. Небольшой сервер Express хранит ваш секретный ключ и пересылает запросы на Uncensored Chatbot API, а простой фронтенд на JavaScript читает потоковый ответ слово за словом. Без фреймворков, без этапа сборки, и ваш ключ никогда не попадает в браузер.

Обновлено

Ключевые моменты

  • Никогда не обращайтесь к API из кода браузера; маршрут сервера сохраняет ключ в тайне и позволяет проверять ввод.
  • Прокси нужно только передавать события, отправляемые сервером; страница разбирает строки данных до [DONE].
  • Используйте textContent во время потоковой передачи и очищайте данные перед отображением markdown.
  • Ограничьте длину истории и max_tokens на сервере, чтобы один пользователь не мог потратить весь ваш баланс.

Что мы строим и зачем нужен прокси

Представьте одну страницу с текстовым полем и прокручиваемым журналом. Вы вводите строку, страница отправляет историю на /api/chat на вашем сервере, а ваш сервер пересылает её вышестоящему серверу, прикрепляя реальный ключ. Ответ приходит потоком, и каждый фрагмент появляется мгновенно. Мы дадим боту немного характера — это будет смотритель маяка по имени Рен, чтобы демо казалось живым.

Вам нужен Node 18 или новее (для встроенного fetch) и API-ключ. Если у вас его ещё нет, получите бесплатный пробный баланс на странице ключей: новым аккаунтам начисляется $0.50 на 7 дней, данные карты не требуются. Базовый URL — https://api.uncensoredchatbotapi.com/v1, а идентификатор модели — uncensored.

Почему вам нужен прокси

Соблазнительно вставить ключ в код фронтенда и обращаться к API напрямую. Пожалуйста, не делайте этого. Всё, что отправляется в браузер, может быть прочитано любым, кто откроет инструменты разработчика, а утечка ключа означает опустошение баланса. Прокси решает эту проблему и дает три дополнительные выгоды.

  • Секретность. Ключ хранится в переменной окружения на сервере.
  • Контроль. Вы решаете системный промпт, длину истории и max_tokens, поэтому пользователи не могут их изменить.
  • Место для правил. Лимиты для каждого пользователя, возрастные ограничения и политика логирования относятся к этому слою. Руководство по безопасности строится на основе именно этого маршрута.

Шаг 1: настройка проекта

Создайте папку, инициализируйте её как проект ES-модуля и установите Express. Всё остальное встроено.

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

В итоге у вас будет два компонента: server.js в корне и папка public, содержащая страницу и её скрипт.

Шаг 2: напишите прокси Express

Сервер раздает статические файлы и предоставляет один маршрут POST. Прочитайте его в трёх частях. Сначала он очищает входящую историю: проходят только роли user и assistant, каждое сообщение обрезается до 4 000 символов, и сохраняются только последние 30 реплик. Затем он добавляет ваше системное сообщение, которое браузер не может изменить. Наконец, он вызывает вышестоящий эндпоинт с параметром stream: true и передаёт байты напрямую обратно.

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

Стоит объяснить два варианта. Передача необработанных байтов означает, что вам вообще не нужно понимать формат событий на сервере. А возврат статуса от вышестоящего сервиса при ошибках позволяет странице адекватно реагировать; например, код 402 означает, что ваш баланс пуст, а 429 — что кто-то превышает лимит запросов.

Шаг 3: страница и считыватель потока

Теперь фронтенд. Сохраните это как public/index.html; он намеренно простой, чтобы вы могли стилизовать его позже.

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

Далее public/chat.js. Самая интересная часть — цикл чтения. Сетевые фрагменты не учитывают границы строк, поэтому мы сохраняем buffer, разбиваем его по символам новой строки и удерживаем последнюю неполную строку до поступления дополнительных данных. Каждая полная строка, начинающаяся с data: , является JSON, за исключением финального маркера [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 });
});

Обратите внимание на массив history. API не сохраняет память между вызовами, поэтому страница повторно отправляет всю историю каждый раз, а сервер обрезает её. Запустите приложение и откройте его в браузере:

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

Заметка об отображении markdown

Модели чата любят звёздочки, списки и случайные блоки кода. Наш демо-пример показывает обычный текст, что безопасно. Когда вам нужен красивый вывод, используйте библиотеку для рендеринга markdown, но следуйте двум правилам. Пропускайте HTML через санитайзер перед вставкой через innerHTML, так как модель или пользователь, который её обманет, могут вывести теги и атрибуты, которые вы не планировали. И осторожно выполняйте инкрементальный рендеринг: повторный анализ всего ответа на каждом фрагменте подходит для коротких сообщений, но незавершённый markdown может мерцать, поэтому некоторые создатели показывают обычный текст во время потоковой передачи и переключаются на форматированный вывод, когда поток заканчивается.

Форматирование ролевой игры — отдельная особенность. Многие персонажи оборачивают действия звёздочками, например, *поправляет лампу*. Решите, будет ли ваше приложение стилизовать их как курсив, и укажите персонажу в системном промпте, какой стандарт соблюдать. Наше руководство по созданию персонажа показывает формулировки промптов для этого.

Шаг 4: проверка маршрута

Прежде чем винить браузер, проверьте прокси напрямую. Если терминал работает, сервер в порядке, и любая оставшаяся ошибка находится в странице.

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

Флаг -N отключает собственное буферизование curl, поэтому вы должны видеть, как строки data: появляются постепенно, заканчиваясь на data: [DONE]. Если вместо этого вы получаете ошибку JSON, прочитайте его статус: 401 означает, что ключ в вашей среде неверен, 402 — что баланс пуст, а 404 — что в URL вышестоящего сервера есть опечатка.

СимптомВероятная причинаИсправление
Страница показывает сообщение о недоступностиСервер вернул статус, отличный от 200Запустите тест curl и прочитайте статус
Текст появляется только в концеСлой прокси буферизует ответОтключите буферизацию для маршрута
Непонятные символыДекодер используется без stream: trueСохраните опцию установленной в TextDecoder.decode
Ответ обрывается на полусловеmax_tokens достигнутПовысить лимит на сервере

С этими четырьмя исправлениями вы сможете диагностировать почти все проблемы первого запуска менее чем за минуту. Сохраните команду curl в заметки; она также удобна для проверки работоспособности после того, как вы позже измените прокси.

Мелкие штрихи, которые делают интерфейс завершённым

Чат с потоковой передачей уже приятен, но несколько деталей отличают демонстрацию от продукта, к которому пользователи возвращаются снова.

  • Отключите кнопку отправки, пока приходит ответ. Двойные нажатия создают переплетённые истории сообщений, которые сбивают с толку и пользователя, и модель.
  • Добавьте кнопку остановки. Создайте AbortController, передайте его сигнал в fetch и вызовите abort() по клику. Сохраните весь текст, который поступил на данный момент, как реплику ассистента.
  • Сохраняйте журнал. Храните историю в sessionStorage, чтобы обновление страницы не стирало историю чата, и предложите кнопку очистки чата, которая её очищает.
  • Умная автопрокрутка. Прокручивайте вниз только тогда, когда пользователь уже находится в нижней части чата; в противном случае дайте ему спокойно читать старые сообщения.
  • Покажите сообщение об ошибке. Замените пустой пузырь на ссылку для повторной отправки, которая отправит последнее сообщение пользователя.

Каждое из этих решений занимает пару десятков строк обычного JavaScript, поэтому воздержитесь от использования фреймворков, пока вашему интерфейсу действительно не понадобятся компоненты, маршрутизация или общее состояние.

Устойчивость и дальнейшие шаги

У вас теперь есть рабочий чат. Прежде чем придут реальные пользователи, добавьте несколько мер предосторожности в server.js. Ограничьте количество запросов с одного IP или сессии, так как одному ключу разрешено в общей сложности 300 запросов в минуту, и один нетерпеливый пользователь может исчерпать лимит. Явно обрабатывайте ошибки вышестоящего сервера: 503 с upstream_busy заслуживает дружелюбной кнопки повторной попытки, а 403 content_blocked — чёткого сообщения, а не пустого пузыря. Держите max_tokens на умеренном уровне; 500 достаточно для чата, тогда как разрешённый максимум составляет 32 000 на запрос.

Затем подумайте о стоимости. В качестве иллюстрации предположим, что каждый обмен отправляет 1 200 токенов промпта и получает 250 токенов завершения. Это около $0.0003 за ввод и $0.00025 за вывод, примерно $0.00055 за обмен. Эти подсчёты токенов — предположения, поэтому измеряйте свои собственные с помощью поля usage. Документация охватывает это поле, а руководство по размещению моделей без цензуры объясняет, чего ожидать от такого рода сервиса.

Вопросы и ответы

Могу ли я вызывать API прямо из браузера?

Технически да, но тогда ваш ключ будет доступен всем посетителям. Используйте серверный маршрут, как в этом руководстве, чтобы ключ оставался в переменной окружения.

Нужно ли серверу разбирать поток?

Нет. Он может передавать байты без изменений, а страница считывает строки данных. Разбирайте поток на сервере только если хотите логировать текст или фильтровать вывод.

Почему ответ приходит сразу целиком?

Что-то между сервером и клиентом буферизует данные. Убедитесь, что в запросе установлен параметр stream в true, и что любой обратный прокси или слой сжатия не удерживает ответ.

Как дать боту память?

Отправляйте историю сообщений при каждом вызове, отбрасывая самые старые реплики по мере роста. Контекст из 100 000 токенов передаётся вместе с ответом.

Ваш ключ — в одной форме от вас

Создайте аккаунт, скопируйте ключ, измените базовый URL. Это вся настройка.

Получить API-ключПрочитать документацию