Reponses prompt updates and templates.

This commit is contained in:
jrmyr 2025-05-06 19:21:55 +00:00
parent 589360b412
commit 8231b5a105
6 changed files with 219 additions and 181 deletions

View File

@ -1,8 +1,9 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import { MessageFlags } from 'discord-api-types/v10'; import { MessageFlags } from 'discord-api-types/v10';
import { exec } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
const execAsync = promisify(exec); // Use execFile to avoid shell interpretation of arguments
const execFileAsync = promisify(execFile);
// Wrap Git errors // Wrap Git errors
class GitError extends Error { class GitError extends Error {
@ -19,12 +20,23 @@ class GitError extends Error {
* @throws {GitError} - When the git command exits with an error. * @throws {GitError} - When the git command exits with an error.
*/ */
async function runGit(args) { async function runGit(args) {
// Sanitize arguments: disallow dangerous shell metacharacters
if (!Array.isArray(args)) {
throw new GitError('Invalid git arguments');
}
const dangerous = /[;&|<>`$\\]/;
for (const arg of args) {
if (dangerous.test(arg)) {
throw new GitError(`Illegal character in git argument: ${arg}`);
}
}
try { try {
const { stdout, stderr } = await execAsync(`git ${args.join(' ')}`); // Exec git directly without shell
const out = stdout.trim() || stderr.trim(); const { stdout, stderr } = await execFileAsync('git', args);
const out = (stdout || stderr || '').toString().trim();
return out || '(no output)'; return out || '(no output)';
} catch (err) { } catch (err) {
const msg = err.stderr?.trim() || err.message; const msg = err.stderr?.toString().trim() || err.message;
throw new GitError(msg); throw new GitError(msg);
} }
} }

View File

@ -361,29 +361,6 @@ const extendPocketBase = (client, pb, logger) => {
return await pb.deleteOne('message_queue', id); return await pb.deleteOne('message_queue', id);
}; };
// ===== PUB/SUB OPERATIONS =====
/**
* Publish a message into the "message_queue" collection.
* @param {string} source - Origin identifier for the message.
* @param {string} destination - Target identifier (e.g. channel or client ID).
* @param {string} dataType - A short string describing the type of data.
* @param {object} data - The payload object to deliver.
* @returns {Promise<object>} The created message_queue record.
*/
pb.publishMessage = async (source, destination, dataType, data) => {
try {
return await pb.collection('message_queue').create({
source,
destination,
dataType,
data: JSON.stringify(data)
});
} catch (error) {
logger.error(`Failed to publish message to message_queue: ${error.message}`);
throw error;
}
};
// ===== CACHE MANAGEMENT ===== // ===== CACHE MANAGEMENT =====
@ -509,13 +486,15 @@ const setupConnectionHandling = (pb, logger) => {
logger.info('Reconnecting to PocketBase...'); logger.info('Reconnecting to PocketBase...');
// Attempt to refresh the auth if we have a refresh token // Attempt to refresh the auth if we have a refresh token
if (pb.authStore.token && pb.authStore.model?.id) { if (pb.authStore.token && pb.authStore.model?.id) {
await pb.admins.authRefresh(); // Refresh session using the configured users collection
await pb.collection('_users').authRefresh();
} else if (pb._config.username && pb._config.password) { } else if (pb._config.username && pb._config.password) {
// Fall back to full re-authentication if credentials available // Fall back to full re-authentication if credentials available
await pb.admins.authWithPassword( // Re-authenticate using the configured users collection credentials
pb._config.username, await pb.collection('_users').authWithPassword(
pb._config.password pb._config.username,
); pb._config.password
);
} else { } else {
logger.error('No credentials available to reconnect PocketBase'); logger.error('No credentials available to reconnect PocketBase');
pb.isConnected = false; pb.isConnected = false;

View File

@ -7,6 +7,7 @@
import { OpenAI } from 'openai'; import { OpenAI } from 'openai';
import axios from 'axios'; import axios from 'axios';
import { AttachmentBuilder, PermissionFlagsBits } from 'discord.js'; import { AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
import { expandTemplate } from '../_src/template.js';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
@ -132,7 +133,7 @@ async function handleImage(client, message, resp, cfg) {
if (!fn?.arguments) return false; if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`); client.logger.debug(`Image function args: ${fn.arguments}`);
let args; let args;
try { args = JSON.parse(fn.arguments); } catch { return false; } try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
if (!args.prompt?.trim()) { if (!args.prompt?.trim()) {
await message.reply('Cannot generate image: empty prompt.'); await message.reply('Cannot generate image: empty prompt.');
return true; return true;
@ -248,7 +249,6 @@ async function onMessage(client, cfg, message) {
client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`); client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`);
// Check if bot should respond, based on config (mentions/replies) // Check if bot should respond, based on config (mentions/replies)
if (!(await shouldRespond(message, botId, cfg, logger))) return; if (!(await shouldRespond(message, botId, cfg, logger))) return;
await message.channel.sendTyping();
// Determine channel/thread key for context // Determine channel/thread key for context
const key = message.thread?.id || message.channel.id; const key = message.thread?.id || message.channel.id;
@ -258,6 +258,12 @@ async function onMessage(client, cfg, message) {
const last = lockMap.get(key) || Promise.resolve(); const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence // Handler to run in sequence
const handler = async () => { const handler = async () => {
// Start typing indicator loop every 9 seconds
const typingInterval = setInterval(() => {
message.channel.sendTyping().catch(() => {});
}, 9000);
// Initial typing
message.channel.sendTyping().catch(() => {});
try { try {
// Previous response ID for context continuity // Previous response ID for context continuity
const prev = client.pb?.cache?.get(key); const prev = client.pb?.cache?.get(key);
@ -298,13 +304,33 @@ async function onMessage(client, cfg, message) {
const userInput = referencePrefix const userInput = referencePrefix
? `${referencePrefix}\n${speakerMention} said to you: ${message.content}` ? `${referencePrefix}\n${speakerMention} said to you: ${message.content}`
: `${speakerMention} said to you: ${message.content}`; : `${speakerMention} said to you: ${message.content}`;
// Prepare template context
const locationName = message.thread?.name || message.channel.name;
const locationId = message.thread?.id || message.channel.id;
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0];
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
const ctx = {
clientId: client.config.id,
userName: message.author.username,
userId: message.author.id,
userTag: message.author.tag,
// add guild context
guildName: message.guild?.name || '',
guildId: message.guild?.id || '',
input: userInput,
locationName, locationId,
date, time, datetime
};
const instructions = expandTemplate(client.responsesPrompt, ctx);
const body = { const body = {
model: cfg.defaultModel, model: cfg.defaultModel,
instructions: client.responsesPrompt, instructions,
input: userInput, input: userInput,
previous_response_id: prev, previous_response_id: prev,
max_output_tokens: cfg.defaultMaxTokens, max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature, temperature: cfg.defaultTemperature
}; };
// Assemble any enabled tools // Assemble any enabled tools
const tools = []; const tools = [];
@ -416,6 +442,8 @@ async function onMessage(client, cfg, message) {
} }
} catch (err) { } catch (err) {
logger.error(`Queued onMessage error for ${key}: ${err.message}`); logger.error(`Queued onMessage error for ${key}: ${err.message}`);
} finally {
clearInterval(typingInterval);
} }
}; };
// Chain the handler to the last promise // Chain the handler to the last promise
@ -423,39 +451,6 @@ async function onMessage(client, cfg, message) {
lockMap.set(key, next); lockMap.set(key, next);
// Queue enqueued; handler will send response when its turn arrives // Queue enqueued; handler will send response when its turn arrives
return; 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);
}
}
} }
/** /**
@ -469,13 +464,28 @@ export async function sendNarrative(client, cfg, channelId, text) {
const logger = client.logger; const logger = client.logger;
try { try {
// Build the narrative instructions // Build the narrative instructions
const instructions = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`; // Expand template for sendNarrative
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0];
const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
const ctx = {
clientId: client.config.id,
userName: client.user.username,
userId: client.user.id,
input: text,
locationName: channel.name,
locationId: channel.id,
date, time, datetime
};
const raw = `${client.responsesPrompt}\n\nGenerate the following as an engaging narrative:`;
const instructions = expandTemplate(raw, ctx);
const body = { const body = {
model: cfg.defaultModel, model: cfg.defaultModel,
instructions, instructions,
input: text, input: text,
max_output_tokens: cfg.defaultMaxTokens, max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature, temperature: cfg.defaultTemperature
}; };
logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`); logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`);
const resp = await client.openai.responses.create(body); const resp = await client.openai.responses.create(body);

View File

@ -1,161 +1,153 @@
import { MessageFlags } from 'discord-api-types/v10';
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import fetch from 'node-fetch'; // Placeholder info for template variables
const TEMPLATE_KEYS_INFO = 'Available keys: userName, userId, locationName, locationId, date, time, datetime, clientId';
// Modal text input limits // Modal text input limits
const MAX_LEN = 4000; const MAX_LEN = 4000;
const MAX_FIELDS = 5; const MAX_FIELDS = 5;
/** /**
* responsesPrompt module * responsesPrompt module
* Provides a /prompt command to view or update the AI response prompt * Implements `/prompt [version]` to edit the current or historical prompt in a single PocketBase collection.
* Stored in PocketBase collection 'responses_prompts' with fields: * responses_prompts collection holds all versions; newest record per client is the live prompt.
* clientId (string), prompt (text), updatedBy (string), created/updated timestamps
*/ */
export const commands = [ export const commands = [
{ {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('prompt') .setName('prompt')
.setDescription('View or update the AI response prompt') .setDescription('Edit the AI response prompt (current or past version)')
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false) .setDMPermission(false)
.addStringOption(opt => .addStringOption(opt =>
opt.setName('url') opt.setName('version')
.setDescription('URL to a .txt file containing the prompt') .setDescription('ID of a past prompt version to load')
.setRequired(false) .setRequired(false)
.setAutocomplete(true)
), ),
async execute(interaction, client) { async execute(interaction, client) {
const url = interaction.options.getString('url');
const clientId = client.config.id; const clientId = client.config.id;
// URL-based update const versionId = interaction.options.getString('version');
if (url) { // Fetch prompt: live latest or selected historic
client.logger.info(`[cmd:prompt] URL update requested for client ${clientId}: ${url}`); let promptText = client.responsesPrompt || '';
await interaction.deferReply({ flags: MessageFlags.Ephemeral}); if (versionId) {
if (!url.toLowerCase().endsWith('.txt')) {
client.logger.warn(`[cmd:prompt] Invalid URL extension, must end .txt: ${url}`);
return interaction.editReply({ content: 'URL must point to a .txt file.', flags: MessageFlags.Ephemeral});
}
try { try {
const res = await fetch(url); const rec = await client.pb.getOne('responses_prompts', versionId);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (rec?.prompt) promptText = rec.prompt;
const text = await res.text();
// Upsert into PocketBase
const existing = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`);
const recId = existing?.id;
await client.pb.upsert('responses_prompts', recId, {
clientId,
prompt: text,
updatedBy: interaction.user.id
});
client.responsesPrompt = text;
return interaction.editReply({ content: 'Prompt updated from URL.', flags: MessageFlags.Ephemeral});
} catch (err) { } catch (err) {
client.logger.error(`[cmd:prompt] URL update failed: ${err.message}`); client.logger.error(`Failed to load prompt version ${versionId}: ${err.message}`);
return interaction.editReply({ content: `Error fetching URL: ${err.message}`, ephemeral: true });
} }
} }
// Modal-based edit // Prepare modal fields: one SHORT help, then paragraph chunks
const existingPrompt = client.responsesPrompt || ''; // Help field
// Prevent modal if prompt exceeds capacity const helpField = new TextInputBuilder()
if (existingPrompt.length > MAX_LEN * MAX_FIELDS) { .setCustomId('template_help')
return interaction.reply({ .setLabel('Template variables (no edits)')
content: 'Current prompt too large for modal editing (exceeds 20000 chars); please use URL.', .setStyle(TextInputStyle.Short)
ephemeral: true .setRequired(false)
}); // prefill with the list of usable keys
} .setValue(TEMPLATE_KEYS_INFO);
// Prepare chunks
const chunks = [];
for (let offset = 0; offset < existingPrompt.length; offset += MAX_LEN) {
chunks.push(existingPrompt.slice(offset, offset + MAX_LEN));
}
// Build modal
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId(`promptModal-${clientId}`) .setCustomId(`promptModal-${versionId || 'current'}`)
.setTitle('Edit AI Prompt'); .setTitle('Edit AI Prompt')
// Add text inputs for existing chunks .addComponents(new ActionRowBuilder().addComponents(helpField));
chunks.forEach((chunk, idx) => { // Prompt chunks
const chunks = [];
for (let off = 0; off < promptText.length && chunks.length < MAX_FIELDS - 1; off += MAX_LEN) {
chunks.push(promptText.slice(off, off + MAX_LEN));
}
chunks.forEach((text, idx) => {
const input = new TextInputBuilder() const input = new TextInputBuilder()
.setCustomId(`prompt_${idx}`) .setCustomId(`prompt_${idx}`)
.setLabel(`Part ${idx + 1}`) .setLabel(`Part ${idx + 1}`)
.setStyle(TextInputStyle.Paragraph) .setStyle(TextInputStyle.Paragraph)
.setRequired(idx === 0) .setRequired(idx === 0)
.setMaxLength(MAX_LEN) .setMaxLength(MAX_LEN)
.setValue(chunk); .setValue(text);
modal.addComponents(new ActionRowBuilder().addComponents(input)); modal.addComponents(new ActionRowBuilder().addComponents(input));
}); });
// Add one extra empty field to allow expansion (up to MAX_FIELDS) // Empty fields to fill out to MAX_FIELDS
if (chunks.length < MAX_FIELDS) { for (let i = chunks.length; i < MAX_FIELDS - 1; i++) {
const i = chunks.length; modal.addComponents(new ActionRowBuilder().addComponents(
const input = new TextInputBuilder() new TextInputBuilder()
.setCustomId(`prompt_${i}`) .setCustomId(`prompt_${i}`)
.setLabel(`Part ${i + 1}`) .setLabel(`Part ${i + 1}`)
.setStyle(TextInputStyle.Paragraph) .setStyle(TextInputStyle.Paragraph)
.setRequired(false) .setRequired(false)
.setMaxLength(MAX_LEN); .setMaxLength(MAX_LEN)
modal.addComponents(new ActionRowBuilder().addComponents(input)); ));
} }
client.logger.info(`[cmd:prompt] Showing modal editor for client ${clientId} with ${chunks.length} parts`);
await interaction.showModal(modal); await interaction.showModal(modal);
} }
} }
]; ];
/** track clients for modal handling */ // Store clients for event hooks
const _clients = []; const _clients = [];
/**
* init hook: load existing prompt and register modal submit handler
*/
export async function init(client, clientConfig) { export async function init(client, clientConfig) {
client.logger.info('[module:responsesPrompt] Module loaded');
const clientId = clientConfig.id; const clientId = clientConfig.id;
// Load prompt from PocketBase or fallback to file client.logger.info('[module:responsesPrompt] initialized');
let prompt = ''; // Load live prompt (latest version)
try { try {
const record = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`); const { items } = await client.pb.collection('responses_prompts')
if (record && record.prompt) { .getList(1, 1, { filter: `clientId="${clientId}"`, sort: '-created' });
prompt = record.prompt; client.responsesPrompt = items[0]?.prompt || '';
} else if (clientConfig.responses?.systemPromptPath) {
const filePath = path.isAbsolute(clientConfig.responses.systemPromptPath)
? clientConfig.responses.systemPromptPath
: path.join(process.cwd(), clientConfig.responses.systemPromptPath);
prompt = fs.readFileSync(filePath, 'utf8');
}
} catch (err) { } catch (err) {
client.logger.error(`responsesPrompt init error: ${err.message}`); client.logger.error(`Error loading current prompt: ${err.message}`);
client.responsesPrompt = '';
} }
client.responsesPrompt = prompt;
_clients.push({ client, clientConfig }); _clients.push({ client, clientConfig });
// Modal submit listener // Autocomplete versions
client.on('interactionCreate', async interaction => {
if (!interaction.isAutocomplete() || interaction.commandName !== 'prompt') return;
const focused = interaction.options.getFocused(true);
if (focused.name === 'version') {
try {
const { items } = await client.pb.collection('responses_prompts')
.getList(1, 25, { filter: `clientId="${clientId}"`, sort: '-created' });
const choices = items.map(r => ({ name: new Date(r.created).toLocaleString(), value: r.id }));
await interaction.respond(choices);
} catch (err) {
client.logger.error(`Prompt autocomplete error: ${err.message}`);
await interaction.respond([]);
}
}
});
// Modal submission: save new version & prune old
client.on('interactionCreate', async interaction => { client.on('interactionCreate', async interaction => {
if (!interaction.isModalSubmit()) return; if (!interaction.isModalSubmit()) return;
if (interaction.customId !== `promptModal-${clientId}`) return; const id = interaction.customId;
// Reassemble prompt from modal fields, inserting line breaks between parts if (!id.startsWith('promptModal-')) return;
client.logger.info(`[cmd:prompt] Modal submission received for client ${clientId}`);
const parts = []; const parts = [];
for (let i = 0; i < MAX_FIELDS; i++) { for (let i = 0; i < MAX_FIELDS; i++) {
try { try {
const value = interaction.fields.getTextInputValue(`prompt_${i}`) || ''; const v = interaction.fields.getTextInputValue(`prompt_${i}`) || '';
if (value.trim().length > 0) parts.push(value); if (v.trim()) parts.push(v);
} catch {} } catch {}
} }
const newPrompt = parts.join('\n'); const newPrompt = parts.join('\n');
// Save to PocketBase // Persist new version
let newRec;
try { try {
client.logger.debug(`[cmd:prompt] Saving new prompt for client ${clientId}, length=${newPrompt.length}`); newRec = await client.pb.createOne('responses_prompts', { clientId, prompt: newPrompt, updatedBy: interaction.user.id });
const existing = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`);
const recId = existing?.id;
await client.pb.upsert('responses_prompts', recId, {
clientId,
prompt: newPrompt,
updatedBy: interaction.user.id
});
client.responsesPrompt = newPrompt; client.responsesPrompt = newPrompt;
await interaction.reply({ content: 'Prompt updated!', flags: MessageFlags.Ephemeral});
} catch (err) { } catch (err) {
client.logger.error(`responsesPrompt modal submit error: ${err.message}`); client.logger.error(`Failed to save prompt: ${err.message}`);
await interaction.reply({ content: `Error saving prompt: ${err.message}`, ephemeral: true }); return interaction.reply({ content: `Error saving prompt: ${err.message}`, ephemeral: true });
} }
// Prune older versions beyond the 10 most recent
try {
const { items } = await client.pb.collection('responses_prompts')
.getList(1, 100, { filter: `clientId="${clientId}"`, sort: '-created' });
const toDelete = items.map(r => r.id).slice(10);
for (const id of toDelete) {
await client.pb.deleteOne('responses_prompts', id);
}
} catch (err) {
client.logger.error(`Failed to prune old prompts: ${err.message}`);
}
await interaction.reply({ content: 'Prompt saved!', ephemeral: true });
}); });
} }

View File

@ -5,6 +5,7 @@ import { MessageFlags } from 'discord-api-types/v10';
* including optional image generation function calls. * including optional image generation function calls.
*/ */
import { SlashCommandBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js'; import { SlashCommandBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
import { expandTemplate } from '../_src/template.js';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import axios from 'axios'; import axios from 'axios';
@ -47,7 +48,7 @@ async function handleImageInteraction(client, interaction, resp, cfg, ephemeral)
if (!fn?.arguments) return false; if (!fn?.arguments) return false;
client.logger.debug(`Image function args: ${fn.arguments}`); client.logger.debug(`Image function args: ${fn.arguments}`);
let args; let args;
try { args = JSON.parse(fn.arguments); } catch { return false; } try { args = JSON.parse(fn.arguments); } catch (e) { return false; }
if (!args.prompt?.trim()) { if (!args.prompt?.trim()) {
await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral }); await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral });
return true; return true;
@ -206,17 +207,42 @@ export const commands = [
const last = lockMap.get(key) || Promise.resolve(); const last = lockMap.get(key) || Promise.resolve();
// Handler to run in sequence // Handler to run in sequence
const handler = async () => { const handler = async () => {
// Read previous response ID // Kick off a repeated typing indicator during processing
const typingInterval = setInterval(() => interaction.channel.sendTyping().catch(() => {}), 9000);
// initial typing
interaction.channel.sendTyping().catch(() => {});
// Read previous response ID
const previous = client.pb?.cache?.get(key); const previous = client.pb?.cache?.get(key);
// Build request body // Build request body
const body = { // Expand template for query
model: cfg.defaultModel, const now = new Date();
instructions: client.responsesPrompt, const date = now.toISOString().split('T')[0];
input: prompt, const time = now.toTimeString().split(' ')[0];
previous_response_id: previous, const datetime = now.toISOString().replace('T',' ').replace(/\..+$/,'');
max_output_tokens: cfg.defaultMaxTokens, const channel = await client.channels.fetch(interaction.channelId);
temperature: cfg.defaultTemperature, const locationName = channel.name;
}; const locationId = channel.id;
const ctx = {
clientId: client.config.id,
userName: interaction.user.username,
userId: interaction.user.id,
userTag: interaction.user.tag,
// add guild context
guildName: interaction.guild?.name || '',
guildId: interaction.guild?.id || '',
input: prompt,
locationName, locationId,
date, time, datetime
};
const instructions = expandTemplate(client.responsesPrompt, ctx);
const body = {
model: cfg.defaultModel,
instructions,
input: prompt,
previous_response_id: previous,
max_output_tokens: cfg.defaultMaxTokens,
temperature: cfg.defaultTemperature
};
// Assemble enabled tools // Assemble enabled tools
const tools = []; const tools = [];
if (cfg.tools?.imageGeneration) { if (cfg.tools?.imageGeneration) {
@ -292,6 +318,7 @@ export const commands = [
} }
} catch (err) { } catch (err) {
client.logger.error(`AI error in /query: ${err.message}`); client.logger.error(`AI error in /query: ${err.message}`);
clearInterval(typingInterval);
return interaction.editReply({ content: 'Error generating response.', ephemeral }); return interaction.editReply({ content: 'Error generating response.', ephemeral });
} }
@ -303,11 +330,13 @@ export const commands = [
// Handle image function call if present // Handle image function call if present
if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) { if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) {
clearInterval(typingInterval);
return; return;
} }
// Send text reply chunks // Send text reply chunks
const text = resp.output_text?.trim() || ''; const text = resp.output_text?.trim() || '';
if (!text) { if (!text) {
clearInterval(typingInterval);
return interaction.editReply({ content: 'No response generated.', ephemeral }); return interaction.editReply({ content: 'No response generated.', ephemeral });
} }
const chunks = splitLongMessage(text, 2000); const chunks = splitLongMessage(text, 2000);
@ -318,6 +347,7 @@ export const commands = [
await interaction.followUp({ content: chunks[i], ephemeral }); await interaction.followUp({ content: chunks[i], ephemeral });
} }
} }
clearInterval(typingInterval);
}; };
// Chain handler after last and await // Chain handler after last and await
const next = last.then(handler).catch(err => client.logger.error(`Queued /query error for ${key}: ${err.message}`)); const next = last.then(handler).catch(err => client.logger.error(`Queued /query error for ${key}: ${err.message}`));

15
_src/template.js Normal file
View File

@ -0,0 +1,15 @@
"use strict";
/**
* expandTemplate: simple variable substitution in {{key}} placeholders.
* @param {string} template - The template string with {{key}} tokens.
* @param {object} context - Mapping of key -> replacement value.
* @returns {string} - The template with keys replaced by context values.
*/
export function expandTemplate(template, context) {
if (typeof template !== 'string') return '';
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_match, key) => {
return Object.prototype.hasOwnProperty.call(context, key)
? String(context[key])
: '';
});
}