Commit 7bd1e40b by Michael Pastushkov

fix clear

parent efce9518
...@@ -10,7 +10,6 @@ const ConversationManager = require('./ConversationManager'); ...@@ -10,7 +10,6 @@ const ConversationManager = require('./ConversationManager');
const MODEL = 'gpt-oss:20b'; // 'llama3:8b'; // 'gpt-oss:20b'; const MODEL = 'gpt-oss:20b'; // 'llama3:8b'; // 'gpt-oss:20b';
const EMBED_MODEL = 'nomic-embed-text'; const EMBED_MODEL = 'nomic-embed-text';
const LABEL_MODEL = 'llama3:8b'; const LABEL_MODEL = 'llama3:8b';
const COOKIE_TIMEOUT = 1 * 1 * 10 * 60 * 1000 // 10 minutes
const sessions = new Map(); // cookie -> ConversationManager const sessions = new Map(); // cookie -> ConversationManager
//const queues = new Map(); // cookie -> Promise (to serialize) //const queues = new Map(); // cookie -> Promise (to serialize)
...@@ -21,12 +20,11 @@ async function run(req, res) { ...@@ -21,12 +20,11 @@ async function run(req, res) {
let cookie = req.cookies?.session; let cookie = req.cookies?.session;
if (!cookie) { if (!cookie) {
cookie = uuidv4(); cookie = uuidv4();
res.cookie('session', cookie, { maxAge: COOKIE_TIMEOUT }); res.cookie('session', cookie, { path: '/', sameSite: 'Lax', httpOnly: true });
utils.log(`[session] New cookie set: ${cookie}`, 5); utils.log(`[session] New cookie set: ${cookie}`, 5);
} else { } else {
utils.log(`[session] Existing cookie: ${cookie}`, 5); utils.log(`[session] Existing cookie: ${cookie}`, 5);
} }
// Get conversation manager // Get conversation manager
let convman = sessions.get(cookie); let convman = sessions.get(cookie);
if (!convman) { if (!convman) {
...@@ -46,8 +44,16 @@ async function run(req, res) { ...@@ -46,8 +44,16 @@ async function run(req, res) {
async function stop(req, res) { async function stop(req, res) {
utils.log('stop'); utils.log('stop');
res.send('Stop OK');
}
async function clear(req, res) {
res.clearCookie('session', { path: '/', sameSite: 'Lax', httpOnly: true });
utils.log('clear');
res.send('Clear OK');
} }
module.exports = { run, stop }; module.exports = { run, stop };
/* Testing * / /* Testing * /
...@@ -64,3 +70,5 @@ function log(data) { ...@@ -64,3 +70,5 @@ function log(data) {
/**/ /**/
module.exports.run = run; module.exports.run = run;
module.exports.stop = stop;
module.exports.clear = clear;
...@@ -21,6 +21,7 @@ function init(app) { ...@@ -21,6 +21,7 @@ function init(app) {
// Misc // Misc
[POST, '/stream', './KotGPT'], [POST, '/stream', './KotGPT'],
[GET, '/stop', './KotGPT', 'stop'], [GET, '/stop', './KotGPT', 'stop'],
[GET, '/clear', './KotGPT', 'clear'],
// Misc // Misc
[GET, '/info', '../common/info'], [GET, '/info', '../common/info'],
[GET, '/logcat', '../utils/logcat'], [GET, '/logcat', '../utils/logcat'],
......
...@@ -178,6 +178,11 @@ ...@@ -178,6 +178,11 @@
let controller = null; let controller = null;
window.addEventListener('DOMContentLoaded', async () => { window.addEventListener('DOMContentLoaded', async () => {
try {
await fetch('/api/clear', { method: 'GET' });
} catch (err) {
// nothing to do
}
await loadLocale(CURRENT_LOCALE); await loadLocale(CURRENT_LOCALE);
inputText.focus(); inputText.focus();
}); });
......
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>KotGPT</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"/>
<style>
body {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #f8f9fa;
}
main { flex: 1 1 auto; overflow-y: auto; position: relative; }
header { position: sticky; top: 0; z-index: 1020; }
.composer { position: sticky; bottom: 0; z-index: 1020; background: #fff; border-top: 1px solid #dee2e6; }
footer { font-size: .75rem; color: #6c757d; text-align: center; padding: .5rem; border-top: 1px solid #dee2e6; background: #fdfdfd; }
.cat-spinner { width: 36px; height: 36px; object-fit: contain; filter: drop-shadow(0 1px 2px rgba(0,0,0,.35)); }
.thinking-cat { width: 64px; height: 64px; object-fit: contain; display:block; margin:.25rem auto; }
.message {
max-width: 900px;
margin: 0 auto 1rem auto;
padding: 1rem 1.25rem;
border-radius: .75rem;
line-height: 1.6;
font-size: 1rem;
word-break: break-word;
}
.user-message { background-color: #ffffff; border: 1px solid #dee2e6; white-space: pre-wrap; }
.assistant-message { background-color: #f8f9fa; border: 1px solid #dee2e6; }
.assistant-message h1, .assistant-message h2, .assistant-message h3 { margin-top: 1rem; margin-bottom: .6rem; }
.assistant-message p { margin: .5rem 0; }
.assistant-message code { background:#fff; border:1px solid #e9ecef; border-radius:.25rem; padding:.05rem .35rem; }
.assistant-message pre code { display:block; padding:.75rem; }
.assistant-message ul, .assistant-message ol { padding-left: 1.25rem; }
.assistant-message table { width: 100%; margin: 1rem 0; }
#mainScroll { display: flex; flex-direction: column; position: relative; }
#welcomeLogo {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
#welcomeLogo img {
max-width: 520px;
width: 100%;
height: auto;
filter: drop-shadow(0 6px 16px rgba(0,0,0,.15));
opacity: .98;
}
</style>
</head>
<body>
<!-- Header -->
<header class="py-3 bg-dark text-white shadow-sm">
<div class="container d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
<img id="catIcon" class="cat-spinner" src="/static/images/cat-still.png" alt="Cat logo" />
<h1 class="h4 mb-0">KotGPT</h1>
</div>
</div>
</header>
<!-- Conversation -->
<main class="container my-3" id="mainScroll">
<div id="welcomeLogo">
<img id="welcomeImg" src="/static/images/ask-a-cat-en.png" alt="Ask a Cat">
</div>
<div id="thread"></div>
</main>
<!-- Composer -->
<div class="composer py-3">
<div class="container">
<form id="promptForm" class="d-flex flex-column gap-2">
<textarea id="inputText" class="form-control" rows="3" placeholder=""></textarea>
<div class="d-flex align-items-center gap-2">
<button id="goBtn" class="btn btn-primary" type="submit"></button>
<button id="stopBtn" class="btn btn-outline-danger" type="button" style="display:none"></button>
</div>
</form>
</div>
</div>
<!-- Disclaimer Footer -->
<footer id="disclaimer">
<!-- text filled by i18n -->
</footer>
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.6/dist/purify.min.js"></script>
<script>
// --- i18n setup ---
const DEFAULT_LOCALE = 'en';
const SUPPORTED = ['en', 'ru'];
let I18N = {};
let CURRENT_LOCALE = detectLocale();
let isRunning = false;
function getLangFromQuery() {
const m = location.search.match(/[?&]lang=(en|ru)\b/i);
return m ? m[1].toLowerCase() : null;
}
function detectLocale() {
const q = getLangFromQuery();
if (q && SUPPORTED.includes(q)) return q;
const nav = (navigator.language || '').toLowerCase();
if (nav.startsWith('ru')) return 'ru';
return DEFAULT_LOCALE;
}
async function loadLocale(locale) {
try {
const res = await fetch(`/locales/${locale}.json`, { cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
I18N = await res.json();
CURRENT_LOCALE = locale;
document.documentElement.lang = locale;
applyI18n();
} catch (e) {
if (locale !== DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE);
}
}
function t(key, fallback='') {
const parts = key.split('.');
let cur = I18N;
for (const p of parts) {
if (cur && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p];
else return fallback || key;
}
return (typeof cur === 'string') ? cur : (fallback || key);
}
function setWelcomeImage() {
const welcomeImg = document.getElementById('welcomeImg');
if (!welcomeImg) return;
const srcByLocale = {
en: '/static/images/ask-a-cat-en.png',
ru: '/static/images/ask-a-cat-ru.png'
};
welcomeImg.src = srcByLocale[CURRENT_LOCALE] || srcByLocale.en;
}
function applyI18n() {
document.title = t('app.title', 'KotGPT');
const catIcon = document.getElementById('catIcon');
if (catIcon) catIcon.alt = t('header.logoAlt', 'Cat logo');
const welcomeImg = document.getElementById('welcomeImg');
if (welcomeImg) welcomeImg.alt = t('main.welcomeAlt', 'Ask a Cat');
const inputText = document.getElementById('inputText');
if (inputText) {
inputText.placeholder = isRunning
? t('status.thinking', 'Thinking…')
: t('composer.placeholder','What can I help you with?');
}
const goBtn = document.getElementById('goBtn');
if (goBtn) goBtn.textContent = t('buttons.go','Go');
const stopBtn = document.getElementById('stopBtn');
if (stopBtn) stopBtn.textContent = t('buttons.stop','Stop');
const disclaimer = document.getElementById('disclaimer');
if (disclaimer) disclaimer.textContent = t('footer.disclaimer','Beware: Cat can make mistakes.');
setWelcomeImage();
}
// --- UI elements ---
const ENDPOINT = '/api/stream';
const STOPPOINT = '/api/stop';
const CAT_GIF = '/static/images/cat-progress.gif';
const thread = document.getElementById('thread');
const promptForm = document.getElementById('promptForm');
const inputText = document.getElementById('inputText');
const goBtn = document.getElementById('goBtn');
const stopBtn = document.getElementById('stopBtn');
const welcomeLogo = document.getElementById('welcomeLogo');
let firstRequestDone = false;
let controller = null;
window.addEventListener('DOMContentLoaded', async () => {
await loadLocale(CURRENT_LOCALE);
inputText.focus();
});
function renderMarkdown(targetEl, markdownText) {
const html = marked.parse(markdownText, { breaks:true, gfm:true, headerIds:false });
targetEl.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES:{ html:true } });
}
function showThinkingCat(targetEl) {
targetEl.innerHTML = '';
const img = document.createElement('img');
img.className = 'thinking-cat';
img.src = CAT_GIF + '?t=' + Date.now();
img.alt = t('status.thinking','Thinking…');
targetEl.appendChild(img);
}
function clearThinkingCatIfPresent(targetEl) {
const img = targetEl.querySelector('img.thinking-cat');
if (img) targetEl.innerHTML = '';
}
function setComposerLocked(locked) {
isRunning = locked;
inputText.placeholder = locked
? t('status.thinking','Thinking…')
: t('composer.placeholder','What can I help you with?');
inputText.placeholder = inputText.placeholder;
void inputText.offsetHeight;
inputText.disabled = locked;
goBtn.style.display = locked ? 'none' : 'inline-block';
stopBtn.style.display = locked ? 'inline-block' : 'none';
if (locked) inputText.blur();
else inputText.focus();
}
async function start() {
const prompt = inputText.value.trim();
if (!prompt) return;
if (!firstRequestDone) { welcomeLogo?.remove(); firstRequestDone = true; }
const userMsg = document.createElement('div');
userMsg.className = 'message user-message';
userMsg.textContent = prompt;
thread.appendChild(userMsg);
const assistantMsg = document.createElement('div');
assistantMsg.className = 'message assistant-message';
showThinkingCat(assistantMsg);
thread.appendChild(assistantMsg);
inputText.value = '';
controller = new AbortController();
setComposerLocked(true);
try {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: prompt }),
signal: controller.signal
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let rawMarkdown = '';
let receivedAny = false;
let rafPending = false;
const flush = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (!receivedAny) { clearThinkingCatIfPresent(assistantMsg); receivedAny = true; }
renderMarkdown(assistantMsg, rawMarkdown);
});
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream:true });
for (const ch of chunk) { rawMarkdown += ch; flush(); }
}
flush();
} catch (err) {
if (err.name !== 'AbortError') {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, `\n\n**${t('status.errorLabel','Error')}:** ${err.message || err}`);
} else {
if (assistantMsg.querySelector('img.thinking-cat')) {
clearThinkingCatIfPresent(assistantMsg);
renderMarkdown(assistantMsg, t('status.stopped','_Stopped._'));
}
}
} finally {
setComposerLocked(false);
controller = null;
}
}
async function stop() {
if (controller) controller.abort();
try { await fetch(STOPPOINT, { method: 'POST' }); } catch (_) {}
}
promptForm.addEventListener('submit', e => {
e.preventDefault();
if (goBtn.style.display !== 'none') start();
});
inputText.addEventListener('keydown', e => {
if (e.key==='Enter' && !e.shiftKey && goBtn.style.display !== 'none') {
e.preventDefault();
start();
}
});
stopBtn.addEventListener('click', stop);
</script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment