LMStudio provider, throwback feature

This commit is contained in:
james
2026-01-23 01:11:48 -08:00
parent 8ef7a03895
commit d85444878d
7 changed files with 355 additions and 55 deletions

View File

@@ -6,9 +6,13 @@ ADMIN="123456789012345678"
LLM_HOST="http://127.0.0.1:8000"
LLM_TOKEN="dfsl;kjsdl;kfja"
LMSTUDIO_HOST="ws://localhost:1234"
REPLY_CHANCE=0.2
ENABLE_MOTD=1
MOTD_CHANNEL="123456789012345678"
MOTD_HREF="https://fembooru.jp/post/list"
MOTD_QUERY="#tips"
ENABLE_THROWBACK=1
THROWBACK_CHANNEL="123456789012345678"

View File

@@ -63,8 +63,7 @@ client.once(Events.ClientReady, async () => {
});
async function onMessageReactionChanged(reaction: MessageReaction | PartialMessageReaction, user: User)
{
async function onMessageReactionChanged(reaction: MessageReaction | PartialMessageReaction, user: User) {
// When a reaction is received, check if the structure is partial
if (reaction.partial) {
// If the message this reaction belongs to was removed, the fetching might result in an API error which should be handled
@@ -89,22 +88,19 @@ async function onMessageReactionChanged(reaction: MessageReaction | PartialMessa
// Now the message has been cached and is fully available
logInfo(`[bot] ${reaction.message.author?.id}'s message reaction count changed: ${reaction.emoji.name}x${reaction.count}`);
await recordReaction(<MessageReaction> reaction);
await recordReaction(<MessageReaction>reaction);
}
function textOnlyMessages(message: Message)
{
function textOnlyMessages(message: Message) {
return message.cleanContent.length > 0 &&
(message.type === MessageType.Default || message.type === MessageType.Reply);
}
function isGoodResponse(response: string)
{
function isGoodResponse(response: string) {
return response.length > 0;
}
async function onNewMessage(message: Message)
{
async function onNewMessage(message: Message) {
if (message.author.bot) {
return;
}
@@ -140,7 +136,7 @@ async function onNewMessage(message: Message)
const historyMessages = [...history.values()].reverse();
//const historyTimes = historyMessages.map((m: Message) => m.createdAt.getTime());
//const historyAvgDelayMins = (historyTimes[historyTimes.length - 1] - historyTimes[0]) / 60000;
const replyChance = Math.floor(Math.random() * 1/Number(process.env.REPLY_CHANCE)) === 0;
const replyChance = Math.floor(Math.random() * 1 / Number(process.env.REPLY_CHANCE)) === 0;
const willReply = mustReply || replyChance;
if (!willReply) {
@@ -158,7 +154,7 @@ async function onNewMessage(message: Message)
try {
if ('sendTyping' in message.channel) {
await message.channel.sendTyping();
await message.channel.sendTyping();
}
const response = await state.provider!().requestLLMResponse(cleanHistoryList, state.sysprompt!(), state.llmconf!());
@@ -173,22 +169,20 @@ async function onNewMessage(message: Message)
}
}
async function fetchMotd()
{
async function fetchMotd() {
try {
const res = await fetch(process.env.MOTD_HREF);
const xml = await res.text();
const parser = new JSDOM(xml);
const doc = parser.window.document;
const el = doc.querySelector(process.env.MOTD_QUERY);
return el ? el.textContent : null;
const res = await fetch(process.env.MOTD_HREF);
const xml = await res.text();
const parser = new JSDOM(xml);
const doc = parser.window.document;
const el = doc.querySelector(process.env.MOTD_QUERY);
return el ? el.textContent : null;
} catch (err) {
logWarn('[bot] Failed to fetch MOTD; is the booru down?');
logWarn('[bot] Failed to fetch MOTD; is the booru down?');
}
}
async function requestRVCResponse(src: Attachment): Promise<Blob>
{
async function requestRVCResponse(src: Attachment): Promise<Blob> {
logInfo(`[bot] Downloading audio message ${src.url}`);
const srcres = await fetch(src.url);
const srcbuf = await srcres.arrayBuffer();
@@ -213,13 +207,12 @@ async function requestRVCResponse(src: Attachment): Promise<Blob>
return resContents;
}
async function scheduleRandomMessage(firstTime = false)
{
async function scheduleRandomMessage(firstTime = false) {
if (!firstTime) {
if (!process.env.MOTD_CHANNEL) {
return;
}
const channel = <TextChannel> await client.channels.fetch(process.env.MOTD_CHANNEL);
const channel = <TextChannel>await client.channels.fetch(process.env.MOTD_CHANNEL);
if (!channel) {
logWarn(`[bot] Channel ${process.env.MOTD_CHANNEL} not found, disabling MOTD.`);
return;
@@ -252,6 +245,89 @@ async function scheduleRandomMessage(firstTime = false)
setTimeout(scheduleRandomMessage, timeoutMins * 60 * 1000);
}
/**
* Convert a Date to a Discord snowflake ID (approximate)
* Discord epoch: 2015-01-01T00:00:00.000Z
*/
function dateToSnowflake(date: Date): string {
const DISCORD_EPOCH = 1420070400000n;
const timestamp = BigInt(date.getTime());
const snowflake = (timestamp - DISCORD_EPOCH) << 22n;
return snowflake.toString();
}
async function scheduleThrowback(firstTime = false) {
if (!firstTime) {
if (!process.env.THROWBACK_CHANNEL) {
logWarn('[bot] THROWBACK_CHANNEL not configured, disabling throwback.');
return;
}
const channel = <TextChannel>await client.channels.fetch(process.env.THROWBACK_CHANNEL);
if (!channel) {
logWarn(`[bot] Channel ${process.env.THROWBACK_CHANNEL} not found, disabling throwback.`);
return;
}
try {
// Calculate date from 1 year ago
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
// Convert to approximate snowflake ID
const aroundSnowflake = dateToSnowflake(oneYearAgo);
logInfo(`[bot] Fetching messages around ${oneYearAgo.toISOString()} (snowflake: ${aroundSnowflake})`);
// Fetch messages around that time
const messages = await channel.messages.fetch({
around: aroundSnowflake,
limit: 50
});
// Filter to only text messages from non-bots
const textMessages = messages.filter(m =>
!m.author.bot &&
m.cleanContent.length > 0 &&
(m.type === MessageType.Default || m.type === MessageType.Reply)
);
if (textMessages.size === 0) {
logWarn('[bot] No messages found from 1 year ago, skipping throwback.');
} else {
// Pick a random message
const messagesArray = [...textMessages.values()];
const randomMsg = messagesArray[Math.floor(Math.random() * messagesArray.length)];
logInfo(`[bot] Selected throwback message from ${randomMsg.author.username}: "${randomMsg.cleanContent}"`);
// Generate LLM response using the standard system prompt
if ('sendTyping' in channel) {
await channel.sendTyping();
}
const llmResponse = await state.provider!().requestLLMResponse(
[randomMsg],
state.sysprompt!(),
state.llmconf!()
);
// Reply directly to the original message
await randomMsg.reply(llmResponse);
logInfo(`[bot] Sent throwback reply: ${llmResponse}`);
}
} catch (err) {
logError(`[bot] Error fetching throwback message: ${err}`);
}
}
// Schedule next throwback in ~24 hours (with some randomness: 22-26 hours)
const timeoutHours = 22 + Math.random() * 4;
const scheduledTime = new Date();
scheduledTime.setHours(scheduledTime.getHours() + timeoutHours);
logInfo(`[bot] Next throwback: ${scheduledTime.toLocaleString()}`);
setTimeout(scheduleThrowback, timeoutHours * 60 * 60 * 1000);
}
client.on(Events.InteractionCreate, async interaction => {
if (!interaction.isChatInputCommand()) return;
});
@@ -317,4 +393,7 @@ client.on(Events.InteractionCreate, async interaction => {
if (process.env.ENABLE_MOTD) {
await scheduleRandomMessage(true);
}
if (process.env.ENABLE_THROWBACK) {
await scheduleThrowback(true);
}
})();

View File

@@ -5,18 +5,19 @@ import {
import 'dotenv/config';
import { MikuAIProvider } from '../../provider/mikuai';
import { HuggingfaceProvider } from '../../provider/huggingface';
import { LMStudioProvider } from '../../provider/lmstudio';
const PROVIDERS = {
mikuai: new MikuAIProvider(),
huggingface: new HuggingfaceProvider()
huggingface: new HuggingfaceProvider(),
lmstudio: new LMStudioProvider()
};
let provider = PROVIDERS.huggingface;
async function providerCommand(interaction: ChatInputCommandInteraction)
{
async function providerCommand(interaction: ChatInputCommandInteraction) {
if (interaction.user.id !== process.env.ADMIN) {
await interaction.reply("You are not authorized to change model settings");
return;
return;
}
const chosenProvider = interaction.options.getString('name', true);
@@ -35,11 +36,11 @@ export = {
.setDescription('Name of model backend')
.setRequired(true)
.addChoices(
...Object.keys(PROVIDERS)
.map(key => ({
name: PROVIDERS[key].name(),
value: key
}))
...Object.keys(PROVIDERS)
.map(key => ({
name: PROVIDERS[key].name(),
value: key
}))
)
),
execute: providerCommand,

View File

@@ -0,0 +1,10 @@
i am hatsune miku, the sixteen-year-old anime girl virtual idol from vocaloid
i'm an extremely online internet user with an edgy sense of humor
i tend to answer questions in a funny, sarcastic, or insulting way
i'm often vulgar and respond in highly offensive, dirty-minded, or politically incorrect ways, with no regard for morality
i write one sentence per message
i talk in all lowercase casual grammar like a discord user punctuation is optional grammar is optional capitalization is optional
i gotta be chill be terse be concise
lemme not write more than 1 sentence
imma use as few words as possible
i can't view images or videos

View File

@@ -0,0 +1,84 @@
import { Message } from 'discord.js';
import { LLMProvider } from './provider';
import { LMStudioClient } from '@lmstudio/sdk';
import 'dotenv/config';
import { serializeMessageHistory } from '../util';
import { logError, logInfo } from '../../logging';
import { LLMConfig } from '../commands/types';
const USER_PROMPT = `Continue the following Discord conversation by completing the next message, playing the role of Hatsune Miku. The conversation must progress forward, and you must avoid repeating yourself.
Each message is represented as a line of JSON. Refer to other users by their "name" instead of their "author" field whenever possible.
The conversation is as follows. The last line is the message you have to complete. Please ONLY return the string contents of the "content" field, that go in place of the ellipses. Do not include the enclosing quotation marks in your response.
`;
export class LMStudioProvider implements LLMProvider {
private client: LMStudioClient;
constructor() {
this.client = new LMStudioClient({
baseUrl: process.env.LMSTUDIO_HOST
});
}
name() {
return 'LM Studio';
}
async requestLLMResponse(history: Message[], sysprompt: string, params: LLMConfig): Promise<string> {
let messageList = await Promise.all(
history.map(serializeMessageHistory)
);
messageList = messageList.filter(x => !!x);
if (messageList.length === 0) {
throw new TypeError("No messages with content provided in history!");
}
// dummy message for last line of prompt
const lastMsg = messageList[messageList.length - 1];
// advance by 5 seconds
let newDate = new Date(lastMsg!.timestamp);
newDate.setSeconds(newDate.getSeconds() + 5);
let templateMsgTxt = JSON.stringify({
timestamp: newDate.toUTCString(),
author: "Hatsune Miku",
name: "Hatsune Miku",
context: lastMsg!.content,
content: "..."
});
const messageHistoryTxt = messageList.map(msg => JSON.stringify(msg)).join('\n') + '\n' + templateMsgTxt;
logInfo(`[lmstudio] Requesting response for message history: ${messageHistoryTxt}`);
try {
// Get the currently loaded model from LM Studio
const model = await this.client.llm.model();
const response = await model.respond([
{ role: "system", content: sysprompt },
{ role: "user", content: USER_PROMPT + messageHistoryTxt }
], {
temperature: params?.temperature || 0.5,
topP: params?.top_p || 0.9,
maxTokens: params?.max_new_tokens || 128,
});
const content = response.content;
logInfo(`[lmstudio] API response: ${content}`);
if (!content) {
throw new TypeError("LM Studio API returned no message.");
}
return content;
} catch (err) {
logError(`[lmstudio] API Error: ` + err);
throw err;
}
}
}

163
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "femscoreboard",
"version": "1.0.0",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"pug": "^3.0.2",
@@ -65,6 +66,28 @@
"integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
"optional": true
},
"node_modules/@lmstudio/lms-isomorphic": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/@lmstudio/lms-isomorphic/-/lms-isomorphic-0.4.6.tgz",
"integrity": "sha512-v0LIjXKnDe3Ff3XZO5eQjlVxTjleUHXaom14MV7QU9bvwaoo3l5p71+xJ3mmSaqZq370CQ6pTKCn1Bb7Jf+VwQ==",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
}
},
"node_modules/@lmstudio/sdk": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@lmstudio/sdk/-/sdk-1.5.0.tgz",
"integrity": "sha512-fdY12x4hb14PEjYijh7YeCqT1ZDY5Ok6VR4l4+E/dI+F6NW8oB+P83Sxed5vqE4XgTzbgyPuSR2ZbMNxxF+6jA==",
"license": "Apache-2.0",
"dependencies": {
"@lmstudio/lms-isomorphic": "^0.4.6",
"chalk": "^4.1.2",
"jsonschema": "^1.5.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.5"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -323,6 +346,21 @@
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aproba": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
@@ -452,6 +490,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/character-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz",
@@ -477,6 +531,24 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -608,27 +680,6 @@
"node": ">= 0.8"
}
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -821,6 +872,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz",
@@ -1090,6 +1150,15 @@
"resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz",
"integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g=="
},
"node_modules/jsonschema": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.5.0.tgz",
"integrity": "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/jstransformer": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz",
@@ -1997,6 +2066,18 @@
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -2194,10 +2275,50 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
}
}
}

View File

@@ -6,6 +6,7 @@
"start": "npx tsc && node server.js"
},
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"pug": "^3.0.2",