This commit is contained in:
Sergey Yarkov 2026-09-15 16:18:12 +03:00
parent b60ccd6915
commit a7be9b1f31
12 changed files with 269 additions and 6 deletions

View File

@ -1 +1,9 @@
API_BASE_URL=http://localhost:1234/v1 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

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules node_modules
.env .env
sys_prompt.md

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"trailingComma": "none",
"printWidth": 120,
"tabWidth": 2,
"singleQuote": false,
"semi": true
}

144
bot.js Normal file
View File

@ -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 "";
}
}

3
constants.js Normal file
View File

@ -0,0 +1,3 @@
export const LLM_MODEL_ID = "qwen/qwen3-14b";
export const MAX_MESSAGES_LIMIT = 50;

17
env.d.ts vendored Normal file
View File

@ -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 {};

View File

@ -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();

View File

@ -8,5 +8,6 @@
"strict": true, "strict": true,
"module": "nodenext", "module": "nodenext",
"resolveJsonModule": true "resolveJsonModule": true
} },
"include": ["**/*.js", "env.d.ts", "types.d.ts"]
} }

14
package-lock.json generated
View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@xmpp/client": "^0.14.0", "@xmpp/client": "^0.14.0",
"@xmpp/debug": "^0.14.0",
"eslint": "^10.9.1", "eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
@ -313,6 +314,19 @@
"node": ">= 20.10" "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": { "node_modules/@xmpp/error": {
"version": "0.14.0", "version": "0.14.0",
"resolved": "https://registry.npmjs.org/@xmpp/error/-/error-0.14.0.tgz", "resolved": "https://registry.npmjs.org/@xmpp/error/-/error-0.14.0.tgz",

View File

@ -3,19 +3,20 @@
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module",
"scripts": { "scripts": {
"start": "node --env-file=.env index.js", "start": "node --env-file=.env index.js ./sys_prompt.md",
"dev": "node --env-file=.env --watch index.js", "dev": "node --env-file=.env --watch index.js ./sys_prompt.md",
"lint": "eslint .", "lint": "eslint .",
"fix": "eslint . --fix" "fix": "eslint . --fix"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"type": "commonjs",
"dependencies": { "dependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@xmpp/client": "^0.14.0", "@xmpp/client": "^0.14.0",
"@xmpp/debug": "^0.14.0",
"eslint": "^10.9.1", "eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",

47
session.js Normal file
View File

@ -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<string, SessionContent>} */
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);
}
}
};
}

13
types.d.ts vendored Normal file
View File

@ -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;
}