From f86097a00aa349364d0209667cafc5f5d45a2dfe Mon Sep 17 00:00:00 2001 From: "Frederico @ VilaRosa02" Date: Wed, 17 Sep 2025 10:54:43 +0000 Subject: [PATCH] init --- .gitignore | 2 + backend.php | 235 ++++++++++++++++++++++++++++++++++++++++++++++++++ composer.json | 14 +++ index.html | 93 ++++++++++++++++++++ index.php | 4 + main.css | 130 ++++++++++++++++++++++++++++ main.js | 112 ++++++++++++++++++++++++ 7 files changed, 590 insertions(+) create mode 100644 .gitignore create mode 100644 backend.php create mode 100644 composer.json create mode 100644 index.html create mode 100644 index.php create mode 100644 main.css create mode 100644 main.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/backend.php b/backend.php new file mode 100644 index 0000000..d36bb56 --- /dev/null +++ b/backend.php @@ -0,0 +1,235 @@ + 0, + 'path' => '/', + 'domain' => '', + 'secure' => $secureCookies, + 'httponly' => true, + 'samesite' => 'Lax', +]); + +session_start(); + +// ------------------------------ +// Vendor autoload (Markdown + Sanitizer) +// ------------------------------ +require_once __DIR__ . '/vendor/autoload.php'; + +// Lightweight JSON responder +function json_out(array $data, int $code = 200): void { + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + header('X-Content-Type-Options: nosniff'); + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +// CSRF token helpers +function csrf_token(): string { + if (empty($_SESSION['__csrf'])) { + $_SESSION['__csrf'] = bin2hex(random_bytes(32)); + } + return $_SESSION['__csrf']; +} +function require_csrf(): void { + $t = $_POST['csrf'] ?? ''; + if (!hash_equals($_SESSION['__csrf'] ?? '', (string)$t)) { + json_out(['ok' => false, 'error' => 'CSRF token invalid'], 419); + } +} + +// ------------------------------ +// 1) Backend: LLM function stub +// ------------------------------ +if (!function_exists('llm')) { + /** + * LLM call + * @param string $prompt + * @param string|null $system_message + * @param array $previousConversation + */ + require_once __DIR__."/../LlamaCli.func.php"; + function llm(string $prompt, ?string $system_message = "You are a helpful assistant", array $previousConversation = []): string { + $opts = []; + if (!empty($previousConversation)) { + // Optionally cap context length to the last N turns + $N = 20; + if (count($previousConversation) > $N) { + $previousConversation = array_slice($previousConversation, -$N); + } + $opts['previousConversation'] = $previousConversation; + } + return LlamaCli_raw($prompt, $system_message ?? "", $opts); + } +} + +// ------------------------------ +// Markdown -> HTML + sanitize +// ------------------------------ +/** @return array{html:string, used_sanitizer:bool} */ +function md_to_safe_html(string $markdown): array { + $html = $markdown; + $used = false; + + // Convert Markdown to HTML (GitHub-flavored if available) + if (class_exists(League\CommonMark\GithubFlavoredMarkdownConverter::class)) { + $converter = new League\CommonMark\GithubFlavoredMarkdownConverter([ + 'html_input' => 'strip', // ignore raw HTML from model + 'allow_unsafe_links' => false, + 'max_nesting_level' => 20, + ]); + $html = (string)$converter->convert($markdown); + } else { + // Fallback: escape ->
 (so we never inject unsafe HTML)
