Ready-made AI Chat Assistant: floating button and window, PHP endpoint /api/ai_ask, key in .env, and MySQL logs with an “AI Logs” admin page. Steps, code, and c…
/api/ai_ask, .env key, MySQL logs)
Want your own AI assistant on your site — no external widgets and full control of your data?
Here’s a copy-paste solution: floating button/bubble,
PHP backend (/api/ai_ask), .env key,
logs in MySQL and an admin screen “AI Logs”. SEO-friendly, GDPR-ready, and scalable.
/api/ai_ask — accepts a question and returns an answer.ai_logs) + admin page “AI Logs”.Create/upgrade the ai_logs table:
CREATE TABLE IF NOT EXISTS `ai_logs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`user_id` INT NULL,
`ip` VARCHAR(45) NULL,
`path` VARCHAR(512) NULL,
`user_agent` TEXT NULL,
`question` MEDIUMTEXT NOT NULL,
`answer` MEDIUMTEXT NULL,
PRIMARY KEY (`id`),
KEY `idx_created_at` (`created_at`),
KEY `idx_user_id` (`user_id`),
KEY `idx_ip` (`ip`),
KEY `idx_path` (`path`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Where? Keep .env outside the web root if possible (e.g. /home/USERNAME/.env or /var/www/site/.env).
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Permissions (example):
chmod 600 /path/to/.env
/api/ai_ask.phpCreate /api/ai_ask.php. It loads .env, validates input, calls the model and writes a log.
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/../config/db.php';
$pdo = db();
/* .env loader */
function load_env(string $path): void {
if (!is_readable($path)) return;
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') === false) continue;
[$k, $v] = explode('=', $line, 2);
$k = trim($k);
$v = trim($v, " \\t\\n\\r\\0\\x0B\"'");
putenv("$k=$v"); $_ENV[$k] = $v; $_SERVER[$k] = $v;
}
}
$paths = [
__DIR__.'/../.env',
dirname(__DIR__,2).'/.env',
(isset($_SERVER['DOCUMENT_ROOT'])?$_SERVER['DOCUMENT_ROOT'].'/../.env':null),
(getenv('HOME')?rtrim(getenv('HOME'),'/').'/.env':null),
];
foreach (array_filter($paths) as $p) { load_env($p); }
/* key */
$OPENAI_API_KEY = getenv('OPENAI_API_KEY') ?: '';
if ($OPENAI_API_KEY === '') {
echo json_encode(['ok'=>true,'answer'=>'AI key is not configured.'], JSON_UNESCAPED_UNICODE);
exit;
}
/* fail helper + error log */
function fail($msg, PDO $pdo, $ctx=[]) {
try {
$st=$pdo->prepare("INSERT INTO ai_logs (created_at,user_id,ip,path,user_agent,question,answer)
VALUES (NOW(), :uid, :ip, :path, :ua, :q, :a)");
$st->execute([
':uid'=>$ctx['user_id']??null, ':ip'=>$_SERVER['REMOTE_ADDR']??'',
':path'=>$ctx['path']??($_SERVER['HTTP_REFERER']??''), ':ua'=>$_SERVER['HTTP_USER_AGENT']??'',
':q'=>$ctx['question']??'', ':a'=>'[ERROR] '.$msg,
]);
} catch(Throwable $e) {}
echo json_encode(['ok'=>true,'answer'=>$msg], JSON_UNESCAPED_UNICODE); exit;
}
/* input */
$raw = file_get_contents('php://input') ?: '';
$data = json_decode($raw, true);
$question = trim((string)($data['question'] ?? ''));
$user_id = isset($data['user_id']) ? (int)$data['user_id'] : null;
$path = trim((string)($data['path'] ?? ($_SERVER['HTTP_REFERER'] ?? '')));
if ($question === '') fail('Please ask a question and try again.', $pdo, compact('question','user_id','path'));
/* system context */
$system = "You are a concise, helpful assistant. Reply in clear English, without clichés.";
/* payload */
$payload = [
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $question],
],
'temperature' => 0.6,
'max_tokens' => 400,
];
/* request */
$ch = curl_init('https://api.openai.com/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer '.$OPENAI_API_KEY,
],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 30,
]);
$resp = curl_exec($ch);
$errno = curl_errno($ch);
$http = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) fail('Temporary connection issue. Please try again shortly.', $pdo, compact('question','user_id','path'));
if ($http===429) fail('AI quota exceeded. Please try again later.', $pdo, compact('question','user_id','path'));
if ($http<200 || $http>=300) fail('Unsuccessful AI response (HTTP '.$http.').', $pdo, compact('question','user_id','path'));
$out = json_decode($resp, true);
$answer = trim((string)($out['choices'][0]['message']['content'] ?? ''));
if ($answer === '') fail('Empty AI response. Please try again.', $pdo, compact('question','user_id','path'));
/* log */
try {
$st=$pdo->prepare("INSERT INTO ai_logs (created_at,user_id,ip,path,user_agent,question,answer)
VALUES (NOW(), :uid, :ip, :path, :ua, :q, :a)");
$st->execute([
':uid'=>$user_id, ':ip'=>$_SERVER['REMOTE_ADDR']??'',
':path'=>$path, ':ua'=>$_SERVER['HTTP_USER_AGENT']??'',
':q'=>$question, ':a'=>$answer,
]);
} catch(Throwable $e) {}
echo json_encode(['ok'=>true,'answer'=>$answer], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
Place the HTML, CSS and JS below — the button toggles the window and posts to /api/ai_ask.
<button id="ai-btn" aria-label="AI assistant">
<i class="fa-solid fa-message"></i>
</button>
<div id="ai-wrap" role="dialog" aria-modal="true" aria-labelledby="ai-title" style="display:none">
<div class="ai-head">
<strong id="ai-title">AI Assistant</strong>
<button class="ai-close" type="button" aria-label="Close">×</button>
</div>
<div class="ai-log" aria-live="polite"></div>
<form class="ai-form" autocomplete="off">
<textarea class="form-control" rows="2" placeholder="Ask about IT, networks, security, web…"></textarea>
<button class="btn btn-primary" type="submit" aria-label="Send message">Send</button>
</form>
</div>
/* Example widget styles; for production use a global #ntg-ai-btn or a separate CSS file */
document.addEventListener('DOMContentLoaded', function () {
const scope = document.getElementById('ai-guide');
if (!scope) return;
// Highlight + Copy buttons inside the article only
scope.querySelectorAll('pre code').forEach((block) => {
try { if (window.hljs) hljs.highlightElement(block); } catch(e) {}
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.type = 'button';
btn.innerHTML = 'Copy';
btn.addEventListener('click', async () => {
const codeText = block.innerText;
try {
await navigator.clipboard.writeText(codeText);
btn.dataset.state = 'copied';
const old = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.dataset.state=''; btn.textContent = old; }, 1200);
} catch (e) {
// fallback selection
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(block);
sel.removeAllRanges();
sel.addRange(range);
try { document.execCommand('copy'); } catch(_) {}
sel.removeAllRanges();
}
});
block.parentElement.style.position = 'relative';
block.parentElement.appendChild(btn);
});
// Minimal chat demo (scoped to the section)
const btn = scope.querySelector('#ai-btn');
const box = scope.querySelector('#ai-wrap');
const close = scope.querySelector('.ai-close');
const log = scope.querySelector('.ai-log');
const form = scope.querySelector('.ai-form');
const input = form ? form.querySelector('textarea') : null;
const scroll = () => { if (log) log.scrollTop = log.scrollHeight; };
const add = (txt, who='bot') => {
if (!log) return;
const d = document.createElement('div');
d.className = 'ai-msg ' + (who==='user'?'ai-user':'ai-bot');
d.textContent = txt;
log.appendChild(d); scroll(); return d;
};
const typing = () => {
if (!log) return null;
const d = document.createElement('div');
d.className = 'ai-msg ai-bot typing';
d.innerHTML = '...';
log.appendChild(d); scroll(); return d;
};
const show = () => { if (box){ box.style.display='block'; setTimeout(()=>{ try{ input && input.focus(); }catch(e){} },0); } };
const hide = () => { if (box) box.style.display='none'; };
btn && btn.addEventListener('click', (e) => { e.preventDefault(); (box && box.style.display === 'block') ? hide() : show(); });
close && close.addEventListener('click', hide);
if (location.search.includes('ai=1')) show();
form && form.addEventListener('submit', async (e) => {
e.preventDefault();
const q = (input?.value || '').trim();
if (!q) return;
add(q, 'user'); if (input) { input.value = ''; input.focus(); }
const loader = typing();
try {
const res = await fetch('/api/ai_ask', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
body: JSON.stringify({ question: q, user_id: window.currentUserId || null, path: location.pathname })
});
const raw = await res.text(); let data = null; try { data = JSON.parse(raw); } catch(_){}
if (loader) loader.remove();
if (!res.ok) { add('⚠️ HTTP ' + res.status + (data?.answer ? (': ' + data.answer) : ''), 'bot'); return; }
if (data && data.ok) { add(String(data.answer || '').trim() || 'No answer.','bot'); }
else { add('⚠️ ' + (data?.answer || data?.error || raw?.slice(0,200) || 'Error.'), 'bot'); }
} catch {
if (loader) loader.remove();
add('⚠️ Network error. Please try again.','bot');
}
});
});
curl -s -X POST https://example.com/api/ai_ask \
-H 'Content-Type: application/json' \
-d '{"question":"How do I set up an office VPN?","user_id":1,"path":"/test"}'
.env path and permissions./api/ai_ask (no .php) and verify Rewrite rules.ai_logs without a basis. Mask IP if needed..env only; restrict access (e.g. chmod 600).defer/lazy).aria-*, contrast and visible focus states.Can I use another model/provider?
Yes. Swap the endpoint and payload per provider, but keep the /api/ai_ask interface.
How to log safely?
Truncate long Q/A, mask tokens, add regular backups and rotation.
Email us at office@ntg.bg or use the contact form.
Tip: keep the key in .env (outside web root), log answers to DB, and review “AI Logs” regularly for quality.