Reponses prompt updates and templates.
This commit is contained in:
parent
589360b412
commit
8231b5a105
@ -1,8 +1,9 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { MessageFlags } from 'discord-api-types/v10';
|
||||
import { exec } from 'child_process';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
const execAsync = promisify(exec);
|
||||
// Use execFile to avoid shell interpretation of arguments
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Wrap Git errors
|
||||
class GitError extends Error {
|
||||
@ -19,12 +20,23 @@ class GitError extends Error {
|
||||
* @throws {GitError} - When the git command exits with an error.
|
||||
*/
|
||||
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 {
|
||||
const { stdout, stderr } = await execAsync(`git ${args.join(' ')}`);
|
||||
const out = stdout.trim() || stderr.trim();
|
||||
// Exec git directly without shell
|
||||
const { stdout, stderr } = await execFileAsync('git', args);
|
||||
const out = (stdout || stderr || '').toString().trim();
|
||||
return out || '(no output)';
|
||||
} catch (err) {
|
||||
const msg = err.stderr?.trim() || err.message;
|
||||
const msg = err.stderr?.toString().trim() || err.message;
|
||||
throw new GitError(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@ -361,29 +361,6 @@ const extendPocketBase = (client, pb, logger) => {
|
||||
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 =====
|
||||
|
||||
@ -509,10 +486,12 @@ const setupConnectionHandling = (pb, logger) => {
|
||||
logger.info('Reconnecting to PocketBase...');
|
||||
// Attempt to refresh the auth if we have a refresh token
|
||||
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) {
|
||||
// Fall back to full re-authentication if credentials available
|
||||
await pb.admins.authWithPassword(
|
||||
// Re-authenticate using the configured users collection credentials
|
||||
await pb.collection('_users').authWithPassword(
|
||||
pb._config.username,
|
||||
pb._config.password
|
||||
);
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
import { OpenAI } from 'openai';
|
||||
import axios from 'axios';
|
||||
import { AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
|
||||
import { expandTemplate } from '../_src/template.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
@ -132,7 +133,7 @@ async function handleImage(client, message, resp, cfg) {
|
||||
if (!fn?.arguments) return false;
|
||||
client.logger.debug(`Image function args: ${fn.arguments}`);
|
||||
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()) {
|
||||
await message.reply('Cannot generate image: empty prompt.');
|
||||
return true;
|
||||
@ -248,7 +249,6 @@ async function onMessage(client, cfg, message) {
|
||||
client.logger.debug(`[onMessage] Received message ${message.id} from ${message.author.id}`);
|
||||
// Check if bot should respond, based on config (mentions/replies)
|
||||
if (!(await shouldRespond(message, botId, cfg, logger))) return;
|
||||
await message.channel.sendTyping();
|
||||
|
||||
// Determine channel/thread key for context
|
||||
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();
|
||||
// Handler to run in sequence
|
||||
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 {
|
||||
// Previous response ID for context continuity
|
||||
const prev = client.pb?.cache?.get(key);
|
||||
@ -298,13 +304,33 @@ async function onMessage(client, cfg, message) {
|
||||
const userInput = referencePrefix
|
||||
? `${referencePrefix}\n${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 = {
|
||||
model: cfg.defaultModel,
|
||||
instructions: client.responsesPrompt,
|
||||
instructions,
|
||||
input: userInput,
|
||||
previous_response_id: prev,
|
||||
max_output_tokens: cfg.defaultMaxTokens,
|
||||
temperature: cfg.defaultTemperature,
|
||||
temperature: cfg.defaultTemperature
|
||||
};
|
||||
// Assemble any enabled tools
|
||||
const tools = [];
|
||||
@ -416,6 +442,8 @@ async function onMessage(client, cfg, message) {
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Queued onMessage error for ${key}: ${err.message}`);
|
||||
} finally {
|
||||
clearInterval(typingInterval);
|
||||
}
|
||||
};
|
||||
// Chain the handler to the last promise
|
||||
@ -423,39 +451,6 @@ async function onMessage(client, cfg, message) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -469,13 +464,28 @@ export async function sendNarrative(client, cfg, channelId, text) {
|
||||
const logger = client.logger;
|
||||
try {
|
||||
// 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 = {
|
||||
model: cfg.defaultModel,
|
||||
instructions,
|
||||
input: text,
|
||||
max_output_tokens: cfg.defaultMaxTokens,
|
||||
temperature: cfg.defaultTemperature,
|
||||
temperature: cfg.defaultTemperature
|
||||
};
|
||||
logger.debug(`[sendNarrative] Calling AI with body: ${JSON.stringify(body).slice(0,1000)}`);
|
||||
const resp = await client.openai.responses.create(body);
|
||||
|
||||
@ -1,161 +1,153 @@
|
||||
import { MessageFlags } from 'discord-api-types/v10';
|
||||
import { SlashCommandBuilder, PermissionFlagsBits, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } from 'discord.js';
|
||||
import fs from 'fs';
|
||||
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
|
||||
const MAX_LEN = 4000;
|
||||
const MAX_FIELDS = 5;
|
||||
|
||||
/**
|
||||
* responsesPrompt module
|
||||
* Provides a /prompt command to view or update the AI response prompt
|
||||
* Stored in PocketBase collection 'responses_prompts' with fields:
|
||||
* clientId (string), prompt (text), updatedBy (string), created/updated timestamps
|
||||
* Implements `/prompt [version]` to edit the current or historical prompt in a single PocketBase collection.
|
||||
* responses_prompts collection holds all versions; newest record per client is the live prompt.
|
||||
*/
|
||||
export const commands = [
|
||||
{
|
||||
data: new SlashCommandBuilder()
|
||||
.setName('prompt')
|
||||
.setDescription('View or update the AI response prompt')
|
||||
.setDescription('Edit the AI response prompt (current or past version)')
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||
.setDMPermission(false)
|
||||
.addStringOption(opt =>
|
||||
opt.setName('url')
|
||||
.setDescription('URL to a .txt file containing the prompt')
|
||||
opt.setName('version')
|
||||
.setDescription('ID of a past prompt version to load')
|
||||
.setRequired(false)
|
||||
.setAutocomplete(true)
|
||||
),
|
||||
async execute(interaction, client) {
|
||||
const url = interaction.options.getString('url');
|
||||
const clientId = client.config.id;
|
||||
// URL-based update
|
||||
if (url) {
|
||||
client.logger.info(`[cmd:prompt] URL update requested for client ${clientId}: ${url}`);
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral});
|
||||
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});
|
||||
}
|
||||
const versionId = interaction.options.getString('version');
|
||||
// Fetch prompt: live latest or selected historic
|
||||
let promptText = client.responsesPrompt || '';
|
||||
if (versionId) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
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});
|
||||
const rec = await client.pb.getOne('responses_prompts', versionId);
|
||||
if (rec?.prompt) promptText = rec.prompt;
|
||||
} catch (err) {
|
||||
client.logger.error(`[cmd:prompt] URL update failed: ${err.message}`);
|
||||
return interaction.editReply({ content: `Error fetching URL: ${err.message}`, ephemeral: true });
|
||||
client.logger.error(`Failed to load prompt version ${versionId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// Modal-based edit
|
||||
const existingPrompt = client.responsesPrompt || '';
|
||||
// Prevent modal if prompt exceeds capacity
|
||||
if (existingPrompt.length > MAX_LEN * MAX_FIELDS) {
|
||||
return interaction.reply({
|
||||
content: 'Current prompt too large for modal editing (exceeds 20000 chars); please use URL.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
// Prepare chunks
|
||||
const chunks = [];
|
||||
for (let offset = 0; offset < existingPrompt.length; offset += MAX_LEN) {
|
||||
chunks.push(existingPrompt.slice(offset, offset + MAX_LEN));
|
||||
}
|
||||
// Build modal
|
||||
// Prepare modal fields: one SHORT help, then paragraph chunks
|
||||
// Help field
|
||||
const helpField = new TextInputBuilder()
|
||||
.setCustomId('template_help')
|
||||
.setLabel('Template variables (no edits)')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
// prefill with the list of usable keys
|
||||
.setValue(TEMPLATE_KEYS_INFO);
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`promptModal-${clientId}`)
|
||||
.setTitle('Edit AI Prompt');
|
||||
// Add text inputs for existing chunks
|
||||
chunks.forEach((chunk, idx) => {
|
||||
.setCustomId(`promptModal-${versionId || 'current'}`)
|
||||
.setTitle('Edit AI Prompt')
|
||||
.addComponents(new ActionRowBuilder().addComponents(helpField));
|
||||
// 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()
|
||||
.setCustomId(`prompt_${idx}`)
|
||||
.setLabel(`Part ${idx + 1}`)
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setRequired(idx === 0)
|
||||
.setMaxLength(MAX_LEN)
|
||||
.setValue(chunk);
|
||||
.setValue(text);
|
||||
modal.addComponents(new ActionRowBuilder().addComponents(input));
|
||||
});
|
||||
// Add one extra empty field to allow expansion (up to MAX_FIELDS)
|
||||
if (chunks.length < MAX_FIELDS) {
|
||||
const i = chunks.length;
|
||||
const input = new TextInputBuilder()
|
||||
// Empty fields to fill out to MAX_FIELDS
|
||||
for (let i = chunks.length; i < MAX_FIELDS - 1; i++) {
|
||||
modal.addComponents(new ActionRowBuilder().addComponents(
|
||||
new TextInputBuilder()
|
||||
.setCustomId(`prompt_${i}`)
|
||||
.setLabel(`Part ${i + 1}`)
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setRequired(false)
|
||||
.setMaxLength(MAX_LEN);
|
||||
modal.addComponents(new ActionRowBuilder().addComponents(input));
|
||||
.setMaxLength(MAX_LEN)
|
||||
));
|
||||
}
|
||||
client.logger.info(`[cmd:prompt] Showing modal editor for client ${clientId} with ${chunks.length} parts`);
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/** track clients for modal handling */
|
||||
// Store clients for event hooks
|
||||
const _clients = [];
|
||||
|
||||
/**
|
||||
* init hook: load existing prompt and register modal submit handler
|
||||
*/
|
||||
export async function init(client, clientConfig) {
|
||||
client.logger.info('[module:responsesPrompt] Module loaded');
|
||||
const clientId = clientConfig.id;
|
||||
// Load prompt from PocketBase or fallback to file
|
||||
let prompt = '';
|
||||
client.logger.info('[module:responsesPrompt] initialized');
|
||||
// Load live prompt (latest version)
|
||||
try {
|
||||
const record = await client.pb.getFirst('responses_prompts', `clientId="${clientId}"`);
|
||||
if (record && record.prompt) {
|
||||
prompt = record.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');
|
||||
}
|
||||
const { items } = await client.pb.collection('responses_prompts')
|
||||
.getList(1, 1, { filter: `clientId="${clientId}"`, sort: '-created' });
|
||||
client.responsesPrompt = items[0]?.prompt || '';
|
||||
} 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 });
|
||||
// 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 => {
|
||||
if (!interaction.isModalSubmit()) return;
|
||||
if (interaction.customId !== `promptModal-${clientId}`) return;
|
||||
// Reassemble prompt from modal fields, inserting line breaks between parts
|
||||
client.logger.info(`[cmd:prompt] Modal submission received for client ${clientId}`);
|
||||
const id = interaction.customId;
|
||||
if (!id.startsWith('promptModal-')) return;
|
||||
const parts = [];
|
||||
for (let i = 0; i < MAX_FIELDS; i++) {
|
||||
try {
|
||||
const value = interaction.fields.getTextInputValue(`prompt_${i}`) || '';
|
||||
if (value.trim().length > 0) parts.push(value);
|
||||
const v = interaction.fields.getTextInputValue(`prompt_${i}`) || '';
|
||||
if (v.trim()) parts.push(v);
|
||||
} catch {}
|
||||
}
|
||||
const newPrompt = parts.join('\n');
|
||||
// Save to PocketBase
|
||||
// Persist new version
|
||||
let newRec;
|
||||
try {
|
||||
client.logger.debug(`[cmd:prompt] Saving new prompt for client ${clientId}, length=${newPrompt.length}`);
|
||||
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
|
||||
});
|
||||
newRec = await client.pb.createOne('responses_prompts', { clientId, prompt: newPrompt, updatedBy: interaction.user.id });
|
||||
client.responsesPrompt = newPrompt;
|
||||
await interaction.reply({ content: 'Prompt updated!', flags: MessageFlags.Ephemeral});
|
||||
} catch (err) {
|
||||
client.logger.error(`responsesPrompt modal submit error: ${err.message}`);
|
||||
await interaction.reply({ content: `Error saving prompt: ${err.message}`, ephemeral: true });
|
||||
client.logger.error(`Failed to save prompt: ${err.message}`);
|
||||
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 });
|
||||
});
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { MessageFlags } from 'discord-api-types/v10';
|
||||
* including optional image generation function calls.
|
||||
*/
|
||||
import { SlashCommandBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
|
||||
import { expandTemplate } from '../_src/template.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
@ -47,7 +48,7 @@ async function handleImageInteraction(client, interaction, resp, cfg, ephemeral)
|
||||
if (!fn?.arguments) return false;
|
||||
client.logger.debug(`Image function args: ${fn.arguments}`);
|
||||
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()) {
|
||||
await interaction.editReply({ content: 'Cannot generate image: empty prompt.', ephemeral });
|
||||
return true;
|
||||
@ -206,16 +207,41 @@ export const commands = [
|
||||
const last = lockMap.get(key) || Promise.resolve();
|
||||
// Handler to run in sequence
|
||||
const handler = async () => {
|
||||
// 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);
|
||||
// Build request body
|
||||
// Expand template for query
|
||||
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 channel = await client.channels.fetch(interaction.channelId);
|
||||
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: client.responsesPrompt,
|
||||
instructions,
|
||||
input: prompt,
|
||||
previous_response_id: previous,
|
||||
max_output_tokens: cfg.defaultMaxTokens,
|
||||
temperature: cfg.defaultTemperature,
|
||||
temperature: cfg.defaultTemperature
|
||||
};
|
||||
// Assemble enabled tools
|
||||
const tools = [];
|
||||
@ -292,6 +318,7 @@ export const commands = [
|
||||
}
|
||||
} catch (err) {
|
||||
client.logger.error(`AI error in /query: ${err.message}`);
|
||||
clearInterval(typingInterval);
|
||||
return interaction.editReply({ content: 'Error generating response.', ephemeral });
|
||||
}
|
||||
|
||||
@ -303,11 +330,13 @@ export const commands = [
|
||||
|
||||
// Handle image function call if present
|
||||
if (await handleImageInteraction(client, interaction, resp, cfg, ephemeral)) {
|
||||
clearInterval(typingInterval);
|
||||
return;
|
||||
}
|
||||
// Send text reply chunks
|
||||
const text = resp.output_text?.trim() || '';
|
||||
if (!text) {
|
||||
clearInterval(typingInterval);
|
||||
return interaction.editReply({ content: 'No response generated.', ephemeral });
|
||||
}
|
||||
const chunks = splitLongMessage(text, 2000);
|
||||
@ -318,6 +347,7 @@ export const commands = [
|
||||
await interaction.followUp({ content: chunks[i], ephemeral });
|
||||
}
|
||||
}
|
||||
clearInterval(typingInterval);
|
||||
};
|
||||
// Chain handler after last and await
|
||||
const next = last.then(handler).catch(err => client.logger.error(`Queued /query error for ${key}: ${err.message}`));
|
||||
|
||||
15
_src/template.js
Normal file
15
_src/template.js
Normal 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])
|
||||
: '';
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user