Configure AI provider settings
This commit is contained in:
@@ -162,6 +162,10 @@ const hardcodedConfig: AppConfig = {
|
||||
soundEnabled: false,
|
||||
devMode: false,
|
||||
},
|
||||
aiBaseURL: 'https://ai.shakespeare.diy/v1',
|
||||
aiApiKey: '',
|
||||
aiModel: 'grok-4.1-fast',
|
||||
aiSystemPrompt: '',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+220
-133
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDown, ChevronUp, Bot, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, RotateCcw, AlertTriangle, Eye, EyeOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
|
||||
@@ -12,10 +11,11 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useShakespeare, sortModelsByCost } from '@/hooks/useShakespeare';
|
||||
import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
|
||||
|
||||
import type { Model } from '@/hooks/useShakespeare';
|
||||
/** Hardcoded default values for Agent provider fields. Used for reset buttons. */
|
||||
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
|
||||
const DEFAULT_AI_MODEL = 'grok-4.1-fast';
|
||||
|
||||
/** The build-time default DSN from the environment variable. */
|
||||
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
|
||||
@@ -35,25 +35,73 @@ export function AdvancedSettings() {
|
||||
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
|
||||
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
|
||||
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
|
||||
const { getAvailableModels } = useShakespeare();
|
||||
const [aiModels, setAiModels] = useState<Model[]>([]);
|
||||
const [aiModelsLoading, setAiModelsLoading] = useState(false);
|
||||
const [baseUrlDraft, setBaseUrlDraft] = useState(config.aiBaseURL);
|
||||
const [apiKeyDraft, setApiKeyDraft] = useState(config.aiApiKey);
|
||||
const [modelDraft, setModelDraft] = useState(config.aiModel);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [systemPromptDraft, setSystemPromptDraft] = useState(config.aiSystemPrompt || DEFAULT_SYSTEM_PROMPT_TEMPLATE);
|
||||
|
||||
// Fetch models lazily when the AI section is opened
|
||||
useEffect(() => {
|
||||
if (!aiOpen || !user || aiModels.length > 0) return;
|
||||
let cancelled = false;
|
||||
setAiModelsLoading(true);
|
||||
getAvailableModels()
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setAiModels(sortModelsByCost(response.data));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cancelled) setAiModelsLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [aiOpen, user, aiModels.length, getAvailableModels]);
|
||||
useEffect(() => { setBaseUrlDraft(config.aiBaseURL); }, [config.aiBaseURL]);
|
||||
useEffect(() => { setApiKeyDraft(config.aiApiKey); }, [config.aiApiKey]);
|
||||
useEffect(() => { setModelDraft(config.aiModel); }, [config.aiModel]);
|
||||
|
||||
const commitBaseUrl = () => {
|
||||
const trimmed = baseUrlDraft.trim().replace(/\/+$/, '');
|
||||
if (!trimmed) {
|
||||
setBaseUrlDraft(DEFAULT_AI_BASE_URL);
|
||||
if (config.aiBaseURL !== DEFAULT_AI_BASE_URL) {
|
||||
updateConfig((current) => ({ ...current, aiBaseURL: DEFAULT_AI_BASE_URL }));
|
||||
toast({ title: 'Base URL reset to default' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed !== config.aiBaseURL) {
|
||||
updateConfig((current) => ({ ...current, aiBaseURL: trimmed }));
|
||||
toast({ title: 'AI base URL updated' });
|
||||
}
|
||||
};
|
||||
|
||||
const commitApiKey = () => {
|
||||
const trimmed = apiKeyDraft.trim();
|
||||
if (trimmed !== config.aiApiKey) {
|
||||
updateConfig((current) => ({ ...current, aiApiKey: trimmed }));
|
||||
toast({ title: trimmed ? 'API key updated' : 'API key cleared (using NIP-98 auth)' });
|
||||
}
|
||||
};
|
||||
|
||||
const commitModel = () => {
|
||||
const trimmed = modelDraft.trim();
|
||||
if (!trimmed) {
|
||||
setModelDraft(DEFAULT_AI_MODEL);
|
||||
if (config.aiModel !== DEFAULT_AI_MODEL) {
|
||||
updateConfig((current) => ({ ...current, aiModel: DEFAULT_AI_MODEL }));
|
||||
toast({ title: 'AI model reset to default' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmed !== config.aiModel) {
|
||||
updateConfig((current) => ({ ...current, aiModel: trimmed }));
|
||||
toast({ title: 'AI model updated' });
|
||||
}
|
||||
};
|
||||
|
||||
const resetProviderDefaults = () => {
|
||||
setBaseUrlDraft(DEFAULT_AI_BASE_URL);
|
||||
setApiKeyDraft('');
|
||||
setModelDraft(DEFAULT_AI_MODEL);
|
||||
updateConfig((current) => ({
|
||||
...current,
|
||||
aiBaseURL: DEFAULT_AI_BASE_URL,
|
||||
aiApiKey: '',
|
||||
aiModel: DEFAULT_AI_MODEL,
|
||||
}));
|
||||
toast({ title: 'Provider settings reset to defaults' });
|
||||
};
|
||||
|
||||
const providerIsDefault =
|
||||
config.aiBaseURL === DEFAULT_AI_BASE_URL &&
|
||||
config.aiApiKey === '' &&
|
||||
config.aiModel === DEFAULT_AI_MODEL;
|
||||
|
||||
const handleStatsPubkeyChange = (value: string) => {
|
||||
setStatsPubkey(value);
|
||||
@@ -68,6 +116,156 @@ export function AdvancedSettings() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Agent Section */}
|
||||
<div>
|
||||
<Collapsible open={aiOpen} onOpenChange={setAiOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
||||
>
|
||||
<span className="text-base font-semibold">Agent</span>
|
||||
{aiOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pt-3 pb-4 space-y-5 border-b border-border">
|
||||
|
||||
{/* AI Base URL */}
|
||||
<div>
|
||||
<Label htmlFor="ai-base-url" className="text-sm font-medium">
|
||||
Base URL
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
OpenAI-compatible <code className="bg-muted px-1 rounded">/v1</code> endpoint. An API key is required for endpoints that don't support NIP-98 auth.
|
||||
</p>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
type="url"
|
||||
value={baseUrlDraft}
|
||||
onChange={(e) => setBaseUrlDraft(e.target.value)}
|
||||
onBlur={commitBaseUrl}
|
||||
placeholder={DEFAULT_AI_BASE_URL}
|
||||
className="font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<Label htmlFor="ai-api-key" className="text-sm font-medium">
|
||||
API key
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Optional. Required for endpoints that use standard API-key auth (e.g. OpenAI, Anthropic, OpenRouter).
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="ai-api-key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={apiKeyDraft}
|
||||
onChange={(e) => setApiKeyDraft(e.target.value)}
|
||||
onBlur={commitApiKey}
|
||||
placeholder="Leave empty to use NIP-98 auth"
|
||||
className="font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowApiKey((value) => !value)}
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Model */}
|
||||
<div>
|
||||
<Label htmlFor="ai-model" className="text-sm font-medium">
|
||||
Model
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Model ID sent to the provider (e.g. <code className="bg-muted px-1 rounded">grok-4.1-fast</code>, <code className="bg-muted px-1 rounded">claude-opus-4.6</code>, <code className="bg-muted px-1 rounded">gpt-4o</code>).
|
||||
</p>
|
||||
<Input
|
||||
id="ai-model"
|
||||
type="text"
|
||||
value={modelDraft}
|
||||
onChange={(e) => setModelDraft(e.target.value)}
|
||||
onBlur={commitModel}
|
||||
placeholder={DEFAULT_AI_MODEL}
|
||||
className="font-mono text-base md:text-sm"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{!providerIsDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground mt-2"
|
||||
onClick={resetProviderDefaults}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reset provider to default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI System Prompt */}
|
||||
<div>
|
||||
<Label htmlFor="ai-system-prompt" className="text-sm font-medium">
|
||||
System Prompt
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
The base system prompt sent to the AI. Supports <code className="bg-muted px-1 rounded">{'{{SAVED_FEEDS}}'}</code> and <code className="bg-muted px-1 rounded">{'{{USER_IDENTITY}}'}</code> placeholders.
|
||||
</p>
|
||||
<Textarea
|
||||
id="ai-system-prompt"
|
||||
value={systemPromptDraft}
|
||||
onChange={(e) => setSystemPromptDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
const trimmed = systemPromptDraft.trim();
|
||||
const defaultPrompt = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
|
||||
// If the user reverted back to the default text, store empty (meaning "use default")
|
||||
const valueToStore = trimmed === defaultPrompt ? '' : trimmed;
|
||||
if (valueToStore !== config.aiSystemPrompt) {
|
||||
updateConfig(() => ({ aiSystemPrompt: valueToStore }));
|
||||
toast({ title: valueToStore ? 'System prompt updated' : 'System prompt reset to default' });
|
||||
}
|
||||
}}
|
||||
className="min-h-[120px] max-h-[400px] resize-y font-mono text-base leading-relaxed"
|
||||
/>
|
||||
{config.aiSystemPrompt && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground mt-2"
|
||||
onClick={() => {
|
||||
setSystemPromptDraft(DEFAULT_SYSTEM_PROMPT_TEMPLATE);
|
||||
updateConfig(() => ({ aiSystemPrompt: '' }));
|
||||
toast({ title: 'System prompt reset to default' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reset to default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* System Section (includes Stats Source) */}
|
||||
<div>
|
||||
<Collapsible open={systemOpen} onOpenChange={setSystemOpen}>
|
||||
@@ -206,114 +404,6 @@ export function AdvancedSettings() {
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Agent Section */}
|
||||
<div>
|
||||
<Collapsible open={aiOpen} onOpenChange={setAiOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-base font-semibold">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agent
|
||||
</span>
|
||||
{aiOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="px-3 pt-3 pb-4 space-y-5">
|
||||
|
||||
{/* AI Model */}
|
||||
<div>
|
||||
<Label htmlFor="ai-model" className="text-sm font-medium">
|
||||
Model
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
Choose which AI model the Agent uses for chat responses.
|
||||
</p>
|
||||
<Select
|
||||
value={config.aiModel || (aiModels.length > 0 ? aiModels[0].id : '')}
|
||||
onValueChange={(value) => {
|
||||
updateConfig(() => ({ aiModel: value }));
|
||||
toast({ title: 'AI model updated' });
|
||||
}}
|
||||
disabled={aiModelsLoading || aiModels.length === 0}
|
||||
>
|
||||
<SelectTrigger id="ai-model">
|
||||
<SelectValue placeholder={aiModelsLoading ? 'Loading models...' : 'Select model'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{aiModels.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>
|
||||
</div>
|
||||
|
||||
{/* AI System Prompt */}
|
||||
<div>
|
||||
<Label htmlFor="ai-system-prompt" className="text-sm font-medium">
|
||||
System Prompt
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
The base system prompt sent to the AI. Supports <code className="bg-muted px-1 rounded">{'{{SAVED_FEEDS}}'}</code> and <code className="bg-muted px-1 rounded">{'{{USER_IDENTITY}}'}</code> placeholders.
|
||||
</p>
|
||||
<Textarea
|
||||
id="ai-system-prompt"
|
||||
value={systemPromptDraft}
|
||||
onChange={(e) => setSystemPromptDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
const trimmed = systemPromptDraft.trim();
|
||||
const defaultPrompt = DEFAULT_SYSTEM_PROMPT_TEMPLATE;
|
||||
// If the user reverted back to the default text, store empty (meaning "use default")
|
||||
const valueToStore = trimmed === defaultPrompt ? '' : trimmed;
|
||||
if (valueToStore !== config.aiSystemPrompt) {
|
||||
updateConfig(() => ({ aiSystemPrompt: valueToStore }));
|
||||
toast({ title: valueToStore ? 'System prompt updated' : 'System prompt reset to default' });
|
||||
}
|
||||
}}
|
||||
className="min-h-[120px] max-h-[400px] resize-y font-mono text-base leading-relaxed"
|
||||
/>
|
||||
{config.aiSystemPrompt && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground mt-2"
|
||||
onClick={() => {
|
||||
setSystemPromptDraft(DEFAULT_SYSTEM_PROMPT_TEMPLATE);
|
||||
updateConfig(() => ({ aiSystemPrompt: '' }));
|
||||
toast({ title: 'System prompt reset to default' });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reset to default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Error Reporting Section */}
|
||||
<div>
|
||||
<Collapsible open={sentryOpen} onOpenChange={setSentryOpen}>
|
||||
@@ -322,10 +412,7 @@ export function AdvancedSettings() {
|
||||
variant="ghost"
|
||||
className="relative w-full justify-between px-3 py-3.5 h-auto hover:bg-muted/20 hover:text-foreground rounded-none"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-base font-semibold">
|
||||
<Bug className="h-4 w-4" />
|
||||
Error Reporting
|
||||
</span>
|
||||
<span className="text-base font-semibold">Error Reporting</span>
|
||||
{sentryOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
|
||||
@@ -297,10 +297,14 @@ export interface AppConfig {
|
||||
/** Show developer/debug DM UI affordances. */
|
||||
devMode?: boolean;
|
||||
};
|
||||
/** Override the AI model used by the Agent. When not set, the cheapest available model is used. */
|
||||
aiModel?: string;
|
||||
/** Override the AI system prompt. When not set, the default Agent prompt is used. Supports {{SAVED_FEEDS}} and {{USER_IDENTITY}} placeholders. */
|
||||
aiSystemPrompt?: string;
|
||||
/** Base URL for the AI chat-completions provider (OpenAI-compatible /v1 endpoint). */
|
||||
aiBaseURL: string;
|
||||
/** API key for the AI provider. Empty string = use NIP-98 auth (only valid for Shakespeare). */
|
||||
aiApiKey: string;
|
||||
/** AI model identifier sent to the provider (e.g. "grok-4.1-fast", "claude-opus-4.6"). */
|
||||
aiModel: string;
|
||||
/** Custom system prompt for the Agent. Empty string = use the default template. */
|
||||
aiSystemPrompt: string;
|
||||
}
|
||||
|
||||
/** Configuration for a single widget in the right sidebar. */
|
||||
|
||||
@@ -121,6 +121,10 @@ export interface EncryptedSettings {
|
||||
};
|
||||
/** Letter preferences (stationery, font, frame, closing, signature, inbox filters) */
|
||||
letterPreferences?: LetterPreferences;
|
||||
/** Base URL for the AI chat-completions provider */
|
||||
aiBaseURL?: string;
|
||||
/** API key for the AI provider */
|
||||
aiApiKey?: string;
|
||||
/** Override the AI model used by the Agent */
|
||||
aiModel?: string;
|
||||
/** Override the AI system prompt for the Agent */
|
||||
|
||||
+58
-39
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useAppContext } from './useAppContext';
|
||||
import type { NUser } from '@nostrify/react/login';
|
||||
|
||||
/** Error subclass carrying rate-limit metadata. */
|
||||
@@ -125,7 +126,17 @@ export interface CreditsResponse {
|
||||
|
||||
// ─── Provider Configuration ───
|
||||
|
||||
const SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1';
|
||||
const DEFAULT_SHAKESPEARE_API_URL = 'https://ai.shakespeare.diy/v1';
|
||||
|
||||
/** True when `url` points at the Shakespeare-hosted AI proxy (NIP-98 auth). */
|
||||
function isShakespeareEndpoint(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).host;
|
||||
return host === 'ai.shakespeare.diy' || host.endsWith('.shakespeare.diy');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
@@ -262,12 +273,37 @@ function formatError(err: unknown): string {
|
||||
|
||||
export function useShakespeare() {
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
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);
|
||||
|
||||
const apiUrl = useMemo(() => {
|
||||
const raw = (config.aiBaseURL || DEFAULT_SHAKESPEARE_API_URL).trim();
|
||||
return raw.replace(/\/+$/, '');
|
||||
}, [config.aiBaseURL]);
|
||||
|
||||
const buildAuthHeader = useCallback(async (
|
||||
method: string,
|
||||
url: string,
|
||||
body?: unknown,
|
||||
): Promise<string> => {
|
||||
const apiKey = config.aiApiKey.trim();
|
||||
if (apiKey) {
|
||||
return `Bearer ${apiKey}`;
|
||||
}
|
||||
if (!isShakespeareEndpoint(url)) {
|
||||
throw new Error(
|
||||
'An API key is required for this endpoint. ' +
|
||||
'Set one in Agent settings, or change the base URL to an endpoint that supports NIP-98 auth.',
|
||||
);
|
||||
}
|
||||
const token = await createNIP98Token(method, url, body, user ?? undefined);
|
||||
return `Nostr ${token}`;
|
||||
}, [config.aiApiKey, user]);
|
||||
|
||||
// Auto-clear retryAfter once the cooldown expires.
|
||||
useEffect(() => {
|
||||
if (retryAfter === null) return;
|
||||
@@ -313,16 +349,12 @@ export function useShakespeare() {
|
||||
...options,
|
||||
};
|
||||
|
||||
const token = await createNIP98Token(
|
||||
'POST',
|
||||
`${SHAKESPEARE_API_URL}/chat/completions`,
|
||||
requestBody,
|
||||
user,
|
||||
);
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
|
||||
const chatUrl = `${apiUrl}/chat/completions`;
|
||||
const authHeader = await buildAuthHeader('POST', chatUrl, requestBody);
|
||||
const response = await fetch(chatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Nostr ${token}`,
|
||||
'Authorization': authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
@@ -342,7 +374,7 @@ export function useShakespeare() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
}, [user, apiUrl, buildAuthHeader]);
|
||||
|
||||
// ─── Chat completions (streaming) ───
|
||||
//
|
||||
@@ -374,16 +406,12 @@ export function useShakespeare() {
|
||||
...options,
|
||||
};
|
||||
|
||||
const token = await createNIP98Token(
|
||||
'POST',
|
||||
`${SHAKESPEARE_API_URL}/chat/completions`,
|
||||
requestBody,
|
||||
user,
|
||||
);
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/chat/completions`, {
|
||||
const chatUrl = `${apiUrl}/chat/completions`;
|
||||
const authHeader = await buildAuthHeader('POST', chatUrl, requestBody);
|
||||
const response = await fetch(chatUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Nostr ${token}`,
|
||||
'Authorization': authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
@@ -526,7 +554,7 @@ export function useShakespeare() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
}, [user, apiUrl, buildAuthHeader]);
|
||||
|
||||
// ─── Credit balance (Shakespeare AI only) ───
|
||||
|
||||
@@ -536,16 +564,11 @@ export function useShakespeare() {
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await createNIP98Token(
|
||||
'GET',
|
||||
`${SHAKESPEARE_API_URL}/credits`,
|
||||
undefined,
|
||||
user,
|
||||
);
|
||||
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/credits`, {
|
||||
const creditsUrl = `${apiUrl}/credits`;
|
||||
const authHeader = await buildAuthHeader('GET', creditsUrl);
|
||||
const response = await fetch(creditsUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Nostr ${token}` },
|
||||
headers: { 'Authorization': authHeader },
|
||||
});
|
||||
|
||||
await handleAPIError(response);
|
||||
@@ -553,7 +576,7 @@ export function useShakespeare() {
|
||||
} catch (err) {
|
||||
throw new Error(formatError(err));
|
||||
}
|
||||
}, [user]);
|
||||
}, [user, apiUrl, buildAuthHeader]);
|
||||
|
||||
// ─── Available models (merged from both providers) ───
|
||||
|
||||
@@ -566,15 +589,11 @@ export function useShakespeare() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = await createNIP98Token(
|
||||
'GET',
|
||||
`${SHAKESPEARE_API_URL}/models`,
|
||||
undefined,
|
||||
user,
|
||||
);
|
||||
const response = await fetch(`${SHAKESPEARE_API_URL}/models`, {
|
||||
const modelsUrl = `${apiUrl}/models`;
|
||||
const authHeader = await buildAuthHeader('GET', modelsUrl);
|
||||
const response = await fetch(modelsUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Nostr ${token}` },
|
||||
headers: { 'Authorization': authHeader },
|
||||
});
|
||||
await handleAPIError(response);
|
||||
const result = (await response.json()) as ModelsResponse;
|
||||
@@ -593,7 +612,7 @@ export function useShakespeare() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
}, [user, apiUrl, buildAuthHeader]);
|
||||
|
||||
return {
|
||||
// State
|
||||
|
||||
@@ -278,6 +278,8 @@ export const AppConfigSchema = z.object({
|
||||
soundId: z.string().optional(),
|
||||
devMode: z.boolean().optional(),
|
||||
}).optional(),
|
||||
aiBaseURL: z.string().optional(),
|
||||
aiApiKey: z.string().optional(),
|
||||
aiModel: z.string().optional(),
|
||||
aiSystemPrompt: z.string().optional(),
|
||||
});
|
||||
@@ -390,6 +392,8 @@ export const EncryptedSettingsSchema = z.looseObject({
|
||||
return result.success ? [result.data] : [];
|
||||
})
|
||||
).optional(),
|
||||
aiBaseURL: z.string().optional(),
|
||||
aiApiKey: z.string().optional(),
|
||||
aiModel: z.string().optional(),
|
||||
aiSystemPrompt: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -119,6 +119,10 @@ export function TestApp({ children }: TestAppProps) {
|
||||
sandboxDomain: 'iframe.diy',
|
||||
esploraBaseUrl: 'https://mempool.space/api',
|
||||
sidebarWidgets: [],
|
||||
aiBaseURL: 'https://ai.shakespeare.diy/v1',
|
||||
aiApiKey: '',
|
||||
aiModel: 'grok-4.1-fast',
|
||||
aiSystemPrompt: '',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user