diff --git a/src/components/widgets/AIChatWidget.tsx b/src/components/widgets/AIChatWidget.tsx index d614f90e..d3df448e 100644 --- a/src/components/widgets/AIChatWidget.tsx +++ b/src/components/widgets/AIChatWidget.tsx @@ -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(); 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(() => conversationCache.get(cacheKey) ?? []); const [input, setInput] = useState(''); @@ -90,9 +93,37 @@ export function AIChatWidget() { if (!user || !isAuthenticated) { return ( -
- -

Log in to chat with AI

+
+
{'<[o_o]>'}
+

Log in to chat with Dork

+
+ ); + } + + // 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 ( +
+
{'<[o_o]>'}
+

+ Grab some credits on{' '} + + Shakespeare + + {' '}to chat with Dork. +

+ + Open AI Chat +
); } @@ -103,8 +134,8 @@ export function AIChatWidget() {
{messages.length === 0 && !streamingContent && ( -
- +
+
{'<[o_o]>'}

Ask me anything...

)} diff --git a/src/hooks/useShakespeare.ts b/src/hooks/useShakespeare.ts index 217350c8..c286455d 100644 --- a/src/hooks/useShakespeare.ts +++ b/src/hooks/useShakespeare.ts @@ -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 | 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 | 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).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(null); + /** Unix-ms timestamp until which the client is rate-limited, or null. */ + const [retryAfter, setRetryAfter] = useState(null); + const retryTimerRef = useRef>(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 ): Promise => { 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 ): Promise => { @@ -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 => { + 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 => { 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 => { + 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; +} diff --git a/src/index.css b/src/index.css index 657212b5..ae9ddf87 100644 --- a/src/index.css +++ b/src/index.css @@ -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 { diff --git a/src/lib/sidebarWidgets.ts b/src/lib/sidebarWidgets.ts index 7c554927..01a24b3d 100644 --- a/src/lib/sidebarWidgets.ts +++ b/src/lib/sidebarWidgets.ts @@ -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', diff --git a/src/pages/AIChatPage.tsx b/src/pages/AIChatPage.tsx index 56d57fa3..d05a87f6 100644 --- a/src/pages/AIChatPage.tsx +++ b/src/pages/AIChatPage.tsx @@ -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([]); @@ -205,7 +206,6 @@ export function AIChatPage() { const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState(''); const [modelsLoading, setModelsLoading] = useState(false); - const scrollRef = useRef(null); const textareaRef = useRef(null); const messagesEndRef = useRef(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 (
-
-
- +
+
{'<[o_o]>'}
+
+

Dork AI

+

Log in with your Nostr account to start chatting with Dork.

-

AI Chat

-

Log in with your Nostr account to start chatting with AI.

@@ -410,100 +414,112 @@ export function AIChatPage() { } return ( -
+
{/* Header */} -
- } className="px-0 mt-0 mb-0" /> - -
- {/* Model selector */} - - - + + +

AI Chat

-
+ }> + {hasCredits && ( +
+ {/* Model selector */} + + + +
+ )} + {/* Messages Area */} - -
- {messages.length === 0 ? ( - - ) : ( - messages.map((msg) => ( + {messages.length === 0 ? ( +
+ +
+ ) : ( + +
+ {messages.map((msg) => ( - )) - )} + ))} - {/* Loading indicator */} - {(isStreaming || apiLoading) && messages[messages.length - 1]?.role === 'user' && ( - - )} + {/* Loading indicator */} + {(isStreaming || apiLoading) && messages[messages.length - 1]?.role === 'user' && ( + + )} - {/* Error display */} - {apiError && ( -
- {apiError} -
- )} + {/* Error display */} + {apiError && ( + retryAfter ? ( + + ) : apiError.includes('run out of credits') ? ( + + ) : ( +
+ {apiError} +
+ ) + )} -
+
+
+ + )} + + {/* Input Area — hidden when user has no credits */} + {(hasCredits || hasCredits === null) && ( +
+
+