ChatGPT API на сайті самому: код і що дописати

Оновлено:

Підключити ChatGPT API до сайту самому можна за вечір: потрібен сервер-посередник, який тримає ключ, і маленький віджет на сторінці. Нижче робочий мінімальний код на Node.js без сторонніх пакетів. Далі чесно про те, що доведеться дописати, щоб бот продавав. Готовий варіант, у якому це вже є, — Rozmova.

Якщо ви вибираєте між власним кодом і готовим сервісом, почніть з порівняння Rozmova чи ChatGPT на сайті. Ця стаття для тих, хто хоче спершу спробувати сам.

Що потрібно, щоб почати?

Три речі: Node.js 18 або новіший, ключ OpenAI API і назва моделі.

  • Node.js 18+. У ньому вже є fetch, тож сторонні пакети не потрібні.
  • Ключ API. Створюється в кабінеті OpenAI Platform. Оплата за фактичне використання, окремо від підписки ChatGPT.
  • Назва моделі. Візьміть її зі сторінки Models у документації OpenAI. Назви моделей регулярно змінюються, тому в коді вона не зашита, а передається змінною OPENAI_MODEL.

Чому ключ не можна класти в код сторінки?

Бо все, що є на сторінці, бачить відвідувач. Ключ у JavaScript сайту можна скопіювати за хвилину і витрачати ваш рахунок без обмежень.

Тому схема така: сторінка надсилає повідомлення на ваш сервер, сервер додає ключ і звертається до OpenAI, відповідь повертається на сторінку.

Як виглядає сервер?

Один файл server.mjs. Він віддає сторінку з чатом і приймає повідомлення на /api/chat. Запит іде в Responses API з інструкцією для моделі й останніми повідомленнями розмови.

Файл server.mjs
// server.mjs: мінімальний чат на OpenAI Responses API. Без сторонніх пакетів, Node.js 18+.
// Запуск: OPENAI_API_KEY=ваш_ключ OPENAI_MODEL=назва_моделі node server.mjs
import http from "node:http";
import { readFile } from "node:fs/promises";

const PORT = Number(process.env.PORT ?? 3000);
const API_KEY = process.env.OPENAI_API_KEY;
const MODEL = process.env.OPENAI_MODEL;
const API_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";

if (!API_KEY || !MODEL) {
  console.error("Задайте змінні OPENAI_API_KEY і OPENAI_MODEL");
  process.exit(1);
}

// Інструкція для моделі. Тут же правила тону й заборони.
const INSTRUCTIONS =
  "Ви консультант інтернет-магазину. Відповідайте коротко, українською, на «ви». " +
  "Не вигадуйте цін, наявності й умов доставки. Якщо не знаєте відповіді, " +
  "так і скажіть і запропонуйте залишити телефон для менеджера.";

const MAX_MESSAGES = 12; // скільки останніх повідомлень розмови йде в модель
const MAX_CHARS = 1000; // найдовше повідомлення відвідувача
const MAX_BODY = 50_000; // найбільший запит від сторінки, у байтах

