Configure AI provider settings

This commit is contained in:
lemon
2026-05-04 22:23:37 -07:00
parent 37513cd44c
commit 6e101e89ef
7 changed files with 302 additions and 176 deletions
+4
View File
@@ -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
View File
@@ -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" />
) : (
+8 -4
View File
@@ -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. */
+4
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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(),
});
+4
View File
@@ -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 (