Skip to content

Commit

Permalink
chore: ai assistant apis (#3632)
Browse files Browse the repository at this point in the history
* chore: ai assistant apis

* chore: linting
  • Loading branch information
SiTaggart authored Dec 1, 2023
1 parent ecbba04 commit b187ab9
Show file tree
Hide file tree
Showing 13 changed files with 900 additions and 7 deletions.
2 changes: 1 addition & 1 deletion packages/paste-website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@
"micromark-extension-mdxjs": "^2.0.0",
"minimist": "^1.2.8",
"next": "^14.0.0",
"openai": "^4.10.0",
"openai": "^4.18.0",
"openai-edge": "^1.2.2",
"pretty-format": "^28.1.0",
"prism-react-renderer": "^1.3.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ChatBubble, ChatMessage, ChatMessageMeta, ChatMessageMetaItem } from "@twilio-paste/chat-log";
import Markdown from "markdown-to-jsx";
import { type ThreadMessage } from "openai/resources/beta/threads/messages";
import * as React from "react";

import { Logo } from "../../assets/Logo";
import { formatTimestamp } from "../../utils/formatTimestamp";

export const AssistantMessage: React.FC<{ threadMessage: ThreadMessage }> = ({ threadMessage }) => {
return (
<ChatMessage variant="inbound">
<ChatBubble>
{threadMessage.content[0].type === "text" && (
<Markdown key={threadMessage.id}>{threadMessage.content[0].text.value}</Markdown>
)}
</ChatBubble>
<ChatMessageMeta aria-label={`said by the assistant at ${formatTimestamp(threadMessage.created_at)}`}>
<ChatMessageMetaItem>
<Logo size={20} />
PasteBot ・ {formatTimestamp(threadMessage.created_at)}
</ChatMessageMetaItem>
</ChatMessageMeta>
</ChatMessage>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
CLEAR_EDITOR_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_ENTER_COMMAND,
useLexicalComposerContext,
} from "@twilio-paste/lexical-library";
import * as React from "react";

export const EnterKeySubmitPlugin = ({ onKeyDown }: { onKeyDown: () => void }): null => {
const [editor] = useLexicalComposerContext();

const handleEnterKey = React.useCallback(
(event: KeyboardEvent) => {
const { shiftKey, ctrlKey } = event;
if (shiftKey || ctrlKey) return false;
event.preventDefault();
event.stopPropagation();
onKeyDown();
editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
return true;
},
[editor, onKeyDown],
);

React.useEffect(() => {
return editor.registerCommand(KEY_ENTER_COMMAND, handleEnterKey, COMMAND_PRIORITY_HIGH);
}, [editor, handleEnterKey]);
return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Box } from "@twilio-paste/box";
import { Button } from "@twilio-paste/button";
import { SendIcon } from "@twilio-paste/icons/esm/SendIcon";
import { CLEAR_EDITOR_COMMAND, useLexicalComposerContext } from "@twilio-paste/lexical-library";

export const SendButtonPlugin = ({ onClick, disabled }: { onClick: () => void; disabled: boolean }): JSX.Element => {
const [editor] = useLexicalComposerContext();

const handleSend = (): void => {
onClick();
editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
};

return (
<Box position="absolute" top="space30" right="space30">
<Button variant="primary_icon" size="reset" onClick={handleSend} disabled={disabled}>
<SendIcon decorative={false} title="Send message" />
</Button>
</Box>
);
};
90 changes: 90 additions & 0 deletions packages/paste-website/src/components/assistant/ThreadList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Box } from "@twilio-paste/box";
import { DeleteIcon } from "@twilio-paste/icons/esm/DeleteIcon";
import {
ListboxPrimitive,
ListboxPrimitiveItem,
type ListboxPrimitiveItemProps,
type ListboxPrimitiveProps,
} from "@twilio-paste/listbox-primitive";
import * as React from "react";

// Styled components that wrap the Paste Box component
const StyledList = React.forwardRef<HTMLDivElement, React.PropsWithChildren>((props, ref) => (
<Box ref={ref} {...props} />
));

const StyledListItem = React.forwardRef<HTMLDivElement, React.PropsWithChildren>((props, ref) => (
<Box
ref={ref}
{...props}
backgroundColor="colorBackgroundBody"
padding="space60"
borderBottomStyle="solid"
borderBottomColor="colorBorderWeaker"
borderBottomWidth="borderWidth10"
cursor="pointer"
position="relative"
display="flex"
columnGap="space30"
justifyContent="space-between"
alignItems="center"
outline="none"
_hover={{
backgroundColor: "colorBackgroundPrimaryWeakest",
}}
_focus={{
backgroundColor: "colorBackgroundPrimaryWeakest",
}}
_selected_after={{
content: '" "',
position: "absolute",
height: "80%",
width: "4px",
backgroundColor: "colorBackgroundPrimary",
borderTopLeftRadius: "borderRadius30",
borderBottomLeftRadius: "borderRadius30",
right: -1,
top: "10%",
}}
_selected_hover_after={{}}
_focus_selected_after={{}}
/>
));

export const ThreadList = React.forwardRef<HTMLDivElement, ListboxPrimitiveProps>((props, ref) => (
<ListboxPrimitive {...props} as={StyledList} ref={ref} />
));

type ListItemProps = { onDelete: () => void };
export const ThreadListItem = React.forwardRef<HTMLButtonElement, ListboxPrimitiveItemProps & ListItemProps>(
({ children, onDelete, ...props }, ref) => (
<ListboxPrimitiveItem
{...props}
as={StyledListItem}
ref={ref}
onKeyDown={(event) => {
if (event.key === "Backspace") {
onDelete();
}
}}
>
<Box>{children}</Box>
<Box
cursor="pointer"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
>
<DeleteIcon decorative={false} title=": press backspace to delete" color="colorTextDestructive" />
</Box>
</ListboxPrimitiveItem>
),
);

export const ThreadListTitle = React.forwardRef<HTMLDivElement, React.PropsWithChildren>((props, ref) => (
<Box ref={ref} {...props} fontWeight="fontWeightMedium" />
));
export const ThreadListMeta = React.forwardRef<HTMLDivElement, React.PropsWithChildren>((props, ref) => (
<Box ref={ref} {...props} color="colorTextWeak" fontSize="fontSize20" />
));
21 changes: 21 additions & 0 deletions packages/paste-website/src/components/assistant/UserMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ChatBubble, ChatMessage, ChatMessageMeta, ChatMessageMetaItem } from "@twilio-paste/chat-log";
import Markdown from "markdown-to-jsx";
import { type ThreadMessage } from "openai/resources/beta/threads/messages";
import * as React from "react";

