Reponses prompt updates and templates.
This commit is contained in:
parent
589360b412
commit
8231b5a105
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -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
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