-
Notifications
You must be signed in to change notification settings - Fork 116
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* chore: ai assistant apis * chore: linting
- Loading branch information
Showing
13 changed files
with
900 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
packages/paste-website/src/components/assistant/AssistantMessage.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
29 changes: 29 additions & 0 deletions
29
packages/paste-website/src/components/assistant/EnterKeySubmitPlugin.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
21 changes: 21 additions & 0 deletions
21
packages/paste-website/src/components/assistant/SendButtonPlugin.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
90
packages/paste-website/src/components/assistant/ThreadList.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
21
packages/paste-website/src/components/assistant/UserMessage.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
177
packages/paste-website/src/pages/api/paste-assistant-message.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 */ |
Oops, something went wrong.
b187ab9
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Successfully deployed to the following URLs:
paste-remix – ./
paste-remix-git-main-twilio-developer-education.vercel.app
paste-remix-theta.vercel.app
remix.twilio.design
paste-remix-twilio-developer-education.vercel.app