import { formatTimestamp } from "../../utils/formatTimestamp";

export const UserMessage: React.FC<{ threadMessage: ThreadMessage }> = ({ threadMessage }) => {
return (
<ChatMessage variant="outbound">
<ChatBubble>
{threadMessage.content[0].type === "text" && (
<Markdown key={threadMessage.id}>{threadMessage.content[0].text.value}</Markdown>
)}
</ChatBubble>
<ChatMessageMeta aria-label={`said by you at ${formatTimestamp(threadMessage.created_at)}`}>
<ChatMessageMetaItem>You ・ {formatTimestamp(threadMessage.created_at)}</ChatMessageMetaItem>
</ChatMessageMeta>
</ChatMessage>
);
};
177 changes: 177 additions & 0 deletions packages/paste-website/src/pages/api/paste-assistant-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/* eslint-disable camelcase */
import type { NextApiRequest, NextApiResponse } from "next";
import OpenAI from "openai";
import Rollbar from "rollbar";

import { logger } from "../../functions-utils/logger";

const rollbar = new Rollbar({
accessToken: process.env.ROLLBAR_ACCESS_TOKEN,
captureUncaught: true,
captureUnhandledRejections: true,
});

const openAiKey = process.env.OPENAI_API_KEY;
const openAiSecret = process.env.OPENAI_API_SECRET;
const assistantID = process.env.OPENAI_ASSISTANT_ID;

const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // defaults to process.env["OPENAI_API_KEY"]
});

const LOG_PREFIX = "[/api/paste-assistant-message]:";

async function createUserMessage({
threadId,
message,
}: { threadId: string; message: string }): Promise<OpenAI.Beta.Threads.Messages.ThreadMessage> {
return openai.beta.threads.messages.create(threadId, { role: "user", content: message });
}

async function getRelevantDocs(functionArguments: string): Promise<any> {
return fetch("https://paste.twilio.design/api/docs-search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: functionArguments,
});
}

