## Summary Frontend companion to backend [#16](ai_ml_operations/chat_backend#16) / [#17](ai_ml_operations/chat_backend#17) / [#36](ai_ml_operations/chat_backend#36). - **Token display (#16)**: assistant messages show `Tokens in … · out …`; missing usage renders as `—` (never fabricated 0). - **Plan + quota UI (#17/#36)**: Account `BillingSection` loads `GET /finance/subscription/` (plan name, prompt remaining, period tokens). Chat dashboard `UsageSummaryBar` shows the same snapshot. - **Backer path**: Sign-up skips Stripe checkout when `needs_checkout` is false. Requires backend PR on `feature/plans-quotas-token-usage`. ## Test plan - [ ] With subscribed user: usage bar + billing show plan and prompt remaining - [ ] Assistant messages with null tokens show `—`; known counts render numbers - [ ] Backer signup does not redirect to Stripe - [ ] Founders signup still starts checkout - [ ] Jest: `BillingSection`, `ConversationDetailCard`, `finance` helpers passReviewed-on: #72
224 lines
8.6 KiB
TypeScript
224 lines
8.6 KiB
TypeScript
import { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react";
|
|
import { WebSocketContext } from "./WebSocketContext";
|
|
import { AccountContext } from "./AccountContext";
|
|
import { ConversationContext } from "./ConversationContext";
|
|
import { ConversationPrompt, ConversationPromptType } from "../data";
|
|
import { axiosInstance } from "../../axiosApi";
|
|
import { AxiosResponse } from "axios";
|
|
import { AnalyticsEvents, trackEvent } from "../utils/analytics";
|
|
|
|
type MessageProviderProps ={
|
|
children? : ReactNode;
|
|
}
|
|
|
|
type IMessageContext = {
|
|
stateMessage: string;
|
|
setStateMessage: (message: string) => void;
|
|
conversationDetails: ConversationPrompt[];
|
|
setConversationDetails: (conversationPrompts: ConversationPrompt[]) => void;
|
|
isGeneratingMessage: boolean;
|
|
/** True when a stream was interrupted by a socket drop; cleared on refetch/retry. */
|
|
streamInterrupted: boolean;
|
|
clearStreamInterrupted: () => void;
|
|
}
|
|
|
|
const initialValues = {
|
|
stateMessage: '',
|
|
setStateMessage: () => {},
|
|
conversationDetails: [],
|
|
setConversationDetails: () => {},
|
|
isGeneratingMessage: false,
|
|
streamInterrupted: false,
|
|
clearStreamInterrupted: () => {},
|
|
}
|
|
|
|
const MessageContext = createContext<IMessageContext>(initialValues);
|
|
|
|
const STREAM_INTERRUPTED_MESSAGE =
|
|
'_Response interrupted — connection lost. Re-send your message if needed._';
|
|
|
|
const MessageProvider = ( {children}: MessageProviderProps) => {
|
|
const [subscribe, unsubscribe, , , , , reconnectGeneration] = useContext(WebSocketContext)
|
|
const { account } = useContext(AccountContext)
|
|
const {selectedConversation, setSelectedConversation} = useContext(ConversationContext);
|
|
|
|
const [stateMessage, setStateMessage] = useState<string>('')
|
|
const [conversationDetails, setConversationDetails] = useState<ConversationPrompt[]>([])
|
|
const [isGeneratingMessage, setIsGeneratingMessage] = useState<boolean>(false)
|
|
const [streamInterrupted, setStreamInterrupted] = useState<boolean>(false)
|
|
|
|
const messageRef = useRef('')
|
|
const messageResponsePart = useRef(0);
|
|
const conversationRef = useRef(conversationDetails)
|
|
const selectedConversationRef = useRef<undefined | number>(undefined)
|
|
const isGeneratingRef = useRef(false)
|
|
const prevReconnectGenerationRef = useRef(0)
|
|
|
|
const clearStreamInterrupted = () => setStreamInterrupted(false)
|
|
|
|
async function GetConversationDetails(conversationId: number | undefined) {
|
|
if (!conversationId) {
|
|
setConversationDetails([])
|
|
return
|
|
}
|
|
|
|
try {
|
|
selectedConversationRef.current = conversationId
|
|
const { data }: AxiosResponse<ConversationPromptType[]> =
|
|
await axiosInstance.get(
|
|
`conversation_details?conversation_id=${conversationId}`,
|
|
)
|
|
|
|
const tempConversations: ConversationPrompt[] = data.map(
|
|
(item) =>
|
|
new ConversationPrompt({
|
|
id: item.id,
|
|
message: item.message,
|
|
user_created: item.user_created,
|
|
created_timestamp: item.created_timestamp,
|
|
tokens_in: item.tokens_in ?? null,
|
|
tokens_out: item.tokens_out ?? null,
|
|
}),
|
|
)
|
|
if (tempConversations.length === 1) {
|
|
// first message still awaiting assistant reply placeholder
|
|
tempConversations.push(
|
|
new ConversationPrompt({ message: '', user_created: false }),
|
|
)
|
|
}
|
|
conversationRef.current = tempConversations
|
|
setConversationDetails(tempConversations)
|
|
} catch (err) {
|
|
console.log('Failed to refetch conversation after reconnect', err)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
GetConversationDetails(selectedConversation)
|
|
}, [selectedConversation])
|
|
|
|
// Streaming recovery: on reconnect after a drop mid-stream, refetch conversation
|
|
// and mark the in-flight assistant turn interrupted (no resume protocol on backend).
|
|
useEffect(() => {
|
|
if (reconnectGeneration === prevReconnectGenerationRef.current) {
|
|
return
|
|
}
|
|
prevReconnectGenerationRef.current = reconnectGeneration
|
|
if (reconnectGeneration === 0) {
|
|
return
|
|
}
|
|
|
|
const wasGenerating = isGeneratingRef.current || messageResponsePart.current !== 0
|
|
if (!wasGenerating) {
|
|
return
|
|
}
|
|
|
|
const partial = messageRef.current
|
|
messageRef.current = ''
|
|
messageResponsePart.current = 0
|
|
isGeneratingRef.current = false
|
|
setIsGeneratingMessage(false)
|
|
setStateMessage('')
|
|
setStreamInterrupted(true)
|
|
|
|
const details = [...conversationRef.current]
|
|
if (details.length > 0) {
|
|
const last = details[details.length - 1]
|
|
if (last && !last.user_created && (!last.message || last.message.length === 0)) {
|
|
details[details.length - 1] = new ConversationPrompt({
|
|
message: partial
|
|
? `${partial}\n\n${STREAM_INTERRUPTED_MESSAGE}`
|
|
: STREAM_INTERRUPTED_MESSAGE,
|
|
user_created: false,
|
|
})
|
|
conversationRef.current = details
|
|
setConversationDetails(details)
|
|
}
|
|
}
|
|
|
|
if (selectedConversationRef.current) {
|
|
GetConversationDetails(selectedConversationRef.current)
|
|
}
|
|
}, [reconnectGeneration])
|
|
|
|
useEffect(() => {
|
|
/* register a consistent channel name for identifing this chat messages */
|
|
const channelName = `ACCOUNT_ID_${account?.email}`
|
|
|
|
/* subscribe to channel and register callback */
|
|
subscribe(channelName, (message: string) => {
|
|
/* when a message is received just add it to the UI */
|
|
|
|
if (message === 'END_OF_THE_STREAM_ENDER_GAME_42'){
|
|
messageResponsePart.current = 0
|
|
|
|
conversationRef.current.pop()
|
|
|
|
//handleAssistantPrompt({prompt: messageRef.current})
|
|
setConversationDetails([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})])
|
|
console.log([...conversationRef.current, new ConversationPrompt({message: `${messageRef.current}`, user_created:false})])
|
|
messageRef.current = ''
|
|
setStateMessage('')
|
|
isGeneratingRef.current = false
|
|
setIsGeneratingMessage(false)
|
|
setStreamInterrupted(false)
|
|
}
|
|
else if (message === 'START_OF_THE_STREAM_ENDER_GAME_42'){
|
|
conversationRef.current = conversationDetails
|
|
isGeneratingRef.current = true
|
|
setIsGeneratingMessage(true)
|
|
setStreamInterrupted(false)
|
|
messageResponsePart.current = 2
|
|
|
|
}else if (message === 'CONVERSATION_ID'){
|
|
isGeneratingRef.current = true
|
|
setIsGeneratingMessage(true)
|
|
setStreamInterrupted(false)
|
|
messageResponsePart.current = 1
|
|
}else{
|
|
isGeneratingRef.current = true
|
|
setIsGeneratingMessage(true)
|
|
if (messageResponsePart.current === 1){
|
|
// this has to do with the conversation id
|
|
if(!selectedConversation){
|
|
const conversationId = Number(message);
|
|
setSelectedConversation(conversationId)
|
|
trackEvent(AnalyticsEvents.CONVERSATION_CREATED, {
|
|
conversationId,
|
|
});
|
|
}
|
|
}
|
|
else if (messageResponsePart.current === 2){
|
|
messageRef.current += message
|
|
setStateMessage(messageRef.current)
|
|
|
|
}
|
|
}
|
|
|
|
})
|
|
|
|
return () => {
|
|
/* unsubscribe from channel during cleanup */
|
|
unsubscribe(channelName)
|
|
}
|
|
}, [account, subscribe, unsubscribe, conversationDetails, selectedConversation, setSelectedConversation])
|
|
|
|
|
|
return(
|
|
<MessageContext.Provider value={{
|
|
stateMessage,
|
|
setStateMessage,
|
|
conversationDetails,
|
|
setConversationDetails,
|
|
isGeneratingMessage,
|
|
streamInterrupted,
|
|
clearStreamInterrupted,
|
|
}}>
|
|
{children}
|
|
</MessageContext.Provider>
|
|
)
|
|
|
|
}
|
|
|
|
export { MessageContext, MessageProvider}
|