init
This commit is contained in:
commit
f86097a00a
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
vendor/
|
||||||
|
composer.lock
|
||||||
235
backend.php
Normal file
235
backend.php
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
<?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();
|
||||||
14
composer.json
Normal file
14
composer.json
Normal file
@ -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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
index.html
Normal file
93
index.html
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
<?php // index.php — Simple ChatGPT-like UI in a single PHP file (Bootstrap 5)
|
||||||
|
require_once __DIR__."/backend.php"; ?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="<?= $theme ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Mini ChatGPT (PHP + Bootstrap)</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/main.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container py-4 chat-wrap">
|
||||||
|
<div class="d-flex align-items-center mb-3 text-white">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="me-2"><path d="M12 20h9"/><path d="M16 4h-3a2 2 0 0 0-2 2v14"/><path d="M18 14h-8"/><path d="M7 8h8"/></svg>
|
||||||
|
<h1 class="h4 m-0">Mini ChatGPT</h1>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<button id="resetBtn" class="btn btn-sm btn-outline-light">Reset</button>
|
||||||
|
</div>
|
||||||
|
<button id="themeToggle" class="btn btn-sm btn-outline-light ms-2" aria-pressed="false">
|
||||||
|
Toggle Theme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="accordion mb-3" id="systemAcc">
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header" id="headingOne">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
|
||||||
|
System prompt (optional)
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="collapseOne" class="accordion-collapse collapse" aria-labelledby="headingOne" data-bs-parent="#systemAcc">
|
||||||
|
<div class="accordion-body">
|
||||||
|
<textarea id="systemInput" class="form-control" rows="3" placeholder="You are a concise assistant that..."><?= $systemPrefill ?></textarea>
|
||||||
|
<div class="form-text">Saved in the session and applied to each message until changed.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chat" class="chat-box p-3 mb-3 text-white">
|
||||||
|
<?php if (!$history): ?>
|
||||||
|
<div class="text-secondary">Start the conversation below…</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($history as $m): ?>
|
||||||
|
<?php
|
||||||
|
$role = htmlspecialchars($m['role'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
$ts = (int)($m['ts'] ?? time());
|
||||||
|
?>
|
||||||
|
<div class="msg <?= $role ?>">
|
||||||
|
<div class="content">
|
||||||
|
<?php if ($role === 'assistant' && !empty($m['content_html'])): ?>
|
||||||
|
<!-- Assistant: already sanitized HTML -->
|
||||||
|
<?= $m['content_html'] ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<!-- User (or legacy items): escape + preserve newlines -->
|
||||||
|
<?= nl2br(htmlspecialchars($m['content'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), false) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="time"><?= date('H:i', $ts) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="chatForm" class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<textarea id="messageInput" class="form-control" rows="3" placeholder="Ask something… (Shift+Enter for newline)" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button id="sendBtn" type="submit" class="btn btn-primary">Send</button>
|
||||||
|
<button id="saveSysBtn" type="button" class="btn btn-outline-secondary">Save System Prompt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Expose CSRF + theme to main.js
|
||||||
|
window.__APP__ = {
|
||||||
|
csrf: "<?= $csrf ?>",
|
||||||
|
theme: "<?= $theme ?>",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Optional client-side second layer sanitation (defense-in-depth) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js" integrity="" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
index.php
Normal file
4
index.php
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<?php
|
||||||
|
// index.php — Simple ChatGPT-like UI in a single PHP file (Bootstrap 5)
|
||||||
|
require_once __DIR__."/backend.php";
|
||||||
|
require_once __DIR__."/index.html";
|
||||||
130
main.css
Normal file
130
main.css
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/* --- WhatsApp-ish palette --- */
|
||||||
|
:root {
|
||||||
|
/* Dark theme (WhatsApp-like) */
|
||||||
|
--wa-bg: #0b141a;
|
||||||
|
--wa-panel: #111b21;
|
||||||
|
--wa-incoming: #202c33; /* assistant (other person) */
|
||||||
|
--wa-outgoing: #005c4b; /* user (me) */
|
||||||
|
--wa-text: #e9edef;
|
||||||
|
--wa-incoming-text: var(--wa-text);
|
||||||
|
--wa-outgoing-text: var(--wa-text);
|
||||||
|
--wa-muted: #8696a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] {
|
||||||
|
/* Light theme (WhatsApp-like) */
|
||||||
|
--wa-bg: #ece5dd;
|
||||||
|
--wa-panel: #ffffff;
|
||||||
|
--wa-incoming: #ffffff; /* assistant (other person) */
|
||||||
|
--wa-outgoing: #dcf8c6; /* user (me) */
|
||||||
|
--wa-text: #111b21;
|
||||||
|
--wa-incoming-text: var(--wa-text);
|
||||||
|
--wa-outgoing-text: var(--wa-text);
|
||||||
|
--wa-muted: #667781;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use WA background/panel */
|
||||||
|
body { background: var(--wa-bg); color: var(--wa-text); }
|
||||||
|
.chat-box {
|
||||||
|
height: 60vh; overflow-y: auto;
|
||||||
|
background: var(--wa-panel);
|
||||||
|
border-radius: .75rem;
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
padding: 12px; /* a bit tighter */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the old role pill if any remains */
|
||||||
|
.role-pill { display: none !important; }
|
||||||
|
|
||||||
|
/* Bubble base */
|
||||||
|
.msg {
|
||||||
|
max-width: 75%;
|
||||||
|
padding: 8px 10px 18px; /* extra space for time */
|
||||||
|
border-radius: 16px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0px 0px 10px rgba(0,0,0,0.2);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Incoming (assistant) looks like the person you're chatting with (left, grey/white) */
|
||||||
|
.msg.assistant {
|
||||||
|
background: var(--wa-incoming);
|
||||||
|
color: var(--wa-incoming-text);
|
||||||
|
margin-right: auto; /* left side */
|
||||||
|
border-top-left-radius: 4px; /* WA-ish cut */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outgoing (user) looks like your messages (right, green) */
|
||||||
|
.msg.user {
|
||||||
|
background: var(--wa-outgoing);
|
||||||
|
color: var(--wa-outgoing-text);
|
||||||
|
margin-left: auto; /* right side */
|
||||||
|
border-top-right-radius: 4px; /* WA-ish cut */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tiny time inside bubble, bottom-right */
|
||||||
|
.msg .time {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.8;
|
||||||
|
color: var(--wa-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bubble tails (simple triangles) */
|
||||||
|
.msg.assistant::after,
|
||||||
|
.msg.user::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.assistant::after {
|
||||||
|
left: -2px;
|
||||||
|
background: var(--wa-incoming);
|
||||||
|
clip-path: polygon(100% 0, 0 0, 0 100%);
|
||||||
|
filter: drop-shadow(0 1px 0 rgba(0,0,0,0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg.user::after {
|
||||||
|
right: -2px;
|
||||||
|
background: var(--wa-outgoing);
|
||||||
|
clip-path: polygon(0 0, 100% 0, 100% 100%);
|
||||||
|
filter: drop-shadow(0 1px 0 rgba(0,0,0,0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep header/buttons readable across themes */
|
||||||
|
.chat-wrap .d-flex.align-items-center.mb-3 { color: inherit; }
|
||||||
|
.text-secondary { color: var(--wa-muted) !important; }
|
||||||
|
html[data-theme="light"] #resetBtn.btn-outline-light,
|
||||||
|
html[data-theme="light"] #themeToggle.btn-outline-light {
|
||||||
|
color: var(--wa-text);
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Light theme overrides for header & buttons --- */
|
||||||
|
html[data-theme="light"] .chat-wrap .d-flex.align-items-center.mb-3 {
|
||||||
|
color: var(--text); /* ensures title text is dark */
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .chat-wrap h1 {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] #resetBtn.btn-outline-light,
|
||||||
|
html[data-theme="light"] #themeToggle.btn-outline-light {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] #resetBtn.btn-outline-light:hover,
|
||||||
|
html[data-theme="light"] #themeToggle.btn-outline-light:hover {
|
||||||
|
background-color: var(--muted);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
112
main.js
Normal file
112
main.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// Minimal client that sends CSRF, renders assistant HTML, and toggles theme.
|
||||||
|
|
||||||
|
const qs = (s, r = document) => 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 = `
|
||||||
|
<div class="content">${escapeHtmlWithNewlines(text)}</div>
|
||||||
|
<div class="time">${nowTime()}</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="content">${clean}</div>
|
||||||
|
<div class="time">${nowTime()}</div>
|
||||||
|
`;
|
||||||
|
chatEl.appendChild(wrap);
|
||||||
|
chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlWithNewlines(s) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.innerText = s;
|
||||||
|
return t.innerHTML.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<p class="text-danger">Error: ${res.error || 'Unknown'}</p>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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('<em>System prompt saved for next messages.</em>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset chat
|
||||||
|
resetBtn?.addEventListener('click', async () => {
|
||||||
|
const res = await post('reset', {});
|
||||||
|
if (res.ok) {
|
||||||
|
chatEl.innerHTML = `<div class="text-secondary">Start the conversation below…</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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}));
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user