Merge branch 'main' of gitlab.com:soapbox-pub/ditto
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+156
-98
@@ -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>🎭</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>🎭</span>
|
||||
Get Credits
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user