feat: add web search, stagger responses
Node.js CI / Lint and Test (push) Successful in 39s

This commit is contained in:
2025-10-10 17:53:36 -07:00
parent 94a4d7e043
commit 4300cf0d3f
6 changed files with 180 additions and 70 deletions
+31 -32
View File
@@ -10,8 +10,8 @@ import {
type Message, type Message,
type OmitPartialGroupDMChannel, type OmitPartialGroupDMChannel,
} from "discord.js"; } from "discord.js";
import { personality } from "../config/personality.js"; import { makeAiRequest } from "../modules/makeAiRequest.js";
import { ai } from "../utils/ai.js"; import { sendAiResponse } from "../modules/sendAiResponse.js";
import { calculateCost } from "../utils/calculateCost.js"; import { calculateCost } from "../utils/calculateCost.js";
import { isNaomiMessage } from "../utils/isNaomi.js"; import { isNaomiMessage } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.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. * @param message - The message payload from Discord.
*/ */
export const handleDmMessage 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<Message>): Promise<void> => { = async(message: OmitPartialGroupDMChannel<Message>): Promise<void> => {
try { try {
if (message.author.bot) { if (message.author.bot) {
@@ -32,17 +32,19 @@ export const handleDmMessage
if (!isNaomi) { if (!isNaomi) {
return; return;
} }
const historyRequest await message.channel.sendTyping();
= await message.channel.messages.fetch({ limit: 20 }); const historyRequest = await message.channel.messages.fetch({
limit: 20,
});
const history = [ ...historyRequest.values() ]; const history = [ ...historyRequest.values() ];
const clearMessageIndex = history.findIndex((messageInner) => { const clearMessageIndex = history.findIndex((messageInner) => {
return ( return (
messageInner.content === "<Clear History>" messageInner.content === "<Clear History>"
&& messageInner.author.id === message.client.user.id && messageInner.author.id === message.client.user.id
); );
}); });
if (clearMessageIndex !== -1) { 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); history.splice(clearMessageIndex, history.length - clearMessageIndex);
} }
const context: Array<MessageParam> = history. const context: Array<MessageParam> = history.
@@ -51,34 +53,30 @@ export const handleDmMessage
return { return {
content: messageInner.content, content: messageInner.content,
role: role:
messageInner.author.id === message.client.user.id messageInner.author.id === message.client.user.id
? "assistant" ? "assistant"
: "user", : "user",
}; };
}); });
const messages = await ai.messages.create({ const { content, usage } = await makeAiRequest(
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. context,
max_tokens: 3000, message.author.displayName,
messages: context, );
model: "claude-sonnet-4-5-20250929", const cost = calculateCost(usage);
system: `${personality} The user's name is ${message.author.displayName}`,
temperature: 1,
});
const response = messages.content.find((messageInner) => { await sendAiResponse(
return messageInner.type === "text"; [ ...content, cost ],
}); message.channel.send.bind(message.channel),
message.channel.sendTyping.bind(message.channel),
const cost = calculateCost(messages.usage);
await message.channel.send(
`${response?.text ?? "There was an error. Please try again later."}\n\n${cost}`,
); );
await logger.metric("dm_message", 1, { cost }); await logger.metric("dm_message", 1, { cost });
} catch (error) { } catch (error) {
await logger.error("message event", error instanceof Error await logger.error(
? error "message event",
: new Error(String(error))); error instanceof Error
? error
: new Error(String(error)),
);
const button = new ButtonBuilder(). const button = new ButtonBuilder().
setLabel("Need help?"). setLabel("Need help?").
setStyle(ButtonStyle.Link). setStyle(ButtonStyle.Link).
@@ -86,9 +84,10 @@ export const handleDmMessage
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button); const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
await message.reply({ await message.reply({
components: [ row ], components: [ row ],
content: error instanceof Error content:
? error.message error instanceof Error
: "Something went wrong.", ? error.message
: "Something went wrong.",
}); });
} }
}; };
+15 -20
View File
@@ -9,8 +9,8 @@ import {
ButtonStyle, ButtonStyle,
type Message, type Message,
} from "discord.js"; } from "discord.js";
import { personality } from "../config/personality.js"; import { makeAiRequest } from "../modules/makeAiRequest.js";
import { ai } from "../utils/ai.js"; import { sendAiResponse } from "../modules/sendAiResponse.js";
import { calculateCost } from "../utils/calculateCost.js"; import { calculateCost } from "../utils/calculateCost.js";
import { isNaomiMessage } from "../utils/isNaomi.js"; import { isNaomiMessage } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
@@ -20,7 +20,7 @@ import { logger } from "../utils/logger.js";
* @param message - The message payload from Discord. * @param message - The message payload from Discord.
*/ */
export const handleGuildMessage export const handleGuildMessage
// eslint-disable-next-line max-lines-per-function -- We're off by one bloody line.
= async(message: Message<true>): Promise<void> => { = async(message: Message<true>): Promise<void> => {
try { try {
if (message.author.bot) { if (message.author.bot) {
@@ -39,26 +39,21 @@ export const handleGuildMessage
if (!isNaomi) { if (!isNaomi) {
return; 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({ const thread = await message.startThread({
name: `${message.author.displayName}'s Thread`, name: `${message.author.displayName}'s Thread`,
}); });
await thread.send( await thread.sendTyping();
`${response?.text ?? "There was an error. Please try again later."}\n\n${cost}`,
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 }); await logger.metric("guild_message", 1, { cost });
} catch (error) { } catch (error) {
+12 -18
View File
@@ -10,8 +10,8 @@ import {
type AnyThreadChannel, type AnyThreadChannel,
type Message, type Message,
} from "discord.js"; } from "discord.js";
import { personality } from "../config/personality.js"; import { makeAiRequest } from "../modules/makeAiRequest.js";
import { ai } from "../utils/ai.js"; import { sendAiResponse } from "../modules/sendAiResponse.js";
import { calculateCost } from "../utils/calculateCost.js"; import { calculateCost } from "../utils/calculateCost.js";
import { isNaomiMessage } from "../utils/isNaomi.js"; import { isNaomiMessage } from "../utils/isNaomi.js";
import { logger } from "../utils/logger.js"; import { logger } from "../utils/logger.js";
@@ -38,6 +38,7 @@ export const handleThreadMessage
if (!isNaomi) { if (!isNaomi) {
return; return;
} }
await channel.sendTyping();
const historyRequest = await message.channel.messages.fetch({ limit: 20 }); const historyRequest = await message.channel.messages.fetch({ limit: 20 });
const history = [ ...historyRequest.values() ]; const history = [ ...historyRequest.values() ];
const context: Array<MessageParam> = history. const context: Array<MessageParam> = history.
@@ -51,23 +52,16 @@ export const handleThreadMessage
: "user", : "user",
}; };
}); });
const messages = await ai.messages.create({ const { content, usage } = await makeAiRequest(
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required key format for SDK. context,
max_tokens: 3000, message.author.displayName,
messages: context, );
model: "claude-sonnet-4-5-20250929", const cost = calculateCost(usage);
system: `${personality} The user's name is ${message.author.displayName}`,
temperature: 1,
});
const response = messages.content.find((messageInner) => { await sendAiResponse(
return messageInner.type === "text"; [ ...content, cost ],
}); message.channel.send.bind(message.channel),
message.channel.sendTyping.bind(message.channel),
const cost = calculateCost(messages.usage);
await message.channel.send(
`${response?.text ?? "There was an error. Please try again later."}\n\n${cost}`,
); );
await logger.metric("thread_message", 1, { cost }); await logger.metric("thread_message", 1, { cost });
} catch (error) { } catch (error) {
+79
View File
@@ -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<MessageParam>,
username: string,
): Promise<{ content: Array<string>; 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 };
};
+27
View File
@@ -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<string>,
send: GuildTextBasedChannel["send"] | DMChannel["send"] | Message["reply"],
type: GuildTextBasedChannel["sendTyping"],
): Promise<void> => {
for (const line of content) {
await send(line);
await type();
await sleep(2500);
}
};
+16
View File
@@ -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<void> => {
await new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
};