From a7be9b1f31b7f4def39862f3bce3b3df2343aa95 Mon Sep 17 00:00:00 2001 From: sergeyyarkov Date: Tue, 15 Sep 2026 16:18:12 +0300 Subject: [PATCH] done --- .env.example | 10 +++- .gitignore | 3 +- .prettierrc | 7 +++ bot.js | 144 ++++++++++++++++++++++++++++++++++++++++++++++ constants.js | 3 + env.d.ts | 17 ++++++ index.js | 7 +++ jsconfig.json | 3 +- package-lock.json | 14 +++++ package.json | 7 ++- session.js | 47 +++++++++++++++ types.d.ts | 13 +++++ 12 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 .prettierrc create mode 100644 bot.js create mode 100644 constants.js create mode 100644 env.d.ts create mode 100644 session.js create mode 100644 types.d.ts diff --git a/.env.example b/.env.example index 3862ecb..6854093 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,9 @@ -API_BASE_URL=http://localhost:1234/v1 \ No newline at end of file +API_BASE_URL=http://localhost:1234/v1 +API_KEY=lm-studio +XMPP_SERVICE=xmpp://chat.xmpp.com:5222/xmpp-websocket +XMPP_DOMAIN=chat.xmpp.com +XMPP_USERNAME=username +XMPP_PASSWORD=password +SESSION_MANAGER=local +MODEL_ID=qwen/qwen3-14b +MAX_MESSAGES_LIMIT=50 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1dcef2d..b1dd8d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -.env \ No newline at end of file +.env +sys_prompt.md \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4e0a820 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "trailingComma": "none", + "printWidth": 120, + "tabWidth": 2, + "singleQuote": false, + "semi": true +} diff --git a/bot.js b/bot.js new file mode 100644 index 0000000..5bc3f6c --- /dev/null +++ b/bot.js @@ -0,0 +1,144 @@ +import { client, xml } from "@xmpp/client"; +import { OpenAI } from "openai"; +import debug from "@xmpp/debug"; +import { createSessionManager } from "./session.js"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const xmpp = client({ + service: process.env.XMPP_SERVICE, + domain: process.env.XMPP_DOMAIN, + username: process.env.XMPP_USERNAME, + password: process.env.XMPP_PASSWORD +}); + +export const lmStudio = new OpenAI({ + baseURL: process.env.API_BASE_URL, + apiKey: process.env.API_KEY +}); + +const sessions = createSessionManager(process.env.SESSION_MANAGER); + +debug(xmpp, false); + +xmpp.on("error", (err) => { + console.error(err); +}); + +xmpp.on("online", async (address) => { + console.log("online as", address.toString()); + await xmpp.send(xml("presence")); +}); + +xmpp.on("stanza", async (stanza) => { + if (!stanza.is("message") || stanza.attrs.type !== "chat") return; + + const body = stanza.getChildText("body"); + + if (!body) return; + + if (body.includes("@clr")) { + sessions.flush(); + console.log("@clr: All sessions cleared"); + return; + } + + const systemPromptReply = await readPromptFile(); + + const from = stanza.attrs.from; + const sessionId = from.split("/")[0]; + console.log(stanza.toString()); + + const history = sessions.getHistory(sessionId); + + await xmppSendTypingSignal(from); + + sessions.saveMessage(sessionId, { role: "user", content: body }); + + let reply = ""; + let lastPlacedChunkIdx = 0; + + const responseStream = await lmStudio.chat.completions.create({ + model: process.env.MODEL_ID, + messages: [{ role: "system", content: systemPromptReply }, ...history.messages], + stream: true, + temperature: 0.85, + top_p: 0.9, + frequency_penalty: 0.2, + presence_penalty: 0.1, + reasoning_effort: "none", + stream_options: { + include_usage: true + } + }); + + for await (const chunk of responseStream) { + const content = chunk.choices[0]?.delta?.content || ""; + reply += content; + + const replyPart = reply.slice(lastPlacedChunkIdx); + + if (replyPart.length > 200) { + lastPlacedChunkIdx = reply.length; + await xmppSendMessage(from, replyPart); + await xmppSendTypingSignal(from); + } + } + + await xmppSendMessage(from, reply.slice(lastPlacedChunkIdx)); + + if (reply) { + sessions.saveMessage(sessionId, { role: "assistant", content: reply }); + } + + await xmppSendActiveSignal(from); + + console.log(history); +}); + +/** @param {string} to */ +async function xmppSendTypingSignal(to) { + await xmpp.send( + xml( + "message", + { to, type: "chat" }, + xml("composing", { + xmlns: "http://jabber.org/protocol/chatstates" + }) + ) + ); +} + +/** + * @param {string} to + * @param {string} message + */ +async function xmppSendMessage(to, message) { + await xmpp.send(xml("message", { type: "chat", to }, xml("body", {}, `${message}`))); +} + +/** + * @param {string} to + */ +async function xmppSendActiveSignal(to) { + await xmpp.send( + xml( + "message", + { type: "chat", to }, + xml("active", { + xmlns: "http://jabber.org/protocol/chatstates" + }) + ) + ); +} + +async function readPromptFile() { + try { + const sysPromptFilename = process.argv[2] || ""; + const text = await fs.readFile(path.join(process.cwd(), sysPromptFilename), { encoding: "utf-8" }); + return text; + } catch (error) { + console.error(`Error while reading system prompt file:`, error); + return ""; + } +} diff --git a/constants.js b/constants.js new file mode 100644 index 0000000..a91e021 --- /dev/null +++ b/constants.js @@ -0,0 +1,3 @@ +export const LLM_MODEL_ID = "qwen/qwen3-14b"; + +export const MAX_MESSAGES_LIMIT = 50; diff --git a/env.d.ts b/env.d.ts new file mode 100644 index 0000000..fe4b510 --- /dev/null +++ b/env.d.ts @@ -0,0 +1,17 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + API_BASE_URL: string; + API_KEY: string; + XMPP_SERVICE: string; + XMPP_DOMAIN: string; + XMPP_USERNAME: string; + XMPP_PASSWORD: string; + SESSION_MANAGER: "local"; + MODEL_ID: string; + MAX_MESSAGES_LIMIT: string; + } + } +} + +export {}; diff --git a/index.js b/index.js index e69de29..502d38a 100644 --- a/index.js +++ b/index.js @@ -0,0 +1,7 @@ +import { xmpp, lmStudio } from "./bot.js"; + +const { data: modelsList } = await lmStudio.models.list(); + +console.log("Available models:", modelsList.map((m) => m.id).join(", ")); + +await xmpp.start(); diff --git a/jsconfig.json b/jsconfig.json index dc486a2..77908b8 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -8,5 +8,6 @@ "strict": true, "module": "nodenext", "resolveJsonModule": true - } + }, + "include": ["**/*.js", "env.d.ts", "types.d.ts"] } diff --git a/package-lock.json b/package-lock.json index 9c31796..39d0ef6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@eslint/js": "^10.0.1", "@xmpp/client": "^0.14.0", + "@xmpp/debug": "^0.14.0", "eslint": "^10.9.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", @@ -313,6 +314,19 @@ "node": ">= 20.10" } }, + "node_modules/@xmpp/debug": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xmpp/debug/-/debug-0.14.0.tgz", + "integrity": "sha512-Smj0ECPLVMbPjh4gMPgzhCzXHgQonJ9drZP/3MKJygRzUk92H/dr9Pfrq3odBAiqbTbnKQxxeanE2LG8m3YSgA==", + "license": "ISC", + "dependencies": { + "@xmpp/xml": "^0.14.0", + "ltx": "^3.1.2" + }, + "engines": { + "node": ">= 20.10" + } + }, "node_modules/@xmpp/error": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@xmpp/error/-/error-0.14.0.tgz", diff --git a/package.json b/package.json index 74c63bf..28dd789 100644 --- a/package.json +++ b/package.json @@ -3,19 +3,20 @@ "version": "1.0.0", "description": "", "main": "index.js", + "type": "module", "scripts": { - "start": "node --env-file=.env index.js", - "dev": "node --env-file=.env --watch index.js", + "start": "node --env-file=.env index.js ./sys_prompt.md", + "dev": "node --env-file=.env --watch index.js ./sys_prompt.md", "lint": "eslint .", "fix": "eslint . --fix" }, "keywords": [], "author": "", "license": "ISC", - "type": "commonjs", "dependencies": { "@eslint/js": "^10.0.1", "@xmpp/client": "^0.14.0", + "@xmpp/debug": "^0.14.0", "eslint": "^10.9.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", diff --git a/session.js b/session.js new file mode 100644 index 0000000..3218596 --- /dev/null +++ b/session.js @@ -0,0 +1,47 @@ +/** @import { OpenAI } from "openai" */ +/** @import { SessionDriverInterface, SessionContent, ChatMessage } from "./types.js" */ + +/** + * @param {'local'} driver + */ +export function createSessionManager(driver) { + switch (driver) { + case "local": + return createLocalSessionManager(); + default: + throw new Error("Unknown session manager"); + } +} + +/** @returns {SessionDriverInterface} */ +function createLocalSessionManager() { + /** @type {Map} */ + const sessions = new Map(); + + return { + getHistory(from) { + let chatHistory = sessions.get(from); + + if (!chatHistory) { + chatHistory = { messages: [] }; + sessions.set(from, chatHistory); + } + + return chatHistory; + }, + + flush() { + sessions.clear(); + }, + + saveMessage(from, message) { + const chatHistory = this.getHistory(from); + chatHistory.messages.push(message); + + if (message.role === "assistant") { + if (chatHistory.messages.length > Number.parseInt(process.env.MAX_MESSAGES_LIMIT, 10)) + chatHistory.messages.splice(0, 2); + } + } + }; +} diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..559372d --- /dev/null +++ b/types.d.ts @@ -0,0 +1,13 @@ +import type { OpenAI } from "openai"; + +export type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam; + +export interface SessionContent { + messages: ChatMessage[]; +} + +export interface SessionDriverInterface { + getHistory(from: string): SessionContent; + saveMessage(from: string, message: ChatMessage): void; + flush(): void; +}