Блог разработчика 1С-Битрикс

Как сделать чат с GPT на сайте: от теста до продакшена (PHP + API)

Хотите быстро добавить на сайт чат с AI и при этом не "слить" API-ключ в браузер? В этой статье показываю два рабочих варианта интеграции: сначала — простой тестовый пример на PHP (чтобы убедиться, что всё отвечает), затем — полноценная версия "как для продакшена" со стримингом ответа, rate limit, логами, хранением истории в базе и фильтрацией ввода.

Как сделать чат с GPT на сайте

Если вам нужно разместить на сайте форму для взаимодействия с GPT через сторонний API, самый безопасный путь — делать запросы только с сервера (PHP), а не из JavaScript в браузере. Так ваш ключ останется приватным, а вы сможете контролировать лимиты, логи и безопасность.

Ниже — два решения:

  1. Простой тестовый чат (без стриминга, без базы) — “проверить, что работает”.
  2. Продвинутый чат — потоковая выдача (stream=true), SQLite-база, rate limit, логи, фильтрация.

Сервис и endpoint: Chat01.ai
API endpoint: https://chat01.ai/v1/chat/completions

Вариант №1. Простой код для тестирования (быстрый старт)

Этот вариант — минимальный: форма, история в PHP-сессии, запрос к https://chat01.ai/v1/chat/completions. Отлично подходит, чтобы убедиться, что ключ и запросы работают корректно.

Важно: ключ не должен попадать в JS/HTML. Только сервер.

<?php
declare(strict_types=1);
session_start();

/**
 * =========================
 * НАСТРОЙКИ
 * =========================
 */

// Полный URL API:
$API_URL = 'https://chat01.ai/v1/chat/completions';

// Ключ лучше брать из переменной окружения.
// Быстрый вариант (НЕ лучший) — вставить строкой:
$API_KEY = getenv('CHAT01_API_KEY') ?: 'PASTE_YOUR_API_KEY_HERE';

// Модель: зависит от вашего провайдера. Если не знаете — уточните в документации chat01.ai
$MODEL = getenv('CHAT01_MODEL') ?: 'gpt-4o-mini';

// Параметры генерации:
$TEMPERATURE = 0.7;
$MAX_TOKENS  = 800;

// (Опционально) системная инструкция:
$SYSTEM_PROMPT = 'Ты полезный ассистент. Отвечай по-русски.';

/**
 * =========================
 * ИНИЦИАЛИЗАЦИЯ ИСТОРИИ
 * =========================
 */
if (!isset($_SESSION['messages']) || !is_array($_SESSION['messages'])) {
    $_SESSION['messages'] = [
        ['role' => 'system', 'content' => $SYSTEM_PROMPT],
    ];
}

$error = null;

/**
 * =========================
 * ОБРАБОТКА ФОРМЫ
 * =========================
 */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $action = $_POST['action'] ?? 'send';

    if ($action === 'reset') {
        // Сброс диалога
        $_SESSION['messages'] = [
            ['role' => 'system', 'content' => $SYSTEM_PROMPT],
        ];
        header("Location: " . strtok($_SERVER["REQUEST_URI"], '?'));
        exit;
    }

    if ($action === 'send') {
        $userText = trim((string)($_POST['prompt'] ?? ''));

        if ($userText === '') {
            $error = 'Введите запрос.';
        } elseif ($API_KEY === 'PASTE_YOUR_API_KEY_HERE') {
            $error = 'Не задан API ключ (CHAT01_API_KEY).';
        } else {
            // Добавляем сообщение пользователя в историю
            $_SESSION['messages'][] = ['role' => 'user', 'content' => $userText];

            // Делаем запрос к API
            $payload = [
                'model' => $MODEL,
                'messages' => $_SESSION['messages'],
                'temperature' => $TEMPERATURE,
                'max_tokens' => $MAX_TOKENS,
                'stream' => false,
            ];

            $ch = curl_init($API_URL);
            curl_setopt_array($ch, [
                CURLOPT_POST => true,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => [
                    'Content-Type: application/json',
                    'Authorization: Bearer ' . $API_KEY,
                ],
                CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
                CURLOPT_TIMEOUT => 60,
            ]);

            $raw = curl_exec($ch);
            $curlErr = curl_error($ch);
            $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);

            if ($raw === false) {
                $error = 'Ошибка cURL: ' . $curlErr;
            } else {
                $data = json_decode($raw, true);

                // Если API вернул ошибку
                if ($httpCode < 200 || $httpCode >= 300) {
                    $apiMsg = $data['error']['message'] ?? $data['message'] ?? $raw;
                    $error = "Ошибка API (HTTP $httpCode): " . $apiMsg;
                } else {
                    $assistantText = $data['choices'][0]['message']['content'] ?? null;

                    if (!$assistantText) {
                        $error = 'Не удалось прочитать ответ модели. Сырой ответ: ' . $raw;
                    } else {
                        // Добавляем ответ ассистента в историю
                        $_SESSION['messages'][] = ['role' => 'assistant', 'content' => $assistantText];
                    }
                }
            }
        }
    }
}

