236 lines
7.8 KiB
PHP
236 lines
7.8 KiB
PHP
<?php
|
|
// Backend expects a function llm(string $prompt, ?string $sys): string (to be implemented by you)
|
|
|
|
declare(strict_types=1);
|
|
|
|
// --- Harden session cookies before session_start() ---
|
|
$secureCookies = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
|
session_set_cookie_params([
|
|
'lifetime' => 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 -> <pre> (so we never inject unsafe HTML)
|
|
//$html = '<pre>'.htmlspecialchars($markdown, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'</pre>';
|
|
$html = '<pre>ERROR: GitHubFlavoredMarkdownConverter not available</pre>';
|
|
}
|
|
|
|
// 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<int, array{role:string, content:string, content_html?:string, ts:int}>
|
|
*/
|
|
function chat_history(): array {
|
|
return $_SESSION[SESSION_KEY];
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $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();
|