Skip to content

Commit

Permalink
!refactor: handle conversation context out of react router
Browse files Browse the repository at this point in the history
  • Loading branch information
edwardzjl committed Feb 20, 2024
1 parent 061db98 commit e0ce2fa
Show file tree
Hide file tree
Showing 8 changed files with 223 additions and 165 deletions.
12 changes: 0 additions & 12 deletions api/chatbot/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,6 @@ async def chat(
case "on_chain_start":
parent_run_id = event["run_id"]
history.add_message(message.to_lc())
conv.last_message_at = utcnow()
await conv.save()
# Inform the client that the message has been added to conversation history.
# This is useful for the client to update the UI.
info_message = InfoMessage(
conversation=message.conversation,
from_="ai",
content={
"type": "msg-added",
},
)
await websocket.send_text(info_message.model_dump_json())
case "on_chain_end":
msg = ChatMessage(
parent_id=parent_run_id,
Expand Down
139 changes: 139 additions & 0 deletions web/src/contexts/conversation.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { createContext, useEffect, useReducer } from "react";


/**
* messages, dispatch
*/
export const ConversationContext = createContext({
groupedConvs: {},
dispatch: () => { },
});

const flatConvs = (groupedConvs) => {
return Object.entries(groupedConvs).flatMap(([_, convs]) => (
[...convs]
));
};

const sortConvs = (conversations) => {
// sort by pinned and last_message_at
// See <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSorted>
// and <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#sorting_array_of_objects>
return conversations.toSorted((a, b) => {
if (a.pinned && !b.pinned) {
return -1;
}
if (!a.pinned && b.pinned) {
return 1;
}
if (a.last_message_at > b.last_message_at) {
return -1;
}
if (a.last_message_at < b.last_message_at) {
return 1;
}
return 0;
});
};

const groupConvs = (conversations) => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const lastSevenDays = new Date(today);
lastSevenDays.setDate(lastSevenDays.getDate() - 7);

return Object.groupBy(conversations, (item) => {
if (item.pinned) {
return "pinned";
}
const itemDate = new Date(item.last_message_at);
if (itemDate.toDateString() === today.toDateString()) {
return "Today";
} else if (itemDate.toDateString() === yesterday.toDateString()) {
return "Yesterday";
} else if (itemDate > lastSevenDays) {
return "Last seven days";
} else {
return `${itemDate.toLocaleString("default", { month: "long" })} ${itemDate.getFullYear()}`;
}
});
};

export const ConversationProvider = ({ children }) => {

// It seems that I can't use async function as the `init` param.
// So I opt to use `useEffect` instead.
const [groupedConvs, dispatch] = useReducer(
conversationReducer,
{},
);

useEffect(() => {
const init = async () => {
const convs = await fetch("/api/conversations", {
}).then((res) => res.json());

// This assumes that the convs are already sorted by the server.
// Otherwise, I need to call `sortConvs` first.
const groupedConvs = groupConvs(convs);

dispatch({
type: "replaceAll",
groupedConvs
});
};

init();
}, []);

return (
<ConversationContext.Provider value={{ groupedConvs, dispatch }}>
{children}
</ConversationContext.Provider>
);
}


export const conversationReducer = (groupedConvs, action) => {
switch (action.type) {
case "added": {
// action.conv: { id, title, created_at, last_message_at, owner, pinned }
return { ...groupedConvs, Today: [action.conv, ...groupedConvs.Today] };
}
case "deleted": {
const convs = flatConvs(groupedConvs);
return groupConvs(convs.filter((conv) => conv.id !== action.convId));
}
case "renamed": {
const convs = flatConvs(groupedConvs);
return groupConvs(convs.map((conv) => {
if (conv.id === action.convId) {
return { ...conv, title: action.title };
}
return conv;
}));
}
case "reordered": {
const convs = flatConvs(groupedConvs);
console.log("convs", convs);
const updatedConvs = convs.map((conv) => {
if (conv.id === action.conv.id) {
return { ...conv, ...action.conv };
}
return conv;
});
console.log("updatedConvs", updatedConvs);
const sortedConvs = sortConvs(updatedConvs);
console.log("sortedConvs", sortedConvs);
return groupConvs(sortedConvs);
}
case "replaceAll": {
return { ...action.groupedConvs };
}
default: {
console.error("Unknown action: ", action);
return groupedConvs;
}
}
};
60 changes: 9 additions & 51 deletions web/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import {
RouterProvider,
} from "react-router-dom";

import Root, { loader as rootLoader, action as rootAction } from "routes/root";
import Root, { action as rootAction } from "routes/root";
import Index from "routes/index";
import Conversation, { loader as conversationLoader, action as conversationAction } from "routes/conversation";
import Conversation, { loader as conversationLoader } from "routes/conversation";
import ErrorPage from "error-page";

import reportWebVitals from "./reportWebVitals";

import { SnackbarProvider } from "contexts/snackbar";
import { ThemeProvider } from "contexts/theme";
import { UserProvider } from "contexts/user";
import { ConversationProvider } from "contexts/conversation";
import { MessageProvider } from "contexts/message";

const router = createBrowserRouter([
Expand All @@ -26,52 +27,7 @@ const router = createBrowserRouter([
id: "root",
element: <Root />,
errorElement: <ErrorPage />,
loader: rootLoader,
action: rootAction,
shouldRevalidate: ({ currentParams, nextParams, formMethod }) => {
// Root revalidation logic, revalidates (fetches) conversation list.
// from index to index
if (currentParams.convId === undefined && nextParams.convId === undefined) {
return false;
}
// from index to conv
if (currentParams.convId === undefined) {
// Revalidate post method so that the newly created conv will show up.
if (formMethod === "post") {
return true;
}
// ignore others to prevent fetch when clicking on a conversation from index.
return false;
}
// from conv to index
if (nextParams.convId === undefined) {
// Revalidate delete method so that the deleted conv will be removed.
if (formMethod === "delete") {
return true;
}
// ignore others to prevent fetch when clicking on index from any conversation.
return false;
}
// from conv to same conv
if (currentParams.convId === nextParams.convId) {
// revalidate on post
// this is a bit hacky, I trigger a 'post' action on message send for 2 reasons:
// 1. I need to revalidate the conversations to get the 'last_message_at' updated.
// 2. I need to revalidate the conversations when title generated.
// 3. The 'get' method doesn't trigger actions.
if (formMethod === "post") {
return true;
}
// revalidate on put
// I need to revalidate the conversations when conversation pinned.
if (formMethod === "put") {
return true;
}
return false;
}
// Ignore revalidation on conv to another conv.
return false;
},
children: [
{
errorElement: <ErrorPage />,
Expand All @@ -84,8 +40,8 @@ const router = createBrowserRouter([
path: "conversations/:convId",
element: <Conversation />,
loader: conversationLoader,
action: conversationAction,
shouldRevalidate: ({ currentParams, nextParams }) => {
// prevent revalidating when clicking on the same conversation
return currentParams.convId !== nextParams.convId;
}
}
Expand All @@ -101,9 +57,11 @@ root.render(
<ThemeProvider>
<SnackbarProvider>
<UserProvider>
<MessageProvider>
<RouterProvider router={router} />
</MessageProvider>
<ConversationProvider>
<MessageProvider>
<RouterProvider router={router} />
</MessageProvider>
</ConversationProvider>
</UserProvider>
</SnackbarProvider>
</ThemeProvider>
Expand Down
23 changes: 19 additions & 4 deletions web/src/routes/conversation/ChatInput/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import { useContext, useState, useRef, useEffect } from "react";

import { UserContext } from "contexts/user";
import { MessageContext } from "contexts/message";
import { ConversationContext } from "contexts/conversation";
import { WebsocketContext } from "contexts/websocket";


/**
*
*/
const ChatInput = ({ convId }) => {
const ChatInput = ({ conv }) => {
const { username } = useContext(UserContext);
const { dispatch } = useContext(MessageContext);
const { groupedConvs, dispatch: dispatchConv } = useContext(ConversationContext);
const [ready, send] = useContext(WebsocketContext);

const [input, setInput] = useState("");
Expand All @@ -22,10 +24,10 @@ const ChatInput = ({ convId }) => {
* Focus on input when convId changes.
*/
useEffect(() => {
if (convId) {
if (conv.id) {
inputRef.current.focus();
}
}, [convId]);
}, [conv.id]);

/**
* Adjusting height of textarea.
Expand All @@ -46,7 +48,7 @@ const ChatInput = ({ convId }) => {
}
const message = { id: crypto.randomUUID(), from: username, content: input, type: "text" };
const payload = {
conversation: convId,
conversation: conv.id,
...message,
};
setInput("");
Expand All @@ -55,6 +57,19 @@ const ChatInput = ({ convId }) => {
type: "added",
message: message,
});
// update last_message_at of the conversation to re-order conversations
if (conv.pinned && groupedConvs.pinned && groupedConvs.pinned[0]?.id !== conv.id) {
dispatchConv({
type: "reordered",
conv: { id: conv.id, last_message_at: new Date().toISOString() },
});
}
if (groupedConvs.Today && groupedConvs.Today[0]?.id !== conv.id) {
dispatchConv({
type: "reordered",
conv: { id: conv.id, last_message_at: new Date().toISOString() },
});
}
send(JSON.stringify(payload));
};

Expand Down
34 changes: 1 addition & 33 deletions web/src/routes/conversation/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,38 +12,6 @@ import ChatLog from "./ChatLog";
import ChatMessage from "./ChatMessage";
import ChatInput from "./ChatInput";

export async function action({ params, request }) {
if (request.method === "POST") {
// TODO: this is hacky, I trigger a 'post' action on message send.
// It doesn't return anything, it is just used to revalidate the conversations.
return null;
}
if (request.method === "PUT") {
const conversation = await request.json();
const resp = await fetch(`/api/conversations/${params.convId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: conversation.title,
pinned: conversation.pinned,
}),
});
if (!resp.ok) {
console.error("error updating conversation", resp);
// TODO: handle error
}
const _conv = await resp.json();
return { _conv };
}
if (request.method === "DELETE") {
await fetch(`/api/conversations/${params.convId}`, {
method: "DELETE",
});
return redirect("/");
}
}

export async function loader({ params }) {
const resp = await fetch(`/api/conversations/${params.convId}`, {});
Expand Down Expand Up @@ -91,7 +59,7 @@ const Conversation = () => {
))}
</ChatLog>
<div className="input-bottom">
<ChatInput convId={conversation.id} />
<ChatInput conv={conversation} />
<div className="footer">Chatbot can make mistakes. Consider checking important information.</div>
</div>
</>
Expand Down
Loading

0 comments on commit e0ce2fa

Please sign in to comment.