| |
| |
| |
| |
| |
| |
| |
| import { useCallback, useEffect, useState } from 'react'; |
| import { useAgentChat } from '@/hooks/useAgentChat'; |
| import { useAgentStore } from '@/store/agentStore'; |
| import { useSessionStore } from '@/store/sessionStore'; |
| import MessageList from '@/components/Chat/MessageList'; |
| import ChatInput from '@/components/Chat/ChatInput'; |
| import ExpiredBanner from '@/components/Chat/ExpiredBanner'; |
| import BillingBanner from '@/components/Chat/BillingBanner'; |
| import ChatErrorBanner from '@/components/Chat/ChatErrorBanner'; |
| import { useUserQuota } from '@/hooks/useUserQuota'; |
| import { isPremiumPath } from '@/utils/model'; |
| import { apiFetch } from '@/utils/api'; |
| import { logger } from '@/utils/logger'; |
|
|
| interface SessionChatProps { |
| sessionId: string; |
| isActive: boolean; |
| onSessionDead: (sessionId: string) => void; |
| } |
|
|
| export default function SessionChat({ sessionId, isActive, onSessionDead }: SessionChatProps) { |
| const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore(); |
| const { updateSessionTitle, sessions, setSessionProcessing } = useSessionStore(); |
| const sessionMeta = sessions.find((s) => s.id === sessionId); |
| const isExpired = sessionMeta?.expired === true; |
| const [chatError, setChatError] = useState<string | null>(null); |
|
|
| const { |
| messages, |
| sendMessage, |
| stop, |
| status, |
| undoLastTurn, |
| editAndRegenerate, |
| approveTools, |
| refreshMessages, |
| } = useAgentChat({ |
| sessionId, |
| isActive, |
| |
| |
| |
| isProcessing: sessionMeta?.isProcessing ?? false, |
| onReady: () => logger.log(`Session ${sessionId} ready`), |
| onError: (error) => { |
| logger.error(`Session ${sessionId} error:`, error); |
| setChatError(error); |
| }, |
| onSessionDead, |
| }); |
|
|
| |
| |
| |
| useEffect(() => { |
| if (isActive) { |
| useAgentStore.getState().switchActiveSession(sessionId); |
| useAgentStore.getState().setConnected(true); |
| } |
| }, [isActive, sessionId]); |
|
|
| |
| |
| |
| useEffect(() => { |
| const onVisible = () => { |
| if (document.visibilityState === 'visible' && isActive) { |
| useAgentStore.getState().switchActiveSession(sessionId); |
| } |
| }; |
| document.addEventListener('visibilitychange', onVisible); |
| return () => document.removeEventListener('visibilitychange', onVisible); |
| }, [isActive, sessionId]); |
|
|
| |
| const handleStop = useCallback(() => { |
| stop(); |
| updateSession(sessionId, { activityStatus: { type: 'cancelled' } }); |
| }, [stop, updateSession, sessionId]); |
|
|
| |
| const sdkBusy = status === 'streaming' || status === 'submitted'; |
| const busy = isProcessing || sdkBusy; |
| const { quota, refresh: refreshQuota } = useUserQuota({ enabled: isActive }); |
|
|
| |
| |
| |
| |
| const [premiumBilled, setPremiumBilled] = useState(false); |
| const [premiumQuotaCounted, setPremiumQuotaCounted] = useState(false); |
| const onPremiumModel = isPremiumPath(sessionMeta?.model ?? undefined); |
| useEffect(() => { |
| if (!isActive || !onPremiumModel) { |
| setPremiumBilled(false); |
| setPremiumQuotaCounted(false); |
| return; |
| } |
| if (busy) return; |
| let cancelled = false; |
| apiFetch(`/api/session/${sessionId}`) |
| .then((r) => (r.ok ? r.json() : null)) |
| .then((d) => { |
| if (!cancelled && d) { |
| setPremiumBilled(Boolean(d.premium_user_billed)); |
| setPremiumQuotaCounted(Boolean(d.premium_quota_counted)); |
| } |
| }) |
| .catch(() => {}); |
| return () => { |
| cancelled = true; |
| }; |
| }, [busy, isActive, onPremiumModel, sessionId]); |
|
|
| const sessionPremiumBilled = premiumBilled || Boolean(sessionMeta?.premiumUserBilled); |
| const sessionPremiumQuotaCounted = |
| premiumQuotaCounted || Boolean(sessionMeta?.premiumQuotaCounted); |
| const premiumBillingNotice = |
| sessionPremiumBilled || |
| (isActive && |
| onPremiumModel && |
| quota?.premiumRemaining === 0 && |
| !sessionPremiumQuotaCounted); |
|
|
| const handleSendMessage = useCallback( |
| async (text: string) => { |
| if (!text.trim() || busy) return; |
|
|
| setChatError(null); |
| updateSession(sessionId, { isProcessing: true, activityStatus: { type: 'thinking' } }); |
| setSessionProcessing(sessionId, true); |
| sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } }); |
|
|
| |
| const isFirstMessage = messages.filter((m) => m.role === 'user').length === 0; |
| if (isFirstMessage) { |
| apiFetch('/api/title', { |
| method: 'POST', |
| body: JSON.stringify({ session_id: sessionId, text: text.trim() }), |
| }) |
| .then((res) => res.json()) |
| .then((data) => { |
| if (data.title) updateSessionTitle(sessionId, data.title); |
| }) |
| .catch(() => { |
| const raw = text.trim(); |
| updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '\u2026' : raw); |
| }); |
| } |
| }, |
| [sessionId, sendMessage, messages, updateSessionTitle, busy, updateSession, setSessionProcessing], |
| ); |
|
|
| |
| if (!isActive) return null; |
|
|
| return ( |
| <> |
| <MessageList |
| messages={messages} |
| isProcessing={busy} |
| sessionId={sessionId} |
| approveTools={approveTools} |
| onUndoLastTurn={undoLastTurn} |
| onEditAndRegenerate={editAndRegenerate} |
| /> |
| {isExpired ? ( |
| <ExpiredBanner sessionId={sessionId} /> |
| ) : ( |
| <> |
| {premiumBillingNotice && <BillingBanner />} |
| {chatError && ( |
| <ChatErrorBanner |
| error={chatError} |
| sessionId={sessionId} |
| model={sessionMeta?.model} |
| onDismiss={() => setChatError(null)} |
| /> |
| )} |
| <ChatInput |
| sessionId={sessionId} |
| initialModelPath={sessionMeta?.model} |
| onSend={handleSendMessage} |
| onStop={handleStop} |
| onDatasetUploaded={refreshMessages} |
| isProcessing={busy} |
| disabled={!isConnected || activityStatus.type === 'waiting-approval'} |
| quota={quota} |
| refreshQuota={refreshQuota} |
| placeholder={ |
| activityStatus.type === 'waiting-approval' |
| ? 'Approve or reject pending tools first...' |
| : undefined |
| } |
| /> |
| </> |
| )} |
| </> |
| ); |
| } |
|
|