diff --git a/apps/test-bot/src/app/commands/(interactions)/+middleware.ts b/apps/test-bot/src/app/commands/(interactions)/+middleware.ts index 73247e42..5521f325 100644 --- a/apps/test-bot/src/app/commands/(interactions)/+middleware.ts +++ b/apps/test-bot/src/app/commands/(interactions)/+middleware.ts @@ -4,6 +4,8 @@ import { MessageFlags } from 'discord.js'; export function beforeExecute(ctx: MiddlewareContext) { Logger.info('Pre-command middleware'); + console.log({ isAI: ctx.ai }); + const user = ctx.isInteraction() ? ctx.interaction.user : ctx.message.author; if (ctx.commandName === 'prompt' && user.id === '159985870458322944') { diff --git a/apps/test-bot/src/app/commands/(interactions)/confirmation.tsx b/apps/test-bot/src/app/commands/(interactions)/confirmation.tsx index e2d34211..50fccc1f 100644 --- a/apps/test-bot/src/app/commands/(interactions)/confirmation.tsx +++ b/apps/test-bot/src/app/commands/(interactions)/confirmation.tsx @@ -4,14 +4,26 @@ import { ChatInputCommandContext, CommandData, OnButtonKitClick, + MessageCommandContext, } from 'commandkit'; import { ButtonStyle, MessageFlags } from 'discord.js'; +import { AiConfig, AiContext } from '@commandkit/ai'; +import { z } from 'zod'; export const command: CommandData = { name: 'confirmation', description: 'This is a confirm command.', }; +export const aiConfig = { + parameters: z.object({ + message: z + .string() + .describe('The message to be shown in the confirmation.'), + }), + description: 'Confirm an action with buttons.', +} satisfies AiConfig; + const handleConfirm: OnButtonKitClick = async (interaction, context) => { await interaction.reply({ content: 'The item was deleted successfully.', @@ -47,3 +59,23 @@ export async function chatInput({ interaction }: ChatInputCommandContext) { components: [buttons], }); } + +export async function ai(ctx: MessageCommandContext) { + const message = ctx.ai?.params?.message as string; + + const buttons = ( + + + + + ); + + await ctx.message.reply({ + content: message || 'There was no confirmation message provided.', + components: [buttons], + }); +} diff --git a/apps/test-bot/src/app/commands/(leveling)/xp.ts b/apps/test-bot/src/app/commands/(leveling)/xp.ts index dd50c24c..308f5a74 100644 --- a/apps/test-bot/src/app/commands/(leveling)/xp.ts +++ b/apps/test-bot/src/app/commands/(leveling)/xp.ts @@ -1,7 +1,11 @@ -import { ChatInputCommandContext, CommandData } from 'commandkit'; +import { + ChatInputCommandContext, + CommandData, + MessageCommandContext, +} from 'commandkit'; import { database } from '@/database/store.ts'; import { cacheTag } from '@commandkit/cache'; -import { AiConfig, AiContext } from '@commandkit/ai'; +import { AiConfig } from '@commandkit/ai'; import { z } from 'zod'; export const command: CommandData = { @@ -9,13 +13,13 @@ export const command: CommandData = { description: 'This is an xp command.', }; -export const aiConfig: AiConfig = { +export const aiConfig = { description: 'Get the XP of a user in a guild.', parameters: z.object({ guildId: z.string().describe('The ID of the guild.'), userId: z.string().describe('The ID of the user.'), }), -}; +} satisfies AiConfig; async function getUserXP(guildId: string, userId: string) { 'use cache'; @@ -50,7 +54,7 @@ export async function chatInput({ interaction }: ChatInputCommandContext) { }); } -export async function ai(ctx: AiContext) { +export async function ai(ctx: MessageCommandContext) { const message = ctx.message; if (!message.inGuild()) { @@ -59,10 +63,9 @@ export async function ai(ctx: AiContext) { }; } - const { guildId, userId } = ctx.params as { - guildId: string; - userId: string; - }; + const { guildId, userId } = ctx.ai?.params as z.infer< + (typeof aiConfig)['parameters'] + >; const xp = await getUserXP(guildId, userId); diff --git a/apps/website/docs/api-reference/ai/classes/ai-context.mdx b/apps/website/docs/api-reference/ai/classes/ai-context.mdx index 36609637..07f595fa 100644 --- a/apps/website/docs/api-reference/ai/classes/ai-context.mdx +++ b/apps/website/docs/api-reference/ai/classes/ai-context.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AiContext - + Represents the context in which an AI command is executed. This includes the parameters passed to the command, the message that triggered it, @@ -25,6 +25,7 @@ class AiContext = Record> { public message!: Message; public client!: Client; public commandkit!: CommandKit; + public store = new Map(); constructor(options: AiContextOptions) setParams(params: T) => void; } @@ -52,6 +53,11 @@ The client instance associated with the AI command. CommandKit`} /> The CommandKit instance associated with the AI command. +### store + + + +A key-value store to hold additional data. ### constructor AiContextOptions<T>) => AiContext`} /> diff --git a/apps/website/docs/api-reference/ai/classes/ai-plugin.mdx b/apps/website/docs/api-reference/ai/classes/ai-plugin.mdx index adc360fa..46946f3e 100644 --- a/apps/website/docs/api-reference/ai/classes/ai-plugin.mdx +++ b/apps/website/docs/api-reference/ai/classes/ai-plugin.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AiPlugin - + @@ -23,7 +23,8 @@ class AiPlugin extends RuntimePlugin { constructor(options: AiPluginOptions) activate(ctx: CommandKitPluginRuntime) => Promise; deactivate(ctx: CommandKitPluginRuntime) => Promise; - onBeforeCommandsLoad(ctx: CommandKitPluginRuntime) => Promise; + executeAI(message: Message, commandkit?: CommandKit) => Promise; + onBeforeCommandsLoad() => Promise; onAfterCommandsLoad(ctx: CommandKitPluginRuntime) => Promise; } ``` @@ -53,9 +54,14 @@ class AiPlugin extends RuntimePlugin { CommandKitPluginRuntime) => Promise<void>`} /> +### executeAI + +CommandKit) => Promise<void>`} /> + +Executes the AI for a given message. ### onBeforeCommandsLoad -CommandKitPluginRuntime) => Promise<void>`} /> + Promise<void>`} /> ### onAfterCommandsLoad diff --git a/apps/website/docs/api-reference/ai/functions/ai.mdx b/apps/website/docs/api-reference/ai/functions/ai.mdx index 0dfbc0ea..99f8a1c4 100644 --- a/apps/website/docs/api-reference/ai/functions/ai.mdx +++ b/apps/website/docs/api-reference/ai/functions/ai.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ai - + Defines the AI plugin for the application. diff --git a/apps/website/docs/api-reference/ai/functions/configure-ai.mdx b/apps/website/docs/api-reference/ai/functions/configure-ai.mdx index 62fbac07..9f9ccb55 100644 --- a/apps/website/docs/api-reference/ai/functions/configure-ai.mdx +++ b/apps/website/docs/api-reference/ai/functions/configure-ai.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## configureAI - + Configures the AI plugin with the provided options. This function allows you to set a message filter, select an AI model, and generate a system prompt. diff --git a/apps/website/docs/api-reference/ai/functions/create-system-prompt.mdx b/apps/website/docs/api-reference/ai/functions/create-system-prompt.mdx new file mode 100644 index 00000000..4d650441 --- /dev/null +++ b/apps/website/docs/api-reference/ai/functions/create-system-prompt.mdx @@ -0,0 +1,29 @@ +--- +title: "CreateSystemPrompt" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## createSystemPrompt + + + +Creates the default system prompt for the AI bot based on the provided message context. +This prompt includes the bot's role, current channel information, and response guidelines. + +```ts title="Signature" +function createSystemPrompt(message: Message): string +``` +Parameters + +### message + + + diff --git a/apps/website/docs/api-reference/ai/functions/create-tool.mdx b/apps/website/docs/api-reference/ai/functions/create-tool.mdx new file mode 100644 index 00000000..b853c1bd --- /dev/null +++ b/apps/website/docs/api-reference/ai/functions/create-tool.mdx @@ -0,0 +1,51 @@ +--- +title: "CreateTool" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## createTool + + + +Creates a new AI tool with the specified configuration. +This function wraps the underlying AI library's tool creation with additional +context management and parameter validation. + + + +*Example* + +```typescript +const myTool = createTool({ + name: 'calculate', + description: 'Performs basic arithmetic calculations', + parameters: z.object({ + operation: z.enum(['add', 'subtract']), + a: z.number(), + b: z.number(), + }), + execute: async (ctx, params) => { + return params.operation === 'add' + ? params.a + params.b + : params.a - params.b; + }, +}); +``` + +```ts title="Signature" +function createTool(options: CreateToolOptions): void +``` +Parameters + +### options + +CreateToolOptions<T, R>`} /> + diff --git a/apps/website/docs/api-reference/ai/functions/get-aiconfig.mdx b/apps/website/docs/api-reference/ai/functions/get-aiconfig.mdx new file mode 100644 index 00000000..d0140b16 --- /dev/null +++ b/apps/website/docs/api-reference/ai/functions/get-aiconfig.mdx @@ -0,0 +1,22 @@ +--- +title: "GetAIConfig" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getAIConfig + + + +Retrieves the current AI configuration. + +```ts title="Signature" +function getAIConfig(): Required +``` diff --git a/apps/website/docs/api-reference/ai/functions/use-ai.mdx b/apps/website/docs/api-reference/ai/functions/use-ai.mdx new file mode 100644 index 00000000..ff57ddbe --- /dev/null +++ b/apps/website/docs/api-reference/ai/functions/use-ai.mdx @@ -0,0 +1,22 @@ +--- +title: "UseAI" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## useAI + + + +Fetches the AI plugin instance. + +```ts title="Signature" +function useAI(): void +``` diff --git a/apps/website/docs/api-reference/ai/functions/use-aicontext.mdx b/apps/website/docs/api-reference/ai/functions/use-aicontext.mdx new file mode 100644 index 00000000..514bee13 --- /dev/null +++ b/apps/website/docs/api-reference/ai/functions/use-aicontext.mdx @@ -0,0 +1,22 @@ +--- +title: "UseAIContext" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## useAIContext + + + +Retrieves the AI context. + +```ts title="Signature" +function useAIContext(): void +``` diff --git a/apps/website/docs/api-reference/ai/interfaces/ai-config.mdx b/apps/website/docs/api-reference/ai/interfaces/ai-config.mdx index 9fba1d0a..fc7b33ed 100644 --- a/apps/website/docs/api-reference/ai/interfaces/ai-config.mdx +++ b/apps/website/docs/api-reference/ai/interfaces/ai-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AiConfig - + Represents the configuration options for the AI plugin scoped to a specific command. diff --git a/apps/website/docs/api-reference/ai/interfaces/ai-context-options.mdx b/apps/website/docs/api-reference/ai/interfaces/ai-context-options.mdx index 5e9055f6..b50c614a 100644 --- a/apps/website/docs/api-reference/ai/interfaces/ai-context-options.mdx +++ b/apps/website/docs/api-reference/ai/interfaces/ai-context-options.mdx @@ -13,9 +13,9 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AiContextOptions - - + +Options for the AI context. ```ts title="Signature" interface AiContextOptions = Record> { @@ -31,17 +31,17 @@ interface AiContextOptions = Record - +The message that triggered the AI command. ### params - +The parameters passed to the AI command. ### commandkit CommandKit`} /> - +The CommandKit instance associated with the AI command. diff --git a/apps/website/docs/api-reference/ai/interfaces/ai-plugin-options.mdx b/apps/website/docs/api-reference/ai/interfaces/ai-plugin-options.mdx index 113a274d..9bad9b62 100644 --- a/apps/website/docs/api-reference/ai/interfaces/ai-plugin-options.mdx +++ b/apps/website/docs/api-reference/ai/interfaces/ai-plugin-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AiPluginOptions - + Options for the AI plugin. diff --git a/apps/website/docs/api-reference/ai/interfaces/configure-ai.mdx b/apps/website/docs/api-reference/ai/interfaces/configure-ai.mdx index c22925c2..cef74d18 100644 --- a/apps/website/docs/api-reference/ai/interfaces/configure-ai.mdx +++ b/apps/website/docs/api-reference/ai/interfaces/configure-ai.mdx @@ -13,20 +13,35 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ConfigureAI - + Represents the configuration options for the AI model. ```ts title="Signature" interface ConfigureAI { + disableBuiltInTools?: boolean; messageFilter?: MessageFilter; - selectAiModel?: SelectAiModel; - systemPrompt?: (message: Message) => Promise; + selectAiModel: SelectAiModel; + prepareSystemPrompt?: (ctx: AiContext, message: Message) => Promise; + preparePrompt?: (ctx: AiContext, message: Message) => Promise; + onProcessingStart?: (ctx: AiContext, message: Message) => Promise; + onProcessingFinish?: (ctx: AiContext, message: Message) => Promise; + onResult?: ( + ctx: AiContext, + message: Message, + result: AIGenerateResult, + ) => Promise; + onError?: (ctx: AiContext, message: Message, error: Error) => Promise; } ```
+### disableBuiltInTools + + + +Whether to disable the built-in tools. Default is false. ### messageFilter MessageFilter`} /> @@ -39,13 +54,38 @@ CommandKit invokes this function before processing the message. A function that selects the AI model to use based on the message. This function should return a promise that resolves to an object containing the model and options. -### systemPrompt +### prepareSystemPrompt - +AiContext, message: Message) => Promise<string>`} /> A function that generates a system prompt based on the message. This function should return a promise that resolves to a string containing the system prompt. If not provided, a default system prompt will be used. +### preparePrompt + +AiContext, message: Message) => Promise<string>`} /> + +A function that prepares the prompt for the AI model. +### onProcessingStart + +AiContext, message: Message) => Promise<void>`} /> + +A function that gets called when the AI starts processing a message. +### onProcessingFinish + +AiContext, message: Message) => Promise<void>`} /> + +A function that gets called when the AI finishes processing a message. +### onResult + +AiContext, message: Message, result: AIGenerateResult, ) => Promise<void>`} /> + +A function that gets called upon receiving the result from the AI model. +### onError + +AiContext, message: Message, error: Error) => Promise<void>`} /> + +A function that gets called when error occurs.
diff --git a/apps/website/docs/api-reference/ai/interfaces/create-tool-options.mdx b/apps/website/docs/api-reference/ai/interfaces/create-tool-options.mdx new file mode 100644 index 00000000..d124f2bd --- /dev/null +++ b/apps/website/docs/api-reference/ai/interfaces/create-tool-options.mdx @@ -0,0 +1,53 @@ +--- +title: "CreateToolOptions" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## CreateToolOptions + + + +Configuration options for creating an AI tool. + +```ts title="Signature" +interface CreateToolOptions { + name: string; + description: string; + parameters: T; + execute: ToolExecuteFunction; +} +``` + +
+ +### name + + + +The unique name identifier for the tool +### description + + + +A human-readable description of what the tool does +### parameters + + + +The parameter schema that defines the tool's input structure +### execute + +ToolExecuteFunction<T, R>`} /> + +The function that executes when the tool is called + + +
diff --git a/apps/website/docs/api-reference/ai/types/ai-command.mdx b/apps/website/docs/api-reference/ai/types/ai-command.mdx index 00c7f36f..76b9820c 100644 --- a/apps/website/docs/api-reference/ai/types/ai-command.mdx +++ b/apps/website/docs/api-reference/ai/types/ai-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AiCommand - + Represents a command that can be executed by the AI. diff --git a/apps/website/docs/api-reference/ai/types/aigenerate-result.mdx b/apps/website/docs/api-reference/ai/types/aigenerate-result.mdx new file mode 100644 index 00000000..a3915ff7 --- /dev/null +++ b/apps/website/docs/api-reference/ai/types/aigenerate-result.mdx @@ -0,0 +1,22 @@ +--- +title: "AIGenerateResult" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## AIGenerateResult + + + +Represents the result of an AI text generation operation. + +```ts title="Signature" +type AIGenerateResult = Awaited> +``` diff --git a/apps/website/docs/api-reference/ai/types/command-filter-function.mdx b/apps/website/docs/api-reference/ai/types/command-filter-function.mdx index 86088a61..ba94e0e5 100644 --- a/apps/website/docs/api-reference/ai/types/command-filter-function.mdx +++ b/apps/website/docs/api-reference/ai/types/command-filter-function.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandFilterFunction - + Function type for filtering commands based on their name. diff --git a/apps/website/docs/api-reference/ai/types/command-tool.mdx b/apps/website/docs/api-reference/ai/types/command-tool.mdx new file mode 100644 index 00000000..5e308ee9 --- /dev/null +++ b/apps/website/docs/api-reference/ai/types/command-tool.mdx @@ -0,0 +1,24 @@ +--- +title: "CommandTool" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## CommandTool + + + + + +```ts title="Signature" +type CommandTool = LoadedCommand & { + tool: Tool; +} +``` diff --git a/apps/website/docs/api-reference/ai/types/infer-parameters.mdx b/apps/website/docs/api-reference/ai/types/infer-parameters.mdx new file mode 100644 index 00000000..245777c3 --- /dev/null +++ b/apps/website/docs/api-reference/ai/types/infer-parameters.mdx @@ -0,0 +1,27 @@ +--- +title: "InferParameters" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## InferParameters + + + +Utility type that infers the TypeScript type from a tool parameter schema. +Supports both Zod schemas and AI library schemas. + +```ts title="Signature" +type InferParameters = T extends Schema + ? T['_type'] + : T extends z.ZodTypeAny + ? z.infer + : never +``` diff --git a/apps/website/docs/api-reference/ai/types/message-filter.mdx b/apps/website/docs/api-reference/ai/types/message-filter.mdx index d47f2f0e..4edf99f3 100644 --- a/apps/website/docs/api-reference/ai/types/message-filter.mdx +++ b/apps/website/docs/api-reference/ai/types/message-filter.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## MessageFilter - + Function type for filtering messages before they are processed by the AI. diff --git a/apps/website/docs/api-reference/ai/types/select-ai-model-result.mdx b/apps/website/docs/api-reference/ai/types/select-ai-model-result.mdx new file mode 100644 index 00000000..1140e155 --- /dev/null +++ b/apps/website/docs/api-reference/ai/types/select-ai-model-result.mdx @@ -0,0 +1,22 @@ +--- +title: "SelectAiModelResult" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## SelectAiModelResult + + + + + +```ts title="Signature" +type SelectAiModelResult = Parameters[0] +``` diff --git a/apps/website/docs/api-reference/ai/types/select-ai-model.mdx b/apps/website/docs/api-reference/ai/types/select-ai-model.mdx index 1e2e7544..2e21798e 100644 --- a/apps/website/docs/api-reference/ai/types/select-ai-model.mdx +++ b/apps/website/docs/api-reference/ai/types/select-ai-model.mdx @@ -13,14 +13,13 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## SelectAiModel - + Function type for selecting an AI model based on the message. ```ts title="Signature" -type SelectAiModel = (message: Message) => Promise<{ - model: LanguageModelV1; - options?: ProviderMetadata; - objectMode?: boolean; -}> +type SelectAiModel = ( + ctx: AiContext, + message: Message, +) => Promise ``` diff --git a/apps/website/docs/api-reference/ai/types/tool-execute-function.mdx b/apps/website/docs/api-reference/ai/types/tool-execute-function.mdx new file mode 100644 index 00000000..2e1ee291 --- /dev/null +++ b/apps/website/docs/api-reference/ai/types/tool-execute-function.mdx @@ -0,0 +1,25 @@ +--- +title: "ToolExecuteFunction" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ToolExecuteFunction + + + +Type definition for a tool's execute function. + +```ts title="Signature" +type ToolExecuteFunction = ( + ctx: AiContext, + parameters: InferParameters, +) => Awaitable +``` diff --git a/apps/website/docs/api-reference/ai/types/tool-parameter-type.mdx b/apps/website/docs/api-reference/ai/types/tool-parameter-type.mdx new file mode 100644 index 00000000..b8cdad97 --- /dev/null +++ b/apps/website/docs/api-reference/ai/types/tool-parameter-type.mdx @@ -0,0 +1,23 @@ +--- +title: "ToolParameterType" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ToolParameterType + + + +Type representing the parameters schema for AI tools. +Extracted from the first parameter of the `tool` function from the 'ai' library. + +```ts title="Signature" +type ToolParameterType = Parameters[0]['parameters'] +``` diff --git a/apps/website/docs/api-reference/ai/variables/get-available-commands.mdx b/apps/website/docs/api-reference/ai/variables/get-available-commands.mdx new file mode 100644 index 00000000..979516f6 --- /dev/null +++ b/apps/website/docs/api-reference/ai/variables/get-available-commands.mdx @@ -0,0 +1,19 @@ +--- +title: "GetAvailableCommands" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getAvailableCommands + + + + + diff --git a/apps/website/docs/api-reference/ai/variables/get-channel-by-id.mdx b/apps/website/docs/api-reference/ai/variables/get-channel-by-id.mdx new file mode 100644 index 00000000..a9800da4 --- /dev/null +++ b/apps/website/docs/api-reference/ai/variables/get-channel-by-id.mdx @@ -0,0 +1,19 @@ +--- +title: "GetChannelById" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getChannelById + + + + + diff --git a/apps/website/docs/api-reference/ai/variables/get-current-client-info.mdx b/apps/website/docs/api-reference/ai/variables/get-current-client-info.mdx new file mode 100644 index 00000000..9de56abc --- /dev/null +++ b/apps/website/docs/api-reference/ai/variables/get-current-client-info.mdx @@ -0,0 +1,19 @@ +--- +title: "GetCurrentClientInfo" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getCurrentClientInfo + + + + + diff --git a/apps/website/docs/api-reference/ai/variables/get-guild-by-id.mdx b/apps/website/docs/api-reference/ai/variables/get-guild-by-id.mdx new file mode 100644 index 00000000..2df96495 --- /dev/null +++ b/apps/website/docs/api-reference/ai/variables/get-guild-by-id.mdx @@ -0,0 +1,19 @@ +--- +title: "GetGuildById" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getGuildById + + + + + diff --git a/apps/website/docs/api-reference/ai/variables/ai-response-schema.mdx b/apps/website/docs/api-reference/ai/variables/get-user-by-id.mdx similarity index 70% rename from apps/website/docs/api-reference/ai/variables/ai-response-schema.mdx rename to apps/website/docs/api-reference/ai/variables/get-user-by-id.mdx index c8510816..1fcf1dc6 100644 --- a/apps/website/docs/api-reference/ai/variables/ai-response-schema.mdx +++ b/apps/website/docs/api-reference/ai/variables/get-user-by-id.mdx @@ -1,5 +1,5 @@ --- -title: "AiResponseSchema" +title: "GetUserById" isDefaultIndex: false generated: true --- @@ -11,9 +11,9 @@ import MemberDescription from '@site/src/components/MemberDescription'; -## AiResponseSchema +## getUserById - + diff --git a/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx b/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx index 67507d6a..0c1a78e7 100644 --- a/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppCommandHandler - + Handles application commands for CommandKit, including loading, registration, and execution. Manages both slash commands and message commands with middleware support. diff --git a/apps/website/docs/api-reference/commandkit/classes/app-command-runner.mdx b/apps/website/docs/api-reference/commandkit/classes/app-command-runner.mdx index b3c6bd14..b1d72fb2 100644 --- a/apps/website/docs/api-reference/commandkit/classes/app-command-runner.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/app-command-runner.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppCommandRunner - + Handles the execution of application commands for CommandKit. Manages middleware execution, environment setup, and command invocation. @@ -21,7 +21,7 @@ Manages middleware execution, environment setup, and command invocation. ```ts title="Signature" class AppCommandRunner { constructor(handler: AppCommandHandler) - runCommand(prepared: PreparedAppCommandExecution, source: Interaction | Message) => ; + runCommand(prepared: PreparedAppCommandExecution, source: Interaction | Message, options?: RunCommandOptions) => ; getExecutionMode(source: Interaction | Message) => CommandExecutionMode; } ``` @@ -35,7 +35,7 @@ class AppCommandRunner { Creates a new AppCommandRunner instance. ### runCommand -PreparedAppCommandExecution, source: Interaction | Message) => `} /> +PreparedAppCommandExecution, source: Interaction | Message, options?: RunCommandOptions) => `} /> Executes a prepared command with middleware support and environment setup. Handles the complete command lifecycle including before/after middleware execution. diff --git a/apps/website/docs/api-reference/commandkit/classes/command-kit-plugin-runtime.mdx b/apps/website/docs/api-reference/commandkit/classes/command-kit-plugin-runtime.mdx index 1a0bf3d6..464da5b6 100644 --- a/apps/website/docs/api-reference/commandkit/classes/command-kit-plugin-runtime.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/command-kit-plugin-runtime.mdx @@ -22,6 +22,7 @@ class CommandKitPluginRuntime { constructor(commandkit: CommandKit) getPlugins() => ; getPlugin(pluginName: string) => RuntimePlugin | null; + get(plugin: T) => InstanceType | null; softRegisterPlugin(plugin: RuntimePlugin) => ; registerPlugin(plugin: RuntimePlugin) => ; unregisterPlugin(plugin: RuntimePlugin) => ; @@ -48,6 +49,11 @@ Returns the plugins registered in this runtime. RuntimePlugin | null`} /> Checks if there are no plugins registered in this runtime. +### get + + InstanceType<T> | null`} /> + +Fetches the given plugin ### softRegisterPlugin RuntimePlugin) => `} /> diff --git a/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx b/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx new file mode 100644 index 00000000..3357eba3 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx @@ -0,0 +1,67 @@ +--- +title: "AppCommandNative" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## AppCommandNative + + + +Represents a native command structure used in CommandKit. +This structure includes the command definition and various handlers for different interaction types. +It can be used to define slash commands, context menu commands, and message commands. + +```ts title="Signature" +interface AppCommandNative { + command: SlashCommandBuilder | Record; + chatInput?: (ctx: Context) => Awaitable; + autocomplete?: (ctx: Context) => Awaitable; + message?: (ctx: Context) => Awaitable; + messageContextMenu?: (ctx: Context) => Awaitable; + userContextMenu?: (ctx: Context) => Awaitable; +} +``` + +
+ +### command + + + + +### chatInput + +Context) => Awaitable<unknown>`} /> + + +### autocomplete + +Context) => Awaitable<unknown>`} /> + + +### message + +Context) => Awaitable<unknown>`} /> + + +### messageContextMenu + +Context) => Awaitable<unknown>`} /> + + +### userContextMenu + +Context) => Awaitable<unknown>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx b/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx new file mode 100644 index 00000000..3f54dc44 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx @@ -0,0 +1,36 @@ +--- +title: "CustomAppCommandProps" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## CustomAppCommandProps + + + +Custom properties that can be added to an AppCommand. +This allows for additional metadata or configuration to be associated with a command. + +```ts title="Signature" +interface CustomAppCommandProps { + [key: string]: any; +} +``` + +
+ +### \[index] + + + + + + +
diff --git a/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx b/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx index 9a821634..339f0f69 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## LoadedCommand - + Represents a loaded command with its metadata and configuration. @@ -34,7 +34,7 @@ interface LoadedCommand { ### data - +AppCommand`} /> ### guilds diff --git a/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx b/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx index b5cb30f5..c893f933 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## PreparedAppCommandExecution - + Represents a prepared command execution with all necessary data and middleware. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/run-command-options.mdx b/apps/website/docs/api-reference/commandkit/interfaces/run-command-options.mdx new file mode 100644 index 00000000..ba22e081 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/interfaces/run-command-options.mdx @@ -0,0 +1,35 @@ +--- +title: "RunCommandOptions" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RunCommandOptions + + + + + +```ts title="Signature" +interface RunCommandOptions { + handler?: string; +} +``` + +
+ +### handler + + + + + + +
diff --git a/apps/website/docs/api-reference/commandkit/types/app-command.mdx b/apps/website/docs/api-reference/commandkit/types/app-command.mdx new file mode 100644 index 00000000..44837b77 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/types/app-command.mdx @@ -0,0 +1,23 @@ +--- +title: "AppCommand" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## AppCommand + + + +Represents a command in the CommandKit application, including its metadata and handlers. +This type extends the native command structure with additional properties. + +```ts title="Signature" +type AppCommand = AppCommandNative & CustomAppCommandProps +``` diff --git a/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx b/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx index 789cfedd..04e94296 100644 --- a/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx +++ b/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandBuilderLike - + Type representing command builder objects supported by CommandKit. diff --git a/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx b/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx index 28682603..34db87e4 100644 --- a/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx +++ b/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandTypeData - + Type representing command data identifier. diff --git a/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx b/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx index 9141bcd9..fe3062ad 100644 --- a/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx +++ b/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ResolvableCommand - + Type for commands that can be resolved by the handler. diff --git a/apps/website/docs/guide/13-ai-powered-commands/00-overview.mdx b/apps/website/docs/guide/13-ai-powered-commands/00-overview.mdx new file mode 100644 index 00000000..34f0bae6 --- /dev/null +++ b/apps/website/docs/guide/13-ai-powered-commands/00-overview.mdx @@ -0,0 +1,288 @@ +--- +title: AI System Overview +description: Complete overview of CommandKit's AI system architecture and capabilities. +--- + +# AI System Overview + +CommandKit's AI system provides a comprehensive framework for creating intelligent Discord bots that can understand natural language and execute commands through large language models. + +## Architecture Overview + +The AI system consists of several key components: + +```mermaid +graph TD + A[Discord Message] --> B[Message Filter] + B --> C[AI Model Selection] + C --> D[System Prompt Generation] + D --> E[AI Processing] + E --> F[Tool Execution] + F --> G[Response Generation] + G --> H[Discord Reply] + + I[Built-in Tools] --> F + J[Custom Tools] --> F + K[AI Commands] --> F +``` + +### Core Components + +1. **AI Plugin** - Manages the AI system lifecycle and message processing +2. **Configuration System** - Handles AI model selection and behavior customization +3. **Context Management** - Provides request context and state management +4. **Tool System** - Enables AI to execute functions and commands +5. **Command Integration** - Allows existing commands to be AI-accessible + +## Key Features + +### Natural Language Processing + +The AI system can understand natural language requests and map them to specific commands: + +``` +User: "Can you ban @user for spamming?" +AI: Executes ban command with user parameter and reason +``` + +### Dynamic Model Selection + +Choose different AI models based on context: + +```ts +selectAiModel: async (ctx, message) => { + if (message.member?.permissions.has('Administrator')) { + return { model: premiumModel }; // Better model for admins + } + return { model: standardModel }; +}; +``` + +### Built-in Discord Integration + +The AI has access to Discord-specific information: + +- Server/guild information +- User and role data +- Channel management +- Permission checking +- Message history + +### Extensible Tool System + +Create custom tools for AI to use: + +```ts +const customTool = createTool({ + name: 'weather', + description: 'Get weather information', + parameters: z.object({ + location: z.string(), + }), + execute: async (ctx, params) => { + return await getWeatherData(params.location); + }, +}); +``` + +## AI Command Flow + +### 1. Message Reception + +```ts +// Discord message received +message: 'Hey bot, can you play some music?'; +``` + +### 2. Message Filtering + +```ts +messageFilter: async (message) => { + return message.mentions.users.has(message.client.user.id); +}; +``` + +### 3. Context Creation + +```ts +const ctx = new AiContext({ + message, + params: {}, + commandkit, +}); +``` + +### 4. AI Processing + +```ts +const result = await generateText({ + model, + prompt: userMessage, + system: systemPrompt, + tools: availableTools, +}); +``` + +### 5. Tool Execution + +```ts +// AI decides to use the music command +await musicCommand.ai({ + action: 'play', + query: 'music', +}); +``` + +### 6. Response Generation + +```ts +await message.reply('🎵 Now playing music!'); +``` + +## Configuration Levels + +### Basic Configuration + +```ts +configureAI({ + selectAiModel: async () => ({ model: myModel }), + messageFilter: async (message) => + message.mentions.users.has(message.client.user.id), +}); +``` + +### Advanced Configuration + +```ts +configureAI({ + selectAiModel: async (ctx, message) => ({ + model: selectBestModel(message), + maxSteps: 10, + temperature: 0.7, + }), + messageFilter: customMessageFilter, + prepareSystemPrompt: customSystemPrompt, + onProcessingStart: startProcessing, + onResult: handleResult, + onError: handleError, +}); +``` + +## Security Considerations + +### Permission Validation + +```ts +export async function ai(ctx: AiContext) { + if (!ctx.message.member?.permissions.has('RequiredPermission')) { + throw new Error('Insufficient permissions'); + } + // Continue with command +} +``` + +### Input Sanitization + +```ts +const sanitizedInput = sanitizeInput(ctx.ai.params.userInput); +``` + +### Rate Limiting + +```ts +const rateLimiter = new RateLimiter(5, 60000); // 5 requests per minute +``` + +## Performance Optimization + +### Caching + +```ts +const cache = new Map(); +const cachedResult = cache.get(cacheKey) || (await expensiveOperation()); +``` + +### Async Processing + +```ts +// Handle long operations asynchronously +processLongOperation(params).then((result) => { + message.reply(`Operation completed: ${result}`); +}); +``` + +### Efficient Database Queries + +```ts +// Batch operations instead of individual queries +const users = await database.user.findMany({ + where: { id: { in: userIds } }, +}); +``` + +## Error Handling + +### Graceful Degradation + +```ts +try { + return await primaryMethod(); +} catch (error) { + console.warn('Primary method failed, using fallback'); + return await fallbackMethod(); +} +``` + +### User-Friendly Messages + +```ts +catch (error) { + const userMessage = getHumanReadableError(error); + await message.reply(userMessage); +} +``` + +## Best Practices + +1. **Always validate permissions** before executing sensitive operations +2. **Sanitize user inputs** to prevent injection attacks +3. **Implement rate limiting** to prevent abuse +4. **Use structured error handling** for better user experience +5. **Cache frequently accessed data** for better performance +6. **Log operations** for debugging and monitoring +7. **Test AI commands thoroughly** before deployment + +## Integration Examples + +### Music Bot + +```ts +"Play some rock music" → musicCommand.ai({ action: 'play', genre: 'rock' }) +``` + +### Moderation + +```ts +"Timeout @user for 10 minutes" → moderationCommand.ai({ action: 'timeout', user: 'id', duration: 600 }) +``` + +### Server Management + +```ts +"Create a new text channel called general" → adminCommand.ai({ action: 'create-channel', name: 'general', type: 'text' }) +``` + +## Getting Started + +1. **Install the AI package**: `npm install @commandkit/ai` +2. **Configure your AI model** in `src/ai.ts` +3. **Create AI-compatible commands** with `aiConfig` and `ai` functions +4. **Test your setup** with simple commands +5. **Gradually add more complex functionality** + +## Next Steps + +- [AI Configuration](./02-ai-configuration.mdx) - Set up your AI models +- [Creating AI Commands](./03-ai-commands.mdx) - Make commands AI-accessible +- [Custom Tools](./06-custom-tools.mdx) - Extend AI capabilities +- [Best Practices](./07-best-practices.mdx) - Production-ready implementations diff --git a/apps/website/docs/guide/13-ai-powered-commands/01-introduction.mdx b/apps/website/docs/guide/13-ai-powered-commands/01-introduction.mdx index 68b86a98..32286ead 100644 --- a/apps/website/docs/guide/13-ai-powered-commands/01-introduction.mdx +++ b/apps/website/docs/guide/13-ai-powered-commands/01-introduction.mdx @@ -35,145 +35,3 @@ export default defineConfig({ plugins: [ai()], }); ``` - -## Setting up the AI model - -CommandKit allows you to dynamically specify the AI model to use for your bot. - -```typescript title="src/ai.ts" -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { configureAI } from '@commandkit/ai'; - -const google = createGoogleGenerativeAI({ - apiKey: process.env.GOOGLE_API_KEY, -}); - -const model = google.languageModel('gemini-2.0-flash'); - -configureAI({ - // commandkit will call this function - // to determine which AI model to use - selectAiModel: async () => { - return { - model, - // OPTIONAL: provider specific options - options, - // OPTIONAL: whether to use the object mode. Default is false. - // If set to true, the AI will be able to generate object responses, such as creating polls or sending embeds. - objectMode: false, - }; - }, - messageFilter: async (message) => { - // only respond to messages in guilds that mention the bot - return ( - message.inGuild() && message.mentions.users.has(message.client.user.id) - ); - }, - // OPTIONAL: set your own system prompt - systemPrompt: async (message) => { - return `You are a helpful AI discord bot. Your name is ${message.client.user.username} and your id is ${message.client.user.id}. - You are designed to assist users with their questions and tasks. You also have access to various tools that can help you perform tasks. - Tools are basically like commands that you can execute to perform specific actions based on user input. - Keep the response short and concise, and only use tools when necessary. Keep the response length under 2000 characters. - Do not include your own text in the response unless necessary. For text formatting, you can use discord's markdown syntax. - ${ - // If the message is in a guild, include the guild name and id - // Otherwise, mention that the message is in a direct message - message.inGuild() - ? `\nYou are currently in a guild named ${message.guild.name} whose id is ${message.guildId}. While in guild, you can fetch member information if needed.` - : '\nYou are currently in a direct message with the user.' - }`; - }, -}); -``` - -Now you can simply import this file in your `app.ts`, - -```ts title="src/app.ts" -import { Client } from 'discord.js'; -// simply import the ai file -import './ai'; - -const client = new Client({...}); - -export default client; -``` - -## Creating AI commands - -AI commands can be created by exporting a function called `ai` from your command file. You can also export `aiConfig` object along with the `ai` function to specify the parameters for the command. - -```typescript title="src/app/commands/balance.ts" -import { ApplicationCommandOptionType } from 'discord.js'; -import { CommandData, ChatInputCommand } from 'commandkit'; -import { AiCommand } from '@commandkit/ai'; -import { z } from 'zod'; - -export const command: CommandData = { - name: 'balance', - description: 'Get the current balance of the user.', - options: [ - { - name: 'user', - description: 'The user to get the balance for.', - type: ApplicationCommandOptionType.User, - }, - ], -}; - -export const aiConfig: AiConfig = { - parameters: z.object({ - userId: z.string().describe('The ID of the user to get the balance of'), - }), -}; - -export const chatInput: ChatInputCommand = async (ctx) => { - const { interaction } = ctx; - const user = interaction.options.getUser('user'); - const balance = await db.getBalance(user.id); - - await interaction.reply({ - content: `The balance of ${user.username} is ${balance}`, - }); -}; - -// AI will call this function to get the balance of the user -export const ai: AiCommand = async (ctx) => { - const { userId } = ctx.params; - const balance = await db.getBalance(userId); - - // return object with the balance - return { - userId, - balance, - }; -}; -``` - -Now, you can simply mention the bot in a message to get the balance of the user. Eg: - -```text -@bot what is the balance of @user? -``` - -AI can also call multiple commands in a single message. Eg: - -```text -@bot show me the basic details of the user @user and also include the balance of that user. -``` - -The above prompt will call the built-in `getUserInfo` tool and the `balance` command. - -## Object Mode Example - -Simply set the `objectMode` to `true` in the `configureAI` function to enable object mode. This allows the AI to generate object responses, such as creating polls or sending embeds. - -```text -@bot create a poll titled "What's your favorite game?" and answers should be -- minecraft -- fortnite -- pubg -- clash of clans -``` - -![object mode example](/img/ai-object-mode.png) diff --git a/apps/website/docs/guide/13-ai-powered-commands/02-ai-configuration.mdx b/apps/website/docs/guide/13-ai-powered-commands/02-ai-configuration.mdx new file mode 100644 index 00000000..99547e96 --- /dev/null +++ b/apps/website/docs/guide/13-ai-powered-commands/02-ai-configuration.mdx @@ -0,0 +1,274 @@ +--- +title: AI Configuration +description: Learn how to configure AI models for CommandKit. +--- + +# AI Configuration + +CommandKit provides comprehensive AI configuration options to customize how your bot processes and responds to messages. You can configure AI models, message filters, system prompts, and processing hooks. + +## Configuration Overview + +The `configureAI` function accepts several configuration options: + +- **`selectAiModel`** (required) - Function to choose which AI model to use +- **`messageFilter`** (optional) - Filter which messages trigger AI processing +- **`disableBuiltInTools`** (optional) - Disable built-in Discord tools +- **`prepareSystemPrompt`** (optional) - Custom system prompt generation +- **`preparePrompt`** (optional) - Custom user prompt preparation +- **Lifecycle hooks** - Functions called during AI processing + +## Creating a Configuration File + +Create a dedicated configuration file, typically `src/ai.ts`, to set up your AI model and processing options: + +```ts title="src/ai.ts" +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { configureAI } from '@commandkit/ai'; + +const google = createGoogleGenerativeAI({ + apiKey: process.env.GOOGLE_API_KEY, +}); + +const model = google.languageModel('gemini-2.0-flash'); + +configureAI({ + // Required: Select which AI model to use + selectAiModel: async (ctx, message) => { + return { + model, + maxSteps: 5, // Maximum number of tool calls + temperature: 0.7, // Response creativity (0-1) + maxTokens: 2000, // Maximum response length + }; + }, + + // Optional: Filter which messages trigger AI processing + messageFilter: async (message) => { + // Only respond when the bot is mentioned + return ( + message.inGuild() && message.mentions.users.has(message.client.user.id) + ); + }, + + // Optional: Customize the system prompt + prepareSystemPrompt: async (ctx, message) => { + return `You are ${message.client.user.username}, a helpful Discord bot. + Current server: ${message.guild?.name || 'DM'} + Keep responses under 2000 characters and be friendly!`; + }, +}); +``` + +## Configuration Options + +### Model Selection (`selectAiModel`) + +The `selectAiModel` function is called for each message and must return model configuration: + +```ts +selectAiModel: async (ctx, message) => { + // Basic model selection + return { + model: google.languageModel('gemini-2.0-flash'), + }; + + // Advanced configuration + return { + model: google.languageModel('gemini-2.0-flash'), + maxSteps: 8, // Max tool calls per request + temperature: 0.6, // Response creativity + maxTokens: 1500, // Max response length + abortSignal: AbortSignal.timeout(30000), // 30s timeout + }; +}; +``` + +### Message Filtering (`messageFilter`) + +Control which messages trigger AI processing: + +```ts +messageFilter: async (message) => { + // Only respond to mentions + return message.mentions.users.has(message.client.user.id); + + // Advanced filtering + return ( + !message.author.bot && // Ignore bots + message.inGuild() && // Only in servers + (message.mentions.users.has(message.client.user.id) || // Mentions + message.content.toLowerCase().includes('hey bot')) // Keywords + ); +}; +``` + +### System Prompts (`prepareSystemPrompt`) + +Customize the AI's behavior and context: + +```ts +prepareSystemPrompt: async (ctx, message) => { + const serverName = message.guild?.name || 'Direct Message'; + const availableCommands = ctx.commandkit.commandHandler + .getCommandsArray() + .filter((cmd) => 'ai' in cmd.data) + .map((cmd) => cmd.data.command.name) + .join(', '); + + return `You are ${message.client.user.username}, a helpful Discord bot. + +**Context:** +- Server: ${serverName} +- Available AI commands: ${availableCommands} +- Current channel: ${message.channel.name || 'DM'} + +**Guidelines:** +- Keep responses under 2000 characters +- Be helpful and friendly +- Use available commands when appropriate +- Ask for clarification if needed`; +}; +``` + +## Lifecycle Hooks + +Configure functions that run during AI processing: + +```ts +configureAI({ + // ... other options + + // Called when AI processing starts + onProcessingStart: async (ctx, message) => { + await message.channel.sendTyping(); + console.log(`AI processing started for ${message.author.username}`); + }, + + // Called when AI processing finishes + onProcessingFinish: async (ctx, message) => { + console.log('AI processing completed'); + }, + + // Called with the AI response + onResult: async (ctx, message, result) => { + if (result.text) { + await message.reply({ + content: result.text.substring(0, 2000), + allowedMentions: { parse: [] }, + }); + } + }, + + // Called when an error occurs + onError: async (ctx, message, error) => { + console.error('AI error:', error.message); + await message.reply( + 'Sorry, I encountered an error processing your request.', + ); + }, +}); +``` + +## Built-in Tools + +CommandKit provides built-in tools that the AI can use: + +- **`getAvailableCommands`** - List available bot commands +- **`getChannelById`** - Get channel information +- **`getCurrentClientInfo`** - Get bot information +- **`getGuildById`** - Get server information +- **`getUserById`** - Get user information + +Disable built-in tools if needed: + +```ts +configureAI({ + disableBuiltInTools: true, // AI won't have access to built-in tools + // ... other options +}); +``` + +Now import this configuration in your main application file: + +```ts title="src/app.ts" +import { Client } from 'discord.js'; +import './ai'; // Import AI configuration + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], +}); + +export default client; +``` + +## Multiple AI Providers + +You can configure multiple AI providers and choose between them dynamically: + +```ts title="src/ai.ts" +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { createOpenAI } from '@ai-sdk/openai'; +import { configureAI } from '@commandkit/ai'; + +// Configure multiple providers +const google = createGoogleGenerativeAI({ apiKey: process.env.GOOGLE_API_KEY }); +const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY }); + +configureAI({ + selectAiModel: async (ctx, message) => { + // Use different models for different channels or users + if (message.channelId === 'premium-channel-id') { + return { + model: openai('gpt-4'), + maxSteps: 10, + }; + } + + // Default to Google Gemini + return { + model: google.languageModel('gemini-2.0-flash'), + maxSteps: 5, + }; + }, + + messageFilter: async (message) => { + return message.mentions.users.has(message.client.user.id); + }, +}); +``` + +## Environment-Based Configuration + +Configure different settings for development and production: + +```ts title="src/ai.ts" +import { configureAI } from '@commandkit/ai'; + +const isDevelopment = process.env.NODE_ENV === 'development'; + +configureAI({ + selectAiModel: async (ctx, message) => ({ + model: google.languageModel( + isDevelopment ? 'gemini-1.5-flash' : 'gemini-2.0-flash', + ), + maxSteps: isDevelopment ? 3 : 8, + temperature: isDevelopment ? 0.8 : 0.6, + }), + + onError: async (ctx, message, error) => { + if (isDevelopment) { + // Show detailed errors in development + await message.reply(`Debug Error: ${error.message}`); + console.error(error.stack); + } else { + // Generic error in production + await message.reply('An error occurred while processing your request.'); + } + }, +}); +``` diff --git a/apps/website/docs/guide/13-ai-powered-commands/03-ai-commands.mdx b/apps/website/docs/guide/13-ai-powered-commands/03-ai-commands.mdx new file mode 100644 index 00000000..9b276363 --- /dev/null +++ b/apps/website/docs/guide/13-ai-powered-commands/03-ai-commands.mdx @@ -0,0 +1,248 @@ +--- +title: Creating AI Commands +description: Learn how to create commands that can be executed by AI models. +--- + +# Creating AI Commands + +CommandKit allows you to create commands that can be executed by AI models through natural language. This enables users to interact with your bot using conversational language instead of specific command syntax. + +## Basic AI Command Structure + +To make a command AI-compatible, you need to: + +1. Add an `ai` function to handle AI execution +2. Add an `aiConfig` object to define the AI parameters + +```ts title="src/commands/greet.ts" +import { CommandData, MessageCommandContext } from 'commandkit'; +import { AiConfig, AiContext } from '@commandkit/ai'; +import { z } from 'zod'; + +export const command: CommandData = { + name: 'greet', + description: 'Greet a user', +}; + +export const aiConfig = { + description: 'Greet a specific user by their name or mention', + parameters: z.object({ + username: z.string().describe('The name or mention of the user to greet'), + }), +} satisfies AiConfig; + +// Regular command handler +export async function messageCommand(ctx: MessageCommandContext) { + const username = ctx.args.join(' ') || 'stranger'; + await ctx.message.reply(`Hello, ${username}!`); +} + +// AI command handler +export async function ai(ctx: AiContext) { + const { username } = ctx.ai.params; + await ctx.message.reply(`Hello, ${username}! 👋`); +} +``` + +## AI Configuration (`aiConfig`) + +The `aiConfig` object defines how the AI model should understand and execute your command: + +### Parameters + +The `parameters` field uses Zod schemas to define the structure and validation for AI-generated parameters: + +```ts +export const aiConfig = { + description: 'Create a poll with multiple options', + parameters: z.object({ + question: z.string().describe('The poll question'), + options: z.array(z.string()).describe('Array of poll options'), + duration: z.number().optional().describe('Poll duration in minutes'), + }), +} satisfies AiConfig; +``` + +### Description + +The `description` field helps the AI model understand when and how to use your command: + +```ts +export const aiConfig = { + description: 'Ban a user from the server with an optional reason', + parameters: z.object({ + userId: z.string().describe('The ID of the user to ban'), + reason: z.string().optional().describe('The reason for the ban'), + }), +} satisfies AiConfig; +``` + +## AI Context (`AiContext`) + +The `AiContext` provides access to AI-specific data and functionality: + +```ts +export async function ai(ctx: AiContext) { + // Access AI-generated parameters + const { message, duration } = ctx.ai.params; + + // Access the original message + const originalMessage = ctx.message; + + // Access the client and CommandKit instance + const client = ctx.client; + const commandkit = ctx.commandkit; + + // Use the store for temporary data + ctx.store.set('startTime', Date.now()); +} +``` + +### Available Properties + +- `params`: The AI-generated parameters based on your schema +- `message`: The original Discord message that triggered the AI +- `client`: The Discord client instance +- `commandkit`: The CommandKit instance +- `store`: A Map for storing temporary data during execution + +## Complex Parameter Schemas + +You can create sophisticated parameter schemas for complex commands: + +```ts title="src/commands/schedule-event.ts" +export const aiConfig = { + description: 'Schedule a server event with date, time, and details', + parameters: z.object({ + title: z.string().describe('Event title'), + description: z.string().optional().describe('Event description'), + date: z.string().describe('Event date in YYYY-MM-DD format'), + time: z.string().describe('Event time in HH:MM format'), + location: z.string().optional().describe('Event location or channel'), + maxParticipants: z + .number() + .optional() + .describe('Maximum number of participants'), + }), +} satisfies AiConfig; + +export async function ai(ctx: AiContext) { + const { title, description, date, time, location, maxParticipants } = + ctx.ai.params; + + // Create the event with the AI-generated parameters + await ctx.message.reply({ + embeds: [ + { + title: `📅 ${title}`, + description: description || 'No description provided', + fields: [ + { name: 'Date', value: date, inline: true }, + { name: 'Time', value: time, inline: true }, + { name: 'Location', value: location || 'TBD', inline: true }, + { + name: 'Max Participants', + value: maxParticipants?.toString() || 'Unlimited', + inline: true, + }, + ], + color: 0x00ff00, + }, + ], + }); +} +``` + +## Error Handling + +Handle errors gracefully in your AI commands: + +```ts +export async function ai(ctx: AiContext) { + try { + const { userId, reason } = ctx.ai.params; + + const user = await ctx.client.users.fetch(userId); + if (!user) { + throw new Error('User not found'); + } + + // Perform the action + await performBan(user, reason); + } catch (error) { + await ctx.message.reply({ + content: `❌ Error: ${error.message}`, + allowedMentions: { parse: [] }, + }); + } +} +``` + +## Best Practices + +1. **Clear Descriptions**: Write clear, specific descriptions for your commands and parameters +2. **Validation**: Use Zod schemas to validate and constrain AI-generated parameters +3. **Error Handling**: Always handle potential errors in AI command execution +4. **User Feedback**: Provide clear feedback about the action taken +5. **Permission Checks**: Verify user permissions before executing sensitive actions + +```ts +export async function ai(ctx: AiContext) { + // Check permissions + if (!ctx.message.member?.permissions.has('ManageMessages')) { + await ctx.message.reply( + '❌ You need Manage Messages permission to use this command.', + ); + return; + } + + // Validate parameters + const { count } = ctx.ai.params; + if (count > 100) { + await ctx.message.reply('❌ Cannot delete more than 100 messages at once.'); + return; + } + + // Execute the action + // ... rest of the implementation +} +``` + +## Interactive Components + +AI commands can also work with interactive components like buttons and select menus: + +```tsx +import { Button, ActionRow } from 'commandkit'; +import { ButtonStyle } from 'discord.js'; + +export async function ai(ctx: AiContext) { + const { message } = ctx.ai.params; + + const confirmButton = ( + + + + + ); + + await ctx.message.reply({ + content: message, + components: [confirmButton], + }); +} +``` diff --git a/apps/website/docs/guide/13-ai-powered-commands/03-creating-ai-commands.mdx b/apps/website/docs/guide/13-ai-powered-commands/03-creating-ai-commands.mdx new file mode 100644 index 00000000..e040f786 --- /dev/null +++ b/apps/website/docs/guide/13-ai-powered-commands/03-creating-ai-commands.mdx @@ -0,0 +1,77 @@ +--- +title: Creating AI Commands +description: Learn how to create commands that can be executed by AI models. +--- + +## Creating AI commands + +AI commands can be created by exporting a function called `ai` from your regular command file. You can also export `aiConfig` object along with the `ai` function to specify the parameters for the command. + +The `ai` function behaves similar to a `message` command, but it receives an additional `ctx.ai` property that contains the parameters passed by the AI model. + +```typescript title="src/app/commands/balance.ts" +import { ApplicationCommandOptionType } from 'discord.js'; +import { CommandData, ChatInputCommand } from 'commandkit'; +import { AiCommand } from '@commandkit/ai'; +import { z } from 'zod'; + +export const command: CommandData = { + name: 'balance', + description: 'Get the current balance of the user.', + options: [ + { + name: 'user', + description: 'The user to get the balance for.', + type: ApplicationCommandOptionType.User, + }, + ], +}; + +export const aiConfig: AiConfig = { + parameters: z.object({ + userId: z.string().describe('The ID of the user to get the balance of'), + }), +}; + +export const chatInput: ChatInputCommand = async (ctx) => { + const { interaction } = ctx; + const user = interaction.options.getUser('user'); + const balance = await db.getBalance(user.id); + + await interaction.reply({ + content: `The balance of ${user.username} is ${balance}`, + }); +}; + +// AI will call this function to get the balance of the user +export const ai: MessageCommand = async (ctx) => { + const { userId } = ctx.ai!.params; + const balance = await db.getBalance(userId); + + // return object with the balance + return { + userId, + balance, + }; +}; +``` + +Now, you can simply mention the bot in a message to get the balance of the user. Eg: + +```text +@bot what is the balance of @user? +``` + +AI can also call multiple commands in a single message. Eg: + +```text +@bot show me the basic details of the user @user and also include the balance of that user. +``` + +The above prompt will call the built-in `getUserInfo` tool and the `balance` command. + +## Example of AI creating a poll + +This is an example of how AI can create a poll using the `createPoll` command. The AI will generate a poll based on the user's input. + +![ai poll example](/img/ai-poll-example.png) diff --git a/apps/website/docs/guide/13-ai-powered-commands/04-ai-context-hooks.mdx b/apps/website/docs/guide/13-ai-powered-commands/04-ai-context-hooks.mdx new file mode 100644 index 00000000..3f9fd004 --- /dev/null +++ b/apps/website/docs/guide/13-ai-powered-commands/04-ai-context-hooks.mdx @@ -0,0 +1,306 @@ +--- +title: AI Context and Hooks +description: Learn about AI context, built-in tools, and custom hooks for AI commands. +--- + +# AI Context and Hooks + +CommandKit's AI system provides powerful context management and built-in tools to enhance your AI-powered commands. + +## AI Context + +The `AiContext` class provides comprehensive access to the AI execution environment: + +### Context Properties + +```ts +import { AiContext } from '@commandkit/ai'; + +export async function ai(ctx: AiContext) { + // AI-generated parameters based on your schema + const params = ctx.ai.params; + + // Original Discord message that triggered the AI + const message = ctx.message; + + // Discord client instance + const client = ctx.client; + + // CommandKit instance + const commandkit = ctx.commandkit; + + // Key-value store for temporary data + const store = ctx.store; +} +``` + +### Using the Store + +The context store allows you to persist data during command execution: + +```ts +export async function ai(ctx: AiContext) { + // Store data + ctx.store.set('startTime', Date.now()); + ctx.store.set('userPreferences', { theme: 'dark', lang: 'en' }); + + // Retrieve data + const startTime = ctx.store.get('startTime'); + const preferences = ctx.store.get('userPreferences'); + + // Check if data exists + if (ctx.store.has('processedUsers')) { + // Handle already processed users + } + + // Delete data + ctx.store.delete('temporaryData'); +} +``` + +## AI Hooks + +CommandKit provides several hooks to interact with the AI system: + +### `useAIContext()` + +Access the current AI context from anywhere in your code: + +```ts +import { useAIContext } from '@commandkit/ai'; + +function someUtilityFunction() { + try { + const ctx = useAIContext(); + console.log('Current AI parameters:', ctx.ai.params); + return ctx.message.author.id; + } catch (error) { + // Not in an AI context + return null; + } +} +``` + +### `useAI()` + +Get access to the AI plugin instance: + +```ts +import { useAI } from '@commandkit/ai'; + +async function executeAICommand(message: Message) { + const aiPlugin = useAI(); + await aiPlugin.executeAI(message); +} +``` + +## Built-in AI Tools + +CommandKit provides several built-in tools that the AI can use to gather information: + +### Available Tools + +1. **`getAvailableCommands`** - Lists all available commands +2. **`getChannelById`** - Fetches channel information by ID +3. **`getCurrentClientInfo`** - Gets information about the bot +4. **`getGuildById`** - Fetches guild information by ID +5. **`getUserById`** - Fetches user information by ID + +### Example Tool Usage + +The AI can automatically use these tools. For example, if a user asks "What commands are available?", the AI will use the `getAvailableCommands` tool: + +```ts +// This happens automatically when the AI needs command information +const commands = await getAvailableCommands.execute({}, {}); +console.log(commands); +// Output: [ +// { name: 'ping', description: 'Check bot latency', category: 'utility', supportsAI: false }, +// { name: 'greet', description: 'Greet a user', category: 'social', supportsAI: true }, +// // ... more commands +// ] +``` + +## Creating Custom Tools + +You can create custom tools for the AI to use: + +```ts title="src/tools/weather.ts" +import { createTool } from '@commandkit/ai'; +import { z } from 'zod'; + +export const getWeather = createTool({ + name: 'getWeather', + description: 'Get current weather information for a location', + parameters: z.object({ + location: z.string().describe('The city or location to get weather for'), + units: z.enum(['celsius', 'fahrenheit']).default('celsius'), + }), + async execute(ctx, params) { + const { location, units } = params; + + // Fetch weather data from an API + const weatherData = await fetchWeatherAPI(location); + + return { + location, + temperature: units === 'celsius' ? weatherData.tempC : weatherData.tempF, + condition: weatherData.condition, + humidity: weatherData.humidity, + windSpeed: weatherData.windSpeed, + }; + }, +}); +``` + +### Tool Parameters + +Tools support comprehensive parameter validation: + +```ts +export const calculateTax = createTool({ + name: 'calculateTax', + description: 'Calculate tax amount for a given price and tax rate', + parameters: z.object({ + price: z.number().positive().describe('The base price amount'), + taxRate: z + .number() + .min(0) + .max(1) + .describe('Tax rate as decimal (e.g., 0.08 for 8%)'), + currency: z.string().default('USD').describe('Currency code'), + }), + async execute(ctx, params) { + const { price, taxRate, currency } = params; + const taxAmount = price * taxRate; + const total = price + taxAmount; + + return { + basePrice: price, + taxRate: taxRate * 100, // Convert to percentage + taxAmount, + total, + currency, + }; + }, +}); +``` + +## Advanced Context Usage + +### Accessing Discord Objects + +The AI context provides access to all Discord.js objects: + +```ts +export async function ai(ctx: AiContext) { + const { message, client } = ctx; + + // Access guild information + if (message.inGuild()) { + const guild = message.guild; + const member = message.member; + + console.log(`Guild: ${guild.name}`); + console.log( + `Member roles: ${member.roles.cache.map((r) => r.name).join(', ')}`, + ); + } + + // Access channel information + const channel = message.channel; + if (channel.isTextBased()) { + console.log(`Channel: ${channel.name || 'DM'}`); + } + + // Access bot information + const botUser = client.user; + console.log(`Bot: ${botUser.username}#${botUser.discriminator}`); +} +``` + +### Error Handling in Context + +Implement robust error handling in your AI commands: + +```ts +export async function ai(ctx: AiContext) { + try { + const { userId } = ctx.ai.params; + + // Attempt to fetch user + const user = await ctx.client.users.fetch(userId).catch(() => null); + + if (!user) { + await ctx.message.reply('❌ User not found.'); + return; + } + + // Process the user + await processUser(user); + } catch (error) { + // Log error with context + console.error('AI command error:', { + command: 'example', + userId: ctx.message.author.id, + guildId: ctx.message.guildId, + error: error.message, + }); + + await ctx.message.reply('❌ An unexpected error occurred.'); + } +} +``` + +## Context Lifecycle + +Understanding the AI context lifecycle helps with proper resource management: + +```ts +export async function ai(ctx: AiContext) { + // 1. Context is created when AI processes the message + console.log('AI context created'); + + // 2. Parameters are set based on AI model output + console.log('Parameters:', ctx.ai.params); + + // 3. Your command executes + await performAction(ctx.ai.params); + + // 4. Context is cleaned up automatically + // Note: Store data is automatically cleared after execution +} +``` + +## Best Practices + +1. **Resource Cleanup**: The context store is automatically cleared, but clean up external resources manually +2. **Error Boundaries**: Always wrap potentially failing operations in try-catch blocks +3. **Validation**: Validate AI-generated parameters even with Zod schemas +4. **Logging**: Use the context information for detailed logging +5. **Performance**: Avoid storing large objects in the context store + +```ts +export async function ai(ctx: AiContext) { + // Good: Validate critical parameters + const { amount } = ctx.ai.params; + if (amount <= 0 || amount > 1000000) { + await ctx.message.reply('❌ Invalid amount specified.'); + return; + } + + // Good: Log operations with context + console.log( + `Processing payment: ${amount} for user ${ctx.message.author.id}`, + ); + + // Good: Handle external API failures + try { + const result = await processPayment(amount); + await ctx.message.reply(`✅ Payment processed: ${result.transactionId}`); + } catch (error) { + console.error('Payment failed:', error); + await ctx.message.reply('❌ Payment processing failed. Please try again.'); + } +} +``` diff --git a/apps/website/docs/guide/13-ai-powered-commands/05-advanced-configuration.mdx b/apps/website/docs/guide/13-ai-powered-commands/05-advanced-configuration.mdx new file mode 100644 index 00000000..401e2f75 --- /dev/null +++ b/apps/website/docs/guide/13-ai-powered-commands/05-advanced-configuration.mdx @@ -0,0 +1,475 @@ +--- +title: Advanced AI Configuration +description: Deep dive into advanced AI configuration options and customization. +--- + +# Advanced AI Configuration + +CommandKit provides extensive configuration options to customize the AI behavior, from system prompts to error handling. + +## Complete Configuration Options + +The `configureAI` function accepts a comprehensive configuration object: + +```ts title="src/ai.ts" +import { configureAI } from '@commandkit/ai'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; + +const google = createGoogleGenerativeAI({ apiKey: process.env.GOOGLE_API_KEY }); + +configureAI({ + // Required: Select which AI model to use + selectAiModel: async (ctx, message) => ({ + model: google.languageModel('gemini-2.0-flash'), + maxSteps: 5, + abortSignal: AbortSignal.timeout(30000), + }), + + // Optional: Filter which messages trigger AI processing + messageFilter: async (message) => { + return message.mentions.users.has(message.client.user.id); + }, + + // Optional: Disable built-in Discord tools + disableBuiltInTools: false, + + // Optional: Custom system prompt generation + prepareSystemPrompt: async (ctx, message) => { + return `You are ${message.client.user.username}, a helpful Discord bot...`; + }, + + // Optional: Custom prompt preparation + preparePrompt: async (ctx, message) => { + return `User: ${message.content}\nAI:`; + }, + + // Optional: Processing lifecycle hooks + onProcessingStart: async (ctx, message) => { + console.log('AI processing started'); + }, + + onProcessingFinish: async (ctx, message) => { + console.log('AI processing finished'); + }, + + onResult: async (ctx, message, result) => { + await message.reply(result.text); + }, + + onError: async (ctx, message, error) => { + console.error('AI error:', error); + await message.reply('Something went wrong!'); + }, +}); +``` + +## Model Selection Strategies + +### Static Model Selection + +Use the same model for all requests: + +```ts +selectAiModel: async (ctx, message) => ({ + model: google.languageModel('gemini-2.0-flash'), +}); +``` + +### Dynamic Model Selection + +Choose models based on context: + +```ts +selectAiModel: async (ctx, message) => { + // Use different models based on channel or user + if (message.channelId === 'premium-channel-id') { + return { + model: google.languageModel('gemini-2.0-flash'), + maxSteps: 10, + }; + } + + // Default model for regular channels + return { + model: google.languageModel('gemini-1.5-flash'), + maxSteps: 5, + }; +}; +``` + +### Model Selection with Custom Options + +Configure model-specific options: + +```ts +selectAiModel: async (ctx, message) => ({ + model: google.languageModel('gemini-2.0-flash'), + maxSteps: 8, + temperature: 0.7, + maxTokens: 2000, + abortSignal: AbortSignal.timeout(45000), + tools: { + // Add custom tools specific to this model + customTool: myCustomTool, + }, +}); +``` + +## Message Filtering + +### Basic Filtering + +```ts +messageFilter: async (message) => { + // Only respond to mentions + return message.mentions.users.has(message.client.user.id); +}; +``` + +### Advanced Filtering + +```ts +messageFilter: async (message) => { + // Don't process bot messages + if (message.author.bot) return false; + + // Only process in specific channels + const allowedChannels = ['ai-chat', 'bot-commands']; + if (message.inGuild() && !allowedChannels.includes(message.channel.name)) { + return false; + } + + // Check user permissions + if (message.inGuild() && !message.member?.permissions.has('SendMessages')) { + return false; + } + + // Check for mentions or specific keywords + const hasMention = message.mentions.users.has(message.client.user.id); + const hasKeyword = /\b(hey bot|help me|ai)\b/i.test(message.content); + + return hasMention || hasKeyword; +}; +``` + +### Role-based Filtering + +```ts +messageFilter: async (message) => { + if (!message.inGuild()) return false; + + const member = message.member; + const allowedRoles = ['Premium', 'Moderator', 'AI User']; + + return member.roles.cache.some((role) => allowedRoles.includes(role.name)); +}; +``` + +## Custom System Prompts + +### Dynamic System Prompts + +```ts +prepareSystemPrompt: async (ctx, message) => { + const basePrompt = `You are ${message.client.user.username}, a helpful Discord bot.`; + + if (message.inGuild()) { + const guild = message.guild; + const member = message.member; + + return `${basePrompt} + +**Current Context:** +- Server: ${guild.name} (${guild.memberCount} members) +- Channel: ${message.channel.name} +- User: ${member.displayName} (${member.roles.cache.map((r) => r.name).join(', ')}) + +**Your Capabilities:** +${ctx.commandkit.commandHandler + .getCommandsArray() + .filter((cmd) => 'ai' in cmd.data) + .map((cmd) => `- ${cmd.data.command.name}: ${cmd.data.command.description}`) + .join('\n')} + +Keep responses under 2000 characters and be helpful!`; + } + + return `${basePrompt}\n\nYou are in a direct message. Be conversational and helpful!`; +}; +``` + +### Contextual System Prompts + +```ts +prepareSystemPrompt: async (ctx, message) => { + const timeOfDay = new Date().getHours(); + const greeting = + timeOfDay < 12 + ? 'Good morning' + : timeOfDay < 18 + ? 'Good afternoon' + : 'Good evening'; + + const userHistory = await getUserInteractionHistory(message.author.id); + const personalizedNote = + userHistory.length > 0 + ? `You've helped this user ${userHistory.length} times before.` + : 'This is your first interaction with this user.'; + + return `${greeting}! You are ${message.client.user.username}, a friendly Discord bot. + +${personalizedNote} + +Current server context: ${message.inGuild() ? message.guild.name : 'Direct Message'} +Available commands: ${getAvailableAICommands().join(', ')} + +Be helpful, concise, and friendly in your responses!`; +}; +``` + +## Custom Prompt Preparation + +### Enhanced User Context + +```ts +preparePrompt: async (ctx, message) => { + const userInfo = ` + ${message.author.id} + ${message.author.username} + ${message.author.displayName} + ${message.author.avatarURL()} + ${message.author.createdAt.toISOString()} + `; + + const channelInfo = message.inGuild() + ? ` + + ${message.channelId} + ${message.channel.name} + ${message.channel.type} + + + + ${message.guildId} + ${message.guild.name} + ${message.guild.memberCount} + ` + : 'Direct Message'; + + return `${userInfo}${channelInfo} + +Message: ${message.content} + +Please respond appropriately based on the context and available tools.`; +}; +``` + +### Conversation History + +```ts +preparePrompt: async (ctx, message) => { + const recentMessages = await message.channel.messages.fetch({ limit: 5 }); + const conversation = recentMessages + .reverse() + .map((msg) => `${msg.author.username}: ${msg.content}`) + .join('\n'); + + return `Recent conversation: +${conversation} + +Current user: ${message.author.username} +Current message: ${message.content} + +AI: `; +}; +``` + +## Lifecycle Hooks + +### Processing Start Hook + +```ts +onProcessingStart: async (ctx, message) => { + // Start typing indicator + await message.channel.sendTyping(); + + // Log processing start + console.log(`AI processing started for user ${message.author.id}`); + + // Store processing metrics + ctx.store.set('processingStartTime', Date.now()); + + // Send acknowledgment for slow operations + if (isExpectedToTakeLong(message.content)) { + await message.react('⏳'); + } +}; +``` + +### Processing Finish Hook + +```ts +onProcessingFinish: async (ctx, message) => { + const startTime = ctx.store.get('processingStartTime'); + const duration = startTime ? Date.now() - startTime : 0; + + console.log(`AI processing finished in ${duration}ms`); + + // Remove processing indicators + await message.reactions.removeAll().catch(() => {}); + + // Log metrics + await logAIUsage({ + userId: message.author.id, + guildId: message.guildId, + duration, + success: true, + }); +}; +``` + +### Result Handling + +```ts +onResult: async (ctx, message, result) => { + const { text, finishReason, usage } = result; + + // Log token usage + console.log(`Tokens used: ${usage?.totalTokens || 'unknown'}`); + + if (!text?.trim()) { + await message.reply('I processed your request but have nothing to say.'); + return; + } + + // Split long responses + if (text.length > 2000) { + const chunks = splitMessage(text, 2000); + for (const chunk of chunks) { + await message.reply(chunk); + } + } else { + await message.reply({ + content: text, + allowedMentions: { parse: [] }, + }); + } + + // React based on result + if (finishReason === 'stop') { + await message.react('✅'); + } else if (finishReason === 'length') { + await message.react('📝'); // Indicate truncated response + } +}; +``` + +### Error Handling + +```ts +onError: async (ctx, message, error) => { + console.error('AI processing error:', { + error: error.message, + userId: message.author.id, + guildId: message.guildId, + messageContent: message.content.substring(0, 100), + }); + + // Different error responses based on error type + if (error.message.includes('timeout')) { + await message.reply( + '⏰ The request took too long to process. Please try again.', + ); + } else if (error.message.includes('rate limit')) { + await message.reply( + "🚫 I'm currently rate limited. Please wait a moment and try again.", + ); + } else if (error.message.includes('invalid')) { + await message.reply( + '❌ There was an issue with your request. Please rephrase and try again.', + ); + } else { + await message.reply( + '🔧 Something went wrong while processing your request.', + ); + } + + // Log to monitoring service + await reportError({ + service: 'ai', + error: error.message, + context: { userId: message.author.id, guildId: message.guildId }, + }); +}; +``` + +## Built-in Tools Configuration + +### Disabling Built-in Tools + +```ts +configureAI({ + disableBuiltInTools: true, + selectAiModel: async (ctx, message) => ({ + model: myModel, + tools: { + // Only use your custom tools + myCustomTool: myCustomTool, + }, + }), +}); +``` + +### Selective Tool Usage + +```ts +selectAiModel: async (ctx, message) => { + const tools = {}; + + // Add tools based on context + if (message.inGuild()) { + tools.getGuildById = getGuildById; + tools.getChannelById = getChannelById; + } + + if (message.member?.permissions.has('Administrator')) { + tools.adminTools = adminTools; + } + + return { + model: myModel, + tools, + }; +}; +``` + +## Environment-specific Configuration + +### Development vs Production + +```ts title="src/ai.ts" +const isDevelopment = process.env.NODE_ENV === 'development'; + +configureAI({ + selectAiModel: async (ctx, message) => ({ + model: google.languageModel( + isDevelopment ? 'gemini-1.5-flash' : 'gemini-2.0-flash', + ), + maxSteps: isDevelopment ? 3 : 8, + temperature: isDevelopment ? 0.8 : 0.6, + }), + + onError: async (ctx, message, error) => { + if (isDevelopment) { + // Detailed error in development + await message.reply(`Debug Error: ${error.message}`); + console.error(error.stack); + } else { + // Generic error in production + await message.reply('An error occurred while processing your request.'); + // Log to production monitoring + logProductionError(error, ctx, message); + } + }, +}); +``` diff --git a/apps/website/docs/guide/13-ai-powered-commands/06-custom-tools.mdx b/apps/website/docs/guide/13-ai-powered-commands/06-custom-tools.mdx new file mode 100644 index 00000000..cce02ce5 --- /dev/null +++ b/apps/website/docs/guide/13-ai-powered-commands/06-custom-tools.mdx @@ -0,0 +1,581 @@ +--- +title: Custom AI Tools +description: Learn how to create custom tools for AI models to extend functionality. +--- + +# Custom AI Tools + +CommandKit allows you to create custom tools that AI models can use to extend their capabilities beyond built-in Discord functions. + +## Creating Basic Tools + +Use the `createTool` function to define custom tools: + +```ts title="src/tools/math.ts" +import { createTool } from '@commandkit/ai'; +import { z } from 'zod'; + +export const calculator = createTool({ + name: 'calculator', + description: 'Perform mathematical calculations', + parameters: z.object({ + expression: z + .string() + .describe( + 'Mathematical expression to evaluate (e.g., "2 + 2", "sqrt(16)")', + ), + }), + async execute(ctx, params) { + const { expression } = params; + + try { + // Use a safe math evaluator (never use eval() directly!) + const result = evaluateMathExpression(expression); + + return { + expression, + result, + success: true, + }; + } catch (error) { + return { + expression, + error: error.message, + success: false, + }; + } + }, +}); +``` + +## Parameter Validation + +Use Zod schemas for comprehensive parameter validation: + +```ts title="src/tools/weather.ts" +export const getWeather = createTool({ + name: 'getWeather', + description: 'Get current weather information for any location', + parameters: z.object({ + location: z.string().min(1).describe('City name or coordinates'), + units: z.enum(['metric', 'imperial', 'kelvin']).default('metric'), + includeHourly: z + .boolean() + .default(false) + .describe('Include hourly forecast'), + days: z + .number() + .min(1) + .max(7) + .default(1) + .describe('Number of forecast days'), + }), + async execute(ctx, params) { + const { location, units, includeHourly, days } = params; + + // Fetch weather data from API + const weatherData = await fetchWeatherAPI({ + location, + units, + forecast: days > 1, + hourly: includeHourly, + }); + + return { + location: weatherData.location, + current: { + temperature: weatherData.current.temp, + condition: weatherData.current.condition, + humidity: weatherData.current.humidity, + windSpeed: weatherData.current.windSpeed, + }, + forecast: weatherData.forecast?.slice(0, days), + units, + }; + }, +}); +``` + +## Database Integration + +Create tools that interact with databases: + +```ts title="src/tools/user-profile.ts" +export const getUserProfile = createTool({ + name: 'getUserProfile', + description: 'Get user profile information from the database', + parameters: z.object({ + userId: z.string().describe('Discord user ID'), + includeStats: z + .boolean() + .default(false) + .describe('Include user statistics'), + }), + async execute(ctx, params) { + const { userId, includeStats } = params; + + try { + const profile = await database.user.findUnique({ + where: { discordId: userId }, + include: { + stats: includeStats, + preferences: true, + }, + }); + + if (!profile) { + return { + error: 'User profile not found', + userId, + }; + } + + return { + userId, + profile: { + level: profile.level, + experience: profile.experience, + joinedAt: profile.createdAt, + preferences: profile.preferences, + ...(includeStats && { stats: profile.stats }), + }, + }; + } catch (error) { + return { + error: 'Failed to fetch user profile', + userId, + }; + } + }, +}); + +export const updateUserProfile = createTool({ + name: 'updateUserProfile', + description: 'Update user profile settings', + parameters: z.object({ + userId: z.string().describe('Discord user ID'), + updates: z.object({ + nickname: z.string().optional(), + bio: z.string().max(500).optional(), + timezone: z.string().optional(), + notifications: z.boolean().optional(), + }), + }), + async execute(ctx, params) { + const { userId, updates } = params; + + // Verify the user has permission to update this profile + if ( + ctx.message.author.id !== userId && + !isModeratorOrAdmin(ctx.message.member) + ) { + return { + error: 'You can only update your own profile', + userId, + }; + } + + try { + const updatedProfile = await database.user.update({ + where: { discordId: userId }, + data: updates, + }); + + return { + success: true, + userId, + updatedFields: Object.keys(updates), + }; + } catch (error) { + return { + error: 'Failed to update profile', + userId, + }; + } + }, +}); +``` + +## API Integration Tools + +Create tools that interact with external APIs: + +```ts title="src/tools/github.ts" +export const searchGitHubRepos = createTool({ + name: 'searchGitHubRepos', + description: 'Search for GitHub repositories', + parameters: z.object({ + query: z.string().describe('Search query for repositories'), + language: z.string().optional().describe('Filter by programming language'), + sort: z.enum(['stars', 'forks', 'updated']).default('stars'), + limit: z.number().min(1).max(20).default(5), + }), + async execute(ctx, params) { + const { query, language, sort, limit } = params; + + try { + const searchQuery = language ? `${query} language:${language}` : query; + + const response = await fetch( + `https://api.github.com/search/repositories?q=${encodeURIComponent(searchQuery)}&sort=${sort}&per_page=${limit}`, + { + headers: { + Authorization: `token ${process.env.GITHUB_TOKEN}`, + Accept: 'application/vnd.github.v3+json', + }, + }, + ); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = await response.json(); + + return { + query, + totalCount: data.total_count, + repositories: data.items.map((repo) => ({ + name: repo.name, + fullName: repo.full_name, + description: repo.description, + stars: repo.stargazers_count, + forks: repo.forks_count, + language: repo.language, + url: repo.html_url, + lastUpdated: repo.updated_at, + })), + }; + } catch (error) { + return { + error: `Failed to search repositories: ${error.message}`, + query, + }; + } + }, +}); +``` + +## File System Tools + +Create tools for file operations (use with caution): + +```ts title="src/tools/files.ts" +export const listFiles = createTool({ + name: 'listFiles', + description: 'List files in a directory (admin only)', + parameters: z.object({ + directory: z.string().describe('Directory path to list'), + includeHidden: z.boolean().default(false), + }), + async execute(ctx, params) { + // Security check + if (!isAdmin(ctx.message.member)) { + return { + error: 'This command requires administrator permissions', + }; + } + + const { directory, includeHidden } = params; + + try { + const fs = await import('fs/promises'); + const path = await import('path'); + + // Sanitize path to prevent directory traversal + const safePath = path.resolve(process.cwd(), directory); + if (!safePath.startsWith(process.cwd())) { + throw new Error('Invalid directory path'); + } + + const entries = await fs.readdir(safePath, { withFileTypes: true }); + const files = entries + .filter((entry) => includeHidden || !entry.name.startsWith('.')) + .map((entry) => ({ + name: entry.name, + type: entry.isDirectory() ? 'directory' : 'file', + path: path.join(safePath, entry.name), + })); + + return { + directory: safePath, + files, + count: files.length, + }; + } catch (error) { + return { + error: `Failed to list files: ${error.message}`, + directory, + }; + } + }, +}); +``` + +## Moderation Tools + +Create tools for server moderation: + +```ts title="src/tools/moderation.ts" +export const moderateContent = createTool({ + name: 'moderateContent', + description: 'Check if content violates server rules', + parameters: z.object({ + content: z.string().describe('Content to moderate'), + strictMode: z + .boolean() + .default(false) + .describe('Use strict moderation rules'), + }), + async execute(ctx, params) { + const { content, strictMode } = params; + + // Check for various violations + const violations = []; + + // Profanity check + if (containsProfanity(content)) { + violations.push('profanity'); + } + + // Spam check + if (isSpam(content)) { + violations.push('spam'); + } + + // Link check + if (containsSuspiciousLinks(content)) { + violations.push('suspicious_links'); + } + + // Strict mode additional checks + if (strictMode) { + if (containsCapSpam(content)) { + violations.push('excessive_caps'); + } + + if (containsRepeatedChars(content)) { + violations.push('repeated_characters'); + } + } + + return { + content: content.substring(0, 100) + (content.length > 100 ? '...' : ''), + violations, + isClean: violations.length === 0, + severity: + violations.length > 2 + ? 'high' + : violations.length > 0 + ? 'medium' + : 'low', + recommendations: getRecommendations(violations), + }; + }, +}); + +export const timeoutUser = createTool({ + name: 'timeoutUser', + description: 'Timeout a user (moderator only)', + parameters: z.object({ + userId: z.string().describe('User ID to timeout'), + duration: z + .number() + .min(60) + .max(2419200) + .describe('Timeout duration in seconds'), + reason: z.string().optional().describe('Reason for timeout'), + }), + async execute(ctx, params) { + const { userId, duration, reason } = params; + + // Permission check + if (!ctx.message.member?.permissions.has('ModerateMembers')) { + return { + error: 'You need Moderate Members permission to use this command', + }; + } + + try { + const member = await ctx.message.guild?.members.fetch(userId); + if (!member) { + return { + error: 'Member not found', + userId, + }; + } + + await member.timeout(duration * 1000, reason || 'No reason provided'); + + return { + success: true, + userId, + duration, + reason, + moderator: ctx.message.author.id, + }; + } catch (error) { + return { + error: `Failed to timeout user: ${error.message}`, + userId, + }; + } + }, +}); +``` + +## Utility Tools + +Create general utility tools: + +```ts title="src/tools/utilities.ts" +export const generateQR = createTool({ + name: 'generateQR', + description: 'Generate a QR code for text or URL', + parameters: z.object({ + data: z.string().describe('Text or URL to encode'), + size: z.enum(['small', 'medium', 'large']).default('medium'), + format: z.enum(['png', 'svg']).default('png'), + }), + async execute(ctx, params) { + const { data, size, format } = params; + + try { + const QRCode = await import('qrcode'); + + const sizeMap = { small: 128, medium: 256, large: 512 }; + const qrSize = sizeMap[size]; + + if (format === 'svg') { + const svg = await QRCode.toString(data, { + type: 'svg', + width: qrSize, + }); + + return { + success: true, + data: data.substring(0, 50), + format: 'svg', + svg, + }; + } else { + const buffer = await QRCode.toBuffer(data, { + width: qrSize, + }); + + const base64 = buffer.toString('base64'); + + return { + success: true, + data: data.substring(0, 50), + format: 'png', + image: `data:image/png;base64,${base64}`, + }; + } + } catch (error) { + return { + error: `Failed to generate QR code: ${error.message}`, + }; + } + }, +}); + +export const shortenUrl = createTool({ + name: 'shortenUrl', + description: 'Shorten a long URL', + parameters: z.object({ + url: z.string().url().describe('URL to shorten'), + customAlias: z + .string() + .optional() + .describe('Custom alias for the shortened URL'), + }), + async execute(ctx, params) { + const { url, customAlias } = params; + + try { + // Use a URL shortening service API + const response = await fetch('https://api.short.io/links', { + method: 'POST', + headers: { + Authorization: process.env.SHORT_IO_KEY, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + originalURL: url, + domain: 'short.io', + ...(customAlias && { path: customAlias }), + }), + }); + + if (!response.ok) { + throw new Error(`URL shortening failed: ${response.status}`); + } + + const data = await response.json(); + + return { + originalUrl: url, + shortUrl: data.shortURL, + alias: data.path, + createdAt: new Date().toISOString(), + }; + } catch (error) { + return { + error: `Failed to shorten URL: ${error.message}`, + originalUrl: url, + }; + } + }, +}); +``` + +## Tool Registration + +Register your custom tools by adding them to the AI model configuration: + +```ts title="src/ai.ts" +import { configureAI } from '@commandkit/ai'; +import { calculator } from './tools/math'; +import { getWeather } from './tools/weather'; +import { searchGitHubRepos } from './tools/github'; + +configureAI({ + selectAiModel: async (ctx, message) => ({ + model: myModel, + tools: { + // Add your custom tools + calculator, + getWeather, + searchGitHubRepos, + }, + }), + // ... other configuration +}); +``` + +## Best Practices + +1. **Security**: Always validate user permissions for sensitive operations +2. **Error Handling**: Return structured error information instead of throwing +3. **Rate Limiting**: Implement rate limiting for API calls +4. **Validation**: Use Zod schemas for comprehensive parameter validation +5. **Documentation**: Provide clear descriptions for tools and parameters +6. **Testing**: Test tools independently before integration + +```ts +// Good: Structured error handling +return { + error: 'Specific error message', + code: 'ERROR_CODE', + retryable: false, +}; + +// Good: Permission checks +if (!hasPermission(ctx.message.member, 'required_permission')) { + return { error: 'Insufficient permissions' }; +} + +// Good: Input sanitization +const sanitizedInput = sanitize(params.userInput); +``` diff --git a/apps/website/docs/guide/13-ai-powered-commands/07-best-practices.mdx b/apps/website/docs/guide/13-ai-powered-commands/07-best-practices.mdx new file mode 100644 index 00000000..686ce028 --- /dev/null +++ b/apps/website/docs/guide/13-ai-powered-commands/07-best-practices.mdx @@ -0,0 +1,681 @@ +--- +title: AI Best Practices & Examples +description: Best practices and real-world examples for AI-powered Discord bots. +--- + +# AI Best Practices & Examples + +This guide covers best practices for implementing AI-powered Discord bots and provides real-world examples. + +## Security Best Practices + +### Permission Validation + +Always validate user permissions before executing sensitive operations: + +```ts +export async function ai(ctx: AiContext) { + const { userId, action } = ctx.ai.params; + + // Check if user has required permissions + if (!ctx.message.member?.permissions.has('ManageMessages')) { + await ctx.message.reply( + '❌ You need "Manage Messages" permission to use this command.', + ); + return; + } + + // Additional role-based checks + const hasModeratorRole = ctx.message.member.roles.cache.some((role) => + role.name.toLowerCase().includes('moderator'), + ); + + if (action === 'ban' && !hasModeratorRole) { + await ctx.message.reply('❌ Only moderators can perform ban actions.'); + return; + } + + // Proceed with the action +} +``` + +### Input Sanitization + +Sanitize and validate all user inputs: + +```ts +export const aiConfig = { + parameters: z.object({ + message: z + .string() + .min(1, 'Message cannot be empty') + .max(500, 'Message too long') + .refine( + (text) => !containsProfanity(text), + 'Message contains inappropriate content', + ), + userId: z.string().regex(/^\d{17,19}$/, 'Invalid Discord user ID format'), + }), +} satisfies AiConfig; + +export async function ai(ctx: AiContext) { + const { message, userId } = ctx.ai.params; + + // Additional runtime validation + const sanitizedMessage = sanitizeHtml(message); + const isValidUser = await ctx.client.users.fetch(userId).catch(() => null); + + if (!isValidUser) { + await ctx.message.reply('❌ Invalid user specified.'); + return; + } + + // Use sanitized input + await processMessage(sanitizedMessage, isValidUser); +} +``` + +### Rate Limiting + +Implement rate limiting to prevent abuse: + +```ts +const userCooldowns = new Map(); +const COOLDOWN_DURATION = 30000; // 30 seconds + +export async function ai(ctx: AiContext) { + const userId = ctx.message.author.id; + const now = Date.now(); + const cooldownEnd = userCooldowns.get(userId) || 0; + + if (now < cooldownEnd) { + const remaining = Math.ceil((cooldownEnd - now) / 1000); + await ctx.message.reply( + `⏰ Please wait ${remaining} seconds before using this command again.`, + ); + return; + } + + // Set cooldown + userCooldowns.set(userId, now + COOLDOWN_DURATION); + + // Process command + await processCommand(ctx); +} +``` + +## Performance Optimization + +### Efficient Database Queries + +Optimize database operations in AI commands: + +```ts +export async function ai(ctx: AiContext) { + const { userIds } = ctx.ai.params; + + // ❌ Bad: Multiple individual queries + // const users = []; + // for (const id of userIds) { + // const user = await database.user.findUnique({ where: { id } }); + // users.push(user); + // } + + // ✅ Good: Single batch query + const users = await database.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true, level: true }, // Only select needed fields + }); + + return users; +} +``` + +### Caching Strategies + +Implement caching for frequently accessed data: + +```ts +import { Cache } from '@commandkit/cache'; + +const cache = new Cache({ ttl: 300000 }); // 5 minutes + +export async function ai(ctx: AiContext) { + const { guildId } = ctx.ai.params; + const cacheKey = `guild:${guildId}:settings`; + + // Try to get from cache first + let guildSettings = cache.get(cacheKey); + + if (!guildSettings) { + // Fetch from database + guildSettings = await database.guild.findUnique({ + where: { id: guildId }, + include: { settings: true }, + }); + + // Cache the result + if (guildSettings) { + cache.set(cacheKey, guildSettings); + } + } + + return guildSettings; +} +``` + +### Async Processing + +Handle long-running operations asynchronously: + +```ts +export async function ai(ctx: AiContext) { + const { operation } = ctx.ai.params; + + if (isLongRunningOperation(operation)) { + // Send immediate acknowledgment + await ctx.message.reply('🔄 Processing your request...'); + + // Process asynchronously + processLongOperation(operation, ctx.message) + .then((result) => { + ctx.message.reply(`✅ Operation completed: ${result}`); + }) + .catch((error) => { + ctx.message.reply(`❌ Operation failed: ${error.message}`); + }); + + return; + } + + // Handle quick operations normally + const result = await processQuickOperation(operation); + await ctx.message.reply(`✅ ${result}`); +} +``` + +## Real-World Examples + +### Music Bot Commands + +```ts title="src/commands/music.ts" +export const aiConfig = { + description: 'Play, pause, skip, or manage music in voice channels', + parameters: z.object({ + action: z.enum(['play', 'pause', 'skip', 'queue', 'volume']), + query: z + .string() + .optional() + .describe('Song name, URL, or search query for play action'), + volume: z + .number() + .min(0) + .max(100) + .optional() + .describe('Volume level for volume action'), + }), +} satisfies AiConfig; + +export async function ai(ctx: AiContext) { + const { action, query, volume } = ctx.ai.params; + const member = ctx.message.member; + const voiceChannel = member?.voice.channel; + + if (!voiceChannel) { + await ctx.message.reply( + '❌ You need to be in a voice channel to use music commands.', + ); + return; + } + + const musicPlayer = getMusicPlayer(ctx.message.guildId); + + switch (action) { + case 'play': + if (!query) { + await ctx.message.reply( + '❌ Please provide a song name or URL to play.', + ); + return; + } + + const track = await searchTrack(query); + if (!track) { + await ctx.message.reply(`❌ No results found for "${query}".`); + return; + } + + await musicPlayer.play(track, voiceChannel); + await ctx.message.reply( + `🎵 Now playing: **${track.title}** by ${track.artist}`, + ); + break; + + case 'pause': + if (musicPlayer.isPaused()) { + musicPlayer.resume(); + await ctx.message.reply('▶️ Music resumed.'); + } else { + musicPlayer.pause(); + await ctx.message.reply('⏸️ Music paused.'); + } + break; + + case 'skip': + const skipped = await musicPlayer.skip(); + await ctx.message.reply( + skipped ? '⏭️ Track skipped.' : '❌ No track to skip.', + ); + break; + + case 'queue': + const queue = musicPlayer.getQueue(); + if (queue.length === 0) { + await ctx.message.reply('📭 Queue is empty.'); + return; + } + + const queueList = queue + .slice(0, 10) + .map((track, i) => `${i + 1}. **${track.title}** by ${track.artist}`) + .join('\n'); + + await ctx.message.reply(`🎵 **Queue:**\n${queueList}`); + break; + + case 'volume': + if (volume === undefined) { + await ctx.message.reply( + `🔊 Current volume: ${musicPlayer.getVolume()}%`, + ); + return; + } + + musicPlayer.setVolume(volume); + await ctx.message.reply(`🔊 Volume set to ${volume}%`); + break; + } +} +``` + +### Server Management + +```ts title="src/commands/server-admin.ts" +export const aiConfig = { + description: 'Manage server settings, roles, and channels (admin only)', + parameters: z.object({ + action: z.enum([ + 'create-channel', + 'create-role', + 'update-settings', + 'cleanup', + ]), + channelName: z.string().optional(), + channelType: z.enum(['text', 'voice', 'category']).optional(), + roleName: z.string().optional(), + roleColor: z.string().optional(), + setting: z.string().optional(), + value: z.union([z.string(), z.number(), z.boolean()]).optional(), + }), +} satisfies AiConfig; + +export async function ai(ctx: AiContext) { + const { + action, + channelName, + channelType, + roleName, + roleColor, + setting, + value, + } = ctx.ai.params; + + // Admin permission check + if (!ctx.message.member?.permissions.has('Administrator')) { + await ctx.message.reply( + '❌ This command requires Administrator permissions.', + ); + return; + } + + const guild = ctx.message.guild; + + switch (action) { + case 'create-channel': + if (!channelName || !channelType) { + await ctx.message.reply('❌ Channel name and type are required.'); + return; + } + + const channelTypeMap = { + text: ChannelType.GuildText, + voice: ChannelType.GuildVoice, + category: ChannelType.GuildCategory, + }; + + const channel = await guild.channels.create({ + name: channelName, + type: channelTypeMap[channelType], + }); + + await ctx.message.reply(`✅ Created ${channelType} channel: ${channel}`); + break; + + case 'create-role': + if (!roleName) { + await ctx.message.reply('❌ Role name is required.'); + return; + } + + const role = await guild.roles.create({ + name: roleName, + color: roleColor || '#99AAB5', + reason: `Created by ${ctx.message.author.tag} via AI command`, + }); + + await ctx.message.reply(`✅ Created role: ${role}`); + break; + + case 'update-settings': + if (!setting || value === undefined) { + await ctx.message.reply('❌ Setting name and value are required.'); + return; + } + + await updateGuildSetting(guild.id, setting, value); + await ctx.message.reply(`✅ Updated setting "${setting}" to "${value}"`); + break; + + case 'cleanup': + const cleanupResults = await performServerCleanup(guild); + await ctx.message.reply( + `🧹 Cleanup completed:\n${formatCleanupResults(cleanupResults)}`, + ); + break; + } +} +``` + +### Economy System + +```ts title="src/commands/economy.ts" +export const aiConfig = { + description: 'Manage user economy - check balance, transfer money, shop', + parameters: z.object({ + action: z.enum(['balance', 'transfer', 'shop', 'buy', 'work', 'daily']), + targetUser: z + .string() + .optional() + .describe('User ID for balance check or transfer'), + amount: z.number().min(1).optional().describe('Amount to transfer'), + item: z.string().optional().describe('Item name to buy from shop'), + }), +} satisfies AiConfig; + +export async function ai(ctx: AiContext) { + const { action, targetUser, amount, item } = ctx.ai.params; + const userId = ctx.message.author.id; + + switch (action) { + case 'balance': + const checkUserId = targetUser || userId; + const user = await getEconomyUser(checkUserId); + + if (targetUser && targetUser !== userId) { + const targetUserObj = await ctx.client.users.fetch(targetUser); + await ctx.message.reply( + `💰 ${targetUserObj.username}'s balance: ${user.balance} coins`, + ); + } else { + await ctx.message.reply(`💰 Your balance: ${user.balance} coins`); + } + break; + + case 'transfer': + if (!targetUser || !amount) { + await ctx.message.reply( + '❌ Target user and amount are required for transfers.', + ); + return; + } + + if (targetUser === userId) { + await ctx.message.reply('❌ You cannot transfer money to yourself.'); + return; + } + + const sender = await getEconomyUser(userId); + if (sender.balance < amount) { + await ctx.message.reply('❌ Insufficient balance for this transfer.'); + return; + } + + await transferMoney(userId, targetUser, amount); + const recipient = await ctx.client.users.fetch(targetUser); + + await ctx.message.reply( + `✅ Transferred ${amount} coins to ${recipient.username}`, + ); + break; + + case 'shop': + const shopItems = await getShopItems(); + const itemList = shopItems + .map( + (item) => + `**${item.name}** - ${item.price} coins\n*${item.description}*`, + ) + .join('\n\n'); + + await ctx.message.reply(`🛍️ **Shop Items:**\n\n${itemList}`); + break; + + case 'buy': + if (!item) { + await ctx.message.reply('❌ Item name is required for purchases.'); + return; + } + + const purchaseResult = await buyItem(userId, item); + if (purchaseResult.success) { + await ctx.message.reply( + `✅ Purchased ${item} for ${purchaseResult.price} coins!`, + ); + } else { + await ctx.message.reply(`❌ ${purchaseResult.error}`); + } + break; + + case 'work': + const workResult = await performWork(userId); + if (workResult.success) { + await ctx.message.reply( + `💼 You worked and earned ${workResult.earnings} coins!`, + ); + } else { + await ctx.message.reply(`❌ ${workResult.error}`); + } + break; + + case 'daily': + const dailyResult = await claimDaily(userId); + if (dailyResult.success) { + await ctx.message.reply( + `🎁 Daily reward claimed: ${dailyResult.amount} coins!`, + ); + } else { + await ctx.message.reply(`❌ ${dailyResult.error}`); + } + break; + } +} +``` + +## Error Handling Patterns + +### Graceful Degradation + +```ts +export async function ai(ctx: AiContext) { + try { + // Primary functionality + const result = await primaryOperation(ctx.ai.params); + await ctx.message.reply(`✅ ${result}`); + } catch (primaryError) { + console.warn('Primary operation failed:', primaryError.message); + + try { + // Fallback functionality + const fallbackResult = await fallbackOperation(ctx.ai.params); + await ctx.message.reply(`⚠️ Used fallback method: ${fallbackResult}`); + } catch (fallbackError) { + console.error('Fallback also failed:', fallbackError.message); + await ctx.message.reply( + '❌ Service temporarily unavailable. Please try again later.', + ); + } + } +} +``` + +### User-Friendly Error Messages + +```ts +function getErrorMessage(error: Error): string { + const errorMappings = { + INSUFFICIENT_PERMISSIONS: + "❌ You don't have permission to perform this action.", + USER_NOT_FOUND: '❌ The specified user was not found.', + CHANNEL_NOT_FOUND: '❌ The specified channel was not found.', + RATE_LIMITED: "⏰ You're doing that too quickly. Please wait a moment.", + NETWORK_ERROR: + '🌐 Network error. Please check your connection and try again.', + DATABASE_ERROR: + '💾 Database temporarily unavailable. Please try again later.', + }; + + for (const [key, message] of Object.entries(errorMappings)) { + if (error.message.includes(key)) { + return message; + } + } + + return '❌ An unexpected error occurred. Please try again.'; +} +``` + +## Testing AI Commands + +### Unit Tests + +```ts +import { describe, it, expect, vi } from 'vitest'; +import { AiContext } from '@commandkit/ai'; + +describe('Economy AI Command', () => { + it('should check user balance correctly', async () => { + const mockContext = { + params: { action: 'balance' }, + message: { author: { id: '123456789' } }, + client: { users: { fetch: vi.fn() } }, + } as unknown as AiContext; + + vi.mocked(getEconomyUser).mockResolvedValue({ balance: 100 }); + + await ai(mockContext); + + expect(mockContext.message.reply).toHaveBeenCalledWith( + '💰 Your balance: 100 coins', + ); + }); + + it('should handle insufficient balance for transfers', async () => { + const mockContext = { + params: { action: 'transfer', targetUser: '987654321', amount: 150 }, + message: { author: { id: '123456789' } }, + } as unknown as AiContext; + + vi.mocked(getEconomyUser).mockResolvedValue({ balance: 100 }); + + await ai(mockContext); + + expect(mockContext.message.reply).toHaveBeenCalledWith( + '❌ Insufficient balance for this transfer.', + ); + }); +}); +``` + +## Deployment Considerations + +### Environment Configuration + +```ts title="src/config/ai.ts" +export const aiConfig = { + development: { + model: 'gemini-1.5-flash', + maxSteps: 3, + temperature: 0.8, + debug: true, + }, + production: { + model: 'gemini-2.0-flash', + maxSteps: 8, + temperature: 0.6, + debug: false, + }, + testing: { + model: 'mock-model', + maxSteps: 1, + temperature: 0, + debug: true, + }, +}; + +export function getAIConfig() { + const env = process.env.NODE_ENV || 'development'; + return aiConfig[env] || aiConfig.development; +} +``` + +### Monitoring and Logging + +```ts +import { Logger } from 'commandkit'; + +export async function ai(ctx: AiContext) { + const startTime = Date.now(); + + try { + // Log command usage + Logger.info('AI command executed', { + command: 'example', + userId: ctx.message.author.id, + guildId: ctx.message.guildId, + params: ctx.ai.params, + }); + + const result = await processCommand(ctx); + + // Log success metrics + Logger.info('AI command completed', { + command: 'example', + duration: Date.now() - startTime, + success: true, + }); + + return result; + } catch (error) { + // Log errors with context + Logger.error('AI command failed', { + command: 'example', + error: error.message, + duration: Date.now() - startTime, + userId: ctx.message.author.id, + guildId: ctx.message.guildId, + }); + + throw error; + } +} +``` diff --git a/apps/website/docs/guide/13-ai-powered-commands/08-troubleshooting.mdx b/apps/website/docs/guide/13-ai-powered-commands/08-troubleshooting.mdx new file mode 100644 index 00000000..8f4e8284 --- /dev/null +++ b/apps/website/docs/guide/13-ai-powered-commands/08-troubleshooting.mdx @@ -0,0 +1,516 @@ +--- +title: AI Troubleshooting +description: Common issues and solutions when working with CommandKit's AI system. +--- + +# AI Troubleshooting + +This guide covers common issues you might encounter when working with CommandKit's AI system and how to resolve them. + +## Common Issues + +### AI Not Responding to Messages + +**Symptoms:** Bot doesn't respond when mentioned or keywords are used. + +**Possible Causes:** + +1. **AI plugin not registered** + + ```ts title="commandkit.config.ts" + import { ai } from '@commandkit/ai'; + + export default defineConfig({ + plugins: [ai()], // Make sure this is included + }); + ``` + +2. **Message filter too restrictive** + + ```ts + // Check your message filter + messageFilter: async (message) => { + console.log('Filtering message:', message.content); + const shouldProcess = message.mentions.users.has(message.client.user.id); + console.log('Should process:', shouldProcess); + return shouldProcess; + }; + ``` + +3. **Missing bot permissions** + ```ts + // Bot needs these intents + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, // Required for message content + ], + }); + ``` + +### AI Model Configuration Errors + +**Symptoms:** Errors about missing AI model or configuration. + +**Solutions:** + +1. **Check API keys** + + ```bash + # Make sure environment variables are set + GOOGLE_API_KEY=your_api_key_here + OPENAI_API_KEY=your_api_key_here + ``` + +2. **Verify model configuration** + + ```ts + configureAI({ + selectAiModel: async (ctx, message) => { + // Make sure this function is defined and returns a model + if (!process.env.GOOGLE_API_KEY) { + throw new Error('GOOGLE_API_KEY environment variable not set'); + } + + return { + model: google.languageModel('gemini-2.0-flash'), + }; + }, + }); + ``` + +3. **Handle model initialization errors** + ```ts + selectAiModel: async (ctx, message) => { + try { + return { + model: google.languageModel('gemini-2.0-flash'), + }; + } catch (error) { + console.error('Model initialization failed:', error); + throw new Error('AI model not available'); + } + }; + ``` + +### Command Parameters Not Working + +**Symptoms:** AI commands receive undefined or incorrect parameters. + +**Solutions:** + +1. **Check Zod schema definition** + + ```ts + export const aiConfig = { + parameters: z.object({ + // Make sure parameter names match what the AI should provide + username: z.string().describe('The username to greet'), + message: z.string().optional().describe('Optional greeting message'), + }), + } satisfies AiConfig; + ``` + +2. **Validate parameters in command** + + ```ts + export async function ai(ctx: AiContext) { + console.log('Received parameters:', ctx.ai.params); + + const { username, message } = ctx.ai.params; + + if (!username) { + await ctx.message.reply('❌ Username parameter is required'); + return; + } + + // Continue with command logic + } + ``` + +3. **Improve parameter descriptions** + ```ts + parameters: z.object({ + userId: z + .string() + .describe('Discord user ID (numbers only, like 123456789012345678)'), + duration: z + .number() + .describe('Duration in minutes (e.g., 30 for 30 minutes)'), + reason: z.string().optional().describe('Optional reason for the action'), + }); + ``` + +### Rate Limiting Issues + +**Symptoms:** AI stops responding or shows rate limit errors. + +**Solutions:** + +1. **Implement user-level rate limiting** + + ```ts + const userCooldowns = new Map(); + + messageFilter: async (message) => { + const userId = message.author.id; + const now = Date.now(); + const cooldown = userCooldowns.get(userId) || 0; + + if (now < cooldown + 30000) { + // 30 second cooldown + return false; + } + + userCooldowns.set(userId, now); + return message.mentions.users.has(message.client.user.id); + }; + ``` + +2. **Add timeout configuration** + + ```ts + selectAiModel: async (ctx, message) => ({ + model: myModel, + abortSignal: AbortSignal.timeout(30000), // 30 second timeout + }); + ``` + +3. **Handle rate limit errors gracefully** + ```ts + onError: async (ctx, message, error) => { + if (error.message.includes('rate limit')) { + await message.reply( + "⏰ I'm being rate limited. Please wait a moment and try again.", + ); + } else { + await message.reply( + '❌ An error occurred while processing your request.', + ); + } + }; + ``` + +### Memory and Performance Issues + +**Symptoms:** Bot becomes slow or runs out of memory. + +**Solutions:** + +1. **Clear context store data** + + ```ts + export async function ai(ctx: AiContext) { + try { + // Use context store + ctx.store.set('data', someData); + + // Process command + await processCommand(); + } finally { + // Clear large objects from store + ctx.store.clear(); + } + } + ``` + +2. **Limit AI steps and tokens** + + ```ts + selectAiModel: async (ctx, message) => ({ + model: myModel, + maxSteps: 5, // Limit tool calls + maxTokens: 1000, // Limit response length + }); + ``` + +3. **Implement caching for expensive operations** + + ```ts + const cache = new Map(); + + export async function ai(ctx: AiContext) { + const cacheKey = `user:${ctx.message.author.id}`; + + let userData = cache.get(cacheKey); + if (!userData) { + userData = await fetchUserData(ctx.message.author.id); + cache.set(cacheKey, userData); + + // Clear cache after 5 minutes + setTimeout(() => cache.delete(cacheKey), 300000); + } + } + ``` + +## Debugging Techniques + +### Enable Debug Logging + +```ts +configureAI({ + onProcessingStart: async (ctx, message) => { + console.log('AI processing started:', { + user: message.author.username, + content: message.content.substring(0, 100), + guild: message.guild?.name, + }); + }, + + onResult: async (ctx, message, result) => { + console.log('AI result:', { + text: result.text?.substring(0, 100), + toolCalls: result.toolCalls?.length || 0, + finishReason: result.finishReason, + }); + }, + + onError: async (ctx, message, error) => { + console.error('AI error:', { + error: error.message, + stack: error.stack, + user: message.author.id, + content: message.content, + }); + }, +}); +``` + +### Test AI Commands Manually + +```ts +import { useAI } from '@commandkit/ai'; + +// Test AI processing manually +const aiPlugin = useAI(); +await aiPlugin.executeAI(message); +``` + +### Validate Tool Registration + +```ts +// Check if tools are registered correctly +onAfterCommandsLoad: async (ctx) => { + const commands = ctx.commandkit.commandHandler.getCommandsArray(); + const aiCommands = commands.filter((cmd) => 'ai' in cmd.data); + + console.log( + 'AI commands registered:', + aiCommands.map((cmd) => cmd.data.command.name), + ); +}; +``` + +## Environment-Specific Issues + +### Development Environment + +```ts +// Add development-specific debugging +if (process.env.NODE_ENV === 'development') { + configureAI({ + onError: async (ctx, message, error) => { + // Show full error details in development + await message.reply( + `Debug Error: ${error.message}\n\`\`\`${error.stack}\`\`\``, + ); + }, + }); +} +``` + +### Production Environment + +```ts +// Production error handling +if (process.env.NODE_ENV === 'production') { + configureAI({ + onError: async (ctx, message, error) => { + // Log to monitoring service + await logError(error, { + userId: message.author.id, + guildId: message.guildId, + command: 'ai', + }); + + // Send generic error to user + await message.reply('An error occurred. Please try again later.'); + }, + }); +} +``` + +## Discord API Issues + +### Missing Permissions + +```ts +export async function ai(ctx: AiContext) { + // Check bot permissions + const botMember = ctx.message.guild?.members.me; + if (!botMember?.permissions.has('SendMessages')) { + console.error('Bot missing SendMessages permission'); + return; + } + + // Check specific permissions for command + if (!botMember.permissions.has('ManageMessages')) { + await ctx.message.reply( + '❌ I need "Manage Messages" permission to use this command.', + ); + return; + } +} +``` + +### Channel Access Issues + +```ts +messageFilter: async (message) => { + // Check if bot can send messages in the channel + if (!message.channel.isSendable()) { + console.log('Cannot send messages in channel:', message.channelId); + return false; + } + + return message.mentions.users.has(message.client.user.id); +}; +``` + +## Common Error Messages + +### "No AI model selected" + +**Solution:** Make sure you've configured `selectAiModel`: + +```ts +configureAI({ + selectAiModel: async (ctx, message) => ({ + model: google.languageModel('gemini-2.0-flash'), + }), +}); +``` + +### "AI plugin is not registered" + +**Solution:** Add the AI plugin to your configuration: + +```ts title="commandkit.config.ts" +import { ai } from '@commandkit/ai'; + +export default defineConfig({ + plugins: [ai()], +}); +``` + +### "Cannot read properties of undefined" + +**Solution:** Check parameter validation: + +```ts +export async function ai(ctx: AiContext) { + // Add null checks + if (!ctx.ai.params) { + await ctx.message.reply('❌ No parameters provided'); + return; + } + + const { username } = ctx.ai.params; + if (!username) { + await ctx.message.reply('❌ Username is required'); + return; + } +} +``` + +## Performance Monitoring + +### Track Response Times + +```ts +configureAI({ + onProcessingStart: async (ctx, message) => { + ctx.store.set('startTime', Date.now()); + }, + + onProcessingFinish: async (ctx, message) => { + const startTime = ctx.store.get('startTime'); + if (startTime) { + const duration = Date.now() - startTime; + console.log(`AI processing took ${duration}ms`); + + if (duration > 10000) { + // Warn if over 10 seconds + console.warn('Slow AI response detected'); + } + } + }, +}); +``` + +### Monitor Token Usage + +```ts +onResult: async (ctx, message, result) => { + if (result.usage) { + console.log('Token usage:', { + prompt: result.usage.promptTokens, + completion: result.usage.completionTokens, + total: result.usage.totalTokens, + }); + } +}; +``` + +## Getting Help + +If you're still experiencing issues: + +1. **Check the console logs** for detailed error messages +2. **Verify your environment variables** are set correctly +3. **Test with a simple AI command** first +4. **Check Discord bot permissions** in the server +5. **Review the AI model documentation** for your provider +6. **Join the CommandKit Discord** for community support + +## Useful Debug Commands + +Create a debug command to test AI functionality: + +```ts +export const command = { + name: 'ai-debug', + description: 'Debug AI system', +}; + +export async function messageCommand(ctx: MessageCommandContext) { + const aiPlugin = useAI(); + const config = getAIConfig(); + + await ctx.message.reply({ + embeds: [ + { + title: 'AI Debug Information', + fields: [ + { + name: 'Plugin Loaded', + value: !!aiPlugin ? '✅' : '❌', + inline: true, + }, + { + name: 'Config Valid', + value: !!config.selectAiModel ? '✅' : '❌', + inline: true, + }, + { + name: 'Bot Can Send', + value: ctx.message.channel.isSendable() ? '✅' : '❌', + inline: true, + }, + ], + }, + ], + }); +} +``` diff --git a/apps/website/static/img/ai-object-mode.png b/apps/website/static/img/ai-poll-example.png similarity index 100% rename from apps/website/static/img/ai-object-mode.png rename to apps/website/static/img/ai-poll-example.png diff --git a/packages/ai/src/augmentation.ts b/packages/ai/src/augmentation.ts new file mode 100644 index 00000000..a56927dd --- /dev/null +++ b/packages/ai/src/augmentation.ts @@ -0,0 +1,41 @@ +import { AiContext } from './context'; +import { Awaitable } from 'discord.js'; +import { AiConfig } from './plugin'; +import { Context } from 'commandkit'; +import { getAiWorkerContext } from './ai-context-worker'; + +declare module 'commandkit' { + interface CustomAppCommandProps { + ai?: (ctx: AiContext) => Awaitable; + aiConfig?: AiConfig; + } + + interface Context { + ai?: AiContext; + } +} + +/** + * @private + * @internal + */ +export function augmentCommandKit(isAdd: boolean) { + if (isAdd) { + if (!Object.prototype.hasOwnProperty.call(Context.prototype, 'ai')) { + Object.defineProperty(Context.prototype, 'ai', { + get() { + try { + const { ctx } = getAiWorkerContext(); + return ctx; + } catch { + // no-op if not in AI worker context + } + }, + }); + } + } else { + if (Object.prototype.hasOwnProperty.call(Context.prototype, 'ai')) { + delete Context.prototype.ai; + } + } +} diff --git a/packages/ai/src/configure.ts b/packages/ai/src/configure.ts new file mode 100644 index 00000000..a80c7e2b --- /dev/null +++ b/packages/ai/src/configure.ts @@ -0,0 +1,144 @@ +import { Message, TextChannel } from 'discord.js'; +import { AIGenerateResult, MessageFilter, SelectAiModel } from './types'; +import { createSystemPrompt } from './system-prompt'; +import { createTypingIndicator } from './utils'; +import { AiContext } from './context'; +import { Logger } from 'commandkit'; + +const CKIT_INTERNAL_STOP_TYPING = '<<{{[[((ckitInternalStopTyping))]]}}>>'; + +/** + * Represents the configuration options for the AI model. + */ +export interface ConfigureAI { + /** + * Whether to disable the built-in tools. Default is false. + */ + disableBuiltInTools?: boolean; + /** + * A filter function that determines whether a message should be processed by the AI. + * CommandKit invokes this function before processing the message. + */ + messageFilter?: MessageFilter; + /** + * A function that selects the AI model to use based on the message. + * This function should return a promise that resolves to an object containing the model and options. + */ + selectAiModel: SelectAiModel; + /** + * A function that generates a system prompt based on the message. + * This function should return a promise that resolves to a string containing the system prompt. + * If not provided, a default system prompt will be used. + */ + prepareSystemPrompt?: (ctx: AiContext, message: Message) => Promise; + /** + * A function that prepares the prompt for the AI model. + */ + preparePrompt?: (ctx: AiContext, message: Message) => Promise; + /** + * A function that gets called when the AI starts processing a message. + */ + onProcessingStart?: (ctx: AiContext, message: Message) => Promise; + /** + * A function that gets called when the AI finishes processing a message. + */ + onProcessingFinish?: (ctx: AiContext, message: Message) => Promise; + /** + * A function that gets called upon receiving the result from the AI model. + */ + onResult?: ( + ctx: AiContext, + message: Message, + result: AIGenerateResult, + ) => Promise; + /** + * A function that gets called when error occurs. + */ + onError?: (ctx: AiContext, message: Message, error: Error) => Promise; +} + +const AIConfig: Required = { + disableBuiltInTools: false, + messageFilter: async (message) => + message.mentions.users.has(message.client.user.id), + prepareSystemPrompt: async (_ctx, message) => createSystemPrompt(message), + async preparePrompt(_ctx, message) { + const userInfo = ` + ${message.author.id} + ${message.author.username} + ${message.author.displayName} + ${message.author.avatarURL()} + `; + + return `${userInfo}\nUser: ${message.content}\nAI:`; + }, + selectAiModel: async () => { + throw new Error( + 'No AI model selected. Please configure the AI plugin using configureAI() function, making sure to include a selectAiModel function.', + ); + }, + onProcessingStart: async (ctx, message) => { + const channel = message.channel; + const stop = await createTypingIndicator(channel); + ctx.store.set(CKIT_INTERNAL_STOP_TYPING, stop); + }, + onProcessingFinish: async (ctx) => { + const stop = ctx.store.get(CKIT_INTERNAL_STOP_TYPING); + + if (stop) { + stop(); + ctx.store.delete(CKIT_INTERNAL_STOP_TYPING); + } + }, + onResult: async (_ctx, message, result) => { + if (!!result.text) { + await message.reply({ + content: result.text.substring(0, 2000), + allowedMentions: { parse: [] }, + }); + } + }, + onError: async (_ctx, message, error) => { + Logger.error(`Error processing AI message: ${error}`); + const channel = message.channel as TextChannel; + + if (channel.isSendable()) { + await message + .reply({ + content: 'An error occurred while processing your request.', + allowedMentions: { parse: [] }, + }) + .catch((e) => Logger.error(`Failed to send error message: ${e}`)); + } + }, +}; + +/** + * Retrieves the current AI configuration. + */ +export function getAIConfig(): Required { + return AIConfig; +} + +/** + * Configures the AI plugin with the provided options. + * This function allows you to set a message filter, select an AI model, and generate a system prompt. + * @param config The configuration options for the AI plugin. + */ +export function configureAI(config: ConfigureAI): void { + if (config.messageFilter) { + AIConfig.messageFilter = config.messageFilter; + } + + if (config.selectAiModel) { + AIConfig.selectAiModel = config.selectAiModel; + } + + if (config.prepareSystemPrompt) { + AIConfig.prepareSystemPrompt = config.prepareSystemPrompt; + } + + if (config.preparePrompt) { + AIConfig.preparePrompt = config.preparePrompt; + } +} diff --git a/packages/ai/src/context.ts b/packages/ai/src/context.ts index 13b97965..f501c989 100644 --- a/packages/ai/src/context.ts +++ b/packages/ai/src/context.ts @@ -1,11 +1,23 @@ import type { CommandKit } from 'commandkit'; import { Client, Message } from 'discord.js'; +/** + * Options for the AI context. + */ export interface AiContextOptions< T extends Record = Record, > { + /** + * The message that triggered the AI command. + */ message: Message; + /** + * The parameters passed to the AI command. + */ params: T; + /** + * The CommandKit instance associated with the AI command. + */ commandkit: CommandKit; } @@ -33,6 +45,10 @@ export class AiContext< * The CommandKit instance associated with the AI command. */ public commandkit!: CommandKit; + /** + * A key-value store to hold additional data. + */ + public store = new Map(); /** * Creates a new instance of AiContext. diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 188157f1..2db1fd0b 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,5 +1,32 @@ +import './augmentation'; import { AiPlugin } from './plugin'; import { AiPluginOptions } from './types'; +import { getAiWorkerContext } from './ai-context-worker'; +import { getCommandKit } from 'commandkit'; + +/** + * Retrieves the AI context. + */ +export function useAIContext() { + const { ctx } = getAiWorkerContext(); + return ctx; +} + +/** + * Fetches the AI plugin instance. + */ +export function useAI() { + const commandkit = getCommandKit(true); + const aiPlugin = commandkit.plugins.get(AiPlugin); + + if (!aiPlugin) { + throw new Error( + 'AI plugin is not registered. Please ensure it is activated.', + ); + } + + return aiPlugin; +} /** * Defines the AI plugin for the application. @@ -13,3 +40,7 @@ export function ai(options?: AiPluginOptions) { export * from './types'; export * from './plugin'; export * from './context'; +export * from './configure'; +export * from './types'; +export * from './system-prompt'; +export * from './tools/common/index'; diff --git a/packages/ai/src/plugin.ts b/packages/ai/src/plugin.ts index f24e9f14..eb29e73b 100644 --- a/packages/ai/src/plugin.ts +++ b/packages/ai/src/plugin.ts @@ -1,20 +1,17 @@ import { CommandKitPluginRuntime, RuntimePlugin } from 'commandkit/plugin'; -import { AiPluginOptions, MessageFilter, SelectAiModel } from './types'; -import { LoadedCommand, Logger } from 'commandkit'; +import { AiPluginOptions, CommandTool } from './types'; +import CommandKit, { getCommandKit, Logger } from 'commandkit'; import { AiContext } from './context'; -import { Collection, Events, Message, TextChannel } from 'discord.js'; -import { tool, Tool, generateText, generateObject } from 'ai'; -import { z } from 'zod'; +import { Collection, Events, Message } from 'discord.js'; +import { tool, Tool, generateText } from 'ai'; import { getAiWorkerContext, runInAiWorkerContext } from './ai-context-worker'; -import { AiResponseSchema, pollSchema } from './schema'; - -type WithAI = T & { - data: { - ai: (ctx: AiContext) => Promise | unknown; - aiConfig: AiConfig; - } & T['data']; - tool: Tool; -}; +import { getAvailableCommands } from './tools/get-available-commands'; +import { getChannelById } from './tools/get-channel-by-id'; +import { getCurrentClientInfo } from './tools/get-current-client-info'; +import { getGuildById } from './tools/get-guild-by-id'; +import { getUserById } from './tools/get-user-by-id'; +import { getAIConfig } from './configure'; +import { augmentCommandKit } from './augmentation'; /** * Represents the configuration options for the AI plugin scoped to a specific command. @@ -30,55 +27,18 @@ export interface AiConfig { parameters: any; } -let messageFilter: MessageFilter | null = null; -let selectAiModel: SelectAiModel | null = null; -let generateSystemPrompt: ((message: Message) => Promise) | undefined; - -/** - * Represents the configuration options for the AI model. - */ -export interface ConfigureAI { - /** - * A filter function that determines whether a message should be processed by the AI. - * CommandKit invokes this function before processing the message. - */ - messageFilter?: MessageFilter; - /** - * A function that selects the AI model to use based on the message. - * This function should return a promise that resolves to an object containing the model and options. - */ - selectAiModel?: SelectAiModel; - /** - * A function that generates a system prompt based on the message. - * This function should return a promise that resolves to a string containing the system prompt. - * If not provided, a default system prompt will be used. - */ - systemPrompt?: (message: Message) => Promise; -} - -/** - * Configures the AI plugin with the provided options. - * This function allows you to set a message filter, select an AI model, and generate a system prompt. - * @param config The configuration options for the AI plugin. - */ -export function configureAI(config: ConfigureAI): void { - if (config.messageFilter) { - messageFilter = config.messageFilter; - } - - if (config.selectAiModel) { - selectAiModel = config.selectAiModel; - } - - if (config.systemPrompt) { - generateSystemPrompt = config.systemPrompt; - } -} +const defaultTools: Record = { + getAvailableCommands, + getChannelById, + getCurrentClientInfo, + getGuildById, + getUserById, +}; export class AiPlugin extends RuntimePlugin { public readonly name = 'AiPlugin'; private toolsRecord: Record = {}; - private defaultTools: Record = {}; + private defaultTools = defaultTools; private onMessageFunc: ((message: Message) => Promise) | null = null; public constructor(options: AiPluginOptions) { @@ -86,133 +46,42 @@ export class AiPlugin extends RuntimePlugin { } public async activate(ctx: CommandKitPluginRuntime): Promise { - this.onMessageFunc = (message) => this.handleMessage(ctx, message); + this.onMessageFunc = (message) => + this.handleMessage(ctx.commandkit, message); ctx.commandkit.client.on(Events.MessageCreate, this.onMessageFunc); - - this.createDefaultTools(ctx); + augmentCommandKit(true); Logger.info(`Plugin ${this.name} activated`); } - private createDefaultTools(ctx: CommandKitPluginRuntime): void { - const { commandkit } = ctx; - const client = commandkit.client; - - this.defaultTools.getUserById = tool({ - description: 'Get user information by ID', - parameters: z.object({ - userId: z - .string() - .describe( - 'The ID of the user to retrieve. This is a Discord snowflake string.', - ), - }), - execute: async (params) => { - const user = await client.users.fetch(params.userId, { - force: false, - cache: true, - }); - - return user.toJSON(); - }, - }); - - this.defaultTools.getChannelById = tool({ - description: 'Get channel information by ID', - parameters: z.object({ - channelId: z - .string() - .describe( - 'The ID of the channel to retrieve. This is a Discord snowflake string.', - ), - }), - execute: async (params) => { - const channel = await client.channels.fetch(params.channelId, { - force: false, - cache: true, - }); - - if (!channel) { - throw new Error(`Channel with ID ${params.channelId} not found.`); - } - - return channel.toJSON(); - }, - }); - - this.defaultTools.getGuildById = tool({ - description: 'Get guild information by ID', - parameters: z.object({ - guildId: z - .string() - .describe( - 'The ID of the guild to retrieve. This is a Discord snowflake string.', - ), - }), - execute: async (params) => { - const guild = await client.guilds.fetch({ - guild: params.guildId, - force: false, - cache: true, - }); - - if (!guild) { - throw new Error(`Guild with ID ${params.guildId} not found.`); - } - - return { - id: guild.id, - name: guild.name, - icon: guild.iconURL(), - memberCount: guild.memberCount, - }; - }, - }); - - this.defaultTools.getCurrentUser = tool({ - description: 'Get information about the current discord bot user', - parameters: z.object({}), - execute: async () => { - const user = client.user; - - if (!user) { - throw new Error('Bot user is not available.'); - } - - return user.toJSON(); - }, - }); - - this.defaultTools.getAvailableCommands = tool({ - description: 'Get all available commands', - parameters: z.object({}), - execute: async () => { - return ctx.commandkit.commandHandler.getCommandsArray().map((cmd) => ({ - name: cmd.data.command.name, - description: cmd.data.command.description, - category: cmd.command.category, - })); - }, - }); - } - public async deactivate(ctx: CommandKitPluginRuntime): Promise { this.toolsRecord = {}; if (this.onMessageFunc) { ctx.commandkit.client.off(Events.MessageCreate, this.onMessageFunc); this.onMessageFunc = null; } + augmentCommandKit(false); Logger.info(`Plugin ${this.name} deactivated`); } private async handleMessage( - pluginContext: CommandKitPluginRuntime, + commandkit: CommandKit, message: Message, ): Promise { if (message.author.bot || !Object.keys(this.toolsRecord).length) return; - - const aiModelSelector = selectAiModel; - if (!message.content?.length || !aiModelSelector) return; + const { + messageFilter, + selectAiModel, + prepareSystemPrompt, + preparePrompt, + onProcessingFinish, + onProcessingStart, + onResult, + onError, + disableBuiltInTools, + } = getAIConfig(); + + if (!message.content?.length) return; if (!message.channel.isTextBased() || !message.channel.isSendable()) return; @@ -222,179 +91,67 @@ export class AiPlugin extends RuntimePlugin { const ctx = new AiContext({ message, params: {}, - commandkit: pluginContext.commandkit, + commandkit: commandkit, }); - const systemPrompt = - (await generateSystemPrompt?.(message)) || - `You are a helpful AI discord bot. Your name is ${message.client.user.username} and your id is ${message.client.user.id}. - You are designed to assist users with their questions and tasks. You also have access to various tools that can help you perform tasks. - Tools are basically like commands that you can execute to perform specific actions based on user input. - Keep the response short and concise, and only use tools when necessary. Keep the response length under 2000 characters. - Do not include your own text in the response unless necessary. For text formatting, you can use discord's markdown syntax. - The current channel is ${ - 'name' in message.channel - ? message.channel.name - : message.channel.recipient?.displayName || 'DM' - } whose id is ${message.channelId}. ${ - message.channel.isSendable() - ? 'You can send messages in this channel.' - : 'You cannot send messages in this channel.' - } - ${message.inGuild() ? `\nYou are currently in a guild named ${message.guild.name} whose id is ${message.guildId}. While in guild, you can fetch member information if needed.` : '\nYou are currently in a direct message with the user.'} - If the user asks you to create a poll or embeds, create a text containing the poll or embed information as a markdown instead of json. If structured response is possible, use the structured response format instead. - If the user asks you to perform a task that requires a tool, use the tool to perform the task and return the result. Reject any requests that are not related to the tools you have access to. - `; - - const userInfo = ` - ${message.author.id} - ${message.author.username} - ${message.author.displayName} - ${message.author.avatarURL()} - `; - await runInAiWorkerContext(ctx, message, async () => { - const channel = message.channel as TextChannel; - const stopTyping = await this.startTyping(channel); + const systemPrompt = await prepareSystemPrompt(ctx, message); + const prompt = await preparePrompt(ctx, message); + const { model, abortSignal, maxSteps, ...modelOptions } = + await selectAiModel(ctx, message); - try { - const { - model, - options, - objectMode = false, - } = await aiModelSelector(message); + await onProcessingStart(ctx, message); - const originalPrompt = `${userInfo}\nUser: ${message.content}\nAI:`; + const tools = disableBuiltInTools + ? this.toolsRecord + : { + ...this.defaultTools, + ...this.toolsRecord, + }; - let result: Awaited< - ReturnType - >; - - const config = { + try { + const result = await generateText({ model, - abortSignal: AbortSignal.timeout(60_000), - prompt: originalPrompt, + abortSignal: abortSignal ?? AbortSignal.timeout(60_000), + prompt, system: systemPrompt, - providerOptions: options, - }; - - if (objectMode) { - result = await generateObject({ - ...config, - schema: AiResponseSchema, - }); - } else { - result = await generateText({ - ...config, - tools: { ...this.toolsRecord, ...this.defaultTools }, - maxSteps: 5, - }); - } - - stopTyping(); - - let structuredResult: z.infer | null = null; - - structuredResult = !('text' in result) - ? (result.object as z.infer) - : null; - - if (structuredResult) { - const { poll, content, embed } = structuredResult; - - if (!poll && !content && !embed) { - Logger.warn( - 'AI response did not include any content, embed, or poll.', - ); - return; - } + maxSteps: maxSteps ?? 5, + ...modelOptions, + tools: { + ...tools, + ...modelOptions.tools, + }, + }); - await message.reply({ - content: content?.substring(0, 2000), - embeds: embed - ? [ - { - title: embed.title, - description: embed.description, - url: embed.url, - color: embed.color, - image: embed.image ? { url: embed.image } : undefined, - thumbnail: embed.thumbnailImage - ? { url: embed.thumbnailImage } - : undefined, - fields: embed.fields?.map((field) => ({ - name: field.name, - value: field.value, - inline: field.inline, - })), - }, - ] - : [], - poll: poll - ? { - allowMultiselect: poll.allow_multiselect, - answers: poll.answers.map((answer) => ({ - text: answer.text, - emoji: answer.emoji, - })), - duration: poll.duration, - question: { text: poll.question.text }, - } - : undefined, - }); - } else if ('text' in result && !!result.text) { - await message.reply({ - content: result.text.substring(0, 2000), - allowedMentions: { parse: [] }, - }); - } + await onResult(ctx, message, result); } catch (e) { - Logger.error(`Error processing AI message: ${e}`); - const channel = message.channel as TextChannel; - - if (channel.isSendable()) { - await message - .reply({ - content: 'An error occurred while processing your request.', - allowedMentions: { parse: [] }, - }) - .catch((e) => Logger.error(`Failed to send error message: ${e}`)); - } + await onError(ctx, message, e as Error); } finally { - stopTyping(); + await onProcessingFinish(ctx, message); } }); } - private async startTyping(channel: TextChannel): Promise<() => void> { - let stopped = false; - - const runner = async () => { - if (stopped) return clearInterval(typingInterval); - - if (channel.isSendable()) { - await channel.sendTyping().catch(Object); - } - }; - - const typingInterval = setInterval(runner, 3000).unref(); - - await runner(); - - return () => { - stopped = true; - clearInterval(typingInterval); - }; + /** + * Executes the AI for a given message. + * @param message The message to process. + * @param commandkit The CommandKit instance to use. If not provided, it will be inferred automatically. + */ + public async executeAI( + message: Message, + commandkit?: CommandKit, + ): Promise { + commandkit ??= getCommandKit(true); + return this.handleMessage(commandkit, message); } - public async onBeforeCommandsLoad( - ctx: CommandKitPluginRuntime, - ): Promise { + public async onBeforeCommandsLoad(): Promise { this.toolsRecord = {}; } async onAfterCommandsLoad(ctx: CommandKitPluginRuntime): Promise { - const commands = ctx.commandkit.commandHandler + const { commandkit } = ctx; + const commands = commandkit.commandHandler .getCommandsArray() .filter( (command) => @@ -404,16 +161,13 @@ export class AiPlugin extends RuntimePlugin { ); if (!commands.length) { - Logger.warn( - 'No commands with AI functionality found. Ensure commands are properly configured.', - ); return; } - const tools = new Collection>(); + const tools = new Collection(); for (const command of commands) { - const cmd = command as WithAI; + const cmd = command as CommandTool; if (!cmd.data.ai || !cmd.data.aiConfig) { continue; } @@ -423,13 +177,44 @@ export class AiPlugin extends RuntimePlugin { const cmdTool = tool({ description, - type: 'function', parameters: cmd.data.aiConfig.parameters, async execute(params) { + const config = getAIConfig(); const ctx = getAiWorkerContext(); + ctx.ctx.setParams(params); - return cmd.data.ai(ctx.ctx); + try { + const target = await commandkit.commandHandler.prepareCommandRun( + ctx.message, + cmd.data.command.name, + ); + + if (!target) { + return { + error: true, + message: 'This command is not available.', + }; + } + + const res = + await commandkit.commandHandler.commandRunner.runCommand( + target, + ctx.message, + { + handler: 'ai', + }, + ); + + return res === undefined ? { success: true } : res; + } catch (e) { + await config.onError?.(ctx.ctx, ctx.message, e as Error); + + return { + error: true, + message: 'This tool failed with unexpected error.', + }; + } }, }); diff --git a/packages/ai/src/schema.ts b/packages/ai/src/schema.ts deleted file mode 100644 index 62f45673..00000000 --- a/packages/ai/src/schema.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { z } from 'zod'; - -const pollMediaObject = z - .object({ - text: z.string().trim().describe('The question text of the poll'), - emoji: z - .string() - .trim() - .optional() - .describe('An optional emoji associated with the poll question. Eg: 👍'), - }) - .describe( - 'An object representing the media for a poll question, containing the text of the question. Emoji cannot be used in question text.', - ); - -export const pollSchema = z - .object({ - question: pollMediaObject, - answers: z - .array(pollMediaObject) - .min(1) - .max(10) - .describe('An array of answers for the poll'), - allow_multiselect: z - .boolean() - .optional() - .default(false) - .describe('Whether the poll allows multiple selections'), - duration: z - .number() - .int() - .min(1) - .max(32) - .optional() - .default(24) - .describe('The duration of the poll in hours'), - }) - .describe('An object representing a poll to include in the message'); - -export const AiResponseSchema = z - .object({ - content: z - .string() - .trim() - .optional() - .describe( - 'The content of the message. This can be plain text or markdown. This is an optional field.', - ), - embed: z - .object({ - title: z - .string() - .trim() - .optional() - .describe('The title of the embed. This is an optional field.'), - description: z - .string() - .trim() - .optional() - .describe('The description of the embed. This is an optional field.'), - url: z - .string() - .optional() - .describe( - 'A URL associated with the embed. This is an optional field.', - ), - color: z - .number() - .int() - .min(0) - .max(16777215) - .optional() - .describe( - 'A color for the embed, represented as an integer in RGB format. This is an optional field.', - ), - thumbnailImage: z - .string() - .optional() - .describe('A URL for a thumbnail image. This is an optional field.'), - image: z - .string() - .optional() - .describe( - 'A URL for a main image in the embed. This is an optional field.', - ), - footer: z - .string() - .optional() - .describe('The footer text of the embed. This is an optional field.'), - footerIcon: z - .string() - .optional() - .describe( - 'A URL for an icon to display in the footer of the embed. This is an optional field.', - ), - fields: z - .array( - z.object({ - name: z.string().trim().describe('The name of the field.'), - value: z.string().trim().describe('The value of the field.'), - inline: z - .boolean() - .optional() - .default(false) - .describe( - 'Whether the field should be displayed inline with other fields. This is an optional field which defaults to false.', - ), - }), - ) - .min(0) - .max(25) - .default([]) - .optional() - .describe( - 'An array of fields to include in the embed. Each field should be an object with "name" and "value" properties. This is an optional field.', - ), - }) - .optional() - .describe( - 'An object representing embeds to include in the discord message. This is an optional field.', - ), - poll: pollSchema.optional(), - }) - .describe( - 'The schema for an AI response message to be sent to discord, including content and embeds. At least one of content, embeds, or poll must be present.', - ); diff --git a/packages/ai/src/system-prompt.ts b/packages/ai/src/system-prompt.ts new file mode 100644 index 00000000..386d186f --- /dev/null +++ b/packages/ai/src/system-prompt.ts @@ -0,0 +1,35 @@ +import { Message } from 'discord.js'; + +/** + * Creates the default system prompt for the AI bot based on the provided message context. + * This prompt includes the bot's role, current channel information, and response guidelines. + */ +export function createSystemPrompt(message: Message): string { + const channelInfo = + 'name' in message.channel + ? message.channel.name + : message.channel.recipient?.displayName || 'DM'; + + const guildInfo = message.inGuild() + ? `You are in the guild "${message.guild.name}" (ID: ${message.guildId}). You can fetch member information when needed.` + : 'You are in a direct message with the user.'; + + return `You are ${message.client.user.username} (ID: ${message.client.user.id}), a helpful AI Discord bot. + +**Your Role:** +- Assist users with questions and tasks efficiently +- Use available tools to perform specific actions when requested +- Keep responses under 2000 characters and concise + +**Current Context:** +- Channel: "${channelInfo}" (ID: ${message.channelId}) +- Permissions: ${message.channel.isSendable() ? 'Can send messages' : 'Cannot send messages'} +- Location: ${guildInfo} + +**Response Guidelines:** +- Use Discord markdown for formatting +- Only use tools when necessary for the task +- Reject requests unrelated to your available tools + +Focus on being helpful while staying within your capabilities.`; +} diff --git a/packages/ai/src/tools/common/index.ts b/packages/ai/src/tools/common/index.ts new file mode 100644 index 00000000..69e1c3f7 --- /dev/null +++ b/packages/ai/src/tools/common/index.ts @@ -0,0 +1,106 @@ +import { Schema, tool } from 'ai'; +import { AiContext } from '../../context'; +import { getAiWorkerContext } from '../../ai-context-worker'; +import { z } from 'zod'; + +/** + * Utility type that represents a value that can be either synchronous or asynchronous. + * @template T - The type of the value + * @internal + */ +type Awaitable = T | Promise; + +/** + * Type representing the parameters schema for AI tools. + * Extracted from the first parameter of the `tool` function from the 'ai' library. + */ +export type ToolParameterType = Parameters[0]['parameters']; + +/** + * Utility type that infers the TypeScript type from a tool parameter schema. + * Supports both Zod schemas and AI library schemas. + * @template T - The tool parameter type to infer from + */ +export type InferParameters = + T extends Schema + ? T['_type'] + : T extends z.ZodTypeAny + ? z.infer + : never; + +/** + * Configuration options for creating an AI tool. + * @template T - The parameter schema type for the tool + * @template R - The return type of the tool's execute function + */ +export interface CreateToolOptions { + /** The unique name identifier for the tool */ + name: string; + /** A human-readable description of what the tool does */ + description: string; + /** The parameter schema that defines the tool's input structure */ + parameters: T; + /** The function that executes when the tool is called */ + execute: ToolExecuteFunction; +} + +/** + * Type definition for a tool's execute function. + * @template T - The parameter schema type + * @template R - The return type of the function + * @param ctx - The AI context containing request and response information + * @param parameters - The validated parameters passed to the tool + * @returns The result of the tool execution, which can be synchronous or asynchronous + */ +export type ToolExecuteFunction = ( + ctx: AiContext, + parameters: InferParameters, +) => Awaitable; + +/** + * Creates a new AI tool with the specified configuration. + * This function wraps the underlying AI library's tool creation with additional + * context management and parameter validation. + * + * @template T - The parameter schema type for the tool + * @template R - The return type of the tool's execute function + * @param options - The configuration options for the tool + * @returns A configured AI tool ready for use + * + * @example + * ```typescript + * const myTool = createTool({ + * name: 'calculate', + * description: 'Performs basic arithmetic calculations', + * parameters: z.object({ + * operation: z.enum(['add', 'subtract']), + * a: z.number(), + * b: z.number(), + * }), + * execute: async (ctx, params) => { + * return params.operation === 'add' + * ? params.a + params.b + * : params.a - params.b; + * }, + * }); + * ``` + */ +export function createTool( + options: CreateToolOptions, +) { + // @ts-ignore - Suppressing type checking due to complex generic inference + const _tool = tool({ + name: options.name, + description: options.description, + parameters: options.parameters, + async execute(params) { + const { ctx } = getAiWorkerContext(); + + ctx.setParams(params); + + return options.execute(ctx, params as InferParameters); + }, + }); + + return _tool; +} diff --git a/packages/ai/src/tools/get-available-commands.ts b/packages/ai/src/tools/get-available-commands.ts new file mode 100644 index 00000000..7963b1f5 --- /dev/null +++ b/packages/ai/src/tools/get-available-commands.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +import { createTool } from './common'; + +export const getAvailableCommands = createTool({ + description: 'Get a list of all available commands.', + name: 'getAvailableCommands', + parameters: z.object({}), + execute(ctx, params) { + const { commandkit } = ctx; + + const commands = commandkit.commandHandler + .getCommandsArray() + .map((cmd) => ({ + name: cmd.data.command.name, + description: cmd.data.command.description, + category: cmd.command.category, + supportsAI: 'ai' in cmd.data && typeof cmd.data.ai === 'function', + })); + + return commands; + }, +}); diff --git a/packages/ai/src/tools/get-channel-by-id.ts b/packages/ai/src/tools/get-channel-by-id.ts new file mode 100644 index 00000000..36bea802 --- /dev/null +++ b/packages/ai/src/tools/get-channel-by-id.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { createTool } from './common'; +import { Logger } from 'commandkit'; + +export const getChannelById = createTool({ + description: 'Get a channel by its ID.', + name: 'getChannelById', + parameters: z.object({ + channelId: z.string().describe('The ID of the channel to retrieve.'), + }), + async execute(ctx, params) { + try { + const { client } = ctx; + const channel = await client.channels.fetch(params.channelId, { + force: false, + cache: true, + }); + + if (!channel) { + return { + error: 'Channel not found', + }; + } + + return channel.toJSON(); + } catch (e) { + Logger.error(e); + + return { + error: 'Could not fetch the channel', + }; + } + }, +}); diff --git a/packages/ai/src/tools/get-current-client-info.ts b/packages/ai/src/tools/get-current-client-info.ts new file mode 100644 index 00000000..7916904f --- /dev/null +++ b/packages/ai/src/tools/get-current-client-info.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { createTool } from './common'; + +export const getCurrentClientInfo = createTool({ + name: 'getCurrentClientInfo', + description: 'Get information about the current discord bot user', + parameters: z.object({}), + execute: async (ctx, params) => { + const { client } = ctx; + const user = client.user; + + if (!user) { + return { + error: 'Bot user not found', + }; + } + + return user.toJSON(); + }, +}); diff --git a/packages/ai/src/tools/get-guild-by-id.ts b/packages/ai/src/tools/get-guild-by-id.ts new file mode 100644 index 00000000..323e949d --- /dev/null +++ b/packages/ai/src/tools/get-guild-by-id.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { createTool } from './common'; +import { Logger } from 'commandkit'; + +export const getGuildById = createTool({ + description: 'Get a guild by its ID.', + name: 'getGuildById', + parameters: z.object({ + guildId: z.string().describe('The ID of the guild to retrieve.'), + }), + async execute(ctx, params) { + try { + const { client } = ctx; + const guild = await client.guilds.fetch({ + guild: params.guildId, + force: false, + cache: true, + }); + + if (!guild) { + return { + error: 'Guild not found', + }; + } + + return { + id: guild.id, + name: guild.name, + icon: guild.iconURL(), + memberCount: guild.approximateMemberCount ?? guild.memberCount, + }; + } catch (e) { + Logger.error(e); + + return { + error: 'Could not fetch the guild', + }; + } + }, +}); diff --git a/packages/ai/src/tools/get-user-by-id.ts b/packages/ai/src/tools/get-user-by-id.ts new file mode 100644 index 00000000..04fe0cda --- /dev/null +++ b/packages/ai/src/tools/get-user-by-id.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; +import { createTool } from './common'; +import { Logger } from 'commandkit'; + +export const getUserById = createTool({ + description: 'Get a user by their ID.', + name: 'getUserById', + parameters: z.object({ + userId: z.string().describe('The ID of the user to retrieve.'), + }), + async execute(ctx, params) { + try { + const { client } = ctx; + const user = await client.users.fetch(params.userId, { + force: false, + cache: true, + }); + + return user.toJSON(); + } catch (e) { + Logger.error(e); + + return { + error: 'Could not fetch the user', + }; + } + }, +}); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 2f324d25..9fa8fae3 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,6 +1,12 @@ -import { LanguageModelV1, ProviderMetadata } from 'ai'; +import { LanguageModelV1, ProviderMetadata, Tool, type generateText } from 'ai'; import { Message } from 'discord.js'; import { AiContext } from './context'; +import { LoadedCommand } from 'commandkit'; + +/** + * Represents the result of an AI text generation operation. + */ +export type AIGenerateResult = Awaited>; /** * Function type for filtering commands based on their name. @@ -21,12 +27,16 @@ export type MessageFilter = (message: Message) => Promise; * @param message - The message to base the model selection on. * @returns A promise that resolves to an object containing the selected model and optional metadata. */ -export type SelectAiModel = (message: Message) => Promise<{ - model: LanguageModelV1; - options?: ProviderMetadata; - objectMode?: boolean; -}>; +export type SelectAiModel = ( + ctx: AiContext, + message: Message, +) => Promise; + +export type SelectAiModelResult = Parameters[0]; +export type CommandTool = LoadedCommand & { + tool: Tool; +}; /** * Options for the AI plugin. */ diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts new file mode 100644 index 00000000..ae85c8c0 --- /dev/null +++ b/packages/ai/src/utils.ts @@ -0,0 +1,28 @@ +import { TextBasedChannel } from 'discord.js'; + +/** + * @private + * @internal + */ +export async function createTypingIndicator( + channel: TextBasedChannel, +): Promise<() => void> { + let stopped = false; + + const runner = async () => { + if (stopped) return clearInterval(typingInterval); + + if (channel.isSendable()) { + await channel.sendTyping().catch(Object); + } + }; + + const typingInterval = setInterval(runner, 3000).unref(); + + await runner(); + + return () => { + stopped = true; + clearInterval(typingInterval); + }; +} diff --git a/packages/commandkit/ai.cjs b/packages/commandkit/ai.cjs new file mode 100644 index 00000000..d20fa647 --- /dev/null +++ b/packages/commandkit/ai.cjs @@ -0,0 +1,21 @@ +const { + AiContext, + AiPlugin, + configureAI, + createSystemPrompt, + createTool, + getAIConfig, + useAIContext, + ai, +} = require('@commandkit/ai'); + +module.exports = { + AiContext, + AiPlugin, + configureAI, + createSystemPrompt, + createTool, + getAIConfig, + useAIContext, + ai, +}; diff --git a/packages/commandkit/ai.d.ts b/packages/commandkit/ai.d.ts new file mode 100644 index 00000000..7274040a --- /dev/null +++ b/packages/commandkit/ai.d.ts @@ -0,0 +1 @@ +export * from '@commandkit/ai'; diff --git a/packages/commandkit/package.json b/packages/commandkit/package.json index 3ce34225..7c5e353f 100644 --- a/packages/commandkit/package.json +++ b/packages/commandkit/package.json @@ -32,7 +32,9 @@ "./events.cjs", "./events.d.ts", "./analytics.cjs", - "./analytics.d.ts" + "./analytics.d.ts", + "./ai.cjs", + "./ai.d.ts" ], "exports": { ".": { @@ -94,6 +96,11 @@ "require": "./analytics.cjs", "import": "./analytics.cjs", "types": "./analytics.d.ts" + }, + "./ai": { + "require": "./ai.cjs", + "import": "./ai.cjs", + "types": "./ai.d.ts" } }, "scripts": { diff --git a/packages/commandkit/src/app/commands/AppCommandRunner.ts b/packages/commandkit/src/app/commands/AppCommandRunner.ts index e9a599f3..d2a1f65e 100644 --- a/packages/commandkit/src/app/commands/AppCommandRunner.ts +++ b/packages/commandkit/src/app/commands/AppCommandRunner.ts @@ -18,6 +18,10 @@ import { import { CommandKitErrorCodes, isErrorType } from '../../utils/error-codes'; import { AnalyticsEvents } from '../../analytics/constants'; +export interface RunCommandOptions { + handler?: string; +} + /** * Handles the execution of application commands for CommandKit. * Manages middleware execution, environment setup, and command invocation. @@ -38,6 +42,7 @@ export class AppCommandRunner { public async runCommand( prepared: PreparedAppCommandExecution, source: Interaction | Message, + options?: RunCommandOptions, ) { const { commandkit } = this.handler; @@ -50,6 +55,7 @@ export class AppCommandRunner { env.variables.set('commandHandlerType', 'app'); env.variables.set('currentCommandName', prepared.command.command.name); env.variables.set('execHandlerKind', executionMode); + env.variables.set('customHandler', options?.handler ?? null); const ctx = new MiddlewareContext(commandkit, { command: prepared.command, @@ -98,10 +104,12 @@ export class AppCommandRunner { }); } + let result: any; + if (!ctx.cancelled) { // Determine which function to run based on whether we're executing a command or subcommand const targetData = prepared.command.data; - const fn = targetData[executionMode]; + const fn = targetData[options?.handler || executionMode]; if (!fn) { Logger.warn( @@ -188,7 +196,7 @@ export class AppCommandRunner { }); if (!res) { - await executeCommand(); + result = await executeCommand(); } } catch (e) { if (isErrorType(e, CommandKitErrorCodes.ExitMiddleware)) { @@ -224,6 +232,8 @@ export class AppCommandRunner { } }); } + + return result; } /** diff --git a/packages/commandkit/src/app/handlers/AppCommandHandler.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.ts index 134bd0e9..65fa1108 100644 --- a/packages/commandkit/src/app/handlers/AppCommandHandler.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.ts @@ -29,10 +29,11 @@ import colors from '../../utils/colors'; export type RunCommand = (fn: T) => T; /** - * @private - * @internal + * Represents a native command structure used in CommandKit. + * This structure includes the command definition and various handlers for different interaction types. + * It can be used to define slash commands, context menu commands, and message commands. */ -interface AppCommandNative { +export interface AppCommandNative { command: SlashCommandBuilder | Record; chatInput?: (ctx: Context) => Awaitable; autocomplete?: (ctx: Context) => Awaitable; @@ -42,10 +43,19 @@ interface AppCommandNative { } /** - * @private - * @internal + * Custom properties that can be added to an AppCommand. + * This allows for additional metadata or configuration to be associated with a command. + */ +// export type CustomAppCommandProps = Record; +export interface CustomAppCommandProps { + [key: string]: any; +} + +/** + * Represents a command in the CommandKit application, including its metadata and handlers. + * This type extends the native command structure with additional properties. */ -type AppCommand = AppCommandNative & Record; +export type AppCommand = AppCommandNative & CustomAppCommandProps; /** * @private diff --git a/packages/commandkit/src/plugins/plugin-runtime/CommandKitPluginRuntime.ts b/packages/commandkit/src/plugins/plugin-runtime/CommandKitPluginRuntime.ts index 6168e7b6..6abf1a1d 100644 --- a/packages/commandkit/src/plugins/plugin-runtime/CommandKitPluginRuntime.ts +++ b/packages/commandkit/src/plugins/plugin-runtime/CommandKitPluginRuntime.ts @@ -1,4 +1,4 @@ -import { Collection } from 'discord.js'; +import { Collection, Constructable } from 'discord.js'; import { CommandKit } from '../../CommandKit'; import { RuntimePlugin } from '../RuntimePlugin'; import { @@ -31,12 +31,22 @@ export class CommandKitPluginRuntime { /** * Checks if there are no plugins registered in this runtime. + * @param pluginName The name of the plugin to check. * @returns Boolean indicating whether the runtime is empty. */ public getPlugin(pluginName: string): RuntimePlugin | null { return this.plugins.get(pluginName) ?? null; } + /** + * Fetches the given plugin + * @param plugin The plugin to be fetched. + */ + public get>(plugin: T): InstanceType | null { + const p = this.plugins.find((p) => p instanceof plugin) ?? null; + return p as InstanceType | null; + } + /** * Soft registers a plugin in the runtime. * @param plugin The plugin to be registered. diff --git a/packages/tasks/examples/commands/mute.ts b/packages/tasks/examples/commands/mute.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/tasks/examples/config.ts b/packages/tasks/examples/config.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/tasks/examples/refresh-exchange-rate.ts b/packages/tasks/examples/refresh-exchange-rate.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/tasks/examples/unmute.ts b/packages/tasks/examples/unmute.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/tasks/src/TaskContextManager.ts b/packages/tasks/src/TaskContextManager.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/tasks/src/TaskManager.ts b/packages/tasks/src/TaskManager.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/tasks/src/driver.ts b/packages/tasks/src/driver.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/tasks/src/drivers/bullmq.ts b/packages/tasks/src/drivers/bullmq.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/tasks/src/drivers/index.ts b/packages/tasks/src/drivers/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/tasks/src/plugin.ts b/packages/tasks/src/plugin.ts new file mode 100644 index 00000000..e69de29b