157 lines
5.6 KiB
JavaScript
157 lines
5.6 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const OpenAI = require('openai');
|
|
|
|
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');
|
|
|
|
const FALLBACK_PERSONALITY = 'You are a friendly bot playing a collaborative story game';
|
|
|
|
// 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((line) => line.trim())
|
|
.filter((line) => line.length > 0);
|
|
} catch (err) {
|
|
console.warn(`[LLMBot] Could not read ${file}: ${err.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
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(config) {
|
|
const configPathExists = fs.existsSync(CONFIG_PATH);
|
|
if (configPathExists) console.log(`[LLMBot] Read config from ${CONFIG_PATH}`);
|
|
|
|
this.options = config && typeof config === 'object' ? config : loadConfig();
|
|
this.model = this.options.model || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
|
|
|
// Throws if OPENAI_API_KEY (or an apiKey in llm_config.json) is missing.
|
|
this.llm = new OpenAI(this.options);
|
|
|
|
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.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.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 complete(messages, { temperature = 0.8, maxTokens = 80 } = {}) {
|
|
const response = await this.llm.chat.completions.create({
|
|
model: this.model,
|
|
messages,
|
|
temperature,
|
|
max_tokens: maxTokens
|
|
});
|
|
return firstContent(response);
|
|
}
|
|
|
|
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,
|
|
maxTokens: 8
|
|
});
|
|
const match = reply.match(/\d+/);
|
|
if (!match) return null;
|
|
const parsed = Number.parseInt(match[0], 10);
|
|
return Number.isInteger(parsed) ? parsed : null;
|
|
}
|
|
|
|
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
|
|
});
|
|
}
|
|
}
|
|
|
|
LLMBot.DEFAULT_MODEL = DEFAULT_MODEL;
|
|
LLMBot.CONFIG_PATH = CONFIG_PATH;
|
|
LLMBot.PERSONALITY_PATH = PERSONALITY_PATH;
|
|
LLMBot.readPersonalities = readPersonalities;
|
|
LLMBot.generateBotPersonality = generateBotPersonality;
|
|
|
|
module.exports = LLMBot;
|