Merge branch 'main' of gitlab.com:soapbox-pub/ditto

This commit is contained in:
Alex Gleason
2026-04-16 17:17:47 -05:00
5 changed files with 432 additions and 201 deletions
+38 -7
View File
@@ -1,10 +1,11 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Send, Bot } from 'lucide-react';
import { Send } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
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 { useShakespeare, useShakespeareCredits, type ChatMessage } from '@/hooks/useShakespeare';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils';
@@ -19,6 +20,7 @@ const conversationCache = new Map<string, ChatMessage[]>();
export function AIChatWidget() {
const { user } = useCurrentUser();
const { sendStreamingMessage, getAvailableModels, isLoading, isAuthenticated } = useShakespeare();
const hasCredits = useShakespeareCredits();
// Fetch available models and select the cheapest as default
const { data: defaultModelId } = useQuery({
@@ -35,6 +37,7 @@ export function AIChatWidget() {
staleTime: 10 * 60_000,
enabled: !!user,
});
const cacheKey = user?.pubkey ?? '';
const [messages, setMessages] = useState<ChatMessage[]>(() => conversationCache.get(cacheKey) ?? []);
const [input, setInput] = useState('');
@@ -90,9 +93,37 @@ export function AIChatWidget() {
if (!user || !isAuthenticated) {
return (
<div className="flex flex-col items-center gap-2 py-4 px-2 text-center">
<Bot className="size-8 text-muted-foreground" />
<p className="text-xs text-muted-foreground">Log in to chat with AI</p>
<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">Log in to chat with Dork</p>
</div>
);
}
// Show credits CTA when the user has no credits (hasCredits === false).
// While loading (hasCredits === undefined) we fall through to the chat UI.
if (hasCredits === false) {
return (
<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"
>
Open AI Chat
</Link>
</div>
);
}
@@ -103,8 +134,8 @@ export function AIChatWidget() {
<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" />
<div className="flex flex-col items-center gap-3 py-6 text-center">
<pre className="text-xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
<p className="text-xs text-muted-foreground">Ask me anything...</p>
</div>
)}
+231 -94
View File
@@ -1,7 +1,20 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
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 +88,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 +99,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 +121,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,41 +137,73 @@ 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) {
let parsed: Record<string, unknown> | undefined;
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') {
parsed = await response.json();
} catch {
// JSON parse failed — fall through to generic message
}
if (parsed) {
const err = parsed.error as Record<string, unknown> | undefined;
if (err?.type === 'invalid_request_error') {
if (err.code === 'minimum_amount_not_met') {
throw new Error(`Minimum credit amount is $${err.minimum_amount}. Please increase your payment amount.`);
} else if (err.code === 'unsupported_method') {
throw new Error('Payment method not supported. Please use "stripe" or "lightning".');
} else if (error.error.code === 'invalid_url') {
} else if (err.code === 'invalid_url') {
throw new Error('Invalid redirect URL provided for Stripe payment.');
}
}
throw new Error(`Invalid request: ${error.error?.message || error.details || error.error || 'Please check your request parameters.'}`);
} catch {
throw new Error('Invalid request. Please check your parameters and try again.');
throw new Error(`Invalid request: ${err?.message || (parsed as Record<string, unknown>).details || err || 'Please check your request parameters.'}`);
}
throw new Error('Invalid request. Please check your parameters and try again.');
} else if (response.status === 404) {
throw new Error('Resource not found. Please check the payment ID or try again.');
} else if (response.status >= 500) {
@@ -163,20 +218,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 +286,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 +312,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 +341,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 +386,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 +403,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 +458,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 +487,47 @@ 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,
};
}
// ─── Shared Credits Hook ───
/**
* Shared hook for checking Shakespeare credits balance.
*
* Returns `true` when the user has credits, `false` when they don't, and
* `null` while loading or when the request fails (so the UI doesn't lock the
* user out on transient errors).
*/
export function useShakespeareCredits(): boolean | null {
const { user } = useCurrentUser();
const { getCreditsBalance } = useShakespeare();
const { data } = useQuery({
queryKey: ['shakespeare-credits-check', user?.pubkey],
queryFn: async (): Promise<boolean | null> => {
try {
const response = await getCreditsBalance();
return response.amount > 0;
} catch {
// On failure return null so the UI stays in "unknown" state
// rather than locking the user out.
return null;
}
},
staleTime: 5 * 60_000,
enabled: !!user,
});
return data ?? null;
}
+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',
+156 -98
View File
@@ -5,7 +5,7 @@ import rehypeSanitize from 'rehype-sanitize';
import { Bot, Send, Trash2, Palette, Type } from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { useShakespeare, type ChatMessage, type Model, type ChatCompletionTool } from '@/hooks/useShakespeare';
import { useShakespeare, useShakespeareCredits, type ChatMessage, type Model, type ChatCompletionTool } from '@/hooks/useShakespeare';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useTheme } from '@/hooks/useTheme';
@@ -196,7 +196,8 @@ 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, isLoading: apiLoading, error: apiError, retryAfter, clearError } = useShakespeare();
const hasCredits = useShakespeareCredits();
const { executeToolCall } = useToolExecutor();
const [messages, setMessages] = useState<DisplayMessage[]>([]);
@@ -205,7 +206,6 @@ export function AIChatPage() {
const [models, setModels] = useState<Model[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [modelsLoading, setModelsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -234,16 +234,20 @@ export function AIChatPage() {
setModelsLoading(true);
getAvailableModels()
.then((response) => {
.then((modelsResponse) => {
if (cancelled) return;
const sorted = response.data.sort((a, b) => {
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) => {
@@ -397,12 +401,12 @@ export function AIChatPage() {
if (!user) {
return (
<main className="flex flex-col items-center justify-center p-6 gap-6">
<div className="flex flex-col items-center gap-3 text-center max-w-sm">
<div className="size-16 rounded-2xl bg-primary/10 flex items-center justify-center">
<Bot className="size-8 text-primary" />
<div className="flex flex-col items-center gap-4 text-center max-w-sm">
<pre className="text-4xl font-mono text-primary leading-none">{'<[o_o]>'}</pre>
<div className="space-y-2">
<h1 className="text-2xl font-bold">Dork AI</h1>
<p className="text-muted-foreground">Log in with your Nostr account to start chatting with Dork.</p>
</div>
<h1 className="text-2xl font-bold">AI Chat</h1>
<p className="text-muted-foreground">Log in with your Nostr account to start chatting with AI.</p>
<LoginArea className="mt-2" />
</div>
</main>
@@ -410,100 +414,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 +528,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">
<code className="text-base font-mono text-primary leading-none whitespace-pre">{face}</code>
{' '}{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>
);
}