diff --git a/schema.prisma b/schema.prisma index b460292..e3d10e8 100644 --- a/schema.prisma +++ b/schema.prisma @@ -8,12 +8,15 @@ datasource db { } model Usage { - timestamp DateTime @id + @@id([timestamp, functionRan]) + timestamp DateTime user BigInt channel BigInt guild BigInt? usageReguest Int usageResponse Int + functionName String? + functionRan Int @default(0) } model Limits { diff --git a/src/config_example.ts b/src/config_example.ts index 2edce9d..6e1af96 100644 --- a/src/config_example.ts +++ b/src/config_example.ts @@ -2,17 +2,17 @@ import { ChatCompletionRequestMessage as OpenAIMessage , CreateChatCompletionReq // Don't forget to rename the file to config.ts -const calendarConfig: Intl.DateTimeFormatOptions = { - weekday: "short", - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, -}; - export default class config { + static readonly calendarConfig: Intl.DateTimeFormatOptions = { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }; + /** Tokens to authenticate with */ static readonly tokens = { Discord: "Discord token here", @@ -22,7 +22,7 @@ export default class config { /** Messages to append at the start of every chat when sending to API */ static systemPrompt(): OpenAIMessage[] { return [ - { role: "system", content: `You are GPTcord, an AI built on top of ChatGPT (a large language model trained by OpenAI) for Discord. Answer as concisely as possible. Current time (${Intl.DateTimeFormat().resolvedOptions().timeZone}): ${new Date().toLocaleString("en-US", calendarConfig)}` } + { role: "system", content: `You are GPTcord, an AI built on top of ChatGPT (a large language model trained by OpenAI) for Discord. Answer as concisely as possible.` } ]; } diff --git a/src/execution.ts b/src/execution.ts index 9104207..b672355 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -4,6 +4,7 @@ import { database, openai } from "./index"; import Moderation from "./moderation"; import config from "./config"; import toOpenAIMessages from "./toOpenAIMessages"; +import FunctionManager from "./funcitonManager"; type NonNullableInObject = { [k in keyof T]: k extends V ? NonNullable : T[k] }; type apiRequest = DiscordApi.Message | DiscordApi.RepliableInteraction; @@ -12,6 +13,9 @@ export type request = apiRequest & NonNullableInObject = new DiscordApi.Collection(); +type ChannelQueue = NonNullable>; +type RequestMessage = NonNullable>; + /** * Gets the user that requested the execution * @param request The request to get the user from @@ -158,13 +162,49 @@ export async function queueRequest(request: apiRequest) { executeFromQueue(request.channelId); } +/** + * Logs used tokens to the terminal and to the database + * @param answer the response that OpenAI returned + * @param message the message that initiated the execution + * @param functionRan counter of how many function have been ran + */ +function logUsedTokens( + answer: Awaited>, + message: RequestMessage, + functionRan: number, + ) { + const usage = answer.data.usage; + const functionName = answer.data.choices[0].message?.function_call?.name; + if (usage != undefined) { + const channelName: string = !message.channel.isDMBased() ? `${message.channel.name} (${message.guild?.name})` : `@${getAuthor(message).tag}`; + console.log(`Used ${usage.total_tokens} (${usage.prompt_tokens} + ${usage.completion_tokens}) tokens for ${getAuthor(message).tag} (${getAuthor(message).id}) in #${channelName}${functionName ? " [Function: " + functionName + "]" : ""}`); + + database.usage.create({ + data: { + timestamp: message.createdAt, + user: BigInt(getAuthor(message).id), + channel: BigInt(message.channelId), + guild: message.guildId ? BigInt(message.guildId) : null, + usageReguest: usage.prompt_tokens, + usageResponse: usage.completion_tokens, + functionName: functionName ?? null, + functionRan: functionName ? functionRan : 0, + } + }).catch((e => { + console.error("Failed to push to a database"); + console.error(e); + })); + } +} + /** * Executes the queue for the channel * @param channel the channel to run the queue for */ async function executeFromQueue(channel: string) { - const channelQueue = channelsRunning.get(channel) as NonNullable>; - const message = channelQueue.at(0) as NonNullable>; + const channelQueue = channelsRunning.get(channel) as ChannelQueue; + const message = channelQueue.at(0) as RequestMessage; + let functionRanCounter = 0; try { let messages: DiscordApi.Collection = await message.channel.messages.fetch({ limit: config.limits.messages, cache: false }); @@ -180,29 +220,39 @@ async function executeFromQueue(channel: string) { message.deferReply(); } - const answer = await openai.createChatCompletion({ + const OpenAImessages = toOpenAIMessages(messages); + let answer = await openai.createChatCompletion({ ...config.chatCompletionConfig, - messages: toOpenAIMessages(messages), + messages: OpenAImessages, + // FIXME: don't use new instance of FunctionManager + functions: new FunctionManager().getFunctions(), }); - const usage = answer.data.usage; - if (usage != undefined) { - const channelName: string = !message.channel.isDMBased() ? `${message.channel.name} (${message.guild?.name})` : `@${getAuthor(message).tag}`; - console.log(`Used ${usage.total_tokens} (${usage.prompt_tokens} + ${usage.completion_tokens}) tokens for ${getAuthor(message).tag} (${getAuthor(message).id}) in #${channelName}`); + logUsedTokens(answer, message, ++functionRanCounter); - database.usage.create({ - data: { - timestamp: message.createdAt, - user: BigInt(getAuthor(message).id), - channel: BigInt(message.channelId), - guild: message.guildId ? BigInt(message.guildId) : null, - usageReguest: usage.prompt_tokens, - usageResponse: usage.completion_tokens - } - }).catch((e => { - console.error("Failed to push to a database"); - console.error(e); - })); + let generatedMessage = answer.data.choices[0].message; + if (!generatedMessage) throw new Error("empty message received"); + + // handle function calls + while (generatedMessage.function_call) { + OpenAImessages.push(generatedMessage); + OpenAImessages.push({ + role: "function", + name: generatedMessage.function_call.name, + // FIXME: don't use new instance of FunctionManager + content: new FunctionManager().handleFunction(generatedMessage.function_call), + }); + answer = await openai.createChatCompletion({ + ...config.chatCompletionConfig, + messages: OpenAImessages, + // FIXME: don't use new instance of FunctionManager + functions: new FunctionManager().getFunctions(), + }); + + logUsedTokens(answer, message, ++functionRanCounter); + + generatedMessage = answer.data.choices[0].message; + if (!generatedMessage) throw new Error("empty message received"); } const answerContent = answer.data.choices[0].message?.content; diff --git a/src/funcitonManager.ts b/src/funcitonManager.ts new file mode 100644 index 0000000..cbc2c99 --- /dev/null +++ b/src/funcitonManager.ts @@ -0,0 +1,83 @@ +import { ChatCompletionFunctions, ChatCompletionRequestMessageFunctionCall } from "openai"; +import config from "./config"; + +type parameterMap = { + string: string, + number: number, +}; + +type nameTypeMap = {[name: string]: keyof parameterMap} | Record; + +type OpenAIFunctionRequestData = { + [name in keyof T]: T[name]; +}; + +/** + * Represents the function that can be ran by the OpenAI model + */ +export interface OpenAIFunction { + name: string, + description?: string, + parameters: { + type: "object", + properties: T extends Record ? Record : { + [name in T[string]]: { + type: T[name], + description?: string, + } + }, + required?: Array, + }, +} + +export abstract class OpenAIFunction { + getSettings(): ChatCompletionFunctions { + return { + name: this.name, + description: this.description, + parameters: this.parameters, + }; + } + + abstract execute(data: OpenAIFunctionRequestData): string; +} + +/* + * Manages functions for the OpenAI + **/ +export default class FunctionManager { + store = new Map(); + + constructor() { + this.store.set("getTime", new GetTime()); + } + + public getFunctions(): ChatCompletionFunctions[] { + const rvalue: ChatCompletionFunctions[] = []; + for (const [, value] of this.store) { + rvalue.push(value.getSettings()); + } + return rvalue; + } + + public handleFunction(request: ChatCompletionRequestMessageFunctionCall) { + + const parsedArguments = JSON.parse(request.arguments ?? ""); + return this.store.get(request.name ?? "")?.execute(parsedArguments); + } +} + +// buildins + +class GetTime extends OpenAIFunction> { + name = "getTime"; + description = "Gets current date and time with a timezone attached"; + parameters = { + type: "object" as const, + properties: {} as Record, + }; + + execute(): string { + return `${Intl.DateTimeFormat().resolvedOptions().timeZone}): ${new Date().toLocaleString("en-US", config.calendarConfig)}`; + } +}