useShakespeare.ts
1 import { useCallback, useState } from 'react'; 2 import { useCurrentUser } from './useCurrentUser'; 3 import type { NUser } from '@nostrify/react/login'; 4 5 // Types for Shakespeare API (compatible with OpenAI ChatCompletionMessageParam) 6 export interface ChatMessage { 7 role: 'user' | 'assistant' | 'system'; 8 content: string | Array<{ 9 type: 'text' | 'image_url'; 10 text?: string; 11 image_url?: { 12 url: string; 13 }; 14 }>; 15 } 16 17 export interface ChatCompletionRequest { 18 model: string; 19 messages: ChatMessage[]; 20 stream?: boolean; 21 temperature?: number; 22 max_tokens?: number; 23 } 24 25 export interface ChatCompletionResponse { 26 id: string; 27 object: string; 28 created: number; 29 model: string; 30 choices: Array<{ 31 index: number; 32 message: ChatMessage; 33 finish_reason: string; 34 }>; 35 usage: { 36 prompt_tokens: number; 37 completion_tokens: number; 38 total_tokens: number; 39 }; 40 } 41 42 export interface Model { 43 id: string; 44 name: string; 45 description: string; 46 object: string; 47 owned_by: string; 48 created: number; 49 context_window: number; 50 pricing: { 51 prompt: string; 52 completion: string; 53 }; 54 } 55 56 export interface ModelsResponse { 57 object: string; 58 data: Model[]; 59 } 60 61 // Configuration 62 const SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1'; 63 64 // Helper function to create NIP-98 token 65 async function createNIP98Token( 66 method: string, 67 url: string, 68 body?: unknown, 69 user?: NUser 70 ): Promise<string> { 71 if (!user?.signer) { 72 throw new Error('User signer is required for NIP-98 authentication'); 73 } 74 75 // Create the tags array 76 const tags: string[][] = [ 77 ['u', url], 78 ['method', method] 79 ]; 80 81 // Add payload hash for requests with body (following NIP-98 spec) 82 if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { 83 const bodyString = JSON.stringify(body); 84 const encoder = new TextEncoder(); 85 const data = encoder.encode(bodyString); 86 const hashBuffer = await crypto.subtle.digest('SHA-256', data); 87 const payloadHash = Array.from(new Uint8Array(hashBuffer)) 88 .map(b => b.toString(16).padStart(2, '0')) 89 .join(''); 90 tags.push(['payload', payloadHash]); 91 } 92 93 // Create the HTTP request event 94 const event = await user.signer.signEvent({ 95 kind: 27235, // NIP-98 HTTP Auth 96 content: '', 97 tags, 98 created_at: Math.floor(Date.now() / 1000) 99 }); 100 101 // Return the token (base64 encoded event) 102 return btoa(JSON.stringify(event)); 103 } 104 105 // Helper function to handle API errors with user-friendly messages 106 async function handleAPIError(response: Response) { 107 if (response.status === 401) { 108 throw new Error('Authentication failed. Please make sure you are logged in with a Nostr account.'); 109 } else if (response.status === 402) { 110 throw new Error('Insufficient credits. Please add credits to your account to use premium models, or use the free "tybalt" model.'); 111 } else if (response.status === 400) { 112 try { 113 const error = await response.json(); 114 if (error.error?.type === 'invalid_request_error') { 115 // Handle specific validation errors 116 if (error.error.code === 'minimum_amount_not_met') { 117 throw new Error(`Minimum credit amount is $${error.error.minimum_amount}. Please increase your payment amount.`); 118 } else if (error.error.code === 'unsupported_method') { 119 throw new Error('Payment method not supported. Please use "stripe" or "lightning".'); 120 } else if (error.error.code === 'invalid_url') { 121 throw new Error('Invalid redirect URL provided for Stripe payment.'); 122 } 123 } 124 throw new Error(`Invalid request: ${error.error?.message || error.details || error.error || 'Please check your request parameters.'}`); 125 } catch { 126 throw new Error('Invalid request. Please check your parameters and try again.'); 127 } 128 } else if (response.status === 404) { 129 throw new Error('Resource not found. Please check the payment ID or try again.'); 130 } else if (response.status >= 500) { 131 throw new Error('Server error. Please try again in a few moments.'); 132 } else if (!response.ok) { 133 try { 134 const errorData = await response.json(); 135 throw new Error(`API error: ${errorData.error?.message || errorData.details || errorData.error || response.statusText}`); 136 } catch { 137 throw new Error(`Network error: ${response.statusText}. Please check your connection and try again.`); 138 } 139 } 140 } 141 142 export function useShakespeare() { 143 const { user } = useCurrentUser(); 144 const [isLoading, setIsLoading] = useState(false); 145 const [error, setError] = useState<string | null>(null); 146 147 // Clear error helper 148 const clearError = useCallback(() => { 149 setError(null); 150 }, []); 151 152 // Chat completion function 153 const sendChatMessage = useCallback(async ( 154 messages: ChatMessage[], 155 model: string = 'shakespeare', 156 options?: Partial<ChatCompletionRequest> 157 ): Promise<ChatCompletionResponse> => { 158 if (!user) { 159 throw new Error('User must be logged in to use AI features'); 160 } 161 162 setIsLoading(true); 163 setError(null); 164 165 try { 166 const requestBody: ChatCompletionRequest = { 167 model, 168 messages, 169 ...options 170 }; 171 172 const token = await createNIP98Token( 173 'POST', 174 `${SHAKESPEARE_API_URL}/chat/completions`, 175 requestBody, 176 user 177 ); 178 179 const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, { 180 method: 'POST', 181 headers: { 182 'Authorization': `Nostr ${token}`, 183 'Content-Type': 'application/json', 184 }, 185 body: JSON.stringify(requestBody), 186 }); 187 188 await handleAPIError(response); 189 return await response.json(); 190 } catch (err) { 191 let errorMessage = 'An unexpected error occurred'; 192 193 if (err instanceof Error) { 194 errorMessage = err.message; 195 } else if (typeof err === 'string') { 196 errorMessage = err; 197 } 198 199 // Add context for common issues 200 if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) { 201 errorMessage = 'Network error: Please check your internet connection and try again.'; 202 } else if (errorMessage.includes('signer')) { 203 errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.'; 204 } 205 206 setError(errorMessage); 207 throw new Error(errorMessage); 208 } finally { 209 setIsLoading(false); 210 } 211 }, [user]); 212 213 // Streaming chat completion function 214 const sendStreamingMessage = useCallback(async ( 215 messages: ChatMessage[], 216 model: string = 'shakespeare', 217 onChunk: (chunk: string) => void, 218 options?: Partial<ChatCompletionRequest> 219 ): Promise<void> => { 220 if (!user) { 221 throw new Error('User must be logged in to use AI features'); 222 } 223 224 setIsLoading(true); 225 setError(null); 226 227 try { 228 const requestBody: ChatCompletionRequest = { 229 model, 230 messages, 231 stream: true, 232 ...options 233 }; 234 235 const token = await createNIP98Token( 236 'POST', 237 `${SHAKESPEARE_API_URL}/chat/completions`, 238 requestBody, 239 user 240 ); 241 242 const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, { 243 method: 'POST', 244 headers: { 245 'Authorization': `Nostr ${token}`, 246 'Content-Type': 'application/json', 247 }, 248 body: JSON.stringify(requestBody), 249 }); 250 251 await handleAPIError(response); 252 253 if (!response.body) { 254 throw new Error('No response body'); 255 } 256 257 const reader = response.body.getReader(); 258 const decoder = new TextDecoder(); 259 260 try { 261 while (true) { 262 const { done, value } = await reader.read(); 263 if (done) break; 264 265 const chunk = decoder.decode(value); 266 const lines = chunk.split('\n'); 267 268 for (const line of lines) { 269 if (line.startsWith('data: ')) { 270 const data = line.slice(6); 271 if (data === '[DONE]') return; 272 273 try { 274 const parsed = JSON.parse(data); 275 const content = parsed.choices?.[0]?.delta?.content; 276 if (content) { 277 onChunk(content); 278 } 279 } catch { 280 // Ignore parsing errors for incomplete chunks 281 } 282 } 283 } 284 } 285 } finally { 286 reader.releaseLock(); 287 } 288 } catch (err) { 289 let errorMessage = 'An unexpected error occurred'; 290 291 if (err instanceof Error) { 292 errorMessage = err.message; 293 } else if (typeof err === 'string') { 294 errorMessage = err; 295 } 296 297 // Add context for common issues 298 if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) { 299 errorMessage = 'Network error: Please check your internet connection and try again.'; 300 } else if (errorMessage.includes('signer')) { 301 errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.'; 302 } 303 304 setError(errorMessage); 305 throw new Error(errorMessage); 306 } finally { 307 setIsLoading(false); 308 } 309 }, [user]); 310 311 // Get available models 312 const getAvailableModels = useCallback(async (): Promise<ModelsResponse> => { 313 if (!user) { 314 throw new Error('User must be logged in to use AI features'); 315 } 316 317 setIsLoading(true); 318 setError(null); 319 320 try { 321 const token = await createNIP98Token( 322 'GET', 323 `${SHAKESPEARE_API_URL}/models`, 324 undefined, 325 user 326 ); 327 328 const response = await fetch(`${SHAKESPEARE_API_URL}/models`, { 329 method: 'GET', 330 headers: { 331 'Authorization': `Nostr ${token}`, 332 }, 333 }); 334 335 await handleAPIError(response); 336 return await response.json(); 337 } catch (err) { 338 let errorMessage = 'An unexpected error occurred'; 339 340 if (err instanceof Error) { 341 errorMessage = err.message; 342 } else if (typeof err === 'string') { 343 errorMessage = err; 344 } 345 346 // Add context for common issues 347 if (errorMessage.includes('Failed to fetch') || errorMessage.includes('Network')) { 348 errorMessage = 'Network error: Please check your internet connection and try again.'; 349 } else if (errorMessage.includes('signer')) { 350 errorMessage = 'Authentication error: Please make sure you are logged in with a Nostr account that supports signing.'; 351 } 352 353 setError(errorMessage); 354 throw new Error(errorMessage); 355 } finally { 356 setIsLoading(false); 357 } 358 }, [user]); 359 360 return { 361 // State 362 isLoading, 363 error, 364 isAuthenticated: !!user, 365 366 // Actions 367 sendChatMessage, 368 sendStreamingMessage, 369 getAvailableModels, 370 clearError, 371 }; 372 }