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

Если вам нужно разместить на сайте форму для взаимодействия с GPT через сторонний API, самый безопасный путь — делать запросы только с сервера (PHP), а не из JavaScript в браузере. Так ваш ключ останется приватным, а вы сможете контролировать лимиты, логи и безопасность.
Ниже — два решения:
- Простой тестовый чат (без стриминга, без базы) — “проверить, что работает”.
- Продвинутый чат — потоковая выдача (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-thinkinggpt-5-2
Подготовка проекта (папки, права, env)
- Рядом со страницей создайте каталоги для БД и логов:
mkdir -p data logs chmod 775 data logs - Настройте переменные окружения:
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 + фильтры).