+        //$html = '
'.htmlspecialchars($markdown, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'
'; + $html = '
ERROR: GitHubFlavoredMarkdownConverter not available
'; + } + + // Sanitize HTML + if (class_exists(\HTMLPurifier::class)) { + $config = \HTMLPurifier_Config::createDefault(); + // allow common formatting + code + tables + kbd + $config->set('Cache.DefinitionImpl', null); + $config->set('HTML.Allowed', implode(',', [ + 'p','br','hr','blockquote','strong','em','del','ins','u','s','sup','sub','kbd', + 'pre','code','span','ol','ul','li', + 'h1','h2','h3','h4','h5','h6', + 'table','thead','tbody','tfoot','tr','th','td', + 'a[href|title|target]','img[src|alt|title|width|height]', + ])); + $config->set('URI.SafeIframeRegexp', '%^https?://%'); // if you ever allow iframes later + $config->set('Attr.AllowedFrameTargets', ['_blank']); + $config->set('AutoFormat.AutoParagraph', false); + + $purifier = new \HTMLPurifier($config); + $html = $purifier->purify($html); + $used = true; + } + + return ['html' => $html, 'used_sanitizer' => $used]; +} + +// -------------------------------- +// 2) Helpers for chat persistence +// -------------------------------- +const SESSION_KEY = 'mini_chatgpt_history'; + +if (!isset($_SESSION[SESSION_KEY])) { + $_SESSION[SESSION_KEY] = []; +} + +/** + * @return array + */ +function chat_history(): array { + return $_SESSION[SESSION_KEY]; +} + +/** + * @param array $extra + */ +function chat_append(string $role, string $content, array $extra = []): void { + $_SESSION[SESSION_KEY][] = array_merge([ + 'role' => $role, + 'content' => $content, + 'ts' => time(), + ], $extra); +} + +function chat_reset(): void { + $_SESSION[SESSION_KEY] = []; +} + +// ------------------------------ +// 3) AJAX endpoints (POST only) +// ------------------------------ +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_csrf(); + $action = $_POST['action'] ?? ''; + + // Reset + if ($action === 'reset') { + chat_reset(); + json_out(['ok' => true]); + } + + // Theme change + if ($action === 'set_theme') { + $theme = $_POST['theme'] ?? ''; + if (!in_array($theme, ['light','dark'], true)) { + json_out(['ok' => false, 'error' => 'Invalid theme'], 422); + } + $_SESSION['__theme'] = $theme; + json_out(['ok' => true, 'theme' => $theme]); + } + + // Chat + if ($action === 'chat') { + $message = trim((string)($_POST['message'] ?? '')); + $system = isset($_POST['system']) ? trim((string)$_POST['system']) : null; + + if ($message === '') { + json_out(['ok' => false, 'error' => 'Empty message'], 422); + } + if (mb_strlen($message) > 4000) { + json_out(['ok' => false, 'error' => 'Message too long'], 413); + } + if ($system !== null && mb_strlen($system) > 4000) { + json_out(['ok' => false, 'error' => 'System too long'], 413); + } + + // Persist optional system prompt + if ($system !== null && $system !== '') { + $_SESSION['__system_prompt'] = $system; + } + $effectiveSystem = $_SESSION['__system_prompt'] ?? ($system ?: null); + + // Build previousConversation from existing session history + $historyRaw = chat_history(); + $previousConversation = []; + foreach ($historyRaw as $turn) { + if (!isset($turn['role'], $turn['content'])) continue; + if ($turn['role'] !== 'user' && $turn['role'] !== 'assistant') continue; + $previousConversation[] = [ + 'role' => $turn['role'], + 'content' => (string)$turn['content'], + ]; + } + + // Record current user message + chat_append('user', $message); + + // Ask the model + $reply_raw = llm($message, $effectiveSystem, $previousConversation); + + // Convert markdown -> sanitized HTML + $md = md_to_safe_html($reply_raw); + $reply_html = $md['html']; + + // Save assistant reply (store both raw + html) + chat_append('assistant', $reply_raw, ['content_html' => $reply_html]); + + json_out([ + 'ok' => true, + 'reply' => $reply_raw, + 'reply_html'=> $reply_html, + 'history' => chat_history(), + ]); + } + + json_out(['ok' => false, 'error' => 'Unknown action'], 400); +} + +// ------------------------------ +// 4) HTML (Bootstrap 5) +// ------------------------------ +$history = chat_history(); +$systemPrefill = htmlspecialchars($_SESSION['__system_prompt'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +$theme = htmlspecialchars($_SESSION['__theme'] ?? 'dark', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +$csrf = csrf_token(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2c8b26d --- /dev/null +++ b/composer.json @@ -0,0 +1,14 @@ +{ + "name": "mini-chatgpt/mini-chatgpt", + "type": "project", + "require": { + "php": ">=8.0", + "league/commonmark": "^2.5", + "ezyang/htmlpurifier": "^4.16" + }, + "autoload": { + "psr-4": { + "MiniChatGPT\\": "src/" + } + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..a5519af --- /dev/null +++ b/index.html @@ -0,0 +1,93 @@ + + + + + + + Mini ChatGPT (PHP + Bootstrap) + + + + +
+
+ +

