-
Notifications
You must be signed in to change notification settings - Fork 0
Fix Chat Page Refreshing Before Saving Chat #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,24 +1,26 @@ | ||
'use client' | ||
|
||
import { useId, useState } from 'react' | ||
import { useEffect, useId, useState } from 'react'; | ||
import { useActions, useAIState, useUIState } from 'ai/rsc' | ||
import { formatNumber } from '@/lib/utils' | ||
import { formatNumber, unixTsNow } from '@/lib/utils' | ||
|
||
import type { AI } from '@/lib/chat/actions' | ||
|
||
interface Purchase { | ||
numberOfShares?: number | ||
symbol: string | ||
price: number | ||
toolCallId: string | ||
status: 'requires_action' | 'completed' | 'expired' | ||
} | ||
|
||
export function Purchase({ | ||
props: { numberOfShares, symbol, price, status = 'expired' } | ||
props: { numberOfShares, symbol, price, toolCallId, status = 'requires_action' } | ||
}: { | ||
props: Purchase | ||
}) { | ||
const [value, setValue] = useState(numberOfShares || 100) | ||
const [purchaseStatus, setPurchaseStatus] = useState(status); | ||
const [purchasingUI, setPurchasingUI] = useState<null | React.ReactNode>(null) | ||
const [aiState, setAIState] = useAIState<typeof AI>() | ||
const [, setMessages] = useUIState<typeof AI>() | ||
|
@@ -59,6 +61,51 @@ export function Purchase({ | |
setAIState({ ...aiState, messages: [...aiState.messages, message] }) | ||
} | ||
|
||
useEffect(() => { | ||
const checkPurchaseStatus = () => { | ||
if (purchaseStatus !== 'requires_action') { | ||
return; | ||
} | ||
// check for purchase completion | ||
// Find the tool message with the matching toolCallId | ||
const toolMessage = aiState.messages.find( | ||
message => | ||
message.role === 'tool' && | ||
message.content.some(part => part.toolCallId === toolCallId) | ||
); | ||
|
||
if (toolMessage) { | ||
const toolMessageIndex = aiState.messages.indexOf(toolMessage); | ||
// Check if the next message is a system message containing "purchased" | ||
const nextMessage = aiState.messages[toolMessageIndex + 1]; | ||
if ( | ||
nextMessage?.role === 'system' && | ||
nextMessage.content.includes('purchased') | ||
) { | ||
setPurchaseStatus('completed'); | ||
} else { | ||
// Check for expiration | ||
const requestedAt = toolMessage.createdAt; | ||
if (!requestedAt || unixTsNow() - requestedAt > 30) { | ||
setPurchaseStatus('expired'); | ||
} | ||
} | ||
} | ||
}; | ||
checkPurchaseStatus(); | ||
|
||
let intervalId: NodeJS.Timeout | null = null; | ||
if (purchaseStatus === 'requires_action') { | ||
intervalId = setInterval(checkPurchaseStatus, 5000); | ||
} | ||
|
||
return () => { | ||
if (intervalId) { | ||
clearInterval(intervalId); | ||
} | ||
}; | ||
}, [purchaseStatus, toolCallId, aiState.messages]); | ||
Comment on lines
+64
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
||
return ( | ||
<div className="p-4 text-green-400 border rounded-xl bg-zinc-950"> | ||
<div className="inline-block float-right px-2 py-1 text-xs rounded-full bg-white/10"> | ||
|
@@ -68,7 +115,7 @@ export function Purchase({ | |
<div className="text-3xl font-bold">${price}</div> | ||
{purchasingUI ? ( | ||
<div className="mt-4 text-zinc-200">{purchasingUI}</div> | ||
) : status === 'requires_action' ? ( | ||
) : purchaseStatus === 'requires_action' ? ( | ||
<> | ||
<div className="relative pb-6 mt-6"> | ||
<p>Shares to purchase</p> | ||
|
@@ -133,12 +180,12 @@ export function Purchase({ | |
Purchase | ||
</button> | ||
</> | ||
) : status === 'completed' ? ( | ||
) : purchaseStatus === 'completed' ? ( | ||
<p className="mb-2 text-white"> | ||
You have successfully purchased {value} ${symbol}. Total cost:{' '} | ||
{formatNumber(value * price)} | ||
</p> | ||
) : status === 'expired' ? ( | ||
) : purchaseStatus === 'expired' ? ( | ||
<p className="mb-2 text-white">Your checkout session has expired!</p> | ||
) : null} | ||
</div> | ||
|
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -1,5 +1,6 @@ | ||||||||||||||||||||||||||||||||||||||||||
import 'server-only' | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
import type { MutableAIState } from '@/lib/types' | ||||||||||||||||||||||||||||||||||||||||||
import { | ||||||||||||||||||||||||||||||||||||||||||
createAI, | ||||||||||||||||||||||||||||||||||||||||||
createStreamableUI, | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -29,7 +30,8 @@ import { | |||||||||||||||||||||||||||||||||||||||||
formatNumber, | ||||||||||||||||||||||||||||||||||||||||||
runAsyncFnWithoutBlocking, | ||||||||||||||||||||||||||||||||||||||||||
sleep, | ||||||||||||||||||||||||||||||||||||||||||
nanoid | ||||||||||||||||||||||||||||||||||||||||||
nanoid, | ||||||||||||||||||||||||||||||||||||||||||
unixTsNow | ||||||||||||||||||||||||||||||||||||||||||
} from '@/lib/utils' | ||||||||||||||||||||||||||||||||||||||||||
import { saveChat } from '@/app/actions' | ||||||||||||||||||||||||||||||||||||||||||
import { SpinnerMessage, UserMessage } from '@/components/stocks/message' | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -82,7 +84,7 @@ async function confirmPurchase(symbol: string, price: number, amount: number) { | |||||||||||||||||||||||||||||||||||||||||
</SystemMessage> | ||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
aiState.done({ | ||||||||||||||||||||||||||||||||||||||||||
aiStateDone(aiState, { | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get(), | ||||||||||||||||||||||||||||||||||||||||||
messages: [ | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get().messages, | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -159,7 +161,7 @@ async function submitUserMessage(content: string) { | |||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
if (done) { | ||||||||||||||||||||||||||||||||||||||||||
textStream.done() | ||||||||||||||||||||||||||||||||||||||||||
aiState.done({ | ||||||||||||||||||||||||||||||||||||||||||
aiStateDone(aiState, { | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get(), | ||||||||||||||||||||||||||||||||||||||||||
messages: [ | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get().messages, | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -199,7 +201,7 @@ async function submitUserMessage(content: string) { | |||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const toolCallId = nanoid() | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
aiState.done({ | ||||||||||||||||||||||||||||||||||||||||||
aiStateDone(aiState, { | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get(), | ||||||||||||||||||||||||||||||||||||||||||
messages: [ | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get().messages, | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -259,8 +261,7 @@ async function submitUserMessage(content: string) { | |||||||||||||||||||||||||||||||||||||||||
await sleep(1000) | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const toolCallId = nanoid() | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
aiState.done({ | ||||||||||||||||||||||||||||||||||||||||||
aiStateDone(aiState, { | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get(), | ||||||||||||||||||||||||||||||||||||||||||
messages: [ | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get().messages, | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -319,7 +320,7 @@ async function submitUserMessage(content: string) { | |||||||||||||||||||||||||||||||||||||||||
const toolCallId = nanoid() | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
if (numberOfShares <= 0 || numberOfShares > 1000) { | ||||||||||||||||||||||||||||||||||||||||||
aiState.done({ | ||||||||||||||||||||||||||||||||||||||||||
aiStateDone(aiState, { | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get(), | ||||||||||||||||||||||||||||||||||||||||||
messages: [ | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get().messages, | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -362,7 +363,7 @@ async function submitUserMessage(content: string) { | |||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
return <BotMessage content={'Invalid amount'} /> | ||||||||||||||||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||||||||||||||||
aiState.done({ | ||||||||||||||||||||||||||||||||||||||||||
aiStateDone(aiState, { | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get(), | ||||||||||||||||||||||||||||||||||||||||||
messages: [ | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get().messages, | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -381,6 +382,7 @@ async function submitUserMessage(content: string) { | |||||||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||||||||
id: nanoid(), | ||||||||||||||||||||||||||||||||||||||||||
role: 'tool', | ||||||||||||||||||||||||||||||||||||||||||
createdAt: unixTsNow(), | ||||||||||||||||||||||||||||||||||||||||||
content: [ | ||||||||||||||||||||||||||||||||||||||||||
{ | ||||||||||||||||||||||||||||||||||||||||||
type: 'tool-result', | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -404,6 +406,7 @@ async function submitUserMessage(content: string) { | |||||||||||||||||||||||||||||||||||||||||
numberOfShares, | ||||||||||||||||||||||||||||||||||||||||||
symbol, | ||||||||||||||||||||||||||||||||||||||||||
price: +price, | ||||||||||||||||||||||||||||||||||||||||||
toolCallId, | ||||||||||||||||||||||||||||||||||||||||||
status: 'requires_action' | ||||||||||||||||||||||||||||||||||||||||||
}} | ||||||||||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -437,7 +440,7 @@ async function submitUserMessage(content: string) { | |||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const toolCallId = nanoid() | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
aiState.done({ | ||||||||||||||||||||||||||||||||||||||||||
aiStateDone(aiState, { | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get(), | ||||||||||||||||||||||||||||||||||||||||||
messages: [ | ||||||||||||||||||||||||||||||||||||||||||
...aiState.get().messages, | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -517,36 +520,45 @@ export const AI = createAI<AIState, UIState>({ | |||||||||||||||||||||||||||||||||||||||||
return | ||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||||||||||||||||
onSetAIState: async ({ state }) => { | ||||||||||||||||||||||||||||||||||||||||||
'use server' | ||||||||||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const session = await auth() | ||||||||||||||||||||||||||||||||||||||||||
const updateChat = async (state: AIState) => { | ||||||||||||||||||||||||||||||||||||||||||
'use server' | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
if (session && session.user) { | ||||||||||||||||||||||||||||||||||||||||||
const { chatId, messages } = state | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const createdAt = new Date() | ||||||||||||||||||||||||||||||||||||||||||
const userId = session.user.id as string | ||||||||||||||||||||||||||||||||||||||||||
const path = `/chat/${chatId}` | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const firstMessageContent = messages[0].content as string | ||||||||||||||||||||||||||||||||||||||||||
const title = firstMessageContent.substring(0, 100) | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const chat: Chat = { | ||||||||||||||||||||||||||||||||||||||||||
id: chatId, | ||||||||||||||||||||||||||||||||||||||||||
title, | ||||||||||||||||||||||||||||||||||||||||||
userId, | ||||||||||||||||||||||||||||||||||||||||||
createdAt, | ||||||||||||||||||||||||||||||||||||||||||
messages, | ||||||||||||||||||||||||||||||||||||||||||
path | ||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||
const session = await auth() | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
await saveChat(chat) | ||||||||||||||||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||||||||||||||||
return | ||||||||||||||||||||||||||||||||||||||||||
if (session && session.user) { | ||||||||||||||||||||||||||||||||||||||||||
const { chatId, messages } = state | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const createdAt = new Date() | ||||||||||||||||||||||||||||||||||||||||||
const userId = session.user.id as string | ||||||||||||||||||||||||||||||||||||||||||
const path = `/chat/${chatId}` | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const firstMessageContent = messages[0].content as string | ||||||||||||||||||||||||||||||||||||||||||
const title = firstMessageContent.substring(0, 100) | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const chat: Chat = { | ||||||||||||||||||||||||||||||||||||||||||
id: chatId, | ||||||||||||||||||||||||||||||||||||||||||
title, | ||||||||||||||||||||||||||||||||||||||||||
userId, | ||||||||||||||||||||||||||||||||||||||||||
createdAt, | ||||||||||||||||||||||||||||||||||||||||||
messages, | ||||||||||||||||||||||||||||||||||||||||||
path | ||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
await saveChat(chat) | ||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+525
to
+549
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion
A robust fix: - const firstMessageContent = messages[0].content as string
- const title = firstMessageContent.substring(0, 100)
+ const firstContent =
+ typeof messages[0]?.content === 'string' ? messages[0].content : ''
+ const title = firstContent.slice(0, 100)
const chat: Chat = {
+ chatId, // keep in sync with UI layer
id: chatId,
@@
path
} 🧰 Tools🪛 Biome (1.9.4)[error] 530-530: Change to an optional chain. Unsafe fix: Change to an optional chain. (lint/complexity/useOptionalChain) |
||||||||||||||||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||||||||||||||||
return | ||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||
}) | ||||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
const aiStateDone = (aiState: MutableAIState<AIState>, newState: AIState) => { | ||||||||||||||||||||||||||||||||||||||||||
runAsyncFnWithoutBlocking(async () => { | ||||||||||||||||||||||||||||||||||||||||||
// resolves race condition in aiState.done - the UI refreshed before db was updated | ||||||||||||||||||||||||||||||||||||||||||
await updateChat(newState); | ||||||||||||||||||||||||||||||||||||||||||
aiState.done(newState); | ||||||||||||||||||||||||||||||||||||||||||
}); | ||||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+555
to
+561
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion
Good idea to gate -runAsyncFnWithoutBlocking(async () => {
- await updateChat(newState);
- aiState.done(newState);
-});
+runAsyncFnWithoutBlocking(async () => {
+ try {
+ await updateChat(newState);
+ } catch (err) {
+ console.error('[aiStateDone] failed to persist chat', err);
+ // TODO: surface non-fatal toast to the user if desired
+ } finally {
+ aiState.done(newState);
+ }
+}); 📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||
export const getUIStateFromAIState = (aiState: Chat) => { | ||||||||||||||||||||||||||||||||||||||||||
return aiState.messages | ||||||||||||||||||||||||||||||||||||||||||
|
@@ -569,8 +581,13 @@ export const getUIStateFromAIState = (aiState: Chat) => { | |||||||||||||||||||||||||||||||||||||||||
</BotCard> | ||||||||||||||||||||||||||||||||||||||||||
) : tool.toolName === 'showStockPurchase' ? ( | ||||||||||||||||||||||||||||||||||||||||||
<BotCard> | ||||||||||||||||||||||||||||||||||||||||||
{/* @ts-expect-error */} | ||||||||||||||||||||||||||||||||||||||||||
<Purchase props={tool.result} /> | ||||||||||||||||||||||||||||||||||||||||||
<Purchase | ||||||||||||||||||||||||||||||||||||||||||
// @ts-expect-error | ||||||||||||||||||||||||||||||||||||||||||
props={{ | ||||||||||||||||||||||||||||||||||||||||||
...(tool.result as object), | ||||||||||||||||||||||||||||||||||||||||||
toolCallId: tool.toolCallId, | ||||||||||||||||||||||||||||||||||||||||||
}} | ||||||||||||||||||||||||||||||||||||||||||
/> | ||||||||||||||||||||||||||||||||||||||||||
</BotCard> | ||||||||||||||||||||||||||||||||||||||||||
) : tool.toolName === 'getEvents' ? ( | ||||||||||||||||||||||||||||||||||||||||||
<BotCard> | ||||||||||||||||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -51,8 +51,10 @@ export const formatNumber = (value: number) => | |||||||||||||
export const runAsyncFnWithoutBlocking = ( | ||||||||||||||
fn: (...args: any) => Promise<any> | ||||||||||||||
) => { | ||||||||||||||
fn() | ||||||||||||||
} | ||||||||||||||
fn().catch(error => { | ||||||||||||||
console.error('An error occurred in the async function:', error); | ||||||||||||||
}); | ||||||||||||||
Comment on lines
+54
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This
Suggested change
|
||||||||||||||
}; | ||||||||||||||
|
||||||||||||||
export const sleep = (ms: number) => | ||||||||||||||
new Promise(resolve => setTimeout(resolve, ms)) | ||||||||||||||
|
@@ -87,3 +89,5 @@ export const getMessageFromCode = (resultCode: string) => { | |||||||||||||
return 'Logged in!' | ||||||||||||||
} | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
export const unixTsNow = () => Math.floor(Date.now() / 1000); |
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.
The logic within
checkPurchaseStatus
could benefit from additional comments to clarify the order of operations and the conditions under which the purchase status is updated. This would improve readability and maintainability.