export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void | Response> {
logger.info(`${LOG_PREFIX} Incoming request`);

if (openAiKey === undefined || openAiSecret === undefined) {
logger.error(`${LOG_PREFIX} OpenAI API key or secret is undefined`);
rollbar.error(`${LOG_PREFIX} OpenAI API key or secret is undefined`);
res.status(500).json({
error: "OpenAI API key or secret is undefined",
});
return;
}

if (assistantID === undefined || assistantID === "") {
logger.error(`${LOG_PREFIX} OpenAI assistant ID is undefined`);
rollbar.error(`${LOG_PREFIX} OpenAI assistant ID is undefined`);
res.status(500).json({
error: "OpenAI assistant ID is undefined",
});
return;
}

const requestData = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
logger.info(`${LOG_PREFIX} Request data`, { requestData });

const { threadId, message } = requestData;

if (threadId === undefined || threadId === "") {
logger.error(`${LOG_PREFIX} Thread ID is undefined`);
rollbar.error(`${LOG_PREFIX} Thread ID is undefined`);
res.status(500).json({
error: "Thread ID is undefined",
});
return;
}

if (message === undefined || message === "") {
logger.error(`${LOG_PREFIX} Message is undefined`);
rollbar.error(`${LOG_PREFIX} Message is undefined`);
res.status(500).json({
error: "Message is undefined",
});
return;
}

logger.info(`${LOG_PREFIX} Creating user message`, { threadId, message });

try {
// Add a new message to the assistant thread
const userMessage = await createUserMessage({ threadId, message });
logger.info(`${LOG_PREFIX} Created user message`, { userMessage });
} catch (error) {
logger.error(`${LOG_PREFIX} Error creating user message`, { error });
rollbar.error(`${LOG_PREFIX} Error creating user message`, { error });
res.status(500).json({
error: "Error creating user message",
});
}

/**
* perform run on the assistant to process the newly added user message
*/
let run = await openai.beta.threads.runs.create(threadId, { assistant_id: assistantID });

/**
* poll the run to see if it's completeor if the assistant need to call some "Functions" find it's status
* keep polling while the status is ['queued', 'in_progress']
*/
while (run.status === "queued" || run.status === "in_progress") {
run = await openai.beta.threads.runs.retrieve(threadId, run.id);
}

/**
* if the run status becomes `completed` we can respond to the user with the assistant's response
*/
if (run.status === "completed") {
logger.info(`${LOG_PREFIX} Assistant run response`, { run });
res.status(200).json({ run });
}

/**
* if the run `requires_action` we need the `required_action` field to know what to do next for the assistant to finish responding to the new user message
* When a run has the status: "requires_action" and required_action.type is submit_tool_outputs
* submit the outputs from the tool calls once they're all completed.
* Tools are "Functions" that can be called from the assistant. The fact OpenAi calls them tools in the API is confusing.
*
* const run = await openai.beta.threads.runs.submitToolOutputs(
* "thread_abc123",
* "run_abc123",
* {
* tool_outputs: [
* {
* tool_call_id: "call_abc123",
* output: "28C",
* },
* ],
* }
* );
*/
if (run.status === "requires_action") {
logger.info(`${LOG_PREFIX} Assistant run requires action ${run.required_action?.type}`);

// this should never happen if the status is "requires_action", satisfy typescript
if (run.required_action == null) {
return;
}

// create an array of actions that need to be completed before we can get an ai response by iterating through the tool_calls from the submit_tool_outputs object
const actions = run.required_action.submit_tool_outputs.tool_calls.map((call) => {
return {
id: call.id,
functionName: call.function.name,
functionArguments: call.function.arguments,
};
});
logger.info(`${LOG_PREFIX} Assistant run requires`, { actions });

// For each actions, call each functionName with the functionArguments and create a tool_outputs array with the tool_call_id and output of each function call
const tool_outputs = [];
for (const action of actions) {
const { functionName, functionArguments } = action;

if (functionName === "get_relevant_docs") {
const output = await getRelevantDocs(functionArguments);
const json = await output.json();
tool_outputs.push({ tool_call_id: action.id, output: JSON.stringify(json) });
}
}

logger.info(`${LOG_PREFIX} Assistant run tool outputs`, { tool_outputs });

// submit the tool_outputs to the assistant
run = await openai.beta.threads.runs.submitToolOutputs(threadId, run.id, { tool_outputs });

res.status(200).json({ run });
}
}
/* eslint-enable camelcase */
Loading

1 comment on commit b187ab9

@vercel
Copy link

@vercel vercel bot commented on b187ab9 Dec 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.