Mini ChatGPT

+
+ +
+ +
+ +
+
+

+ +

+
+
+ +
Saved in the session and applied to each message until changed.
+
+
+
+
+ +
+ +
Start the conversation below…
+ + + +
+
+ + + + + + + +
+
+
+ + +
+ +
+
+
+ +
+
+ + +
+
+
+
+ + + + + + + + + + diff --git a/index.php b/index.php new file mode 100644 index 0000000..5fa6736 --- /dev/null +++ b/index.php @@ -0,0 +1,4 @@ + r.querySelector(s); +const chatEl = qs('#chat'); +const formEl = qs('#chatForm'); +const msgInput = qs('#messageInput'); +const sysInput = qs('#systemInput'); +const resetBtn = qs('#resetBtn'); +const themeBtn = qs('#themeToggle'); + +function post(action, data = {}) { + const body = new URLSearchParams({ action, csrf: (window.__APP__ && window.__APP__.csrf) || '', ...data }); + return fetch(location.pathname, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body + }).then(r => r.json()); +} + +// Simple helpers to append messages to the chat +function appendUserMessage(text) { + const wrap = document.createElement('div'); + wrap.className = 'msg user'; + wrap.innerHTML = ` +
${escapeHtmlWithNewlines(text)}
+
${nowTime()}
+ `; + chatEl.appendChild(wrap); + chatEl.scrollTop = chatEl.scrollHeight; +} + +function appendAssistantHtml(html) { + const clean = (window.DOMPurify ? DOMPurify.sanitize(html) : html); + const wrap = document.createElement('div'); + wrap.className = 'msg assistant'; + wrap.innerHTML = ` +
${clean}
+
${nowTime()}
+ `; + chatEl.appendChild(wrap); + chatEl.scrollTop = chatEl.scrollHeight; +} + +function escapeHtmlWithNewlines(s) { + const t = document.createElement('div'); + t.innerText = s; + return t.innerHTML.replace(/\n/g, '
'); +} + +function nowTime() { + const d = new Date(); + const hh = String(d.getHours()).padStart(2,'0'); + const mm = String(d.getMinutes()).padStart(2,'0'); + return `${hh}:${mm}`; +} + +// Submit chat +formEl?.addEventListener('submit', async (e) => { + e.preventDefault(); + const message = msgInput.value.trim(); + const system = sysInput ? sysInput.value.trim() : ''; + if (!message) return; + + appendUserMessage(message); + msgInput.value = ''; + msgInput.focus(); + + const res = await post('chat', { message, system }); + if (res.ok) { + // Prefer server-rendered sanitized HTML for assistant + appendAssistantHtml(res.reply_html || escapeHtmlWithNewlines(res.reply || '')); + } else { + appendAssistantHtml(`

Error: ${res.error || 'Unknown'}

`); + } +}); + +// Save system prompt +qs('#saveSysBtn')?.addEventListener('click', async () => { + const message = ''; + const system = sysInput ? sysInput.value.trim() : ''; + if (!system) return; + // Use a no-op message to persist system prompt? Better: do nothing and let backend save on 'chat' + // Here we just show a toast-like confirmation: + appendAssistantHtml('System prompt saved for next messages.'); +}); + +// Reset chat +resetBtn?.addEventListener('click', async () => { + const res = await post('reset', {}); + if (res.ok) { + chatEl.innerHTML = `
Start the conversation below…
`; + } +}); + +// Theme toggle +themeBtn?.addEventListener('click', async () => { + const current = (window.__APP__ && window.__APP__.theme) || 'dark'; + const next = current === 'dark' ? 'light' : 'dark'; + const res = await post('set_theme', { theme: next }); + if (res.ok) { + document.documentElement.setAttribute('data-theme', next); + window.__APP__.theme = next; + } +}); + +// With "enter" key press, send message +msgInput.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); // stop newline + formEl.dispatchEvent(new Event('submit', {cancelable: true})); + } +});