Commit 651e3539 by michaelpastushkov
parents 387aeee4 81741ae5
...@@ -12,6 +12,13 @@ ...@@ -12,6 +12,13 @@
"port": 3011 "port": 3011
}, },
"ai": {
"endpoint": "http://127.0.0.1:11434",
"model": "gpt-oss:120b",
"embed_model": "nomic-embed-text",
"label_model": "llama3:8b"
},
"housekeeping": { "housekeeping": {
"cronTime": "0 0 * * * *", "cronTime": "0 0 * * * *",
"on_start": false "on_start": false
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
* Created by: Michael Pastushkov <michael@pastushkov.com> * Created by: Michael Pastushkov <michael@pastushkov.com>
*/ */
const config = require('config');
const HistoryManager = require('./HistoryManager'); const HistoryManager = require('./HistoryManager');
const TopicRouter = require('./TopicRouter'); const TopicRouter = require('./TopicRouter');
const utils = require('../utils/utils'); const utils = require('../utils/utils');
...@@ -75,7 +76,7 @@ class ConversationManager { ...@@ -75,7 +76,7 @@ class ConversationManager {
}); });
// Making the main request to model // Making the main request to model
const res = await fetch('http://127.0.0.1:11434/api/chat', { const res = await fetch(`${config.ai.endpoint}/api/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: body body: body
......
...@@ -3,13 +3,14 @@ ...@@ -3,13 +3,14 @@
* Created by: Michael Pastushkov <michael@pastushkov.com> * Created by: Michael Pastushkov <michael@pastushkov.com>
*/ */
const config = require('config');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const utils = require('../utils/utils'); const utils = require('../utils/utils');
const ConversationManager = require('./ConversationManager'); const ConversationManager = require('./ConversationManager');
const MODEL = 'gpt-oss:20b'; // 'llama3:8b'; // 'gpt-oss:20b'; const MODEL = config.ai.model || 'gpt-oss:20b';
const EMBED_MODEL = 'nomic-embed-text'; const EMBED_MODEL = config.ai.embed_model || 'nomic-embed-text';
const LABEL_MODEL = 'llama3:8b'; const LABEL_MODEL = config.ai.label_model || 'llama3:8b';
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)
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
* Created by: Michael Pastushkov <michael@pastushkov.com> * Created by: Michael Pastushkov <michael@pastushkov.com>
*/ */
const config = require('config');
const utils = require('../utils/utils'); const utils = require('../utils/utils');
class TopicRouter { class TopicRouter {
...@@ -24,7 +25,7 @@ class TopicRouter { ...@@ -24,7 +25,7 @@ class TopicRouter {
utils.log('_mintLabelWithLLM', 5); utils.log('_mintLabelWithLLM', 5);
utils.log(`model: ${this.labelModel}`, 5); utils.log(`model: ${this.labelModel}`, 5);
const sys = `Return a short topic key (1–3 words, lowercase, hyphenated). Only the key.`; const sys = `Return a short topic key (1–3 words, lowercase, hyphenated). Only the key.`;
const r = await fetch('http://127.0.0.1:11434/api/chat', { const r = await fetch(`${config.ai.endpoint}/api/chat`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: this.labelModel, model: this.labelModel,
...@@ -92,7 +93,7 @@ class TopicRouter { ...@@ -92,7 +93,7 @@ class TopicRouter {
async embed(text) { async embed(text) {
utils.log(`Embed ${text}`, 5); utils.log(`Embed ${text}`, 5);
utils.log(`model: ${this.embedModel}`, 5); utils.log(`model: ${this.embedModel}`, 5);
const r = await fetch('http://127.0.0.1:11434/api/embeddings', { const r = await fetch(`${config.ai.endpoint}/api/embeddings`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: this.embedModel, model: this.embedModel,
......
<script>
// --- i18n setup ---
const DEFAULT_LOCALE = 'en';
const SUPPORTED = ['en', 'ru'];
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;
}
let I18N = {};
let CURRENT_LOCALE = detectLocale();
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 = 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');
setWelcomeImage();
}
// --- UI elements ---
const ENDPOINT = '/api/stream';
const STOPPOINT = '/api/stop';
const CAT_GIF = '/static/images/cat-progress.gif';
const CAT_STILL = '/static/images/cat-still.png';
const mainScroll = document.getElementById('mainScroll');
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 catIcon = document.getElementById('catIcon');
const welcomeLogo = document.getElementById('welcomeLogo');
let firstRequestDone = false;
let controller = null;
window.addEventListener('DOMContentLoaded', async () => {
await loadLocale(CURRENT_LOCALE);
inputText.focus();
});
// 🔑 Центральная функция: управление кнопками и плейсхолдером
function setComposerLocked(locked) {
inputText.disabled = locked;
goBtn.style.display = locked ? 'none' : 'inline-block';
stopBtn.style.display = locked ? 'inline-block' : 'none';
if (locked) {
// заменить плейсхолдер на "Thinking..."
inputText.placeholder = t('status.thinking','Thinking…');
inputText.blur();
} else {
// вернуть исходный плейсхолдер
inputText.placeholder = t('composer.placeholder','What can I help you with?');
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 isNearBottom() {
const threshold = 50;
return mainScroll.scrollHeight - mainScroll.scrollTop - mainScroll.clientHeight < threshold;
}
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 = '';
}
// --- основной цикл ---
async function start() {
const prompt = inputText.value.trim();
if (!prompt) return;
if (!firstRequestDone) { welcomeLogo?.remove(); firstRequestDone = true; }
const shouldAutoScroll = isNearBottom();
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);
if (shouldAutoScroll) assistantMsg.scrollIntoView({ behavior:'smooth', block:'start' });
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);
if (shouldAutoScroll && isNearBottom()) mainScroll.scrollTop = mainScroll.scrollHeight;
});
};
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>
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