From 4300cf0d3f76e1d678e47534af5bc318665054bc Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Fri, 10 Oct 2025 17:53:36 -0700 Subject: [PATCH] feat: add web search, stagger responses --- src/events/handleDmMessage.ts | 63 ++++++++++++------------ src/events/handleGuildMessage.ts | 35 ++++++-------- src/events/handleThreadMessage.ts | 30 +++++------- src/modules/makeAiRequest.ts | 79 +++++++++++++++++++++++++++++++ src/modules/sendAiResponse.ts | 27 +++++++++++ src/utils/sleep.ts | 16 +++++++ 6 files changed, 180 insertions(+), 70 deletions(-) create mode 100644 src/modules/makeAiRequest.ts create mode 100644 src/modules/sendAiResponse.ts create mode 100644 src/utils/sleep.ts diff --git a/src/events/handleDmMessage.ts b/src/events/handleDmMessage.ts index 5c2134e..894ba8a 100644 --- a/src/events/handleDmMessage.ts +++ b/src/events/handleDmMessage.ts @@ -10,8 +10,8 @@ import { type Message, type OmitPartialGroupDMChannel, } from "discord.js"; -import { personality } from "../config/personality.js"; -import { ai } from "../utils/ai.js"; +import { makeAiRequest } from "../modules/makeAiRequest.js"; +import { sendAiResponse } from "../modules/sendAiResponse.js"; import { calculateCost } from "../utils/calculateCost.js"; import { isNaomiMessage } from "../utils/isNaomi.js"; import { logger } from "../utils/logger.js"; @@ -22,7 +22,7 @@ import type { MessageParam } from "@anthropic-ai/sdk/resources/index.js"; * @param message - The message payload from Discord. */ export const handleDmMessage -// eslint-disable-next-line max-lines-per-function, max-statements -- We're off by one bloody line. + // eslint-disable-next-line max-lines-per-function, max-statements -- We're off by one bloody line. = async(message: OmitPartialGroupDMChannel): Promise => { try { if (message.author.bot) { @@ -32,17 +32,19 @@ export const handleDmMessage if (!isNaomi) { return; } - const historyRequest - = await message.channel.messages.fetch({ limit: 20 }); + await message.channel.sendTyping(); + const historyRequest = await message.channel.messages.fetch({ + limit: 20, + }); const history = [ ...historyRequest.values() ]; const clearMessageIndex = history.findIndex((messageInner) => { return ( messageInner.content === "" - && messageInner.author.id === message.client.user.id + && messageInner.author.id === message.client.user.id ); }); if (clearMessageIndex !== -1) { - // Remove the clear message and everything sent before it, which means everything after in the array because the array is backwards + // Remove the clear message and everything sent before it, which means everything after in the array because the array is backwards history.splice(clearMessageIndex, history.length - clearMessageIndex); } const context: Array = history. @@ -51,34 +53,30 @@ export const handleDmMessage return { content: messageInner.content, role: - messageInner.author.id === message.client.user.id - ? "assistant" - : "user", + messageInner.author.id === message.client.user.id + ? "assistant" + : "user", }; }); - const messages = await ai.messages.create({ - // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. - max_tokens: 3000, - messages: context, - model: "claude-sonnet-4-5-20250929", - system: `${personality} The user's name is ${message.author.displayName}`, - temperature: 1, - }); + const { content, usage } = await makeAiRequest( + context, + message.author.displayName, + ); + const cost = calculateCost(usage); - const response = messages.content.find((messageInner) => { - return messageInner.type === "text"; - }); - - const cost = calculateCost(messages.usage); - - await message.channel.send( - `${response?.text ?? "There was an error. Please try again later."}\n\n${cost}`, + await sendAiResponse( + [ ...content, cost ], + message.channel.send.bind(message.channel), + message.channel.sendTyping.bind(message.channel), ); await logger.metric("dm_message", 1, { cost }); } catch (error) { - await logger.error("message event", error instanceof Error - ? error - : new Error(String(error))); + await logger.error( + "message event", + error instanceof Error + ? error + : new Error(String(error)), + ); const button = new ButtonBuilder(). setLabel("Need help?"). setStyle(ButtonStyle.Link). @@ -86,9 +84,10 @@ export const handleDmMessage const row = new ActionRowBuilder().addComponents(button); await message.reply({ components: [ row ], - content: error instanceof Error - ? error.message - : "Something went wrong.", + content: + error instanceof Error + ? error.message + : "Something went wrong.", }); } }; diff --git a/src/events/handleGuildMessage.ts b/src/events/handleGuildMessage.ts index ec947af..7217a59 100644 --- a/src/events/handleGuildMessage.ts +++ b/src/events/handleGuildMessage.ts @@ -9,8 +9,8 @@ import { ButtonStyle, type Message, } from "discord.js"; -import { personality } from "../config/personality.js"; -import { ai } from "../utils/ai.js"; +import { makeAiRequest } from "../modules/makeAiRequest.js"; +import { sendAiResponse } from "../modules/sendAiResponse.js"; import { calculateCost } from "../utils/calculateCost.js"; import { isNaomiMessage } from "../utils/isNaomi.js"; import { logger } from "../utils/logger.js"; @@ -20,7 +20,7 @@ import { logger } from "../utils/logger.js"; * @param message - The message payload from Discord. */ export const handleGuildMessage - // eslint-disable-next-line max-lines-per-function -- We're off by one bloody line. + = async(message: Message): Promise => { try { if (message.author.bot) { @@ -39,26 +39,21 @@ export const handleGuildMessage if (!isNaomi) { return; } - const messages = await ai.messages.create({ - // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. - max_tokens: 3000, - messages: [ { content: message.content, role: "user" } ], - model: "claude-sonnet-4-5-20250929", - system: `${personality} The user's name is ${message.author.displayName}`, - temperature: 1, - }); - - const response = messages.content.find((messageInner) => { - return messageInner.type === "text"; - }); - - const cost = calculateCost(messages.usage); - const thread = await message.startThread({ name: `${message.author.displayName}'s Thread`, }); - await thread.send( - `${response?.text ?? "There was an error. Please try again later."}\n\n${cost}`, + await thread.sendTyping(); + + const { content, usage } = await makeAiRequest( + [ { content: message.content, role: "user" } ], + message.author.displayName, + ); + const cost = calculateCost(usage); + + await sendAiResponse( + [ ...content, cost ], + thread.send.bind(thread), + thread.sendTyping.bind(thread), ); await logger.metric("guild_message", 1, { cost }); } catch (error) { diff --git a/src/events/handleThreadMessage.ts b/src/events/handleThreadMessage.ts index 2157de3..dee75f7 100644 --- a/src/events/handleThreadMessage.ts +++ b/src/events/handleThreadMessage.ts @@ -10,8 +10,8 @@ import { type AnyThreadChannel, type Message, } from "discord.js"; -import { personality } from "../config/personality.js"; -import { ai } from "../utils/ai.js"; +import { makeAiRequest } from "../modules/makeAiRequest.js"; +import { sendAiResponse } from "../modules/sendAiResponse.js"; import { calculateCost } from "../utils/calculateCost.js"; import { isNaomiMessage } from "../utils/isNaomi.js"; import { logger } from "../utils/logger.js"; @@ -38,6 +38,7 @@ export const handleThreadMessage if (!isNaomi) { return; } + await channel.sendTyping(); const historyRequest = await message.channel.messages.fetch({ limit: 20 }); const history = [ ...historyRequest.values() ]; const context: Array = history. @@ -51,23 +52,16 @@ export const handleThreadMessage : "user", }; }); - const messages = await ai.messages.create({ - // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. - max_tokens: 3000, - messages: context, - model: "claude-sonnet-4-5-20250929", - system: `${personality} The user's name is ${message.author.displayName}`, - temperature: 1, - }); + const { content, usage } = await makeAiRequest( + context, + message.author.displayName, + ); + const cost = calculateCost(usage); - const response = messages.content.find((messageInner) => { - return messageInner.type === "text"; - }); - - const cost = calculateCost(messages.usage); - - await message.channel.send( - `${response?.text ?? "There was an error. Please try again later."}\n\n${cost}`, + await sendAiResponse( + [ ...content, cost ], + message.channel.send.bind(message.channel), + message.channel.sendTyping.bind(message.channel), ); await logger.metric("thread_message", 1, { cost }); } catch (error) { diff --git a/src/modules/makeAiRequest.ts b/src/modules/makeAiRequest.ts new file mode 100644 index 0000000..5660b4a --- /dev/null +++ b/src/modules/makeAiRequest.ts @@ -0,0 +1,79 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { personality } from "../config/personality.js"; +import { ai } from "../utils/ai.js"; +import type { + MessageParam, + Usage, +} from "@anthropic-ai/sdk/resources/messages.js"; + +/** + * Makes an AI request to the Anthropic API. + * @param context - The message context to send to the API. + * @param username - The username of the user making the request. + * @returns The content of the response and the usage. + */ +// eslint-disable-next-line max-lines-per-function -- The formatting ruins it. +export const makeAiRequest = async( + context: Array, + username: string, +): Promise<{ content: Array; usage: Usage }> => { + const response = await ai.messages.create({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. + max_tokens: 3000, + messages: context, + model: "claude-sonnet-4-5-20250929", + system: `${personality} The user's name is ${username}`, + temperature: 1, + tools: [ { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. + max_uses: 5, + name: "web_search", + type: "web_search_20250305", + } ], + }); + const { usage } = response; + const content = response.content.map((message) => { + if (message.type === "text") { + if ( + message.citations?.length !== undefined + && message.citations.length > 0 + ) { + return `**${message.text}**\n\n-# ${message.citations. + filter((citation) => { + return citation.type === "web_search_result_location"; + }). + map((citation) => { + return `${citation.title ?? "Unknown Title"}\n${citation.url}`; + }). + join(", ")}`; + } + return message.text; + } + if (message.type === "server_tool_use") { + return `Searching for: ${JSON.stringify(message.input)}`; + } + if (message.type === "web_search_tool_result") { + if (!Array.isArray(message.content)) { + return `-# Found: ${JSON.stringify(message.content)}`; + } + return `-# Found: ${message.content. + map((entry) => { + return `[${entry.title}](<${entry.url}>)`; + }). + join(", ")}`; + } + if (message.type === "thinking") { + return `-# Thinking: ${message.thinking}`; + } + if (message.type === "redacted_thinking") { + return `-# Thinking: [Redacted]`; + } + return `-# Tool use: ${message.name}`; + }); + return { content, usage }; +}; diff --git a/src/modules/sendAiResponse.ts b/src/modules/sendAiResponse.ts new file mode 100644 index 0000000..a3c627f --- /dev/null +++ b/src/modules/sendAiResponse.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-await-in-loop -- This is necessary so we can send the responses sequentially.*/ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { sleep } from "../utils/sleep.js"; +import type { DMChannel, GuildTextBasedChannel, Message } from "discord.js"; + +/** + * Sends an AI response to a channel. + * @param content - The content to send. + * @param send - The send or reply function to use. + * @param type - The sendTyping function to use. + */ +export const sendAiResponse = async( + content: Array, + send: GuildTextBasedChannel["send"] | DMChannel["send"] | Message["reply"], + type: GuildTextBasedChannel["sendTyping"], +): Promise => { + for (const line of content) { + await send(line); + await type(); + await sleep(2500); + } +}; diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts new file mode 100644 index 0000000..d553e1b --- /dev/null +++ b/src/utils/sleep.ts @@ -0,0 +1,16 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/** + * Sleeps for a given number of milliseconds. + * @param milliseconds - The number of milliseconds to sleep. + * @returns A promise that resolves after the given number of milliseconds. + */ +export const sleep = async(milliseconds: number): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +};