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:
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>🎭</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>🎭</span>
|
||||
Get Credits
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user