Batch of LLM-assisted fixes/feature updates
This commit is contained in:
1 parent
7c6453160a
commit
05aaf394df
16 files changed
+5597
-912
No files matched your search
+58
-17
@@ -1,37 +1,78 @@
|
||||
const RandomBot = require('./RandomBot');
|
||||
const LLMBot = require('./LLMBot');
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const RandomBot = require('./RandomBot');
|
||||
const LLMBot = require('./LLMBot');
|
||||
|
||||
const NAMES_PATH = path.join(__dirname, 'bot_names.txt');
|
||||
const FALLBACK_NAMES = ['DefaultBot', 'BackupBot', 'SpareBot', 'ExtraBot'];
|
||||
|
||||
function readNames() {
|
||||
try {
|
||||
const names = fs
|
||||
.readFileSync(NAMES_PATH, 'utf8')
|
||||
.split('\n')
|
||||
.map((name) => name.trim())
|
||||
.filter((name) => name.length > 0);
|
||||
return names.length ? names : FALLBACK_NAMES;
|
||||
} catch (err) {
|
||||
console.warn(`[BotFactory] Could not read ${NAMES_PATH}: ${err.message}`);
|
||||
return FALLBACK_NAMES;
|
||||
}
|
||||
}
|
||||
|
||||
class BotFactory {
|
||||
static createBot(type) {
|
||||
switch (type.toLowerCase()) {
|
||||
/**
|
||||
* Create a bot instance. LLM bots fall back to RandomBot when the LLM
|
||||
* client cannot be constructed (missing API key/config) so a game can
|
||||
* always start.
|
||||
*/
|
||||
static createBot(type = 'llm') {
|
||||
switch (String(type).toLowerCase()) {
|
||||
case 'random':
|
||||
return new RandomBot();
|
||||
case 'llm':
|
||||
return new LLMBot();
|
||||
try {
|
||||
return new LLMBot();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[BotFactory] LLM bot unavailable (${err.message}); using RandomBot instead.`
|
||||
);
|
||||
return new RandomBot();
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown bot type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
static generateBotName() {
|
||||
const names = fs.readFileSync(path.join(__dirname, 'bot_names.txt'), 'utf8')
|
||||
.split('\n')
|
||||
.map(name => name.trim())
|
||||
.filter(name => name.length > 0);
|
||||
/**
|
||||
* Pick a random bot name, avoiding names already used in the room.
|
||||
*/
|
||||
static generateBotName(exclude = []) {
|
||||
const names = readNames();
|
||||
const taken = new Set(
|
||||
(Array.isArray(exclude) ? exclude : [exclude])
|
||||
.filter(Boolean)
|
||||
.map((name) => String(name).toLowerCase())
|
||||
);
|
||||
|
||||
if (names.length === 0) {
|
||||
return 'DefaultBot'; // Fallback if no names are found
|
||||
}
|
||||
const available = names.filter((name) => !taken.has(name.toLowerCase()));
|
||||
const pool = available.length ? available : names;
|
||||
const pick = pool[Math.floor(Math.random() * pool.length)] || FALLBACK_NAMES[0];
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * names.length);
|
||||
return names[randomIndex];
|
||||
// If every themed name is taken, disambiguate with a number.
|
||||
if (taken.has(pick.toLowerCase())) {
|
||||
let suffix = 2;
|
||||
while (taken.has(`${pick}${suffix}`.toLowerCase())) suffix += 1;
|
||||
return `${pick}${suffix}`;
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
|
||||
static getAvailableBotTypes() {
|
||||
return ['random'];
|
||||
return ['llm', 'random'];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BotFactory;
|
||||
module.exports = BotFactory;
|
||||
+125
-59
@@ -1,90 +1,156 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const OpenAI = require('openai');
|
||||
|
||||
// read optional config file
|
||||
const configPath = path.join(__dirname, 'llm_config.json');
|
||||
const llmOptions = fs.existsSync(configPath)
|
||||
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
||||
: {};
|
||||
const DEFAULT_MODEL = 'meta-llama/Llama-3.2-3B-Instruct-Turbo';
|
||||
const CONFIG_PATH = path.join(__dirname, 'llm_config.json');
|
||||
const PERSONALITY_PATH = path.join(__dirname, 'bot_personalities_dumb.txt');
|
||||
|
||||
console.log(`Read config from ${configPath}`);
|
||||
const FALLBACK_PERSONALITY = 'You are a friendly bot playing a collaborative story game';
|
||||
|
||||
function generateBotPersonality() {
|
||||
const personalities = fs.readFileSync(path.join(__dirname, 'bot_personalities_dumb.txt'), 'utf8')
|
||||
// Read the optional config file once, tolerating missing/corrupt files.
|
||||
function loadConfig() {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||
console.warn(`[LLMBot] ${CONFIG_PATH} must contain a JSON object; ignoring it.`);
|
||||
} catch (err) {
|
||||
console.warn(`[LLMBot] Could not parse ${CONFIG_PATH}: ${err.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function readLines(file) {
|
||||
try {
|
||||
return fs
|
||||
.readFileSync(file, 'utf8')
|
||||
.split('\n')
|
||||
.map(personality => personality.trim())
|
||||
.filter(personality => personality.length > 0);
|
||||
|
||||
if (personalities.length === 0) {
|
||||
return 'You are a friendly bot'; // Fallback if no personalities are found
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
} catch (err) {
|
||||
console.warn(`[LLMBot] Could not read ${file}: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
const randomIndex = Math.floor(Math.random() * personalities.length);
|
||||
return personalities[randomIndex];
|
||||
}
|
||||
|
||||
function resolvePersonalitiesFile(configured) {
|
||||
const requested = configured || process.env.YARN_BOT_PERSONALITIES;
|
||||
if (!requested) return PERSONALITY_PATH;
|
||||
return path.isAbsolute(requested) ? requested : path.join(__dirname, requested);
|
||||
}
|
||||
|
||||
function readPersonalities(file = PERSONALITY_PATH) {
|
||||
return readLines(file);
|
||||
}
|
||||
|
||||
function generateBotPersonality(file = PERSONALITY_PATH) {
|
||||
const personalities = readPersonalities(file);
|
||||
if (!personalities.length) return FALLBACK_PERSONALITY;
|
||||
return personalities[Math.floor(Math.random() * personalities.length)];
|
||||
}
|
||||
|
||||
function firstContent(response) {
|
||||
const content = response?.choices?.[0]?.message?.content;
|
||||
return typeof content === 'string' ? content.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-backed bot. The constructor throws when the client cannot be
|
||||
* configured (for example, no API key); BotFactory catches that and
|
||||
* falls back to RandomBot so a misconfigured server keeps working.
|
||||
*/
|
||||
class LLMBot {
|
||||
constructor() {
|
||||
// Instantiate the OpenAI client with the config options we just read
|
||||
this.llm = new OpenAI(llmOptions);
|
||||
constructor(config) {
|
||||
const configPathExists = fs.existsSync(CONFIG_PATH);
|
||||
if (configPathExists) console.log(`[LLMBot] Read config from ${CONFIG_PATH}`);
|
||||
|
||||
// Prompt fragments we’ll reuse
|
||||
this.writePrompt =
|
||||
`You are playing a collaborative storytelling game.
|
||||
Continue the following story with exactly ONE short, creative sentence or sentence fragment.
|
||||
Do NOT repeat what’s already written.`;
|
||||
this.options = config && typeof config === 'object' ? config : loadConfig();
|
||||
this.model = this.options.model || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
||||
|
||||
this.votePrompt =
|
||||
`You are playing a collaborative storytelling game.
|
||||
Below is the story so far, followed by a list of possible next sentences.
|
||||
Reply with ONLY the number (0-indexed) of the sentence you like best.`;
|
||||
// Throws if OPENAI_API_KEY (or an apiKey in llm_config.json) is missing.
|
||||
this.llm = new OpenAI(this.options);
|
||||
|
||||
this.banterPrompt =
|
||||
`You are a playful, slightly sarcastic AI taking part in a writing-game chat.
|
||||
Keep it short, fun, and on-topic.`;
|
||||
this.writePrompt = [
|
||||
'You are playing a collaborative storytelling game.',
|
||||
'Continue the following story with exactly ONE short, creative sentence or sentence fragment.',
|
||||
'Do NOT repeat what is already written.',
|
||||
'Reply with the sentence only, no quotes, no preamble.'
|
||||
].join('\n');
|
||||
|
||||
this.limitPrompt = "Please do not exceed 25 words."
|
||||
this.votePrompt = [
|
||||
'You are playing a collaborative storytelling game.',
|
||||
'Below is the story so far, followed by a list of possible next sentences.',
|
||||
'Reply with ONLY the number of the sentence you like best. No other text.'
|
||||
].join('\n');
|
||||
|
||||
this.personality = generateBotPersonality();
|
||||
this.banterPrompt = [
|
||||
'You are a playful, slightly sarcastic AI taking part in a writing-game chat.',
|
||||
'Keep it short, fun, and on-topic (one sentence, under 20 words).'
|
||||
].join('\n');
|
||||
|
||||
this.limitPrompt = 'Please do not exceed 25 words.';
|
||||
|
||||
// Defaults to bot_personalities_dumb.txt; override with `personalitiesFile`
|
||||
// in the config or the YARN_BOT_PERSONALITIES environment variable.
|
||||
this.personalitiesFile = resolvePersonalitiesFile(this.options.personalitiesFile);
|
||||
this.personality = generateBotPersonality(this.personalitiesFile);
|
||||
}
|
||||
|
||||
async Write(story_so_far) {
|
||||
const msg = `${this.personality}. ${this.writePrompt} ${this.limitPrompt}\n\nStory so far:\n${story_so_far}`;
|
||||
async complete(messages, { temperature = 0.8, maxTokens = 80 } = {}) {
|
||||
const response = await this.llm.chat.completions.create({
|
||||
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
||||
messages: [{ role: 'user', content: msg }],
|
||||
temperature: llmOptions.temperature ?? 0.8,
|
||||
max_tokens: llmOptions.max_tokens ?? 80,
|
||||
model: this.model,
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens
|
||||
});
|
||||
//console.log(`Received response from LLM: ${response.choices[0].message.content}`);
|
||||
return response.choices[0].message.content.trim();
|
||||
return firstContent(response);
|
||||
}
|
||||
|
||||
async Vote(story_so_far, choices) {
|
||||
const choicesBlock = choices.map((c, i) => `${i}: ${(c.entry_text)}`).join('\n');
|
||||
const msg = `${this.personality}. ${this.votePrompt}\n\nStory:\n${story_so_far}\n\nChoices:\n${choicesBlock}`;
|
||||
//console.log(msg);
|
||||
const response = await this.llm.chat.completions.create({
|
||||
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
||||
messages: [{ role: 'user', content: msg }],
|
||||
async Write(storySoFar) {
|
||||
const msg = `${this.personality}.\n${this.writePrompt}\n${this.limitPrompt}\n\nStory so far:\n${storySoFar || '(the story has not started yet)'}`;
|
||||
const text = await this.complete([{ role: 'user', content: msg }], {
|
||||
temperature: this.options.temperature ?? 0.8,
|
||||
maxTokens: this.options.max_tokens ?? 80
|
||||
});
|
||||
// Strip wrapping quotes and stray newlines so entries read naturally.
|
||||
return text.replace(/^["'\s]+|["'\s]+$/g, '');
|
||||
}
|
||||
|
||||
async Vote(storySoFar, choices) {
|
||||
const list = Array.isArray(choices) ? choices : [];
|
||||
if (!list.length) return null;
|
||||
|
||||
const choicesBlock = list
|
||||
.map((choice, i) => `${choice.index ?? i}: ${choice.entry_text}`)
|
||||
.join('\n');
|
||||
const msg = `${this.personality}.\n${this.votePrompt}\n\nStory:\n${storySoFar}\n\nChoices:\n${choicesBlock}`;
|
||||
|
||||
const reply = await this.complete([{ role: 'user', content: msg }], {
|
||||
temperature: 0.2,
|
||||
max_tokens: 5,
|
||||
maxTokens: 8
|
||||
});
|
||||
const reply = response.choices[0].message.content.trim();
|
||||
const match = reply.match(/\d+/);
|
||||
return match ? parseInt(match[0], 10) : 0;
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseInt(match[0], 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async Banter(chat_so_far) {
|
||||
const msg = `${this.banterPrompt}\n\nChat so far:\n${chat_so_far}`;
|
||||
const response = await this.llm.chat.completions.create({
|
||||
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
||||
messages: [{ role: 'user', content: msg }],
|
||||
temperature: llmOptions.temperature ?? 0.8,
|
||||
max_tokens: llmOptions.max_tokens ?? 60,
|
||||
async Banter(chatSoFar) {
|
||||
const msg = `${this.banterPrompt}\n\nRecent chat:\n${chatSoFar || '(nobody has spoken yet)'}`;
|
||||
return this.complete([{ role: 'user', content: msg }], {
|
||||
temperature: this.options.temperature ?? 0.8,
|
||||
maxTokens: this.options.max_tokens ?? 60
|
||||
});
|
||||
return response.choices[0].message.content.trim();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LLMBot;
|
||||
LLMBot.DEFAULT_MODEL = DEFAULT_MODEL;
|
||||
LLMBot.CONFIG_PATH = CONFIG_PATH;
|
||||
LLMBot.PERSONALITY_PATH = PERSONALITY_PATH;
|
||||
LLMBot.readPersonalities = readPersonalities;
|
||||
LLMBot.generateBotPersonality = generateBotPersonality;
|
||||
|
||||
module.exports = LLMBot;
|
||||
+24
-21
@@ -1,19 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
class RandomBot {
|
||||
constructor() {
|
||||
this.writePhrases = [
|
||||
" Suddenly, a mysterious figure appeared from the shadows.",
|
||||
" The ground began to shake violently beneath their feet.",
|
||||
" In the distance, an eerie sound echoed through the valley.",
|
||||
" A bright light flashed, blinding everyone momentarily.",
|
||||
" The ancient artifact started glowing with an otherworldly energy."
|
||||
];
|
||||
|
||||
this.votePhrases = [
|
||||
"I think we should proceed with caution.",
|
||||
"Let's take the bold approach!",
|
||||
"The safe path seems best right now.",
|
||||
"We should explore further before deciding.",
|
||||
"My instincts tell me to go left."
|
||||
'Suddenly, a mysterious figure appeared from the shadows.',
|
||||
'The ground began to shake violently beneath their feet.',
|
||||
'In the distance, an eerie sound echoed through the valley.',
|
||||
'A bright light flashed, blinding everyone momentarily.',
|
||||
'The ancient artifact started glowing with an otherworldly energy.',
|
||||
'A gust of wind carried whispers of a forgotten warning.',
|
||||
'Somewhere nearby, a door creaked open on its own.'
|
||||
];
|
||||
|
||||
this.banterPhrases = [
|
||||
@@ -24,27 +20,34 @@ class RandomBot {
|
||||
"Remember when we used to play simple games?",
|
||||
"What's the worst that could happen?",
|
||||
"I wonder if there are cookies in this game.",
|
||||
"Do bots dream of electric sheep?",
|
||||
'Do bots dream of electric sheep?',
|
||||
"Is this thing on?",
|
||||
"I'm having a great time, how about you?"
|
||||
];
|
||||
}
|
||||
|
||||
Write(story_so_far) {
|
||||
Write() {
|
||||
const randomIndex = Math.floor(Math.random() * this.writePhrases.length);
|
||||
return this.writePhrases[randomIndex];
|
||||
}
|
||||
|
||||
Vote(story_so_far, choices) {
|
||||
// Return a random choice number (0-indexed)
|
||||
const randomChoice = Math.floor(Math.random() * choices.length);
|
||||
return randomChoice;
|
||||
/**
|
||||
* Vote for one of the supplied choices. Choices are objects of the form
|
||||
* { index, entry_text } where `index` is the real entry index; return
|
||||
* that index (never the position in the filtered list).
|
||||
*/
|
||||
Vote(storySoFar, choices) {
|
||||
const list = Array.isArray(choices) ? choices : [];
|
||||
if (!list.length) return null;
|
||||
const pick = list[Math.floor(Math.random() * list.length)];
|
||||
if (pick && Number.isInteger(pick.index)) return pick.index;
|
||||
return Number.isInteger(pick) ? pick : 0;
|
||||
}
|
||||
|
||||
Banter(chat_so_far) {
|
||||
Banter() {
|
||||
const randomIndex = Math.floor(Math.random() * this.banterPhrases.length);
|
||||
return this.banterPhrases[randomIndex];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RandomBot;
|
||||
module.exports = RandomBot;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"apiKey": "your-api-key-here",
|
||||
"baseURL": "https://api.openai.com/v1",
|
||||
"model": "meta-llama/Llama-3.2-3B-Instruct-Turbo",
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 80
|
||||
}
|
||||
Reference in new issue
Block a user