Skip to content

Commit c37cb15

Browse files
committed
docs: add object mode to ai
1 parent f905e3f commit c37cb15

File tree

6 files changed

+274
-15
lines changed

6 files changed

+274
-15
lines changed

apps/test-bot/src/ai.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ const model = google.languageModel('gemini-2.0-flash');
99

1010
configureAI({
1111
selectAiModel: async () => {
12-
return { model };
12+
return {
13+
model,
14+
objectMode: true,
15+
};
1316
},
1417
messageFilter: async (message) => {
1518
return (

apps/website/docs/guide/13-ai-powered-commands/01-introduction.mdx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,14 @@ configureAI({
5454
// commandkit will call this function
5555
// to determine which AI model to use
5656
selectAiModel: async () => {
57-
return { model };
57+
return {
58+
model,
59+
// OPTIONAL: provider specific options
60+
options,
61+
// OPTIONAL: whether to use the object mode. Default is false.
62+
// If set to true, the AI will be able to generate object responses, such as creating polls or sending embeds.
63+
objectMode: false,
64+
};
5865
},
5966
messageFilter: async (message) => {
6067
// only respond to messages in guilds that mention the bot
@@ -156,3 +163,17 @@ AI can also call multiple commands in a single message. Eg:
156163
```
157164

158165
The above prompt will call the built-in `getUserInfo` tool and the `balance` command.
166+
167+
## Object Mode Example
168+
169+
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.
170+
171+
```text
172+
@bot create a poll titled "What's your favorite game?" and answers should be
173+
- minecraft
174+
- fortnite
175+
- pubg
176+
- clash of clans
177+
```
178+
179+
![object mode example](/img/ai-object-mode.png)
61.1 KB
Loading

packages/ai/src/plugin.ts

Lines changed: 111 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { AiPluginOptions, MessageFilter, SelectAiModel } from './types';
33
import { LoadedCommand, Logger } from 'commandkit';
44
import { AiContext } from './context';
55
import { Collection, Events, Message, TextChannel } from 'discord.js';
6-
import { tool, Tool, generateText } from 'ai';
6+
import { tool, Tool, generateText, Output } from 'ai';
77
import { z } from 'zod';
88
import { getAiWorkerContext, runInAiWorkerContext } from './ai-context-worker';
9+
import { AiResponseSchema } from './schema';
910

1011
type WithAI<T extends LoadedCommand> = T & {
1112
data: {
@@ -201,7 +202,10 @@ export class AiPlugin extends RuntimePlugin<AiPluginOptions> {
201202
Tools are basically like commands that you can execute to perform specific actions based on user input.
202203
Keep the response short and concise, and only use tools when necessary. Keep the response length under 2000 characters.
203204
Do not include your own text in the response unless necessary. For text formatting, you can use discord's markdown syntax.
204-
${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.'}`;
205+
${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.'}
206+
If the user asks you to create a poll or embeds, create a text containing the poll or embed information. If structured response is possible, use the structured response format.
207+
If the user asks you to perform a task that requires a tool, use the tool to perform the task and return the result.
208+
`;
205209

206210
const userInfo = `<user>
207211
<id>${message.author.id}</id>
@@ -215,22 +219,116 @@ export class AiPlugin extends RuntimePlugin<AiPluginOptions> {
215219
const stopTyping = await this.startTyping(channel);
216220

217221
try {
218-
const { model, options } = await aiModelSelector(message);
219-
const result = await generateText({
220-
abortSignal: AbortSignal.timeout(60_000),
222+
const {
221223
model,
222-
tools: { ...this.toolsRecord, ...this.defaultTools },
223-
prompt: `${userInfo}\nUser: ${message.content}\nAI:`,
224-
system: systemPrompt,
225-
maxSteps: 5,
226-
providerOptions: options,
227-
});
224+
options,
225+
objectMode = false,
226+
} = await aiModelSelector(message);
227+
228+
const originalPrompt = `${userInfo}\nUser: ${message.content}\nAI:`;
229+
230+
const call = ({
231+
prompt = originalPrompt,
232+
includeTools = true,
233+
disableObjectMode = false,
234+
}) =>
235+
generateText({
236+
abortSignal: AbortSignal.timeout(60_000),
237+
model,
238+
...(includeTools && {
239+
tools: { ...this.toolsRecord, ...this.defaultTools },
240+
}),
241+
prompt,
242+
system: systemPrompt,
243+
maxSteps: 5,
244+
providerOptions: options,
245+
...(objectMode && !disableObjectMode
246+
? {
247+
experimental_output: Output.object({
248+
schema: AiResponseSchema,
249+
}),
250+
}
251+
: {}),
252+
});
253+
254+
let result: any;
255+
256+
try {
257+
result = await call({});
258+
} catch {
259+
if (objectMode) {
260+
const r1 = await call({
261+
includeTools: true,
262+
disableObjectMode: true,
263+
});
264+
265+
if (!r1.text) throw new Error('No text response from AI');
266+
267+
const r2 = await call({
268+
includeTools: false,
269+
disableObjectMode: false,
270+
prompt: `Original context: ${r1.text} ${r1.text}\n\nGenerate a structured response based on the previous response`,
271+
});
272+
273+
result = r2;
274+
}
275+
}
228276

229277
stopTyping();
230278

231-
if (!!result.text) {
279+
let structuredResult: z.infer<typeof AiResponseSchema> | null = null;
280+
281+
try {
282+
const val =
283+
'experimental_output' in result && result.experimental_output;
284+
285+
if (val) {
286+
structuredResult = val;
287+
}
288+
} catch {}
289+
290+
if (structuredResult) {
291+
const { poll, content, embeds } = structuredResult;
292+
293+
if (!poll && !content && !embeds) {
294+
Logger.warn(
295+
'AI response did not include any content, embeds, or poll.',
296+
);
297+
return;
298+
}
299+
300+
await message.reply({
301+
content: content?.substring(0, 2000),
302+
embeds: embeds?.map((embed) => ({
303+
title: embed.title,
304+
description: embed.description,
305+
url: embed.url,
306+
color: embed.color,
307+
image: embed.image?.url ? { url: embed.image.url } : undefined,
308+
thumbnail: embed.thumbnail?.url
309+
? { url: embed.thumbnail.url }
310+
: undefined,
311+
fields: embed.fields?.map((field) => ({
312+
name: field.name,
313+
value: field.value,
314+
inline: field.inline,
315+
})),
316+
})),
317+
poll: poll
318+
? {
319+
allowMultiselect: poll.allow_multiselect,
320+
answers: poll.answers.map((answer) => ({
321+
text: answer.text,
322+
emoji: answer.emoji,
323+
})),
324+
duration: poll.duration,
325+
question: { text: poll.question.text },
326+
}
327+
: undefined,
328+
});
329+
} else if (!!result.text) {
232330
await message.reply({
233-
content: result.text,
331+
content: result.text.substring(0, 2000),
234332
allowedMentions: { parse: [] },
235333
});
236334
}

packages/ai/src/schema.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { z } from 'zod';
2+
3+
const pollMediaObject = z
4+
.object({
5+
text: z.string().trim().describe('The question text of the poll'),
6+
emoji: z
7+
.string()
8+
.trim()
9+
.optional()
10+
.describe('An optional emoji associated with the poll question. Eg: 👍'),
11+
})
12+
.describe(
13+
'An object representing the media for a poll question, containing the text of the question. Emoji cannot be used in question text.',
14+
);
15+
16+
export const AiResponseSchema = z
17+
.object({
18+
content: z
19+
.string()
20+
.trim()
21+
.optional()
22+
.describe(
23+
'The content of the message. This can be plain text or markdown. This is an optional field.',
24+
),
25+
embeds: z
26+
.array(
27+
z.object({
28+
title: z
29+
.string()
30+
.trim()
31+
.optional()
32+
.describe('The title of the embed. This is an optional field.'),
33+
description: z
34+
.string()
35+
.trim()
36+
.optional()
37+
.describe(
38+
'The description of the embed. This is an optional field.',
39+
),
40+
url: z
41+
.string()
42+
.optional()
43+
.describe(
44+
'The URL of the embed. No need to specify this if it is not needed. It is not a required field.',
45+
),
46+
color: z
47+
.number()
48+
.int()
49+
.min(0)
50+
.max(16777215)
51+
.optional()
52+
.describe(
53+
'The color of the embed in RGB format. This is an optional field.',
54+
),
55+
image: z
56+
.object({
57+
url: z
58+
.string()
59+
.optional()
60+
.describe(
61+
'The URL of the image in the embed. This is an optional field.',
62+
),
63+
})
64+
.optional(),
65+
thumbnail: z
66+
.object({
67+
url: z
68+
.string()
69+
.optional()
70+
.describe(
71+
'The URL of the thumbnail in the embed. This is an optional field.',
72+
),
73+
})
74+
.optional(),
75+
fields: z
76+
.array(
77+
z.object({
78+
name: z
79+
.string()
80+
.trim()
81+
.describe(
82+
'The name of the field. This is an optional field.',
83+
),
84+
value: z
85+
.string()
86+
.trim()
87+
.describe(
88+
'The value of the field. This is an optional field.',
89+
),
90+
inline: z
91+
.boolean()
92+
.optional()
93+
.default(false)
94+
.describe(
95+
'Whether the field is inline. This is an optional field. It defaults to false.',
96+
),
97+
}),
98+
)
99+
.max(25)
100+
.min(0)
101+
.optional()
102+
.describe('An array of fields in the embed'),
103+
}),
104+
)
105+
.max(10)
106+
.min(0)
107+
.optional()
108+
.describe('An array of embeds to include in the message'),
109+
poll: z
110+
.object({
111+
question: pollMediaObject,
112+
answers: z
113+
.array(pollMediaObject)
114+
.min(1)
115+
.max(10)
116+
.describe('An array of answers for the poll'),
117+
allow_multiselect: z
118+
.boolean()
119+
.optional()
120+
.default(false)
121+
.describe('Whether the poll allows multiple selections'),
122+
duration: z
123+
.number()
124+
.int()
125+
.min(1)
126+
.max(32)
127+
.optional()
128+
.default(24)
129+
.describe('The duration of the poll in hours'),
130+
})
131+
.optional()
132+
.describe('An object representing a poll to include in the message'),
133+
})
134+
.describe(
135+
'The schema for an AI response message, including content and embeds. At least one of content, embeds, or poll must be present.',
136+
);

packages/ai/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type MessageFilter = (message: Message) => Promise<boolean>;
99
export type SelectAiModel = (message: Message) => Promise<{
1010
model: LanguageModelV1;
1111
options?: ProviderMetadata;
12+
objectMode?: boolean;
1213
}>;
1314

1415
export interface AiPluginOptions {}

0 commit comments

Comments
 (0)