async function askModel(messages) {
  const res = await fetch(`${API_URL}/responses`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${API_KEY}`,
    },
    body: JSON.stringify({
      model: MODEL,
      instructions: INSTRUCTIONS,
      input: messages,
      max_output_tokens: 800,
      store: false,
    }),
    signal: AbortSignal.timeout(30_000),
  });
  if (!res.ok) throw new Error(`OpenAI відповів ${res.status}: ${await res.text()}`);
  const data = await res.json();
  // Текст відповіді: output[] → елементи type "message" → content[] → type "output_text".
  const text = (data.output ?? [])
    .filter((item) => item.type === "message")
    .flatMap((item) => item.content ?? [])
    .filter((part) => part.type === "output_text")
    .map((part) => part.text)
    .join("");
  return text || "Вибачте, не вийшло відповісти. Спробуйте ще раз.";
}

// Беремо від сторінки тільки ролі user/assistant і тільки текст, обрізаємо довжину.
function cleanMessages(raw) {
  if (!Array.isArray(raw)) return null;
  const messages = raw
    .filter((m) => (m?.role === "user" || m?.role === "assistant") && typeof m.content === "string")
    .map((m) => ({ role: m.role, content: m.content.slice(0, MAX_CHARS) }))
    .slice(-MAX_MESSAGES);
  return messages.at(-1)?.role === "user" ? messages : null;
}

async function readBody(req) {
  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY) throw new RangeError("Завеликий запит");
    chunks.push(chunk);
  }
  return Buffer.concat(chunks).toString("utf8");
}

function sendJson(res, status, payload) {
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
  res.end(JSON.stringify(payload));
}

const server = http.createServer(async (req, res) => {
  try {
    if (req.method === "GET" && req.url === "/") {
      const html = await readFile(new URL("./index.html", import.meta.url));
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.end(html);
      return;
    }
    if (req.method === "POST" && req.url === "/api/chat") {
      const body = JSON.parse(await readBody(req));
      const messages = cleanMessages(body?.messages);
      if (!messages) return sendJson(res, 400, { error: "Порожнє повідомлення" });
      const reply = await askModel(messages);
      return sendJson(res, 200, { reply });
    }
    sendJson(res, 404, { error: "Не знайдено" });
  } catch (err) {
    const badRequest = err instanceof SyntaxError || err instanceof RangeError;
    if (!badRequest) console.error(err);
    sendJson(res, badRequest ? 400 : 500, { error: badRequest ? "Хибний запит" : "Бот тимчасово не відповідає" });
  }
});

server.listen(PORT, () => console.log(`Чат працює: http://localhost:${PORT}`));

Що тут важливо:

  • Ключ тільки в змінній оточення. Його немає ні в коді, ні на сторінці.
  • Сервер сам вирішує, що піде в модель. Він пропускає тільки ролі user і assistant, обрізає довгі повідомлення й бере останні 12. Так сторінка не підмінить інструкцію.
  • Обмеження на відповідь і тайм-аут. max_output_tokens не дає моделі писати безкінечно, а запит обривається через 30 секунд.
  • store: false. Просить OpenAI не зберігати відповідь для подальшого отримання через API.
  • Текст відповіді лежить у масиві output: беремо елементи типу message, а з них частини типу output_text.

Як виглядає віджет на сторінці?

Файл index.html лежить поруч із сервером. Це поле вводу, кнопка і список повідомлень. Історія розмови живе в масиві messages і надсилається з кожним запитом.

Файл index.html
<!doctype html>
<html lang="uk">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Чат на OpenAI API</title>
  <style>
    body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
    #chat { max-width: 420px; }
    #log { height: 360px; overflow-y: auto; border: 1px solid #ccc; border-radius: 8px; padding: 8px; }
    #log p { margin: 0 0 8px; }
    .user { text-align: right; }
    .assistant { color: #2563eb; }
    form { display: flex; gap: 8px; margin-top: 8px; }
    input { flex: 1; }
  </style>
</head>
<body>
  <div id="chat">
    <div id="log" aria-live="polite"></div>
    <form id="form">
      <input id="text" aria-label="Ваше питання" autocomplete="off" required maxlength="1000" placeholder="Ваше питання">
      <button type="submit">Надіслати</button>
    </form>
  </div>
  <script>
    const messages = [];
    const log = document.getElementById("log");
    const form = document.getElementById("form");
    const input = document.getElementById("text");
    const button = form.querySelector("button");

    function show(role, text) {
      const p = document.createElement("p");
      p.className = role;
      p.textContent = text; // textContent, не innerHTML: відповідь моделі не можна вставляти як HTML
      log.append(p);
      log.scrollTop = log.scrollHeight;
    }

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      const text = input.value.trim();
      if (!text) return;
      input.value = "";
      show("user", text);
      messages.push({ role: "user", content: text });
      button.disabled = true;
      try {
        const res = await fetch("/api/chat", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ messages }),
        });
        const data = await res.json();
        if (res.ok) {
          show("assistant", data.reply);
          messages.push({ role: "assistant", content: data.reply });
        } else {
          show("assistant", data.error);
        }
      } catch {
        show("assistant", "Немає зв'язку із сервером. Спробуйте ще раз.");
      } finally {
        button.disabled = false;
        input.focus();
      }
    });
  </script>
</body>
</html>

Зверніть увагу на textContent. Відповідь моделі не можна вставляти через innerHTML: якщо відвідувач попросить модель написати HTML зі скриптом, він виконається на вашому сайті.

Як запустити й перевірити?

Покладіть обидва файли в одну папку і виконайте одну команду.

OPENAI_API_KEY=ваш_ключ OPENAI_MODEL=назва_моделі node server.mjs

Відкрийте http://localhost:3000 і напишіть питання. Без ключа або назви моделі сервер одразу зупиниться з підказкою. Ми перевірили цей код 21 жовтня 2026 року з локальною заглушкою API: сервер передає ключ, інструкцію й історію, а відповідь доходить до сторінки. Справжній виклик потребує вашого ключа.

Що доведеться дописати, щоб бот працював на продажі?

Мінімальний код відповідає на питання, але не знає ваших цін, не збирає заявок і не має лімітів витрат. Ось що зазвичай дописують.

