ClientX/_opt/responses.js

400 lines
14 KiB
JavaScript
Raw Normal View History

2025-04-25 21:27:00 -04:00
// _opt/responses.js
// Simplified OpenAI Responses module with clear flow and context management
import fs from 'fs/promises';
import path from 'path';
import { OpenAI } from 'openai';
import axios from 'axios';
import { AttachmentBuilder } from 'discord.js';
// Discord message max length
const MAX_DISCORD_MSG_LENGTH = 2000;
/**
* Split a long message into chunks <= maxLength, preserving code fences by closing and reopening.
* @param {string} text
* @param {number} maxLength
* @returns {string[]}
*/
function splitMessage(text, maxLength = MAX_DISCORD_MSG_LENGTH) {
const lines = text.split(/\n/);
const chunks = [];
let chunk = '';
let codeBlockOpen = false;
let codeBlockFence = '```';
for (let line of lines) {
const trimmed = line.trim();
const isFenceLine = trimmed.startsWith('```');
if (isFenceLine) {
if (!codeBlockOpen) {
codeBlockOpen = true;
codeBlockFence = trimmed;
} else if (trimmed === '```') {
// closing fence
codeBlockOpen = false;
}
}
// include the newline that was removed by split
const segment = line + '\n';
// if adding segment exceeds limit
if (chunk.length + segment.length > maxLength) {
if (chunk.length > 0) {
// close open code block if needed
if (codeBlockOpen) chunk += '\n```';
chunks.push(chunk);
// start new chunk, reopen code block if needed
chunk = codeBlockOpen ? (codeBlockFence + '\n' + segment) : segment;
continue;
}
// single segment too long, split it directly
let rest = segment;
while (rest.length > maxLength) {
let part = rest.slice(0, maxLength);
if (codeBlockOpen) part += '\n```';
chunks.push(part);
rest = codeBlockOpen ? (codeBlockFence + '\n' + rest.slice(maxLength)) : rest.slice(maxLength);
}
chunk = rest;
continue;
}
chunk += segment;
}
if (chunk) {
// close any unclosed code block
if (codeBlockOpen) chunk += '\n```';
chunks.push(chunk);
}
// remove trailing newline from each chunk
return chunks.map(c => c.endsWith('\n') ? c.slice(0, -1) : c);
}
/**
* Load the system prompt from disk.
*/
async function loadSystemPrompt(filePath, logger) {
try {
const prompt = await fs.readFile(path.resolve(filePath), 'utf8');
logger.info(`Loaded system prompt: ${filePath}`);
return prompt;
} catch (err) {
logger.error(`Failed to load system prompt: ${err.message}`);
return '';
}
}
/**
* Determine if the bot should respond:
* - Mentioned
* - Direct reply
*/
async function shouldRespond(message, botId, logger) {
if (message.author.bot || !botId) return false;
const isMention = message.mentions.users.has(botId);
let isReply = false;
if (message.reference?.messageId) {
try {
const ref = await message.channel.messages.fetch(message.reference.messageId);
isReply = ref.author.id === botId;
} catch {}
}
logger.debug(`Trigger? mention=${isMention} reply=${isReply}`);
return isMention || isReply;
}
/**
* Cache the last response ID for context continuity.
*/
function cacheResponse(client, key, id, ttlSeconds) {
client.pb?.cache?.set(key, id, ttlSeconds);
}
/**
* Award output tokens via the scorekeeper module.
*/
function awardOutput(client, guildId, userId, amount) {
if (client.scorekeeper && amount > 0) {
client.scorekeeper.addOutput(guildId, userId, amount)
.catch(err => client.logger.error(`Scorekeeper error: ${err.message}`));
}
}
/**
* Handle image generation function calls.
* Returns true if an image was handled and replied.
*/
async function handleImage(client, message, resp, cfg) {
const calls = Array.isArray(resp.output) ? resp.output : [];
const fn = calls.find(o => o.type === 'function_call' && o.name === 'generate_image');
if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`);
let args;
try { args = JSON.parse(fn.arguments); } catch { return false; }
if (!args.prompt?.trim()) {
await message.reply('Cannot generate image: empty prompt.');
return true;
}
// Determine image size based on aspect: square, landscape, or portrait
// Square will always use 1024x1024
let size;
switch (args.aspect) {
case 'landscape':
size = '1792x1024';
break;
case 'portrait':
size = '1024x1792';
break;
case 'square':
size = '1024x1024';
break;
default:
size = '1024x1024';
}
// Determine image quality, defaulting to cfg.imageGeneration.defaultQuality
const quality = ['standard', 'hd'].includes(args.quality)
? args.quality
: cfg.imageGeneration.defaultQuality;
try {
// Generate image via OpenAI
const imgRes = await client.openai.images.generate({ model: 'dall-e-3', prompt: args.prompt, quality: quality, size: size, n: 1 });
const url = imgRes.data?.[0]?.url;
if (!url) throw new Error('No image URL');
// Download and save locally
const dl = await axios.get(url, { responseType: 'arraybuffer' });
const buf = Buffer.from(dl.data);
const filename = `${message.author.id}-${Date.now()}.png`;
const dir = cfg.imageGeneration.imageSavePath || './images';
await fs.mkdir(dir, { recursive: true });
const filePath = path.join(dir, filename);
await fs.writeFile(filePath, buf);
client.logger.info(`Saved image: ${filePath}`);
// Reply with attachment
const attachment = new AttachmentBuilder(buf, { name: filename });
await message.reply({ content: args.prompt, files: [attachment] });
// Follow-up recap to preserve conversation context (submit function tool output)
try {
const convKey = message.thread?.id || message.channel.id;
// Build a function_call_output input item for the Responses API
const toolOutputItem = {
type: 'function_call_output',
call_id: fn.call_id,
output: JSON.stringify({ url }),
};
const recapBody = {
model: cfg.defaultModel,
// re-use original system/developer instructions
instructions: client.responsesSystemPrompt,
previous_response_id: resp.id,
input: [toolOutputItem],
max_output_tokens: Math.min(100, cfg.defaultMaxTokens),
temperature: cfg.defaultTemperature,
};
const recapResp = await client.openai.responses.create(recapBody);
cacheResponse(client, convKey, recapResp.id, Math.floor(cfg.conversationExpiry / 1000));
// Award tokens for the recap chat response
const recapTokens = recapResp.usage?.total_tokens ?? recapResp.usage?.completion_tokens ?? 0;
awardOutput(client, message.guild.id, message.author.id, recapTokens);
} catch (err) {
client.logger.error(`Recap failed: ${err.message}`);
}
} catch (err) {
client.logger.error(`Image error: ${err.message}`);
await message.reply(`Image generation error: ${err.message}`);
}
return true;
}
/**
* Main message handler:
* 1. Determine if bot should respond
* 2. Build and send AI request
* 3. Cache response ID
* 4. Handle image or text reply
* 5. Award output points
*/
async function onMessage(client, cfg, message) {
const logger = client.logger;
const botId = client.user?.id;
if (!(await shouldRespond(message, botId, logger))) return;
await message.channel.sendTyping();
// Determine channel/thread key for context
const key = message.thread?.id || message.channel.id;
// Initialize per-channel lock map
const lockMap = client._responseLockMap || (client._responseLockMap = new Map());
// Get last pending promise for this key
const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence
const handler = async () => {
try {
// Previous response ID for context continuity
const prev = client.pb?.cache?.get(key);
// Enforce minimum score to use AI responses
try {
const scoreData = await client.scorekeeper.getScore(message.guild.id, message.author.id);
if (scoreData.totalScore < cfg.minScore) {
await message.reply(
`You need a score of at least ${cfg.minScore} to use AI responses. Your current score is ${scoreData.totalScore.toFixed(2)}.`
);
return;
}
} catch (err) {
client.logger.error(`Error checking score: ${err.message}`);
}
// Build request body, prefixing with a mention of who spoke
const speakerMention = `<@${message.author.id}>`;
const body = {
model: cfg.defaultModel,
instructions: client.responsesSystemPrompt,
2025-04-26 21:59:30 +00:00
input: `${speakerMention} said to you: ${message.content}`,
2025-04-25 21:27:00 -04:00
previous_response_id: prev,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature,
};
// Assemble any enabled tools
const tools = [];
if (cfg.tools?.imageGeneration) {
tools.push({
type: 'function',
name: 'generate_image',
description: 'Generate an image with a given prompt, aspect, and quality.',
parameters: {
type: 'object',
properties: {
prompt: { type: 'string' },
aspect: { type: 'string', enum: ['square','portrait','landscape'] },
quality: { type: 'string', enum: ['standard', 'hd'] },
},
required: ['prompt','aspect','quality'],
additionalProperties: false,
},
strict: true
});
}
if (cfg.tools?.webSearch) {
tools.push({ type: 'web_search_preview' });
}
if (tools.length) {
body.tools = tools;
}
// Call OpenAI Responses
logger.debug(`Calling AI with body: ${JSON.stringify(body)}`);
const resp = await client.openai.responses.create(body);
logger.info(`AI response id=${resp.id}`);
// Award tokens for the AI chat response
const chatTokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
awardOutput(client, message.guild.id, message.author.id, chatTokens);
// Cache response ID if not a function call
const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call');
if (!isFuncCall && resp.id && cfg.conversationExpiry) {
cacheResponse(client, key, resp.id, Math.floor(cfg.conversationExpiry / 1000));
}
// Handle image function call if present
if (await handleImage(client, message, resp, cfg)) return;
// Otherwise reply with text
const text = resp.output_text?.trim();
if (text) {
const parts = splitMessage(text, MAX_DISCORD_MSG_LENGTH);
for (const part of parts) {
await message.reply(part);
}
}
} catch (err) {
logger.error(`Queued onMessage error for ${key}: ${err.message}`);
}
};
// Chain the handler to the last promise
const next = last.then(handler).catch(err => logger.error(err));
lockMap.set(key, next);
// Queue enqueued; handler will send response when its turn arrives
return;
// Call OpenAI Responses
let resp;
try {
logger.debug(`Calling AI with body: ${JSON.stringify(body)}`);
resp = await client.openai.responses.create(body);
logger.info(`AI response id=${resp.id}`);
// Award tokens for the AI chat response immediately (captures token usage even if image follows)
const chatTokens = resp.usage?.total_tokens ?? resp.usage?.completion_tokens ?? 0;
awardOutput(client, message.guild.id, message.author.id, chatTokens);
} catch (err) {
logger.error(`AI error: ${err.message}`);
return message.reply('Error generating response.');
}
// Cache for next turn only if this was a text response
const isFuncCall = Array.isArray(resp.output) && resp.output.some(o => o.type === 'function_call');
if (!isFuncCall && resp.id && cfg.conversationExpiry) {
cacheResponse(client, key, resp.id, Math.floor(cfg.conversationExpiry / 1000));
}
// Handle image function call if present
if (await handleImage(client, message, resp, cfg)) return;
// Otherwise reply with text (split if over Discord limit)
const text = resp.output_text?.trim();
if (text) {
const parts = splitMessage(text, MAX_DISCORD_MSG_LENGTH);
for (const part of parts) {
await message.reply(part);
}
}
}
/**
* Send a narrative response to a specific Discord channel or thread.
* @param {import('discord.js').Client} client - Discord client instance.
* @param {Object} cfg - Responses module configuration.
* @param {string} channelId - ID of the Discord channel or thread.
* @param {string} text - Narrative input text to process.
*/
export async function sendNarrative(client, cfg, channelId, text) {
const logger = client.logger;
try {
// Build the narrative instructions
const instructions = `${client.responsesSystemPrompt}\n\nGenerate the following as an engaging narrative:`;
const body = {
model: cfg.defaultModel,
instructions,
input: text,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature,
};
logger.debug('sendNarrative: calling AI with body', body);
const resp = await client.openai.responses.create(body);
logger.info(`sendNarrative AI response id=${resp.id}`);
// Fetch the target channel or thread
const channel = await client.channels.fetch(channelId);
if (!channel || typeof channel.send !== 'function') {
logger.error(`sendNarrative: cannot send to channel ID ${channelId}`);
return;
}
// Split the output and send
const content = resp.output_text?.trim();
if (content) {
const parts = splitMessage(content, MAX_DISCORD_MSG_LENGTH);
for (const part of parts) {
await channel.send(part);
}
}
} catch (err) {
client.logger.error(`sendNarrative error: ${err.message}`);
}
}
/**
* Initialize the Responses module
*/
export async function init(client, clientConfig) {
const cfg = clientConfig.responses;
client.logger.info('Initializing Responses module');
client.responsesSystemPrompt = await loadSystemPrompt(cfg.systemPromptPath, client.logger);
client.openai = new OpenAI({ apiKey: cfg.apiKey });
client.on('messageCreate', m => onMessage(client, cfg, m));
client.logger.info('Responses module ready');
}