From ec9b6c43be6aa16db07e6c557cfdb5247b62ca96 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Thu, 16 Apr 2026 15:27:17 -0500 Subject: [PATCH] Overhaul AI chat: handle 429 rate limiting, require Shakespeare credits - Add RateLimitError class with Retry-After header parsing - Distinguish insufficient_quota 429 from rate-limit 429 - Friendly Dork-themed error banners for rate limiting and out-of-credits - Clean no-credits empty state with directive CTA and Get Credits button - Hide model selector, trash, and input when user has no credits - Hide page title on mobile, align model selector right - Simplify sidebar widget to Shakespeare CTA --- src/components/widgets/AIChatWidget.tsx | 179 ++-------------- src/hooks/useShakespeare.ts | 269 ++++++++++++++++-------- src/index.css | 5 + src/lib/sidebarWidgets.ts | 4 +- src/pages/AIChatPage.tsx | 254 +++++++++++++--------- 5 files changed, 373 insertions(+), 338 deletions(-) diff --git a/src/components/widgets/AIChatWidget.tsx b/src/components/widgets/AIChatWidget.tsx index d614f90e..b753b823 100644 --- a/src/components/widgets/AIChatWidget.tsx +++ b/src/components/widgets/AIChatWidget.tsx @@ -1,94 +1,13 @@ -import { useState, useRef, useCallback, useEffect } from 'react'; -import { Send, Bot } from 'lucide-react'; -import { useQuery } from '@tanstack/react-query'; +import { Bot } from 'lucide-react'; +import { Link } from 'react-router-dom'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { DorkThinking } from '@/components/DorkThinking'; -import { useShakespeare, type ChatMessage } from '@/hooks/useShakespeare'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { cn } from '@/lib/utils'; -/** - * Module-level cache so conversation survives collapse/expand (which unmounts - * the component). Keyed by user pubkey. Intentionally not persisted to - * localStorage — sidebar chat is ephemeral. - */ -const conversationCache = new Map(); - -/** Compact AI chat widget for the sidebar. */ +/** Compact AI chat widget for the sidebar. Points users to the full AI Chat page. */ export function AIChatWidget() { const { user } = useCurrentUser(); - const { sendStreamingMessage, getAvailableModels, isLoading, isAuthenticated } = useShakespeare(); - // Fetch available models and select the cheapest as default - const { data: defaultModelId } = useQuery({ - queryKey: ['shakespeare-default-model'], - queryFn: async () => { - const response = await getAvailableModels(); - const sorted = response.data.sort((a, b) => { - const costA = parseFloat(a.pricing.prompt) + parseFloat(a.pricing.completion); - const costB = parseFloat(b.pricing.prompt) + parseFloat(b.pricing.completion); - return costA - costB; - }); - return sorted[0]?.id ?? ''; - }, - staleTime: 10 * 60_000, - enabled: !!user, - }); - const cacheKey = user?.pubkey ?? ''; - const [messages, setMessages] = useState(() => conversationCache.get(cacheKey) ?? []); - const [input, setInput] = useState(''); - const [streamingContent, setStreamingContent] = useState(''); - - // Write back to cache whenever messages change. - useEffect(() => { - if (cacheKey) { - conversationCache.set(cacheKey, messages); - } - }, [messages, cacheKey]); - const scrollRef = useRef(null); - const inputRef = useRef(null); - - const scrollToBottom = useCallback(() => { - const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]'); - if (viewport) { - viewport.scrollTop = viewport.scrollHeight; - } - }, []); - - useEffect(() => { - scrollToBottom(); - }, [messages, streamingContent, scrollToBottom]); - - const handleSend = useCallback(async () => { - const text = input.trim(); - if (!text || isLoading) return; - - const userMessage: ChatMessage = { role: 'user', content: text }; - const newMessages = [...messages, userMessage]; - setMessages(newMessages); - setInput(''); - setStreamingContent(''); - - try { - let accumulated = ''; - await sendStreamingMessage( - newMessages, - defaultModelId || 'shakespeare', - (chunk) => { - accumulated += chunk; - setStreamingContent(accumulated); - }, - ); - setMessages((prev) => [...prev, { role: 'assistant', content: accumulated }]); - setStreamingContent(''); - } catch { - setMessages((prev) => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }]); - setStreamingContent(''); - } - }, [input, isLoading, messages, sendStreamingMessage, defaultModelId]); - - if (!user || !isAuthenticated) { + if (!user) { return (
@@ -98,78 +17,26 @@ export function AIChatWidget() { } return ( -
- {/* Messages area */} - -
- {messages.length === 0 && !streamingContent && ( -
- -

Ask me anything...

-
- )} - {messages.map((msg, i) => ( - - ))} - {streamingContent && ( - - )} - {isLoading && !streamingContent && ( -
-
- -
-
- )} -
-
- - {/* Input area */} -
-
-