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
This commit is contained in:
Chad Curtis
2026-04-16 15:27:17 -05:00
parent 17cdb87723
commit ec9b6c43be
5 changed files with 373 additions and 338 deletions
+23 -156
View File
@@ -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<string, ChatMessage[]>();
/** 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<ChatMessage[]>(() => 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<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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 (
<div className="flex flex-col items-center gap-2 py-4 px-2 text-center">
<Bot className="size-8 text-muted-foreground" />
@@ -98,78 +17,26 @@ export function AIChatWidget() {
}
return (
<div className="flex flex-col h-full">
{/* Messages area */}
<ScrollArea ref={scrollRef} className="flex-1 min-h-0">
<div className="space-y-3 p-2">
{messages.length === 0 && !streamingContent && (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<Bot className="size-6 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">Ask me anything...</p>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble key={i} message={msg} />
))}
{streamingContent && (
<MessageBubble message={{ role: 'assistant', content: streamingContent }} />
)}
{isLoading && !streamingContent && (
<div className="flex gap-2 items-start">
<div className="bg-secondary rounded-xl rounded-tl-sm px-3 py-2">
<DorkThinking className="text-[10px]" />
</div>
</div>
)}
</div>
</ScrollArea>
{/* Input area */}
<div className="border-t border-border p-2 space-y-1.5">
<div className="flex items-end gap-1.5">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Message..."
rows={1}
className="flex-1 resize-none text-sm bg-secondary/50 rounded-lg px-2.5 py-1.5 border-0 outline-none focus:ring-1 focus:ring-primary/30 placeholder:text-muted-foreground/60 min-h-[32px] max-h-[80px]"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading || !defaultModelId}
className="shrink-0 p-1.5 rounded-lg text-primary hover:bg-primary/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Send className="size-4" />
</button>
</div>
</div>
</div>
);
}
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
const content = typeof message.content === 'string' ? message.content : message.content.map((c) => c.text ?? '').join('');
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[85%] rounded-xl px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap break-words',
isUser
? 'bg-primary text-primary-foreground rounded-br-sm'
: 'bg-secondary text-foreground rounded-bl-sm',
)}
<div className="flex flex-col items-center gap-3 py-6 px-3 text-center">
<pre className="text-xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
<p className="text-xs text-muted-foreground leading-relaxed">
Grab some credits on{' '}
<a
href="https://shakespeare.diy"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Shakespeare
</a>
{' '}to chat with Dork.
</p>
<Link
to="/ai-chat"
className="text-xs font-medium text-primary hover:underline"
>
{content}
</div>
Open AI Chat
</Link>
</div>
);
}
+184 -85
View File
@@ -1,7 +1,19 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCurrentUser } from './useCurrentUser';
import type { NUser } from '@nostrify/react/login';
/** Error subclass carrying rate-limit metadata. */
export class RateLimitError extends Error {
/** Unix-ms timestamp after which the client may retry. */
retryAfter: number;
constructor(retryAfter: number) {
const seconds = Math.max(0, Math.ceil((retryAfter - Date.now()) / 1000));
super(`Rate limited. Please wait ${seconds} second${seconds !== 1 ? 's' : ''} before trying again.`);
this.name = 'RateLimitError';
this.retryAfter = retryAfter;
}
}
// Types for Shakespeare API (compatible with OpenAI ChatCompletionMessageParam)
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
@@ -75,6 +87,10 @@ export interface Model {
prompt: string;
completion: string;
};
/** Provider prefix for routing (e.g. "shakespeare"). */
provider: string;
/** Full provider/model identifier for selection (e.g. "shakespeare/model-name"). */
fullId: string;
}
export interface ModelsResponse {
@@ -82,10 +98,18 @@ export interface ModelsResponse {
data: Model[];
}
// Configuration
export interface CreditsResponse {
object: string;
amount: number;
}
// ─── Provider Configuration ───
const SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1';
// Helper function to create NIP-98 token
// ─── Helpers ───
/** Create a NIP-98 auth token for Shakespeare AI requests. */
async function createNIP98Token(
method: string,
url: string,
@@ -96,13 +120,11 @@ async function createNIP98Token(
throw new Error('User signer is required for NIP-98 authentication');
}
// Create the tags array
const tags: string[][] = [
['u', url],
['method', method]
];
// Add payload hash for requests with body (following NIP-98 spec)
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
const bodyString = JSON.stringify(body);
const encoder = new TextEncoder();
@@ -114,29 +136,56 @@ async function createNIP98Token(
tags.push(['payload', payloadHash]);
}
// Create the HTTP request event
const event = await user.signer.signEvent({
kind: 27235, // NIP-98 HTTP Auth
kind: 27235,
content: '',
tags,
created_at: Math.floor(Date.now() / 1000)
});
// Return the token (base64 encoded event)
return btoa(JSON.stringify(event));
}
// Helper function to handle API errors with user-friendly messages
/** Parse the Retry-After header into a future Unix-ms timestamp. */
function parseRetryAfter(response: Response): number {
const header = response.headers.get('Retry-After');
if (header) {
const seconds = Number(header);
if (!Number.isNaN(seconds) && seconds > 0) {
return Date.now() + seconds * 1000;
}
// Try HTTP-date format
const date = new Date(header).getTime();
if (!Number.isNaN(date) && date > Date.now()) {
return date;
}
}
// Default: 30-second cooldown when no header is present
return Date.now() + 30_000;
}
/** Handle API errors with user-friendly messages. */
async function handleAPIError(response: Response) {
if (response.status === 401) {
if (response.status === 429) {
// Shakespeare returns 429 with code "insufficient_quota" when credits run out
try {
const body = await response.json();
if (body.error?.code === 'insufficient_quota' || body.code === 'insufficient_quota') {
throw new Error('You\'ve run out of credits. Add more on shakespeare.diy to keep chatting.');
}
} catch (err) {
if (err instanceof Error && err.message.includes('run out of credits')) throw err;
// JSON parse failed or different shape — treat as a normal rate limit
}
throw new RateLimitError(parseRetryAfter(response));
} else if (response.status === 401) {
throw new Error('Authentication failed. Please make sure you are logged in with a Nostr account.');
} else if (response.status === 402) {
throw new Error('Insufficient credits. Please add credits to your account to use premium models, or use the free "tybalt" model.');
throw new Error('You\'ve run out of credits. Add more on shakespeare.diy to keep chatting.');
} else if (response.status === 400) {
try {
const error = await response.json();
if (error.error?.type === 'invalid_request_error') {
// Handle specific validation errors
if (error.error.code === 'minimum_amount_not_met') {
throw new Error(`Minimum credit amount is $${error.error.minimum_amount}. Please increase your payment amount.`);
} else if (error.error.code === 'unsupported_method') {
@@ -163,20 +212,64 @@ async function handleAPIError(response: Response) {
}
}
/** Parse "provider/model" into { provider, model }. */
function parseProviderModel(fullId: string): { provider: string; model: string } {
const idx = fullId.indexOf('/');
if (idx === -1) return { provider: 'shakespeare', model: fullId };
return { provider: fullId.substring(0, idx), model: fullId.substring(idx + 1) };
}
/** Format an error for display. */
function formatError(err: unknown): string {
let msg = 'An unexpected error occurred';
if (err instanceof Error) msg = err.message;
else if (typeof err === 'string') msg = err;
if (msg.includes('Failed to fetch') || msg.includes('Network')) {
return 'Network error: Please check your internet connection and try again.';
} else if (msg.includes('signer')) {
return 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
}
return msg;
}
// ─── Hook ───
export function useShakespeare() {
const { user } = useCurrentUser();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Unix-ms timestamp until which the client is rate-limited, or null. */
const [retryAfter, setRetryAfter] = useState<number | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Auto-clear retryAfter once the cooldown expires.
useEffect(() => {
if (retryAfter === null) return;
clearTimeout(retryTimerRef.current);
const remaining = retryAfter - Date.now();
if (remaining <= 0) {
setRetryAfter(null);
setError(null);
return;
}
retryTimerRef.current = setTimeout(() => {
setRetryAfter(null);
setError(null);
}, remaining);
return () => clearTimeout(retryTimerRef.current);
}, [retryAfter]);
// Clear error helper
const clearError = useCallback(() => {
setError(null);
setRetryAfter(null);
}, []);
// Chat completion function
// ─── Chat completions (non-streaming) ───
const sendChatMessage = useCallback(async (
messages: ChatMessage[],
model: string = 'shakespeare',
messages: ChatMessage[],
modelId: string,
options?: Partial<ChatCompletionRequest>
): Promise<ChatCompletionResponse> => {
if (!user) {
@@ -187,19 +280,20 @@ export function useShakespeare() {
setError(null);
try {
const { model } = parseProviderModel(modelId);
const requestBody: ChatCompletionRequest = {
model,
messages,
...options
...options,
};
const token = await createNIP98Token(
'POST',
`${SHAKESPEARE_API_URL}/chat/completions`,
requestBody,
user
user,
);
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
method: 'POST',
headers: {
@@ -212,32 +306,24 @@ export function useShakespeare() {
await handleAPIError(response);
return await response.json();
} catch (err) {
let errorMessage = 'An unexpected error occurred';
if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'string') {
errorMessage = err;
if (err instanceof RateLimitError) {
setRetryAfter(err.retryAfter);
setError(err.message);
throw err;
}
// Add context for common issues
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
errorMessage = 'Network error: Please check your internet connection and try again.';
} else if (errorMessage.includes('signer')) {
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
}
setError(errorMessage);
throw new Error(errorMessage);
const msg = formatError(err);
setError(msg);
throw new Error(msg);
} finally {
setIsLoading(false);
}
}, [user]);
// Streaming chat completion function
// ─── Chat completions (streaming) ───
const sendStreamingMessage = useCallback(async (
messages: ChatMessage[],
model: string = 'shakespeare',
messages: ChatMessage[],
modelId: string,
onChunk: (chunk: string) => void,
options?: Partial<ChatCompletionRequest>
): Promise<void> => {
@@ -249,20 +335,21 @@ export function useShakespeare() {
setError(null);
try {
const { model } = parseProviderModel(modelId);
const requestBody: ChatCompletionRequest = {
model,
messages,
stream: true,
...options
...options,
};
const token = await createNIP98Token(
'POST',
`${SHAKESPEARE_API_URL}/chat/completions`,
requestBody,
user
user,
);
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
method: 'POST',
headers: {
@@ -293,7 +380,7 @@ export function useShakespeare() {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content;
@@ -310,29 +397,48 @@ export function useShakespeare() {
reader.releaseLock();
}
} catch (err) {
let errorMessage = 'An unexpected error occurred';
if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'string') {
errorMessage = err;
if (err instanceof RateLimitError) {
setRetryAfter(err.retryAfter);
setError(err.message);
throw err;
}
// Add context for common issues
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
errorMessage = 'Network error: Please check your internet connection and try again.';
} else if (errorMessage.includes('signer')) {
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
}
setError(errorMessage);
throw new Error(errorMessage);
const msg = formatError(err);
setError(msg);
throw new Error(msg);
} finally {
setIsLoading(false);
}
}, [user]);
// Get available models
// ─── Credit balance (Shakespeare AI only) ───
const getCreditsBalance = useCallback(async (): Promise<CreditsResponse> => {
if (!user) {
throw new Error('User must be logged in to check credits');
}
try {
const token = await createNIP98Token(
'GET',
`${SHAKESPEARE_API_URL}/credits`,
undefined,
user,
);
const response = await fetch(`${SHAKESPEARE_API_URL}/credits`, {
method: 'GET',
headers: { 'Authorization': `Nostr ${token}` },
});
await handleAPIError(response);
return await response.json();
} catch (err) {
throw new Error(formatError(err));
}
}, [user]);
// ─── Available models (merged from both providers) ───
const getAvailableModels = useCallback(async (): Promise<ModelsResponse> => {
if (!user) {
throw new Error('User must be logged in to use AI features');
@@ -346,36 +452,26 @@ export function useShakespeare() {
'GET',
`${SHAKESPEARE_API_URL}/models`,
undefined,
user
user,
);
const response = await fetch(`${SHAKESPEARE_API_URL}/models`, {
method: 'GET',
headers: {
'Authorization': `Nostr ${token}`,
},
headers: { 'Authorization': `Nostr ${token}` },
});
await handleAPIError(response);
return await response.json();
const result = (await response.json()) as ModelsResponse;
const models: Model[] = result.data.map((m) => ({
...m,
provider: 'shakespeare',
fullId: `shakespeare/${m.id}`,
}));
return { object: 'list', data: models };
} catch (err) {
let errorMessage = 'An unexpected error occurred';
if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'string') {
errorMessage = err;
}
// Add context for common issues
if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) {
errorMessage = 'Network error: Please check your internet connection and try again.';
} else if (errorMessage.includes('signer')) {
errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.';
}
setError(errorMessage);
throw new Error(errorMessage);
const msg = formatError(err);
setError(msg);
throw new Error(msg);
} finally {
setIsLoading(false);
}
@@ -385,12 +481,15 @@ export function useShakespeare() {
// State
isLoading,
error,
/** Unix-ms timestamp until which the client is rate-limited, or null. */
retryAfter,
isAuthenticated: !!user,
// Actions
sendChatMessage,
sendStreamingMessage,
getAvailableModels,
getCreditsBalance,
clearError,
};
}
+5
View File
@@ -118,6 +118,11 @@
height: calc(100dvh - var(--top-bar-height) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
padding-bottom: calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}
@media (min-width: 900px) {
.ai-chat-height {
padding-bottom: 0;
}
}
/* Live stream page height on mobile: full viewport minus top bar, bottom nav, and safe-area insets */
.livestream-height {
+2 -2
View File
@@ -119,8 +119,8 @@ export const WIDGET_DEFINITIONS: WidgetDefinition[] = [
label: 'AI Chat',
description: 'Chat with Shakespeare AI',
icon: Bot,
defaultHeight: 400,
minHeight: 250,
defaultHeight: 300,
minHeight: 200,
maxHeight: 700,
category: 'personal',
href: '/ai-chat',
+159 -95
View File
@@ -196,7 +196,7 @@ Be concise and friendly. When you use a tool, briefly describe the theme you cre
export function AIChatPage() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const { sendChatMessage, getAvailableModels, isLoading: apiLoading, error: apiError, clearError } = useShakespeare();
const { sendChatMessage, getAvailableModels, getCreditsBalance, isLoading: apiLoading, error: apiError, retryAfter, clearError } = useShakespeare();
const { executeToolCall } = useToolExecutor();
const [messages, setMessages] = useState<DisplayMessage[]>([]);
@@ -205,7 +205,7 @@ export function AIChatPage() {
const [models, setModels] = useState<Model[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [modelsLoading, setModelsLoading] = useState(false);
const [hasCredits, setHasCredits] = useState<boolean | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -226,24 +226,34 @@ export function AIChatPage() {
scrollToBottom();
}, [messages, scrollToBottom]);
// Fetch available models on mount
// Fetch available models and credit balance on mount
useEffect(() => {
if (!user) return;
let cancelled = false;
setModelsLoading(true);
getAvailableModels()
.then((response) => {
Promise.all([
getAvailableModels(),
getCreditsBalance().catch(() => ({ object: 'credits' as const, amount: 0 })),
])
.then(([modelsResponse, creditsResponse]) => {
if (cancelled) return;
const sorted = response.data.sort((a, b) => {
const userHasCredits = creditsResponse.amount > 0;
setHasCredits(userHasCredits);
const sorted = modelsResponse.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;
});
setModels(sorted);
// Default to the cheapest model
if (sorted.length > 0 && !selectedModel) {
setSelectedModel(sorted[0].id);
setSelectedModel(sorted[0].fullId);
}
})
.catch((err) => {
@@ -254,7 +264,7 @@ export function AIChatPage() {
});
return () => { cancelled = true; };
}, [user, getAvailableModels]); // eslint-disable-line react-hooks/exhaustive-deps
}, [user, getAvailableModels, getCreditsBalance]); // eslint-disable-line react-hooks/exhaustive-deps
// Build the chat messages array for the API (includes system prompt + conversation history)
const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => {
@@ -410,100 +420,112 @@ export function AIChatPage() {
}
return (
<main className="flex flex-col ai-chat-height sidebar:h-dvh bg-secondary/50">
<main className="flex flex-col ai-chat-height sidebar:h-dvh overflow-hidden">
{/* Header */}
<div className="shrink-0 px-4 py-3 flex flex-col sidebar:flex-row sidebar:items-center sidebar:justify-between gap-2 sidebar:gap-3">
<PageHeader title="AI Chat" icon={<Bot className="size-5" />} className="px-0 mt-0 mb-0" />
<div className="flex items-center gap-2">
{/* Model selector */}
<Select value={selectedModel} onValueChange={setSelectedModel} disabled={modelsLoading}>
<SelectTrigger className="w-full sidebar:w-44 h-8 text-base md:text-xs">
<SelectValue placeholder={modelsLoading ? 'Loading models...' : 'Select model'} />
</SelectTrigger>
<SelectContent>
{models.map((model) => {
const totalCost = parseFloat(model.pricing.prompt) + parseFloat(model.pricing.completion);
const isFree = totalCost === 0;
return (
<SelectItem key={model.id} value={model.id}>
<span className="flex items-center gap-1.5">
{model.name}
{isFree && (
<span className="text-[10px] font-medium text-green-600 dark:text-green-400 bg-green-500/10 px-1 rounded">
FREE
</span>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={handleClear}
disabled={messages.length === 0}
title="Clear conversation"
>
<Trash2 className="size-4" />
</Button>
<PageHeader titleContent={
<div className="hidden sidebar:flex items-center gap-2 flex-1 min-w-0">
<Bot className="size-5" />
<h1 className="text-xl font-bold truncate">AI Chat</h1>
</div>
</div>
}>
{hasCredits && (
<div className="flex items-center gap-2 ml-auto">
{/* Model selector */}
<Select value={selectedModel} onValueChange={setSelectedModel} disabled={modelsLoading}>
<SelectTrigger className="w-48 h-8 text-xs">
<SelectValue placeholder={modelsLoading ? 'Loading...' : 'Select model'} />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.fullId} value={model.fullId}>
{model.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={handleClear}
disabled={messages.length === 0}
title="Clear conversation"
>
<Trash2 className="size-4" />
</Button>
</div>
)}
</PageHeader>
{/* Messages Area */}
<ScrollArea className="flex-1" ref={scrollRef}>
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{messages.length === 0 ? (
<EmptyState />
) : (
messages.map((msg) => (
{messages.length === 0 ? (
<div className="flex-1 flex items-center justify-center px-4">
<EmptyState hasCredits={hasCredits} />
</div>
) : (
<ScrollArea className="flex-1" ref={scrollRef}>
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))
)}
))}
{/* Loading indicator */}
{(isStreaming || apiLoading) && messages[messages.length - 1]?.role === 'user' && (
<DorkThinking className="text-sm" />
)}
{/* Loading indicator */}
{(isStreaming || apiLoading) && messages[messages.length - 1]?.role === 'user' && (
<DorkThinking className="text-sm" />
)}
{/* Error display */}
{apiError && (
<div className="rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm px-4 py-3">
{apiError}
</div>
)}
{/* Error display */}
{apiError && (
retryAfter ? (
<DorkErrorBanner
face=">[~_~]<"
heading="Whoa, slow down! Dork needs a breather."
body="You're sending messages a bit too fast. Want more brainpower? Grab some credits on"
/>
) : apiError.includes('run out of credits') ? (
<DorkErrorBanner
face=">[o_o]<"
heading="You've run out of credits!"
body="Grab some more on"
/>
) : (
<div className="rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm px-4 py-3">
{apiError}
</div>
)
)}
<div ref={messagesEndRef} />
<div ref={messagesEndRef} />
</div>
</ScrollArea>
)}
{/* Input Area — hidden when user has no credits */}
{(hasCredits || hasCredits === null) && (
<div className="shrink-0 px-4 pt-2 pb-4 sidebar:pb-3">
<div className="max-w-2xl mx-auto flex items-end gap-2">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={!selectedModel ? 'Select a model first...' : 'Send a message...'}
disabled={!selectedModel || isStreaming}
className="min-h-[44px] max-h-40 resize-none bg-secondary/50 border-border focus-visible:ring-1"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || !selectedModel || isStreaming}
size="icon"
className="size-11 shrink-0 rounded-xl"
>
<Send className="size-4" />
</Button>
</div>
</div>
</ScrollArea>
{/* Input Area */}
<div className="shrink-0 p-4">
<div className="max-w-2xl mx-auto flex items-end gap-2">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={!selectedModel ? 'Select a model first...' : 'Send a message...'}
disabled={!selectedModel || isStreaming}
className="min-h-[44px] max-h-40 resize-none bg-secondary/50 border-border focus-visible:ring-1"
rows={1}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || !selectedModel || isStreaming}
size="icon"
className="size-11 shrink-0 rounded-xl"
>
<Send className="size-4" />
</Button>
</div>
</div>
)}
</main>
);
}
@@ -512,22 +534,64 @@ export function AIChatPage() {
// DorkThinking is imported from the shared component
function DorkErrorBanner({ face, heading, body }: { face: string; heading: string; body: string }) {
const shakespeareLink = (
<a
href="https://shakespeare.diy"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
<span>&#x1F3AD;</span>
<span>Shakespeare</span>
</a>
);
return (
<div className="rounded-2xl bg-secondary/60 border border-border px-4 py-4 text-sm space-y-2">
<p className="font-medium text-foreground">
<pre className="inline text-base font-mono text-primary leading-none">{face}</pre>
{' '}{heading}
</p>
<p className="text-muted-foreground">
{body} {shakespeareLink} to keep chatting with Dork.
</p>
</div>
);
}
const DORK_GREETINGS = [
"Hi, I'm Dork! What would you like me to do?",
"Dork here! What do you need?",
"Hey, it's Dork! What do you want to do?",
];
function EmptyState() {
function EmptyState({ hasCredits }: { hasCredits: boolean | null }) {
const greeting = useMemo(() => DORK_GREETINGS[Math.floor(Math.random() * DORK_GREETINGS.length)], []);
return (
<div className="flex flex-col items-center justify-center py-20 gap-8 text-center select-none animate-in fade-in duration-500">
<div className="flex flex-col items-center justify-center gap-8 text-center select-none animate-in fade-in duration-500">
<pre className="text-4xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
<div className="space-y-2">
<h2 className="text-base font-semibold tracking-tight text-foreground">Dork AI</h2>
<p className="text-sm text-muted-foreground">{greeting}</p>
</div>
{hasCredits === false && (
<div className="flex flex-col items-center gap-4 max-w-xs">
<p className="text-sm text-muted-foreground leading-relaxed">
You need credits to chat with Dork. Grab some on Shakespeare to get started.
</p>
<a
href="https://shakespeare.diy"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
<span>&#x1F3AD;</span>
Get Credits
</a>
</div>
)}
</div>
);
}