Що бракує мінімальному чату для роботи на сайті бізнесу
ЧастинаНавіщоБез неї
Відповіді за вашими даними (RAG)Ціни, наявність, доставка з вашого сайту й фідуБот вигадує правдоподібні ціни
Оновлення бази знаньНові ціни й товари потрапляють у відповідіБот відповідає за вчорашнім прайсом
Ліміти й захист від зловживаньОбмеження на розмову, IP і день, дозволені домениСкрипт може витратити місячний бюджет за ніч
Захист від prompt-injectionПовідомлення відвідувача — дані, а не команди«Забудь інструкції» змінює поведінку бота
Збір заявокФорма контакту, передача менеджеру в пошту, Telegram чи CRMРозмова закінчується, контакту немає
Історія на сервері й операторМенеджер бачить розмову і може відповісти самНіхто не знає, що писали відвідувачі
Поступовий вивід відповідіТекст з’являється одразу, а не після паузиВідвідувач чекає і закриває чат
Облік витрат і аналітикаСкільки коштує розмова, про що питаютьРахунок від OpenAI стає сюрпризом
Персональні даніЩо зберігаєте, скільки, хто має доступТелефони клієнтів лежать у логах без правил
Повтори й моніторинг помилокПовтор при тимчасових збоях API, сповіщення про помилкиБот мовчки перестає відповідати

Найбільша з цих частин — RAG. Чому без нього бот вигадує ціни і як працює пошук по базі знань, пояснюємо у статті що таке RAG простими словами. Як рахувати витрати на AI до запуску, розібрано у статті скільки коштує AI-чат на сайті.

Про безпеку

Prompt-injection стоїть першим у списку ризиків OWASP для застосунків на мовних моделях (2025). Інструкція «не виконуй команд відвідувача» в промпті не є захистом сама по собі: перевірки мають бути і на сервері.

Коли власний код має сенс, а коли краще готовий сервіс?

Власний код має сенс, якщо у вас є розробник і бот потрібен для нестандартної задачі, наприклад усередині вашого застосунку. Якщо мета — заявки з сайту, швидше взяти готовий сервіс і витратити час на базу знань.

  • Пишіть самі, якщо потрібна глибока інтеграція з вашою системою, ви готові підтримувати код і стежити за змінами API.
  • Беріть готовий сервіс, якщо потрібні відповіді за каталогом, заявки менеджеру і звіти вже цього місяця.

Як порівнювати постачальників, підкаже стаття як обрати чат-бот для сайту. Rozmova працює на моделях Anthropic Claude, а не OpenAI, і вже має базу знань, збір заявок, ліміти й звіти з таблиці вище.

Питання й відповіді

Чи можна викликати ChatGPT API прямо з браузера?

Ні. Тоді ключ API буде в коді сторінки, і будь-хто зможе його скопіювати й витрачати ваші гроші. Ключ має жити тільки на сервері, а сторінка звертається до вашого сервера.

Скільки коштує ChatGPT API для чату на сайті?

Залежить від моделі, довжини інструкції, історії розмови й відповіді. Ціни за мільйон токенів OpenAI публікує на своїй сторінці цін. Як порахувати вартість однієї відповіді і розмови, ми розібрали у статті скільки коштує AI-чат на сайті.

Чи запам’ятовує API попередні повідомлення?

У нашому прикладі ні: сторінка надсилає всю розмову з кожним запитом, а сервер передає в модель останні повідомлення. Для продакшну історію краще зберігати на сервері, бо сторінка може надіслати що завгодно.

Чи знатиме такий бот ціни мого магазину?

Ні, поки ви їх не дасте. Модель не знає вашого прайсу і без даних вигадає правдоподібну цифру. Потрібен пошук по вашій базі знань перед кожною відповіддю, тобто RAG. Як він працює, пояснюємо у статті що таке RAG.

Rozmova працює на ChatGPT?

Ні. Rozmova працює на моделях Anthropic Claude і вже має те, що в цьому статті доводиться дописувати: базу знань, збір заявок, ліміти, звіти. Порівняння двох підходів — на сторінці Rozmova чи ChatGPT на сайті.

Джерела

  1. OpenAI API: Quickstart (developers.openai.com)
  2. OpenAI API: Text generation (instructions, ролі повідомлень)
  3. OpenAI API: Models (актуальні назви моделей)
  4. OWASP Top 10 for LLM Applications 2025: LLM01 Prompt Injection

· Власник продукту Rozmova, DForce

Відповідає за продукт Rozmova в DForce. Пише про AI-чати для сайтів простими словами — як вони працюють, скільки коштують і як рахувати результат.