Commit 9fe33590 by Michael Pastushkov

Initial commit

parents
config/default.json
node_modules/
temp/
package-lock.json
\ No newline at end of file
{ "to": "avoid NODE_APP_INSTANCE warning"}
\ No newline at end of file
{
"server_api": {
"host": "",
"port": 3010,
"self": "localhost",
"cluster_enabled": false,
"worker_threads": 2
},
"server_admin": {
"host": "",
"port": 3011
},
"housekeeping": {
"cronTime": "0 0 * * * *",
"on_start": false
},
"general": {
"log_level": 3
},
"mysql": {
"host": "localhost",
"user": "root",
"password": "",
"database": "crossme",
"socketPath": "/tmp/mysql.sock",
"connectionLimit": 10
},
"mailer": {
"service": "gmail",
"auth": {
"user": "michaelpastushkov@gmail.com",
"pass": "webhkdxapcnfcuhc",
"jsonTransport": true
},
"from": "noreply@printershare.net"
},
"locales": [ "en" ],
"locales_all": [
"af", "ar", "az", "be", "bg", "bn", "bs", "ca", "cs", "da", "de",
"el", "en", "es", "et", "eu", "fa", "fi", "fr", "ga", "he", "hi",
"hr", "hu", "hy", "id", "ja", "ka", "kk", "ko", "ky", "lt", "lv",
"mk", "ml", "mn", "ms", "nl", "no", "pl", "pt", "ro", "ru", "sk",
"sl", "sq", "sr", "sv", "ta", "te", "tg", "th", "tk", "uk", "uz",
"vi", "zh"
]
}
{
"app": {
"title": "KotGPT"
},
"header": {
"logoAlt": "Cat logo"
},
"main": {
"welcomeAlt": "Ask a Cat"
},
"scroll": {
"ariaLabel": "Scroll down one page",
"title": "Scroll down"
},
"composer": {
"placeholder": "What can I help you with?"
},
"buttons": {
"go": "Go",
"stop": "Stop"
},
"status": {
"thinking": "Cat is thinking …",
"error": "Error",
"errorLabel": "Error",
"stopped": "_Stopped._"
},
"footer": {
"disclaimer": "Beware: Cats can make mistakes."
}
}
\ No newline at end of file
{
"app": {
"title": "КотЖПТ"
},
"header": {
"logoAlt": "Кот"
},
"main": {
"welcomeAlt": "Спроси кота"
},
"scroll": {
"ariaLabel": "Прокрутить вниз на один экран",
"title": "Прокрутить вниз"
},
"composer": {
"placeholder": "Чем я могу вам помочь?"
},
"buttons": {
"go": "Спросить",
"stop": "Стоп"
},
"status": {
"thinking": "Кот думает …",
"error": "Ошибка",
"errorLabel": "Ошибка",
"stopped": "_Остановлено._"
},
"footer": {
"disclaimer": "Не обижайтесь пожалуйста на кота. Он иногда может ошибаться."
}
}
\ No newline at end of file
{
"dependencies": {
"compression": "^1.7.4",
"config": "^3.3.8",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron": "^3.1.7",
"date-and-time": "^2.4.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"i18n": "^0.15.1",
"mysql2": "^2.3.3",
"nodemailer": "^6.8.0",
"uuid": "^11.1.0"
}
}
/*
* Start module for CrossMe Licensing API Server
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
'use strict';
const VERSION = "1.0";
const NAME = 'CatGPT';
const express = require('express');
const compression = require('compression');
const cors = require('cors');
const cluster = require('cluster');
const os = require('os');
const { I18n } = require('i18n');
const cookieParser = require('cookie-parser');
const utils = require('./src/utils/utils');
//const dal = require('./src/utils/dal');
//const housekeeping = require('./src/common/housekeeping');
const app = express();
const config = require('config');
// Variations between Admin and API
let name, port, host, routes
if (process.argv[2] != 'admin') {
routes = require('./src/api/routes');
name = `${NAME} API Server`;
port = config.get('server_api.port');
host = config.get('server_api.host');
} else {
routes = require('./src/admin/routes');
name = `${NAME} Admin Server`;
port = config.get('server_admin.port');
host = config.get('server_admin.host');
}
// Clusterization
if (cluster.isPrimary)
utils.log(`== ${name}`);
if (config.server_api.cluster_enabled) {
let number_of_workers = config.server_api.worker_threads || os.cpus.length || 2;
if (cluster.isPrimary) {
utils.log(`Master thread starts with ${number_of_workers} workers`);
housekeeping.init();
for (let i = 0; i < number_of_workers; i++)
cluster.fork();
return;
}
}
// Needed for catching exceptions in async functions
// !! MUST be first in the middleware list !!
require('express-async-errors');
app.use(compression({ filter: false })); // this is just to provide res.flush with no real compression
// Disable caching
app.disable('etag');
// Localization engine init
const i18n = new I18n({
locales: config.locales,
directory: 'locales',
queryParameter: 'lang', // query parameter to switch locale (ie. /home?lang=ch) - defaults to NULL
register: 'global',
})
// To provide localization during request processing
// via the function t (attached to res)
app.use(function (req, res, next) {
i18n.init(req, res);
res.t = function () {
if (arguments[0] == '__rtl') { // page orientations right-to-left languages
let loc = res.getLocale();
return ['ar', 'fa', 'he'].indexOf(loc) >= 0 ? 'rtl' : 'ltr';
}
return i18n.__.apply(req, arguments);
};
next();
});
// Other middleware
app.use((req, res, next) => {
if (req.method === 'POST' && !req.headers['content-type']) {
req.headers['content-type'] = 'text/plain';
}
next();
});
app.use(cors());
app.use('/static', express.static('static'));
app.use('/locales', express.static('locales'));
app.use(express.urlencoded({ extended: true, limit: '200mb', parameterLimit: 1000000 }));
app.use(express.json({ limit: '200mb' }));
app.use(express.text({ type: 'text/plain' }));
app.use(cookieParser());
app.get('/', (req, res) => {
res.redirect('/static/www');
});
// Initialize some modules
// if (cluster.isPrimary)
// housekeeping.init();
// Centralized request tracking
app.use(function (req, res, next) {
let ip = utils.get_ip(req);
utils.log(`${ip} ${req.method} ${req.url}]`);
utils.log(req.headers, 6);
if (!utils.is_empty(req.body)) {
if (req.body instanceof Buffer)
utils.log(req.body.toString(), 4);
else
utils.log(req.body, 4);
}
res.on("finish", () => {
let details = res.details || '';
utils.log(`${ip} [${res.statusCode}] ${details}`);
});
next();
});
// Initialize routing (it needs to be here!!)
// dal.init().then(() => {
routes.init(app);
// Centralized exception handling (this must be after routes!)
app.use(function (err, req, res, next) {
utils.log_error(err);
let code = 500;
let message = 'Server error';
if (!Array.isArray(err)) {
code = 500;
message = err.toString();
} else {
code = err[0] || 500;
message = err[1] || err.message || err.toString();
}
res.status(code);
res.send({ error: message });
});
//});
// Server start
app.listen(port, host, () => {
utils.log(`Process ${process.pid} listening on ${host}:${port} ... `);
});
module.exports.version = VERSION;
module.exports.name = name;
\ No newline at end of file
/*
* Conversation manager
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
const HistoryManager = require('./HistoryManager');
const TopicRouter = require('./TopicRouter');
const utils = require('../utils/utils');
class ConversationManager {
constructor(model, embedModel, labelModel) {
this.model = model;
this.embedModel = embedModel;
this.labelModel = labelModel;
this.router = new TopicRouter({embedModel: embedModel, labelModel: labelModel });
this.history = new HistoryManager();
this.lastTopic = null;
this.lastTopicAt = 0;
this.followUpWindowMs = 15 * 60 * 1000;
this.followUpMinSim = 0.70;
this.followUpHardStickBelow = 40;
}
async ask(prompt, callback) {
// Determine the topic
const now = Date.now();
const meta = await this.router.resolve(prompt); // { key, bestSim, isNew }
let topic = meta.key;
let followUp = false;
if (this.lastTopic && (now - this.lastTopicAt) < this.followUpWindowMs && this.looksLikeFollowUp(prompt)) {
// Enrich with last assistant turn from lastTopic
const lastThread = this.history.threads.get(this.lastTopic);
const lastAssistant = lastThread?.turns.slice().reverse().find(t => t.role === 'assistant')?.content || '';
const enriched = (lastAssistant ? (lastAssistant + '\n') : '') + prompt;
// Compare enriched vector to last topic centroid
const [vec, centroid] = await Promise.all([
this.router.embed(enriched, this.embedModel),
this.router.getCentroid(this.lastTopic) // add method below
]);
const sim = centroid ? this.router.cos(vec, centroid) : 0;
// Stick to last topic if enriched sim is decent,
// or if plain router also thought it's close (meta.bestSim), whichever is stronger
if (sim >= this.followUpHardStickBelow || meta.key === this.lastTopic || meta.bestSim >= this.followUpMinSim) {
topic = this.lastTopic;
followUp = true;
}
}
// Remember current topic state
this.lastTopic = topic;
this.lastTopicAt = now;
////
const messages = this.history.buildMessages(prompt, topic);
utils.log(messages, 5);
const body = JSON.stringify({
model: this.model,
messages: messages,
stream: true,
keep_alive: '1m', // keeps model in RAM
// options: { num_ctx: 8192 } // optional: larger context if your model supports it
});
// Making the main request to model
const res = await fetch('http://127.0.0.1:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
});
if (!res.ok)
throw new Error(await res.text());
// --------------------------
// Read and decode the response
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = '';
while (true) {
const { value, done } = await reader.read();
if (done)
break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split('\n').filter(Boolean)) {
const j = utils.parse(line);
if (j) {
const delta = j.message?.content || '';
if (delta) {
full += delta;
callback(delta); // <-- send piece to client
}
}
}
}
this.history.addUser(topic, prompt);
this.history.addAssistant(topic, full);
}
/** A language-agnostic “follow-up” heuristic based on shape only. */
looksLikeFollowUp(text) {
const w = text.trim().split(/\s+/);
return w.length <= 8; // short turns tend to be follow-ups; no vocab lists
}
}
module.exports = ConversationManager;
/* Testing * /
function log(data) {
//console.log(data);
}
(async () => {
const cm = new ConversationManager('llama3:8b');
await cm.ask('сколько усов у кота?', log);
await cm.ask('а хвостов?', log);
// // Prompt 2 — different wording, same topic picked automatically
// r = await cm.ask('Name the major ones (just names).');
// console.log('\n\n[topic]', r.topic, '\n', r.text);
// // Prompt 3 — likely a new topic
// r = await cm.ask('Configure Nginx as a reverse proxy for an Express app.');
// console.log('\n\n[topic]', r.topic, '\n', r.text);
})();
/**/
\ No newline at end of file
/*
* History manager
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
const WHOAMI = 'I am a virtual cat helping you to the best of my abilities.';
const ADVICE = 'Prioritize the user\'s latest messages. Use prior turns only as context.';
class HistoryManager {
constructor({ maxTurns = 6 } = {}) {
this.threads = new Map(); // topic -> { turns: [], summary: '' }
this.maxTurns = maxTurns;
}
ensure(topic) {
if (!this.threads.has(topic))
this.threads.set(topic, { turns: [], summary: '' });
return this.threads.get(topic);
}
addUser(topic, content) {
let t = this.ensure(topic);
t.turns.push({ role: 'user', content });
}
addAssistant(topic, content) {
let t = this.ensure(topic);
t.turns.push({ role: 'assistant', content });
}
setSummary(topic, summary) {
let t = this.ensure(topic);
t.summary = summary;
}
pickRelevant(turns, currentPrompt, limit) {
if (!turns.length) return [];
// Simple recency + token overlap; language-agnostic
const needles = new Set(currentPrompt.toLowerCase().split(/\W+/).filter(Boolean));
const scored = turns.map((m, i) => {
const words = m.content.toLowerCase().split(/\W+/).filter(Boolean);
const overlap = words.reduce((acc, w) => acc + (needles.has(w) ? 1 : 0), 0);
const recency = i / Math.max(1, turns.length);
return { m, score: overlap + recency * 0.5 };
});
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
.sort((a, b) => turns.indexOf(a.m) - turns.indexOf(b.m))
.map(s => s.m);
}
buildMessages(prompt, topic) {
const thread = this.ensure(topic);
const relevant = this.pickRelevant(thread.turns, prompt, this.maxTurns);
const msgs = [];
msgs.push({ role: 'system', content: WHOAMI });
msgs.push({ role: 'system', content: ADVICE });
if (thread.summary)
msgs.push({ role: 'system', content: `Context summary (${topic}): ${thread.summary}` });
msgs.push(...relevant);
msgs.push({ role: 'user', content: prompt });
return msgs;
}
}
module.exports = HistoryManager;
/* Testing * /
(async () => {
const cm = new ConversationManager({
model: 'gpt-oss:20b-tuned', // 'llama3:8b',
embedModel: 'nomic-embed-text', // `ollama pull` this if not present
maxTurns: 6
});
// Prompt 1 — auto-creates a "cats" topic (via embeddings + labeler)
let r = await cm.ask('How many cat breeds are there?');
console.log('\n\n[topic]', r.topic, '\n', r.text);
// Prompt 2 — different wording, same topic picked automatically
r = await cm.ask('Name the major ones (just names).');
console.log('\n\n[topic]', r.topic, '\n', r.text);
// Prompt 3 — likely a new topic
r = await cm.ask('Configure Nginx as a reverse proxy for an Express app.');
console.log('\n\n[topic]', r.topic, '\n', r.text);
})();
/**/
\ No newline at end of file
/*
* Entry point to requests
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
const { v4: uuidv4 } = require('uuid');
const utils = require('../utils/utils');
const ConversationManager = require('./ConversationManager');
const MODEL = 'gpt-oss:20b'; // 'llama3:8b'; // 'gpt-oss:20b';
const EMBED_MODEL = 'nomic-embed-text';
const LABEL_MODEL = 'llama3:8b';
const COOKIE_TIMEOUT = 1 * 1 * 10 * 60 * 1000 // 10 minutes
const sessions = new Map(); // cookie -> ConversationManager
//const queues = new Map(); // cookie -> Promise (to serialize)
async function run(req, res) {
// First, let's handle the cookies to separate users (with no login)
let cookie = req.cookies?.session;
if (!cookie) {
cookie = uuidv4();
res.cookie('session', cookie, { maxAge: COOKIE_TIMEOUT });
utils.log(`[session] New cookie set: ${cookie}`, 5);
} else {
utils.log(`[session] Existing cookie: ${cookie}`, 5);
}
// Get conversation manager
let convman = sessions.get(cookie);
if (!convman) {
convman = new ConversationManager(MODEL, EMBED_MODEL, LABEL_MODEL);
sessions.set(cookie, convman);
}
// Begin (or continue) conversation
await convman.ask(req.body.content, (line) => {
process.stdout.write(line);
res.write(`${line}`);
res.flush();
});
res.end();
}
async function stop(req, res) {
utils.log('stop');
}
module.exports = { run, stop };
/* Testing * /
function log(data) {
console.log(data);
}
(async () => {
await run('Сколько в мире котов и кошек?', '1', log);
await run('Найди веб ссылки на материалы', '1', log);
})();
/**/
module.exports.run = run;
\ No newline at end of file
/*
* Topic router
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
const utils = require('../utils/utils');
class TopicRouter {
constructor({ embedModel = 'nomic-embed-text', labelModel = 'llama3:8b', threshold = 0.76 } = {}) {
this.embedModel = embedModel; // ollama pull nomic-embed-text
this.threshold = labelModel; // similarity to attach to existing topic
this.labelModel = threshold; // used only to mint a short label
this.topics = new Map(); // key -> { centroid: number[], count: number, summary: '' }
this._counter = 1; // fallback id
}
getCentroid(key) {
const t = this.topics.get(key);
return t ? t.centroid : null;
}
async _mintLabelWithLLM(text) {
// Single short key, language-agnostic (works for RU/EN/etc.)
utils.log('_mintLabelWithLLM', 5);
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', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.labelModel,
messages: [{ role: 'system', content: sys }, { role: 'user', content: text }],
stream: false
})
});
if (!r.ok)
return null;
const j = await r.json();
const raw = (j.message?.content || '').trim().toLowerCase();
const key = raw.replace(/[^a-z0-9\u0400-\u04FF\- ]/g, '').replace(/\s+/g, '-').slice(0, 48);
utils.log('_mintLabelWithLLM done', 5);
return key || null;
}
async similarityTo(topicKey, text) {
const t = this.topics.get(topicKey);
if (!t) return null;
const v = await this.embed(text);
return this.cos(v, t.centroid);
}
/**
* Resolve prompt to a topic.
* Returns { key, bestSim, isNew } and updates centroid if matched.
*/
async resolve(text) {
const v = await this.embed(text);
let bestKey = null, bestSim = -1;
for (const [key, t] of this.topics) {
const sim = this.cos(v, t.centroid);
if (sim > bestSim) {
bestSim = sim;
bestKey = key;
}
}
if (bestSim >= this.threshold && bestKey) {
const t = this.topics.get(bestKey);
const n = t.count + 1;
t.centroid = t.centroid.map((c, i) => (c * t.count + v[i]) / n);
t.count = n;
return { key: bestKey, bestSim, isNew: false };
}
// New topic: mint a label via LLM; fallback to generic id
let label = await this._mintLabelWithLLM(text);
if (!label) label = `topic-${String(this._counter++).padStart(3, '0')}`;
let unique = label, i = 2;
while (this.topics.has(unique)) unique = `${label}-${i++}`;
this.topics.set(unique, { centroid: v, count: 1, summary: '' });
return { key: unique, bestSim, isNew: true };
}
async embed(text) {
utils.log(`Embed ${text}`, 5);
const r = await fetch('http://127.0.0.1:11434/api/embeddings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.embedModel, prompt: text })
});
utils.log('Embed done', 5);
if (!r.ok)
throw new Error(await r.text());
const { embedding } = await r.json();
return embedding;
}
cos(a, b) {
let d = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
d += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i]
}
return d / (Math.sqrt(na) * Math.sqrt(nb));
}
}
module.exports = TopicRouter;
/* Testing * /
const router = new TopicRouter();
(async () => {
const t1 = await router.resolve('Tell me how many cat breeds are there in the universe?'); // → cats / cats-...
console.log(t1);
const t2 = await router.resolve('Name major ones'); // → same topic as t1
console.log(t2);
const t3 = await router.resolve('Configure Nginx reverse proxy'); // → nginx-...
console.log(t3);
})();
/**/
/*
* Routing for API
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
'use strict';
const utils = require('../utils/utils');
const root_path = '/api';
const GET = 'get';
const POST = 'post';
const PATCH = 'patch';
const PUT = 'put';
const DELETE = 'delete';
function init(app) {
// Main routing table: [ method, path, module, function = 'run' ]
let routing = [
// Misc
[POST, '/stream', './KotGPT'],
[GET, '/stop', './KotGPT', 'stop'],
// Misc
[GET, '/info', '../common/info'],
[GET, '/logcat', '../utils/logcat'],
];
for (const route of routing) {
let mod = require(route[2]);
let func = route[3] || 'run';
app[route[0]] (root_path + route[1], mod[func]);
}
// const dal = require('../utils/dal');
// for (let o of Object.values(dal)) {
// app.get(`/db/${o.table}/:id?`, o.get);
// app.post(`/db/${o.table}`, o.post);
// app.patch(`/db/${o.table}`, o.patch);
// app.delete(`/db/${o.table}/:id`, o.del);
// }
const info = require('../common/info');
const server = require('../../server');
module.exports.root_path = root_path;
module.exports.routing = routing;
info.init(server, module);
}
module.exports.init = init;
/*
* Housekeeping
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
const config = require('config')
const utils = require('../utils/utils');
const { CronJob } = require('cron');
const DEFAULT_CRON_TIME = '0 0 * * * *';
function init() {
cronTime = config.housekeeping.cronTime || DEFAULT_CRON_TIME;
if (cronTime == 'off')
return;
const job = CronJob.from({
cronTime: cronTime,
onTick: cleanup,
start: true,
});
if (config.housekeeping.cleanup_on_start)
cleanup();
utils.log(`Housekeeping started: ${cronTime}`);
}
async function cleanup() {
try {
utils.log('Cleanup ...');
} catch (e) {
utils.log_error(e);
}
}
module.exports.init = init;
\ No newline at end of file
/*
* Provides general information
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
const config = require('config')
const utils = require('../utils/utils');
const Formats = require('../utils/formats');
let server;
let routes;
async function info(req, res) {
if (req.query.log_level) {
level = Number(req.query.log_level);
utils.set_log_level(level);
console.log(`Log level temporarily set to ${level}`);
}
let ret = {
time: Formats.DateTime(new Date()),
name: server.name,
version: server.version,
routing: [],
};
for (let route of routes.routing)
ret.routing.push(`${route[0]} ${route[1]}`);
res.send(ret);
}
function init(srv, rte) {
server = srv;
routes = rte.exports;
}
module.exports.run = info;
module.exports.init = init;
This source diff could not be displayed because it is too large. You can view the blob instead.
async function ask(prompt) {
const res = await fetch('http://127.0.0.1:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-oss:20b',
messages: [{ role: 'user', content: prompt }],
stream: false // <--- disable SSE so you get one JSON object
})
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
console.log(data.message?.content);
}
ask('Сколько в мире котов и кошек?');
\ No newline at end of file
//const fetch = require('node-fetch');
const url = 'http://127.0.0.1:11434/api/generate';
const body = {
model: 'llama3', // or llama3:8b
prompt: 'Say hello from llama3',
stream: false // single JSON result
};
(async () => {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(await res.text());
const json = await res.json();
console.log('RAW JSON:', json);
console.log('TEXT:', json.response); // read .response here
} catch (err) {
console.error('Error:', err);
}
})();
let MODEL = 'llama3:8b';
//let MODEL = 'gpt-oss:20b';
//let MODEL = 'qwen2.5:7b-instruct';
//let MODEL = 'mistral:instruct';
// let MODEL = 'yi:34b';
//let MODEL = 'gemma2:9b';
async function askStream(prompt) {
const res = await fetch('http://127.0.0.1:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MODEL,
messages: [
{ role: 'system', content: 'Всегда веди рассуждения и отвечай на том же языке, на котором задан вопрос.' },
{ role: 'user', content: prompt },
],
stream: true
})
});
console.log(prompt);
console.log('----------------');
if (!res.ok) throw new Error(await res.text());
const reader = res.body.getReader();
const decoder = new TextDecoder();
let content = '';
let thinking = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true }).trim();
if (!chunk) continue;
try {
const obj = JSON.parse(chunk);
console.error(JSON.stringify(obj, null, 2));
if (obj.message?.thinking) {
thinking += obj.message?.thinking;
if (thinking.length > 100) {
console.log(thinking.trim());
thinking = '';
}
}
if (obj.message?.content) {
if (thinking) {
console.log(thinking.trim());
console.log('--------------');
thinking = '';
}
content += obj.message?.content;
if (content.endsWith('\n')) {
console.log(content.trim());
content = '';
}
if (content.length > 100) {
let b = content.substring(0, 100);
let e = content.substring(101);
console.log(content.trim());
content = '';
}
}
} catch (err) {
console.error('Failed to parse chunk:', chunk, err);
}
}
// console.log(thinking);
console.log(content.trim());
}
//askStream('Hello');
askStream('Сколько в мире котов и кошек?');
askStream('Найди вэб ссылки на материалы');
const fetch = require('node-fetch');
let MODEL = 'llama3:8b';
const history = [
{ role: 'system', content: 'Всегда веди рассуждения и отвечай на том же языке, на котором задан вопрос.' }
];
let pending = Promise.resolve();
async function askStream(prompt) {
let dashes = '-'.repeat(prompt.length);
console.log(`\n${dashes}\nВопрос: ${prompt}\n${dashes}\n`);
pending = pending.then(() => _askStream(prompt));
return pending;
}
async function _askStream(prompt) {
history.push({ role: 'user', content: prompt });
const res = await fetch('http://127.0.0.1:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: MODEL,
messages: history,
stream: true,
keep_alive: '10m'
})
});
if (!res.ok) throw new Error(await res.text());
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let assistantText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let lines = buf.split(/\r?\n/); // handle CRLF too
buf = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
let obj;
try { obj = JSON.parse(line); } catch { continue; }
if (obj.message?.content) {
process.stdout.write(obj.message.content);
assistantText += obj.message.content;
}
if (obj.done) process.stdout.write('\n');
}
}
// Flush any leftover without newline
if (buf.trim()) {
try {
const obj = JSON.parse(buf);
if (obj.message?.content) {
process.stdout.write(obj.message.content);
assistantText += obj.message.content;
}
} catch {}
}
history.push({ role: 'assistant', content: assistantText.trim() });
return assistantText.trim();
}
// Example
(async () => {
await askStream('Сколько в мире котов и кошек?');
await askStream('Найди веб ссылки на материалы');
})();
/*
* Data Access Layer
* Created by: Michael Pastushkov <max@pastushkov.com>
*/
const db = require('./database');
const utils = require('../utils/utils');
let objects = {};
async function init() {
let classes = {};
const tables = await db.db_query("SHOW TABLES");
for (let t of tables) {
const table = Object.values(t)[0];
const columns = await db.db_query(`SHOW COLUMNS FROM \`${table}\``);
const className = table.charAt(0).toUpperCase() + table.slice(1);
classes[className] = class extends DataObject {
constructor() {
super(table, columns);
}
}
objects[className] = new classes[className]();
}
// This where we replace exports with objects
//module.exports = objects;
Object.assign(module.exports, objects);
}
class DataObject {
constructor(table, columns) {
this.table = table;
this.columns = columns;
this.fields = [];
for (let c of columns)
this.fields.push(c.Field);
this.post = this.post.bind(this);
this.patch = this.patch.bind(this);
this.get = this.get.bind(this);
this.del = this.del.bind(this);
}
json_stringify(d) {
for (let c of this.columns.filter(col => col.Type === 'json')) {
if (d[c.Field])
d[c.Field] = JSON.stringify(d[c.Field]);
else
d[c.Field] = {}; // MySQL doesn't allow default values for JSON fields
}
return d;
}
strings2dates(d) {
for (let c of this.columns.filter(col => col.Type === 'date' || col.Type === 'datetime')) {
if (d[c.Field])
d[c.Field] = new Date(d[c.Field]);
}
}
// Direct access functions
async create(data) {
data.created = new Date();
return await db.table_insert(this.table, this.fields, this.json_stringify(data));
}
async update(data) {
data.updated = new Date();
return await db.table_update(this.table, this.fields, this.json_stringify(data));
}
async select_one(options, values) {
return (await db.table_select(this.table, options, values)) [0];
}
async select(options, values) {
return await db.table_select(this.table, options, values);
}
async remove(options, values) {
return await db.table_delete(this.table, options, values);
}
// REST access functions
async post(req, res) {
this.strings2dates(req.body);
res.send(await this.create(req.body));
}
async patch(req, res) {
this.strings2dates(req.body);
res.send(await this.update(req.body));
}
async get(req, res) {
let id = req.params.id || 'ORDER BY id DESC';
if (id.startsWith('WHERE') || id.startsWith('ORDER BY'))
res.send(await this.select(id));
else
res.send(await this.select_one("WHERE id = ?", id));
}
async del(req, res) {
res.send(await this.remove("WHERE id = ?", req.params.id));
}
}
// First, we export only init, then it would be
// replaced with objects during init execution
module.exports.init = init;
\ No newline at end of file
/*
* Everything related to MySQL database
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
'use strict';
const config = require('config');
const util = require('util');
const mysql = require('mysql2');
const utils = require('../utils/utils');
const pool = mysql.createPool(config.mysql);
pool.query = util.promisify(pool.query); // for await
async function db_query_one(sql, values = null) {
return (await db_query(sql, values)) [0];
}
async function db_query(sql, values = null) {
utils.log(sql, 5);
if (!utils.is_empty(values))
utils.log(values, 6);
// regular exception trace does not point the code SQL is trigered from
// we store the stack positon before making request, and log it on errors
var stack = new Error().stack;
try {
return await pool.query(sql, values);
} catch (e) {
utils.log_error(e.message);
utils.log_error(stack);
// throw [ 500, 'Internal database error'];
// Temporarily showing the actuaal SQL error
throw [500, e.message];
}
}
//// COMMON DATA OPERATIONS ////
// INSERT record in the table
async function table_insert(table, fields, data) {
let sql = `INSERT INTO ${table} (`;
let vls = ' VALUES (';
if (data.id == 'new')
data.id = null;
// Fields
for (let i = 0; i < fields.length; i++) {
// Do not insert value that has no data (to prevent NULLs from being inserted)
if (data[fields[i]] === undefined)
continue;
sql += fields[i] + ',';
vls += '?,';
}
// Remove trailing commas
sql = sql.slice(0, -1);
vls = vls.slice(0, -1);
sql += ')';
vls += ')';
sql += vls;
// Values
let values = [];
for (let f of fields) {
if (data[f] === undefined)
continue;
values.push(data[f]);
}
// Query
let result = await db_query(sql, values);
data.id = result.insertId;
return result;
}
// Update (UPDATE) record in the table
async function table_update(table, fields, data) {
if (!data.id)
throw new Error(`Cannot update ${table}: no 'id' in the data`);
let sql = `UPDATE ${table} SET`;
// Fields & values
let keys = Object.keys(data);
let values = [];
for (let k of keys) {
if (!fields.includes(k))
continue;
if (k != 'id') {
sql += ` ${k} = ?,`;
values.push(data[k]);
}
}
sql = sql.substring(0, sql.length - 1);
// WHERE clause
sql += ' WHERE id = ?';
values.push(data.id);
// Query
let result = await db_query(sql, values);
return result;
}
// Select (SELECT) record(s) from the table
async function table_select(table, options = '', values = []) {
let sql = `SELECT * FROM ${table} ${options}`;
let result = await db_query(sql, values);
return result;
}
// Remove (DELETE) record from the table
async function table_delete(table, options, values) {
let sql = `DELETE FROM ${table} ${options}`;
let result = await db_query(sql, values);
return result;
}
function db_close() {
pool.end();
utils.log('Database connection closed');
}
//module.exports = pool;
module.exports.db_query = db_query;
module.exports.db_query_one = db_query_one;
module.exports.db_close = db_close;
module.exports.table_insert = table_insert;
module.exports.table_update = table_update;
module.exports.table_select = table_select;
module.exports.table_delete = table_delete;
/*
// Future enhancements: transaction suppot
async function db_transaction(callback) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const result = await callback(connection);
await connection.commit();
return result;
} catch (err) {
await connection.rollback();
throw err;
} finally {
connection.release();
}
}
await db_transaction(async (conn) => {
await db_query("UPDATE ...", conn);
await db_query("UPDATE ...", conn);
await db_query("UPDATE ...", conn);
});
async function db_query(sql, values = null, connection = null) {
utils.log(sql, 5);
if (!utils.is_empty(values))
utils.log(values, 6);
const stack = new Error().stack;
try {
if (connection) {
return await connection.query(sql, values);
} else {
return await pool.query(sql, values);
}
} catch (e) {
throw [500, e.message];
}
}
*/
\ No newline at end of file
/*
* Formatting options
* Created by: Michael Pastushkov <max@pastushkov.com>
*/
const date = require('date-and-time');
class Formats {
static DATE = 'MM/DD/YYYY';
static DATE_SHORT = 'MM/DD';
static DATE_EDIT = 'YYYY-MM-DD';
static TIME = 'HH:mm:ss';
static DATETIME = 'MM-DD-YYYY HH:mm:ss';
static DATETIME_SHORT = 'MM/DD HH:mm:ss';
static Date(d) { return date.format(d, Formats.DATE); }
static DateShort(d) { return date.format(d, Formats.DATE_SHORT); }
static DateEdit(d) { return date.format(d, Formats.DATE_EDIT); }
static Time(d) { return date.format(d, Formats.TIME); }
static DateTime(d) { return date.format(d, Formats.DATETIME); }
static DateTimeShort(d) { return date.format(d, Formats.DATETIME_SHORT); }
}
module.exports = Formats;
\ No newline at end of file
/*
* Displayes log info in the response
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
const utils = require('./utils');
async function logcat(req, res) {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.write('Logcat session open\n');
res.flush();
let listener = function(data) {
res.write(`${data}\n`);
res.flush();
}
utils.add_logcat(listener);
res.on('close', function () {
utils.remove_logcat(listener);
utils.log('logcat connection closed');
});
}
module.exports.run = logcat;
/*
* Sending email
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
'use strict';
const config = require('config');
const utils = require('./utils');
const nodemailer = require('nodemailer');
const Email = require('email-templates');
const transporter = nodemailer.createTransport(config.mailer);
function sendTemplate(to, template, locals) {
let email = new Email({
send: true,
transport: config.mailer,
preview: false
});
email
.send({
template: template,
message: {
from: config.mailer.from,
replyTo: config.mailer.from,
to: to
},
locals: locals
})
.then((m) => {
utils.log(`Email sent to ${m.envelope.to}`);
utils.log(m, 5);
})
.catch(utils.log_error);
}
function send(to, subj, text) {
let mailOptions = {
from: config.mailer.from,
to: to,
subject: subj,
text: text
};
transporter.sendMail(mailOptions, function(error, info) {
if (error) {
console.log(error);
} else {
utils.log(`Email sent to : ${to}: ` + info.response);
}
});
}
module.exports.send = send;
module.exports.sendTemplate = sendTemplate;
\ No newline at end of file
/*
* Utility functions and classes
* Created by: Michael Pastushkov <michael@pastushkov.com>
*/
'use strict';
const config = require('config');
const date = require('date-and-time');
const Formats = require('./formats');
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
let log_level = config.general.log_level | 0;
let logcat_listeners = [];
function log(msg, level = 2, error = false, req) {
if (level > log_level)
return;
let dst = Formats.DateTimeShort(new Date());
if (config.server_api.cluster_enabled && log_level > 2)
dst += ` ${process.pid}`;
let str = msg;
if (typeof msg == 'object') {
str = JSON.stringify(msg);
if (level < 5 && str.length > 100)
str = str.substring(0, 100) + ' ...';
}
if (req && req.headers['user-agent'])
str = `[${req.headers['user-agent']}]: ${str}`;
if (error)
console.error(dst + ' ' + str);
else
console.log(dst + ' ' + str);
// Logcat
for (let l of logcat_listeners)
l(dst + ' ' + str);
}
async function set_log_level(level) {
log_level = level;
}
function log_error(err, req) {
if (err.stack && err.message) {
log(err.stack, 0, true, req);
} else {
log(err, 0, true, req);
}
}
function get_ip(req) {
return req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
}
function is_empty(object) {
return !object || Object.keys(object).length == 0
}
// Random string generator
function randomString(length, type) {
let str = '';
let len = type == "numeric" ? 10 : chars.length;
for (let i = 0; i < length; i++) {
str += chars.charAt(Math.floor(Math.random() * len));
}
return str;
}
// Converting CSV buffer (usually from file upload)
// to array of objects
function convert_csv(buf) {
let arr = [];
let txt = buf.toString();
let lns = txt.split('\n');
// Convert csv into array of objects
let fields;
for (let i = 0; i < lns.length; i++) {
if (!lns[i])
continue;
let elems = lns[i].split(',');
elems = elems.map(elem => {
return elem.trim();
});
if (i == 0) {
fields = elems;
} else {
// Scan through data and create records
rec = {};
for (let j = 0; j < fields.length; j++) {
rec[fields[j]] = elems[j];
}
arr.push(rec);
}
}
return arr;
}
function remove_whitespace(s) {
return s.replace(/\s+/g, ' ').trim();
}
function add_logcat(listener) {
logcat_listeners.push(listener);
}
function remove_logcat(listener) {
for (let i = logcat_listeners.length-1; i >= 0; i--)
if (logcat_listeners[i] === listener)
logcat_listeners.splice(i, 1);
}
function parse(json) {
try {
return JSON.parse(json);
} catch (e) {
log(e.message, 4);
return null;
}
}
module.exports.set_log_level = set_log_level;
module.exports.log = log;
module.exports.log_error = log_error;
module.exports.get_ip = get_ip;
module.exports.is_empty = is_empty;
module.exports.randomString = randomString;
module.exports.convert_csv = convert_csv;
module.exports.remove_whitespace = remove_whitespace;
module.exports.add_logcat = add_logcat;
module.exports.remove_logcat = remove_logcat;
module.exports.parse = parse;
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
<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