/ src / hooks / useShakespeare.ts
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  }