/**
 * =========================
 * ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
 * =========================
 */
function h(string $s): string {
    return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

// Для отображения истории без system-сообщения
$displayMessages = array_values(array_filter($_SESSION['messages'], fn($m) => ($m['role'] ?? '') !== 'system'));

?>
<!doctype html>
<html lang="ru">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>AI Chat (chat01.ai)</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 24px; background: #f7f7f8; }
        .wrap { max-width: 900px; margin: 0 auto; }
        .card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 2px 10px rgba(0,0,0,.06); }
        .chat { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
        .msg { padding: 12px 14px; border-radius: 12px; line-height: 1.4; white-space: pre-wrap; }
        .user { background: #e8f0fe; align-self: flex-end; }
        .assistant { background: #f1f3f4; align-self: flex-start; }
        .meta { font-size: 12px; opacity: .7; margin-bottom: 6px; }
        textarea { width: 100%; min-height: 90px; resize: vertical; padding: 10px; border-radius: 10px; border: 1px solid #ddd; }
        .row { display: flex; gap: 10px; margin-top: 10px; }
        button { border: 0; border-radius: 10px; padding: 10px 14px; cursor: pointer; }
        .send { background: #1a73e8; color: #fff; }
        .reset { background: #e53935; color: #fff; }
        .error { background: #ffe3e3; color: #8a1f1f; padding: 10px 12px; border-radius: 10px; margin-bottom: 12px; }
        .hint { font-size: 12px; opacity: .75; margin-top: 10px; }
    </style>
</head>
<body>
<div class="wrap">
    <div class="card">
        <h2 style="margin-top:0;">Чат с AI (через chat01.ai)</h2>

        <?php if ($error): ?>
            <div class="error"><?= h($error) ?></div>
        <?php endif; ?>

        <div class="chat">
            <?php if (count($displayMessages) === 0): ?>
                <div class="msg assistant">
                    <div class="meta">assistant</div>
                    Привет! Напишите ваш вопрос ниже.
                </div>
            <?php else: ?>
                <?php foreach ($displayMessages as $m): ?>
                    <?php $role = (string)($m['role'] ?? ''); ?>
                    <?php $cls = $role === 'user' ? 'user' : 'assistant'; ?>
                    <div class="msg <?= h($cls) ?>">
                        <div class="meta"><?= h($role) ?></div>
                        <?= h((string)($m['content'] ?? '')) ?>
                    </div>
                <?php endforeach; ?>
            <?php endif; ?>
        </div>

        <form method="post">
            <textarea name="prompt" placeholder="Введите запрос..."></textarea>
            <div class="row">
                <button class="send" type="submit" name="action" value="send">Отправить</button>
                <button class="reset" type="submit" name="action" value="reset" onclick="return confirm('Сбросить диалог?')">Сбросить</button>
            </div>
            <div class="hint">
                История хранится в PHP-сессии. Ключ API должен быть только на сервере.
            </div>
        </form>
    </div>
</div>
</body>
</html>

Как протестировать

  • Сохраните как chat.php, положите на сервер.
  • Установите переменную окружения CHAT01_API_KEY и (опционально) CHAT01_MODEL.
  • Откройте страницу и отправьте запрос.

Результат тиспользования формы 

Результат тиспользования формы

Потраченный кредит на обращение к gpt-4o через форму

 Результат тиспользования формы

Вариант №2. Продвинутый чат "как для продакшена" (stream + rate limit + логи + SQLite + фильтрация)

Когда тестовый вариант уже работает, следующий шаг — сделать реализацию, которую не страшно отдавать пользователям. Здесь мы добавляем:

  • Ограничение частоты запросов (rate limit) по IP и сессии, чтобы не "съели" кредиты.
  • Логи ошибок в файл, чтобы видеть ответы API при сбоях.
  • Потоковую выдачу (stream=true) — текст появляется по мере генерации.
  • Хранение истории в SQLite (через PDO), чтобы чат не пропадал при очистке сессии.
  • Фильтрацию и базовую модерацию ввода (если форма публичная).

Для интеграции используем сервис Chat01.ai — удобно, когда нужен OpenAI-совместимый endpoint.

Поддерживаемые модели, к примеру:

  • gpt-5-2-thinking
  • gpt-5-2

Подготовка проекта (папки, права, env)

  1. Рядом со страницей создайте каталоги для БД и логов:
    mkdir -p data logs
    chmod 775 data logs
  2. Настройте переменные окружения:

    Apache (.htaccess или vhost):

    SetEnv CHAT01_API_KEY "ВАШ_КЛЮЧ"
    SetEnv CHAT01_MODEL "gpt-5-2-thinking"

Полный код: один файл chat.php (UI + streaming endpoint)

Ниже — реализация "всё в одном": обычная страница чата и отдельный режим ?stream=1, который отдаёт поток в браузер и параллельно сохраняет историю в SQLite.

Сервис и endpoint: Chat01.ai
API endpoint: https://chat01.ai/v1/chat/completions

<?php
declare(strict_types=1);
session_start();

/**
 * =========================================
 * CONFIG
 * =========================================
 */
$API_URL = 'https://chat01.ai/v1/chat/completions';
$API_KEY = getenv('CHAT01_API_KEY') ?: 'PASTE_YOUR_API_KEY_HERE';

// model: gpt-5-2-thinking или gpt-5-2
$MODEL = getenv('CHAT01_MODEL') ?: 'gpt-5-2-thinking';

$SYSTEM_PROMPT = 'Ты полезный ассистент. Отвечай по-русски.';
$TEMPERATURE = 0.7;
$MAX_TOKENS  = 900;

// DB / Logs
$DB_PATH  = __DIR__ . '/data/chat.sqlite';
$LOG_PATH = __DIR__ . '/logs/chat01_error.log';

// История: сколько последних сообщений отправлять в модель (чтобы не раздувать контекст)
$HISTORY_LIMIT = 30; // сообщений (user+assistant). System добавляется отдельно.

// Rate limit (простая реализация)
//  - 10 запросов в минуту (на IP+сессия)
//  - 200 запросов в сутки (на IP)
$RL_MINUTE_LIMIT = 10;
$RL_MINUTE_WINDOW = 60;

$RL_DAY_LIMIT = 200;
$RL_DAY_WINDOW = 86400;

/**
 * =========================================
 * HELPERS
 * =========================================
 */
function h(string $s): string {
  return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function client_ip(): string {
  return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}

function now_ts(): int {
  return time();
}

function log_error_line(string $path, array $payload): void {
  $payload['ts'] = date('c');
  $line = json_encode($payload, JSON_UNESCAPED_UNICODE) . PHP_EOL;
  @file_put_contents($path, $line, FILE_APPEND | LOCK_EX);
}

function ensure_csrf(): string {
  if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(16));
  }
  return (string)$_SESSION['csrf'];
}

function verify_csrf(?string $token): bool {
  return is_string($token) && isset($_SESSION['csrf']) && hash_equals((string)$_SESSION['csrf'], $token);
}

/**
 * =========================================
 * MODERATION / FILTER
 * (базовая защита публичной формы)
 * =========================================
 */
function sanitize_user_input(string $text): string {
  $text = trim($text);
  $text = str_replace(["\r\n", "\r"], "\n", $text);
  $text = preg_replace("/\\n{4,}/", "\n\n\n", $text) ?? $text;
  return $text;
}

function moderate_input(string $text): array {
  $len = mb_strlen($text, 'UTF-8');
  if ($len < 1) return [false, 'Введите запрос.'];
  if ($len > 4000) return [false, 'Слишком длинный запрос (макс 4000 символов).'];

  if ($text !== strip_tags($text)) return [false, 'HTML-теги запрещены.'];
  $lower = mb_strtolower($text, 'UTF-8');
  if (str_contains($lower, '<script') || str_contains($lower, '<?php') || str_contains($lower, 'javascript:')) {
    return [false, 'Похоже на попытку вставки кода.'];
  }

  if (preg_match('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', $text)) {
    return [false, 'Недопустимые символы во вводе.'];
  }

  if (preg_match('/(.)\\1{60,}/u', $text)) {
    return [false, 'Похоже на спам (слишком много повторов).'];
  }

  if (preg_match('/\\bsk-[A-Za-z0-9]{20,}\\b/', $text)) {
    return [false, 'Не отправляйте секретные ключи в чат.'];
  }

  return [true, ''];
}

/**
 * =========================================
 * SQLITE (PDO)
 * =========================================
 */
function pdo_sqlite(string $dbPath): PDO {
  $dir = dirname($dbPath);
  if (!is_dir($dir)) {
    @mkdir($dir, 0775, true);
  }
  $pdo = new PDO('sqlite:' . $dbPath, null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  ]);
  return $pdo;
}

function db_init(PDO $pdo): void {
  $pdo->exec("
    CREATE TABLE IF NOT EXISTS conversations (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      session_id TEXT NOT NULL,
      ip TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL
    );
  ");
  $pdo->exec("
    CREATE TABLE IF NOT EXISTS messages (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      conversation_id INTEGER NOT NULL,
      role TEXT NOT NULL,
      content TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      FOREIGN KEY(conversation_id) REFERENCES conversations(id)
    );
  ");
  $pdo->exec("
    CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, id);
  ");
  $pdo->exec("
    CREATE TABLE IF NOT EXISTS rate_limits (
      k TEXT PRIMARY KEY,
      window_start INTEGER NOT NULL,
      count INTEGER NOT NULL
    );
  ");
}

function get_or_create_conversation(PDO $pdo, string $sessionId, string $ip): int {
  if (!empty($_SESSION['conversation_id'])) {
    return (int)$_SESSION['conversation_id'];
  }

  $ts = now_ts();
  $stmt = $pdo->prepare("INSERT INTO conversations(session_id, ip, created_at, updated_at) VALUES(?,?,?,?)");
  $stmt->execute([$sessionId, $ip, $ts, $ts]);
  $id = (int)$pdo->lastInsertId();
  $_SESSION['conversation_id'] = $id;

  $stmt2 = $pdo->prepare("INSERT INTO messages(conversation_id, role, content, created_at) VALUES(?,?,?,?)");
  $stmt2->execute([$id, 'system', (string)($GLOBALS['SYSTEM_PROMPT'] ?? ''), $ts]);

  return $id;
}

function reset_conversation(PDO $pdo, string $sessionId, string $ip): int {
  unset($_SESSION['conversation_id']);
  return get_or_create_conversation($pdo, $sessionId, $ip);
}

function db_add_message(PDO $pdo, int $convId, string $role, string $content): void {
  $ts = now_ts();
  $stmt = $pdo->prepare("INSERT INTO messages(conversation_id, role, content, created_at) VALUES(?,?,?,?)");
  $stmt->execute([$convId, $role, $content, $ts]);
  $pdo->prepare("UPDATE conversations SET updated_at=? WHERE id=?")->execute([$ts, $convId]);
}

function db_get_messages_for_model(PDO $pdo, int $convId, int $limit, string $fallbackSystem): array {
  $stmt = $pdo->prepare("SELECT role, content FROM messages WHERE conversation_id=? ORDER BY id DESC LIMIT ?");
  $stmt->execute([$convId, $limit]);
  $rows = array_reverse($stmt->fetchAll());

  $hasSystem = false;
  foreach ($rows as $r) if (($r['role'] ?? '') === 'system') { $hasSystem = true; break; }
  if (!$hasSystem) {
    array_unshift($rows, ['role' => 'system', 'content' => $fallbackSystem]);
  }
  return $rows;
}

function db_get_messages_for_ui(PDO $pdo, int $convId): array {
  $stmt = $pdo->prepare("SELECT role, content, created_at FROM messages WHERE conversation_id=? ORDER BY id ASC");
  $stmt->execute([$convId]);
  return $stmt->fetchAll();
}

/**
 * =========================================
 * RATE LIMIT (SQLite)
 * =========================================
 */
function rate_limit_hit(PDO $pdo, string $key, int $windowSec, int $limit): array {
  $ts = now_ts();

  $stmt = $pdo->prepare("SELECT window_start, count FROM rate_limits WHERE k=?");
  $stmt->execute([$key]);
  $row = $stmt->fetch();

  if (!$row) {
    $ins = $pdo->prepare("INSERT INTO rate_limits(k, window_start, count) VALUES(?,?,?)");
    $ins->execute([$key, $ts, 1]);
    return [true, 0];
  }

  $start = (int)$row['window_start'];
  $count = (int)$row['count'];

  if (($ts - $start) >= $windowSec) {
    $upd = $pdo->prepare("UPDATE rate_limits SET window_start=?, count=? WHERE k=?");
    $upd->execute([$ts, 1, $key]);
    return [true, 0];
  }

  if ($count >= $limit) {
    $retry = $windowSec - ($ts - $start);
    return [false, max(1, $retry)];
  }

  $upd = $pdo->prepare("UPDATE rate_limits SET count=? WHERE k=?");
  $upd->execute([$count + 1, $key]);
  return [true, 0];
}

/**
 * =========================================
 * STREAM ENDPOINT
 * =========================================
 * POST ?stream=1
 * Body: prompt, csrf
 */
if (isset($_GET['stream']) && $_SERVER['REQUEST_METHOD'] === 'POST') {
  header('X-Content-Type-Options: nosniff');

  $pdo = pdo_sqlite($DB_PATH);
  db_init($pdo);

  $ip = client_ip();
  $sessionId = session_id();

  $csrf = $_POST['csrf'] ?? null;
  if (!verify_csrf(is_string($csrf) ? $csrf : null)) {
    http_response_code(403);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['error' => 'CSRF token invalid'], JSON_UNESCAPED_UNICODE);
    exit;
  }

  if ($API_KEY === 'PASTE_YOUR_API_KEY_HERE') {
    http_response_code(500);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['error' => 'API key is not configured'], JSON_UNESCAPED_UNICODE);
    exit;
  }

  [$ok1, $retry1] = rate_limit_hit($pdo, "m:{$ip}:{$sessionId}", $GLOBALS['RL_MINUTE_WINDOW'], $GLOBALS['RL_MINUTE_LIMIT']);
  if (!$ok1) {
    http_response_code(429);
    header('Content-Type: application/json; charset=utf-8');
    header('Retry-After: ' . $retry1);
    echo json_encode(['error' => "Слишком часто. Попробуйте через {$retry1} сек."], JSON_UNESCAPED_UNICODE);
    exit;
  }

  [$ok2, $retry2] = rate_limit_hit($pdo, "d:{$ip}", $GLOBALS['RL_DAY_WINDOW'], $GLOBALS['RL_DAY_LIMIT']);
  if (!$ok2) {
    http_response_code(429);
    header('Content-Type: application/json; charset=utf-8');
    header('Retry-After: ' . $retry2);
    echo json_encode(['error' => "Дневной лимит исчерпан. Попробуйте позже."], JSON_UNESCAPED_UNICODE);
    exit;
  }

  $prompt = sanitize_user_input((string)($_POST['prompt'] ?? ''));
  [$ok, $reason] = moderate_input($prompt);
  if (!$ok) {
    http_response_code(400);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['error' => $reason], JSON_UNESCAPED_UNICODE);
    exit;
  }

  $convId = get_or_create_conversation($pdo, $sessionId, $ip);
  db_add_message($pdo, $convId, 'user', $prompt);

  $messages = db_get_messages_for_model($pdo, $convId, $HISTORY_LIMIT, $SYSTEM_PROMPT);

  while (ob_get_level() > 0) { @ob_end_flush(); }
  @ini_set('output_buffering', 'off');
  @ini_set('zlib.output_compression', '0');
  @ini_set('implicit_flush', '1');
  ob_implicit_flush(true);

  header('Content-Type: text/plain; charset=utf-8');
  header('Cache-Control: no-cache, no-store, must-revalidate');
  header('Pragma: no-cache');
  header('X-Accel-Buffering: no');

  $payload = [
    'model' => $MODEL,
    'messages' => $messages,
    'temperature' => $TEMPERATURE,
    'max_tokens' => $MAX_TOKENS,
    'stream' => true,
  ];

  $assistantFull = '';
  $lineBuffer = '';

  $ch = curl_init($API_URL);

  curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
      'Content-Type: application/json',
      'Authorization: Bearer ' . $API_KEY,
    ],
    CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
    CURLOPT_RETURNTRANSFER => false,
    CURLOPT_HEADER => false,
    CURLOPT_TIMEOUT => 180,
    CURLOPT_WRITEFUNCTION => function($ch, string $chunk) use (&$assistantFull, &$lineBuffer) {
      $lineBuffer .= $chunk;

      while (($pos = strpos($lineBuffer, "\n")) !== false) {
        $line = substr($lineBuffer, 0, $pos);
        $lineBuffer = substr($lineBuffer, $pos + 1);

        $line = trim($line);
        if ($line === '' || !str_starts_with($line, 'data:')) continue;

        $data = trim(substr($line, 5));
        if ($data === '[DONE]') continue;

        $json = json_decode($data, true);
        if (!is_array($json)) continue;

        $delta = $json['choices'][0]['delta'] ?? null;

        $piece = '';
        if (is_array($delta)) {
          if (isset($delta['content']) && is_string($delta['content'])) $piece = $delta['content'];
          elseif (isset($delta['text']) && is_string($delta['text'])) $piece = $delta['text'];
        } else {
          $msg = $json['choices'][0]['message']['content'] ?? '';
          if (is_string($msg) && $msg !== '') $piece = $msg;
        }

        if ($piece !== '') {
          $assistantFull .= $piece;
          echo $piece;
          @flush();
        }
      }

      return strlen($chunk);
    },
  ]);

  $okCurl = curl_exec($ch);
  $curlErr = curl_error($ch);
  $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
  curl_close($ch);

  if ($okCurl === false || $httpCode < 200 || $httpCode >= 300) {
    log_error_line($LOG_PATH, [
      'type' => 'api_error',
      'ip' => $ip,
      'session' => $sessionId,
      'http_code' => $httpCode,
      'curl_error' => $curlErr,
      'model' => $MODEL,
    ]);
  }

  $assistantFull = trim($assistantFull);
  if ($assistantFull !== '') {
    db_add_message($pdo, $convId, 'assistant', $assistantFull);
  } else {
    log_error_line($LOG_PATH, [
      'type' => 'empty_assistant',
      'ip' => $ip,
      'session' => $sessionId,
      'http_code' => $httpCode,
      'curl_error' => $curlErr,
      'model' => $MODEL,
    ]);
  }

  exit;
}

/**
 * =========================================
 * NORMAL PAGE (UI)
 * =========================================
 */
$pdo = pdo_sqlite($DB_PATH);
db_init($pdo);

$ip = client_ip();
$sessionId = session_id();
$csrf = ensure_csrf();

$convId = get_or_create_conversation($pdo, $sessionId, $ip);

$error = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST' && !isset($_GET['stream'])) {
  $action = $_POST['action'] ?? '';
  if (!verify_csrf($_POST['csrf'] ?? null)) {
    $error = 'CSRF token invalid.';
  } elseif ($action === 'reset') {
    reset_conversation($pdo, $sessionId, $ip);
    header("Location: " . strtok($_SERVER["REQUEST_URI"], '?'));
    exit;
  }
}

$uiMessages = db_get_messages_for_ui($pdo, (int)($_SESSION['conversation_id'] ?? $convId));
$uiMessages = array_values(array_filter($uiMessages, fn($m) => ($m['role'] ?? '') !== 'system'));

?>
<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>AI Chat (chat01.ai)</title>
  <style>
    body { font-family: Arial, sans-serif; margin: 24px; background: #f7f7f8; }
    .wrap { max-width: 920px; margin: 0 auto; }
    .card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 2px 10px rgba(0,0,0,.06); }
    .chat { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; }
    .msg { padding: 12px 14px; border-radius: 12px; line-height: 1.4; white-space: pre-wrap; }
    .user { background: #e8f0fe; align-self: flex-end; }
    .assistant { background: #f1f3f4; align-self: flex-start; }
    .meta { font-size: 12px; opacity: .7; margin-bottom: 6px; }
    textarea { width: 100%; min-height: 90px; resize: vertical; padding: 10px; border-radius: 10px; border: 1px solid #ddd; }
    .row { display: flex; gap: 10px; margin-top: 10px; align-items:center; }
    button { border: 0; border-radius: 10px; padding: 10px 14px; cursor: pointer; }
    .send { background: #1a73e8; color: #fff; }
    .reset { background: #e53935; color: #fff; }
    .error { background: #ffe3e3; color: #8a1f1f; padding: 10px 12px; border-radius: 10px; margin-bottom: 12px; }
    .hint { font-size: 12px; opacity: .75; margin-top: 10px; }
    .spinner { display:none; font-size: 12px; opacity:.75; }
  </style>
</head>
<body>
<div class="wrap">
  <div class="card">
    <h2 style="margin-top:0;">Чат с AI (chat01.ai, model: <?= h($MODEL) ?>)</h2>

    <?php if ($error): ?>
      <div class="error"><?= h($error) ?></div>
    <?php endif; ?>

    <div id="chat" class="chat">
      <?php if (count($uiMessages) === 0): ?>
        <div class="msg assistant">
          <div class="meta">assistant</div>
          Привет! Напишите ваш вопрос ниже.
        </div>
      <?php else: ?>
        <?php foreach ($uiMessages as $m): ?>
          <?php $role = (string)($m['role'] ?? ''); ?>
          <?php $cls = $role === 'user' ? 'user' : 'assistant'; ?>
          <div class="msg <?= h($cls) ?>">
            <div class="meta"><?= h($role) ?></div>
            <?= h((string)($m['content'] ?? '')) ?>
          </div>
        <?php endforeach; ?>
      <?php endif; ?>
    </div>

    <form id="chatForm" method="post" autocomplete="off">
      <input type="hidden" name="csrf" value="<?= h($csrf) ?>">
      <textarea id="prompt" name="prompt" placeholder="Введите запрос..."></textarea>
      <div class="row">
        <button class="send" type="submit">Отправить</button>
        <button class="reset" type="submit" name="action" value="reset" onclick="return confirm('Сбросить диалог?')">Сбросить</button>
        <span id="spinner" class="spinner">Генерирую ответ…</span>
      </div>
      <div class="hint">
        История хранится в SQLite (data/chat.sqlite). Ошибки пишутся в logs/chat01_error.log.
      </div>
    </form>
  </div>
</div>

<script>
(function(){
  const form = document.getElementById('chatForm');
  const chat = document.getElementById('chat');
  const promptEl = document.getElementById('prompt');
  const spinner = document.getElementById('spinner');
  const csrf = form.querySelector('input[name="csrf"]').value;

  function addMsg(role, text) {
    const wrap = document.createElement('div');
    wrap.className = 'msg ' + (role === 'user' ? 'user' : 'assistant');
    const meta = document.createElement('div');
    meta.className = 'meta';
    meta.textContent = role;
    const body = document.createElement('div');
    body.textContent = text || '';
    wrap.appendChild(meta);
    wrap.appendChild(body);
    chat.appendChild(wrap);
    wrap.scrollIntoView({behavior:'smooth', block:'end'});
    return body;
  }

  form.addEventListener('submit', async (e) => {
    const active = document.activeElement;
    if (active && active.name === 'action' && active.value === 'reset') return;

    e.preventDefault();

    const text = (promptEl.value || '').trim();
    if (!text) return;

    promptEl.value = '';
    addMsg('user', text);

    const assistantBody = addMsg('assistant', '');
    spinner.style.display = 'inline';

    try {
      const resp = await fetch(location.pathname + '?stream=1', {
        method: 'POST',
        headers: {'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8'},
        body: new URLSearchParams({prompt: text, csrf})
      });

      const ct = resp.headers.get('content-type') || '';
      if (!resp.ok && ct.includes('application/json')) {
        const j = await resp.json();
        assistantBody.textContent = (j && j.error) ? j.error : ('Ошибка HTTP ' + resp.status);
        return;
      }
      if (!resp.ok) {
        assistantBody.textContent = 'Ошибка HTTP ' + resp.status;
        return;
      }

      const reader = resp.body.getReader();
      const decoder = new TextDecoder('utf-8');

      while (true) {
        const {value, done} = await reader.read();
        if (done) break;
        const chunk = decoder.decode(value, {stream:true});
        assistantBody.textContent += chunk;
        assistantBody.parentElement.scrollIntoView({behavior:'smooth', block:'end'});
      }
    } catch (err) {
      assistantBody.textContent = 'Ошибка сети/браузера: ' + (err?.message || String(err));
    } finally {
      spinner.style.display = 'none';
    }
  });
})();
</script>
</body>
</html>

Что вы получите в итоге

  • Пользовательский чат на сайте с продолжением диалога.
  • Потоковую генерацию (stream=true) — удобно и "по-современному".
  • Контроль расходов через rate limit.
  • Диагностику через файл логов.
  • Историю в базе (SQLite), чтобы чат не исчезал.
  • Базовую безопасность формы (CSRF + фильтры).
Теги: Chat01.ai, GPT, чат на сайте, PHP, API, стриминг ответа, rate limit, логи, база данных, интеграция, продакшен, безопасность, SQLite, фильтрация ввода


Валерий Макеев
13.12.2025 13:22
Простой скрипт. Выполним однократный запрос к Chat01.ai API из командной строки с переданным текстом (или по умолчанию — «Привет! Как тебя зовут?») и выведем ответ модели или ошибку.
Код
<?php
// quick_test_api.php — минимальный скрипт для проверки подключения к Chat01.ai API и получения ответа без веб-интерфейса
$apiKey = getenv('CHAT01_API_KEY') ?: exit("Ошибка: не задан CHAT01_API_KEY\n");
$model = 'gpt-4o-mini';
$prompt = $argv[1] ?? 'Привет! Как тебя зовут?';

$ch = curl_init('https://chat01.ai/v1/chat/completions');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Authorization: Bearer ' . $apiKey,
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'model' => $model,
        'messages' => [['role' => 'user', 'content' => $prompt]],
        'max_tokens' => 200,
    ], JSON_UNESCAPED_UNICODE),
    CURLOPT_TIMEOUT => 30,
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpCode === 200) {
    $data = json_decode($response, true);
    echo ($data['choices'][0]['message']['content'] ?? 'Нет ответа') . PHP_EOL;
} else {
    echo "Ошибка API. HTTP $httpCode\n";
    echo $response . PHP_EOL;
}

Стоимость услуг по разработке и сопровождению сайтов на 1C-Битрикс

Разработка интернет-магазина с готовой версткой

от 4 недель

от 90 000 рублей

* указана минимальная стоимость. Стоимость выбранной лицензии «1С-Битрикс» оплачивается отдельно.

Аутсорсинг

готов помочь, если нет времени

договорная

Могу взять на себя работы по full-stack

* на основе готовой верстки

Модули и компоненты для «1С-Битрикс»

оценка производится на основе предоставленного Технического Задания

от 20 000 рублей
Разработка дополнительных модулей для 1С-Битрикс, расширение функционала, внедрение любых решений, требующихся для выполнения ваших бизнес-задач.

* стоимость зависит от конкретной задачи, ее объема и сложности выполняемых работ.