Compare commits

...

5 Commits

Author SHA1 Message Date
lemon 344d3a0049 Fix duplicate AI chat error display 2026-05-06 23:14:44 -07:00
lemon f6f76d08d4 Configure AI provider settings 2026-05-04 22:24:57 -07:00
lemon 07f0e7d9b9 Add slash commands with autocomplete, /tools listing, and styled notice messages 2026-05-04 22:24:27 -07:00
lemon 9671da4267 Harden AI chat: SSRF protection, capacity tracking, scoped storage, and error handling 2026-05-04 22:24:27 -07:00
lemon f4875266a6 Add AI Agent chat with tool-calling, model selector, and sidebar integration
- Implement 5 read-only tools: get_feed, search_users, search_follow_packs, fetch_page, fetch_event
- Upgrade useShakespeare streaming to support tool calls, AbortSignal, and robust SSE parsing
- Create useAIChatSession hook with streaming, 10-round tool loop, localStorage persistence
- Rewrite AIChatPage with modular architecture, streaming UI, tool call badges, and empty-bubble handling
- Add Agent settings section with model dropdown selector and pre-populated system prompt editor
- Add Agent to left sidebar navigation and right widget sidebar defaults
- Add aiModel and aiSystemPrompt config fields with encrypted settings sync
2026-05-04 22:24:27 -07:00
28 changed files with 2563 additions and 609 deletions
+5
View File
@@ -144,6 +144,7 @@ const hardcodedConfig: AppConfig = {
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
{ id: 'ai-chat' },
],
messaging: {
enabled: true,
@@ -153,6 +154,10 @@ const hardcodedConfig: AppConfig = {
soundEnabled: false,
devMode: false,
},
aiBaseURL: 'https://ai.shakespeare.diy/v1',
aiApiKey: '',
aiModel: 'grok-4.1-fast',
aiSystemPrompt: '',
};
/**
+2
View File
@@ -52,6 +52,7 @@ const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").the
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
@@ -177,6 +178,7 @@ export function AppRouter() {
/>
<Route path="/i/*" element={<ExternalContentPage />} />
<Route path="/actions" element={<ActionsPage />} />
<Route path="/agent" element={<AIChatPage />} />
<Route path="/organizers" element={<OrganizersPage />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
+227 -6
View File
@@ -1,15 +1,21 @@
import { useState } from 'react';
import { ChevronDown, ChevronUp, Bug, RotateCcw, AlertTriangle } from 'lucide-react';
import { useState, useEffect } from '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 { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { RequestToVanishDialog } from '@/components/RequestToVanishDialog';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
/** 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 || '';
@@ -20,6 +26,7 @@ export function AdvancedSettings() {
const { updateSettings } = useEncryptedSettings();
const { user } = useCurrentUser();
const [systemOpen, setSystemOpen] = useState(true);
const [aiOpen, setAiOpen] = useState(false);
const [sentryOpen, setSentryOpen] = useState(false);
const [dangerOpen, setDangerOpen] = useState(false);
const [vanishDialogOpen, setVanishDialogOpen] = useState(false);
@@ -28,6 +35,73 @@ export function AdvancedSettings() {
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
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);
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);
@@ -42,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}>
@@ -188,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" />
) : (
+6 -19
View File
@@ -1,25 +1,12 @@
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
const DORK_ANIMATION = [
'<[o_o]>',
'>[-_-]<',
'<[0_0]>',
'>[-_-]<',
];
/** Animated Dork face shown while the AI is thinking. */
/** Animated thinking indicator shown while the AI agent is processing. */
export function DorkThinking({ className }: { className?: string }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setFrame((f) => (f + 1) % DORK_ANIMATION.length);
}, 100);
return () => clearInterval(interval);
}, []);
return (
<pre className={cn('font-mono text-muted-foreground leading-none', className)}>{DORK_ANIMATION[frame]}</pre>
<div className={cn('flex items-center gap-1.5', className)}>
<span className="size-1.5 rounded-full bg-muted-foreground/60 animate-bounce [animation-delay:0ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/60 animate-bounce [animation-delay:150ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/60 animate-bounce [animation-delay:300ms]" />
</div>
);
}
+5 -8
View File
@@ -94,8 +94,7 @@ export function AIChatWidget() {
if (!user || !isAuthenticated) {
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">Log in to chat with Dork</p>
<p className="text-xs text-muted-foreground">Log in to chat with the Agent</p>
</div>
);
}
@@ -105,7 +104,6 @@ export function AIChatWidget() {
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
@@ -116,13 +114,13 @@ export function AIChatWidget() {
>
Shakespeare
</a>
{' '}to chat with Dork.
{' '}to use the Agent.
</p>
<Link
to="/ai-chat"
to="/agent"
className="text-xs font-medium text-primary hover:underline"
>
Open AI Chat
Open Agent
</Link>
</div>
);
@@ -135,7 +133,6 @@ export function AIChatWidget() {
<div className="space-y-3 p-2">
{messages.length === 0 && !streamingContent && (
<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>
)}
@@ -187,7 +184,7 @@ export function AIChatWidget() {
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === 'user';
const content = typeof message.content === 'string' ? message.content : message.content.map((c) => c.text ?? '').join('');
const content = typeof message.content === 'string' ? message.content : (message.content ?? []).map((c) => c.text ?? '').join('');
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
+8
View File
@@ -253,6 +253,14 @@ export interface AppConfig {
/** Show developer/debug DM UI affordances. */
devMode?: boolean;
};
/** 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. */
+489
View File
@@ -0,0 +1,489 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { useShakespeare, sortModelsByCost, type ChatMessage, type Model } from '@/hooks/useShakespeare';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useAIChatTools, TOOLS, TOOL_SUMMARIES } from '@/hooks/useAIChatTools';
import { type DisplayMessage, type ToolCall } from '@/lib/aiChatTools';
import { buildSystemPrompt, type UserIdentity } from '@/lib/aiChatSystemPrompt';
import type { NostrEvent } from '@nostrify/nostrify';
// ─── Persistence ───
const CHAT_STORAGE_KEY_PREFIX = 'agora:ai-chat-messages';
function chatStorageKey(appId: string, pubkey: string): string {
return `${CHAT_STORAGE_KEY_PREFIX}:${appId}:${pubkey}`;
}
/** Zod schema for a single persisted chat message. */
const StoredToolCallSchema = z.object({
id: z.string(),
name: z.string(),
arguments: z.record(z.string(), z.unknown()),
result: z.string().optional(),
});
const StoredMessageSchema = z.object({
id: z.string(),
role: z.enum(['user', 'assistant', 'system', 'tool_result']),
content: z.string(),
timestamp: z.string(),
toolCalls: z.array(StoredToolCallSchema).optional(),
toolCallId: z.string().optional(),
// nostrEvent is not validated in detail — just needs to be an object if present
nostrEvent: z.record(z.string(), z.unknown()).optional(),
noticeVariant: z.enum(['info', 'error']).optional(),
});
const StoredMessagesSchema = z.array(StoredMessageSchema);
function loadMessages(storageKey: string): DisplayMessage[] {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return [];
const parsed = StoredMessagesSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
console.warn('Discarding corrupted AI chat history:', parsed.error.message);
localStorage.removeItem(storageKey);
return [];
}
return parsed.data.map((m) => ({
...m,
timestamp: new Date(m.timestamp),
nostrEvent: m.nostrEvent as NostrEvent | undefined,
toolCalls: m.toolCalls as ToolCall[] | undefined,
}));
} catch {
return [];
}
}
/** Persist messages and return the serialized byte size. */
function saveMessages(storageKey: string, messages: DisplayMessage[]): number {
try {
const stored = messages.map((m) => ({ ...m, timestamp: m.timestamp.toISOString() }));
const json = JSON.stringify(stored);
localStorage.setItem(storageKey, json);
return new Blob([json]).size;
} catch {
// Storage full or unavailable — silently ignore
return 0;
}
}
/** Measure byte size of the current persisted messages without re-serializing. */
function measureStorageBytes(storageKey: string): number {
try {
const raw = localStorage.getItem(storageKey);
return raw ? new Blob([raw]).size : 0;
} catch {
return 0;
}
}
/** Conservative localStorage budget for chat messages (4 MB). */
const MAX_STORAGE_BYTES = 4 * 1024 * 1024;
// ─── Hook ───
export function useAIChatSession() {
const { user, metadata } = useCurrentUser();
const { config } = useAppContext();
const { sendStreamingMessage, getAvailableModels, getCreditsBalance, isLoading: apiLoading, error: apiError, clearError } = useShakespeare();
const { executeToolCall, savedFeeds } = useAIChatTools();
const storageKey = user ? chatStorageKey(config.appId, user.pubkey) : null;
const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingText, setStreamingText] = useState('');
// Resolve the effective model: config value, or fetch the cheapest as default
const [defaultModel, setDefaultModel] = useState('');
const [models, setModels] = useState<Model[]>([]);
const selectedModel = config.aiModel || defaultModel;
// Capacity tracking
const [lastPromptTokens, setLastPromptTokens] = useState(0);
const [storageBytes, setStorageBytes] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const [loadedStorageKey, setLoadedStorageKey] = useState<string | null>(null);
// Load messages from the current user's scoped storage key.
useEffect(() => {
if (!storageKey) {
setLoadedStorageKey(null);
setMessages([]);
setStorageBytes(0);
return;
}
setMessages(loadMessages(storageKey));
setStorageBytes(measureStorageBytes(storageKey));
setLoadedStorageKey(storageKey);
}, [storageKey]);
// Persist messages to localStorage and update storage bytes
useEffect(() => {
if (!storageKey || loadedStorageKey !== storageKey) return;
const bytes = saveMessages(storageKey, messages);
setStorageBytes(bytes);
}, [storageKey, loadedStorageKey, messages]);
// Scroll to bottom on new messages or streaming text updates
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText]);
// Fetch available models (for default model + context_window lookup)
useEffect(() => {
if (!user) return;
let cancelled = false;
getAvailableModels()
.then((response) => {
if (cancelled) return;
setModels(response.data);
if (!config.aiModel) {
const sorted = sortModelsByCost(response.data);
if (sorted.length > 0) {
setDefaultModel(sorted[0].id);
}
}
})
.catch(() => {});
return () => { cancelled = true; };
}, [user, config.aiModel, getAvailableModels]);
// Compute capacity ratio (0 to 1) — max of token usage and storage usage
const contextWindow = useMemo(() => {
if (!selectedModel || models.length === 0) return 0;
const model = models.find((m) => m.id === selectedModel || m.fullId === selectedModel);
return model?.context_window ?? 0;
}, [selectedModel, models]);
const capacity = useMemo(() => {
const tokenRatio = contextWindow > 0 && lastPromptTokens > 0
? lastPromptTokens / contextWindow
: 0;
const storageRatio = storageBytes / MAX_STORAGE_BYTES;
return Math.min(Math.max(tokenRatio, storageRatio), 1);
}, [lastPromptTokens, contextWindow, storageBytes]);
// Build the system prompt — dynamic based on saved feeds, user identity, + optional custom override
const savedFeedLabels = useMemo(() => savedFeeds.map((f) => f.label), [savedFeeds]);
const userIdentity = useMemo<UserIdentity | undefined>(() => {
if (!user) return undefined;
return {
npub: nip19.npubEncode(user.pubkey),
pubkey: user.pubkey,
displayName: metadata?.display_name || metadata?.name,
nip05: metadata?.nip05,
about: metadata?.about,
};
}, [user, metadata]);
const systemPrompt = useMemo(
() => buildSystemPrompt(config.aiSystemPrompt || undefined, savedFeedLabels, userIdentity),
[config.aiSystemPrompt, savedFeedLabels, userIdentity],
);
// Build the chat messages array for the API
const buildApiMessages = useCallback((displayMsgs: DisplayMessage[]): ChatMessage[] => {
const apiMessages: ChatMessage[] = [systemPrompt];
for (const msg of displayMsgs) {
if (msg.role === 'tool_result') {
apiMessages.push({
role: 'tool',
content: msg.content,
tool_call_id: msg.toolCallId,
});
} else if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
apiMessages.push({
role: 'assistant',
content: msg.content || null,
tool_calls: msg.toolCalls.map((tc) => ({
id: tc.id,
type: 'function' as const,
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
})),
});
} else {
apiMessages.push({ role: msg.role as 'user' | 'assistant' | 'system', content: msg.content });
}
}
return apiMessages;
}, [systemPrompt]);
// Handle sending a message. Pass `override` to send arbitrary text (e.g. suggestion chips).
const handleSend = useCallback(async (override?: string) => {
const trimmed = (override ?? input).trim();
if (!trimmed || isStreaming) return;
// Slash commands — handled locally, never sent to the API
if (trimmed.startsWith('/')) {
const cmd = trimmed.toLowerCase();
if (cmd === '/new' || cmd === '/clear') {
handleClear();
setInput('');
return;
}
if (cmd === '/tools') {
const listing = TOOL_SUMMARIES.map((t) => `- \`${t.name}\`${t.summary}`).join('\n');
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: `**Available tools:**\n\n${listing}`,
timestamp: new Date(),
noticeVariant: 'info',
}]);
setInput('');
return;
}
// Unknown command — show feedback in chat
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: `Unknown command \`${trimmed.split(' ')[0]}\`. Available commands: \`/new\`, \`/clear\`, \`/tools\`.`,
timestamp: new Date(),
noticeVariant: 'info',
}]);
setInput('');
return;
}
if (!selectedModel) return;
// Block sends when conversation capacity is exhausted
if (capacity >= 1) {
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: 'This conversation has reached its limit. Use /clear to start a fresh conversation.',
timestamp: new Date(),
noticeVariant: 'error',
}]);
setInput('');
return;
}
clearError();
setInput('');
const controller = new AbortController();
abortRef.current = controller;
const userMessage: DisplayMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
timestamp: new Date(),
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setIsStreaming(true);
setStreamingText('');
let streamAccumulator = '';
try {
const MAX_TOOL_ROUNDS = 10;
let apiMessages = buildApiMessages(newMessages);
let currentMessages = newMessages;
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
if (controller.signal.aborted) break;
// Stream the response — text chunks update streamingText in real-time
streamAccumulator = '';
const response = await sendStreamingMessage(
apiMessages,
selectedModel,
(chunk) => {
streamAccumulator += chunk;
setStreamingText(streamAccumulator);
},
{ tools: TOOLS },
controller.signal,
);
// Stream finished — clear the streaming text and update token usage
setStreamingText('');
if (response.usage.prompt_tokens > 0) {
setLastPromptTokens(response.usage.prompt_tokens);
}
const choice = response.choices[0];
const assistantMsg = choice.message;
if (!assistantMsg.tool_calls || assistantMsg.tool_calls.length === 0) {
const content = typeof assistantMsg.content === 'string' ? assistantMsg.content : '';
const assistantMessage: DisplayMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content,
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
break;
}
// Execute tool calls
let nostrEvent: NostrEvent | undefined;
const toolCalls: ToolCall[] = [];
for (const tc of assistantMsg.tool_calls) {
if (controller.signal.aborted) break;
let args: Record<string, unknown>;
try {
args = JSON.parse(tc.function.arguments);
} catch (parseErr) {
console.error(
`[AI tool call] Failed to parse arguments for "${tc.function.name}":`,
parseErr instanceof Error ? parseErr.message : parseErr,
'\nRaw arguments string:',
JSON.stringify(tc.function.arguments),
);
toolCalls.push({
id: tc.id,
name: tc.function.name,
arguments: {},
result: JSON.stringify({ error: `Invalid tool call arguments: could not parse JSON for ${tc.function.name}` }),
});
continue;
}
const execResult = await executeToolCall(tc.function.name, args);
if (execResult.nostrEvent) {
nostrEvent = execResult.nostrEvent;
}
toolCalls.push({
id: tc.id,
name: tc.function.name,
arguments: args,
result: execResult.result,
});
}
if (controller.signal.aborted) break;
// Add assistant message with tool calls to display
const toolMsg: DisplayMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: assistantMsg.content || '',
timestamp: new Date(),
toolCalls,
nostrEvent,
};
// Add tool result display messages (hidden in UI, used by buildApiMessages)
const toolResultMsgs: DisplayMessage[] = toolCalls.map((tc) => ({
id: crypto.randomUUID(),
role: 'tool_result' as const,
content: tc.result ?? '',
toolCallId: tc.id,
timestamp: new Date(),
}));
currentMessages = [...currentMessages, toolMsg, ...toolResultMsgs];
setMessages(currentMessages);
// Rebuild API messages
apiMessages = buildApiMessages(currentMessages);
}
} catch (err) {
// User-initiated stop — preserve whatever was streamed so far
if (controller.signal.aborted) {
if (streamAccumulator.trim()) {
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: streamAccumulator,
timestamp: new Date(),
}]);
}
return;
}
// Surface unexpected errors (e.g. buildApiMessages failure, loop bookkeeping)
// so the user gets feedback instead of streaming silently stopping.
// API-level errors are already surfaced via apiError from useShakespeare.
const errorText = err instanceof Error ? err.message : 'An unexpected error occurred.';
setMessages((prev) => [...prev, {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: `Something went wrong: ${errorText}`,
timestamp: new Date(),
noticeVariant: 'error',
}]);
} finally {
abortRef.current = null;
setIsStreaming(false);
setStreamingText('');
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- handleClear is stable (only depends on storageKey+clearError which are already covered)
}, [input, selectedModel, isStreaming, messages, capacity, buildApiMessages, sendStreamingMessage, executeToolCall, clearError]);
// Stop an in-flight generation
const handleStop = useCallback(() => {
abortRef.current?.abort();
}, []);
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}, [handleSend]);
// Clear conversation
const handleClear = useCallback(() => {
setMessages([]);
if (storageKey) localStorage.removeItem(storageKey);
setLastPromptTokens(0);
setStorageBytes(0);
clearError();
}, [storageKey, clearError]);
return {
// State
messages,
input,
setInput,
isStreaming,
streamingText,
selectedModel,
apiLoading,
apiError,
messagesEndRef,
// Capacity
capacity,
lastPromptTokens,
contextWindow,
storageBytes,
maxStorageBytes: MAX_STORAGE_BYTES,
// Actions
handleSend,
handleStop,
handleKeyDown,
handleClear,
getCredits: getCreditsBalance,
};
}
+86
View File
@@ -0,0 +1,86 @@
import { useCallback, useMemo } from 'react';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
import { truncateToolResult } from '@/lib/tools/truncateToolResult';
import { toolToOpenAI } from '@/lib/tools/toolToOpenAI';
import { SearchUsersTool } from '@/lib/tools/SearchUsersTool';
import { SearchFollowPacksTool } from '@/lib/tools/SearchFollowPacksTool';
import { FetchPageTool } from '@/lib/tools/FetchPageTool';
import { FetchEventTool } from '@/lib/tools/FetchEventTool';
import { GetFeedTool } from '@/lib/tools/GetFeedTool';
import type { Tool, ToolContext, ToolResult } from '@/lib/tools/Tool';
// ─── Tool Registry ───
/** All registered tools, keyed by name. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TOOL_REGISTRY: Record<string, Tool<any>> = {
search_users: SearchUsersTool,
search_follow_packs: SearchFollowPacksTool,
fetch_page: FetchPageTool,
fetch_event: FetchEventTool,
get_feed: GetFeedTool,
};
/** OpenAI-formatted tool definitions derived from the registry. */
export const TOOLS = Object.entries(TOOL_REGISTRY).map(
([name, tool]) => toolToOpenAI(name, tool),
);
/** Short human-readable summaries for each tool (name → first sentence of description). */
export const TOOL_SUMMARIES: { name: string; summary: string }[] = Object.entries(TOOL_REGISTRY).map(
([name, tool]) => ({
name,
summary: tool.description.split(/[.\n]/)[0].trim(),
}),
);
// ─── Hook ───
export function useAIChatTools() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { savedFeeds } = useSavedFeeds();
/** Build a ToolContext from current hook values. */
const buildContext = useCallback((): ToolContext => ({
nostr,
user: user ? { pubkey: user.pubkey } : undefined,
config: {
corsProxy: config.corsProxy,
},
savedFeeds,
}), [nostr, user, config, savedFeeds]);
const executeToolCall = useCallback(async (name: string, rawArgs: Record<string, unknown>): Promise<ToolResult> => {
const tool = TOOL_REGISTRY[name];
if (!tool) {
return { result: JSON.stringify({ error: `Unknown tool: ${name}` }) };
}
try {
// Validate and parse args through the tool's Zod schema.
const args = tool.inputSchema.parse(rawArgs);
const ctx = buildContext();
const toolResult = await tool.execute(args, ctx);
return {
result: truncateToolResult(toolResult.result),
nostrEvent: toolResult.nostrEvent,
};
} catch (err) {
return { result: JSON.stringify({ error: `Tool "${name}" failed: ${err instanceof Error ? err.message : 'Unknown error'}` }) };
}
}, [buildContext]);
// Expose savedFeeds for the system prompt (saved feed labels)
const savedFeedsMemo = useMemo(() => savedFeeds, [savedFeeds]);
return { executeToolCall, savedFeeds: savedFeedsMemo };
}
+8
View File
@@ -116,6 +116,14 @@ 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 */
aiSystemPrompt?: string;
}
/**
+1
View File
@@ -21,6 +21,7 @@ const DEFAULT_SIDEBAR_ORDER: string[] = [
'feed',
'notifications',
'communities',
'agent',
'profile',
'settings',
];
+191 -60
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. */
@@ -16,15 +17,25 @@ export class RateLimitError extends Error {
}
// Types for Shakespeare API (compatible with OpenAI ChatCompletionMessageParam)
export interface ToolCallFunction {
id: string;
type: 'function';
function: { name: string; arguments: string };
}
export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string | Array<{
role: 'user' | 'assistant' | 'system' | 'tool';
content: string | null | Array<{
type: 'text' | 'image_url';
text?: string;
image_url?: {
url: string;
};
}>;
/** Present on assistant messages that invoke tools. */
tool_calls?: ToolCallFunction[];
/** Present on tool result messages — must match a tool_calls[].id from the preceding assistant message. */
tool_call_id?: string;
}
/** Tool function definition for chat completions. */
@@ -99,6 +110,15 @@ export interface ModelsResponse {
data: Model[];
}
/** Sort models by total cost (prompt + completion), cheapest first. */
export function sortModelsByCost(models: Model[]): Model[] {
return [...models].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;
});
}
export interface CreditsResponse {
object: string;
amount: number;
@@ -106,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 ───
@@ -243,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;
@@ -294,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),
@@ -323,16 +374,21 @@ export function useShakespeare() {
} finally {
setIsLoading(false);
}
}, [user]);
}, [user, apiUrl, buildAuthHeader]);
// ─── Chat completions (streaming) ───
//
// Streams text via `onChunk` and returns the fully-assembled response
// (including any tool_calls) so callers can use the same tool-loop
// logic as the non-streaming path.
const sendStreamingMessage = useCallback(async (
messages: ChatMessage[],
modelId: string,
onChunk: (chunk: string) => void,
options?: Partial<ChatCompletionRequest>
): Promise<void> => {
options?: Partial<ChatCompletionRequest>,
signal?: AbortSignal,
): Promise<ChatCompletionResponse> => {
if (!user) {
throw new Error('User must be logged in to use AI features');
}
@@ -350,19 +406,16 @@ 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),
signal,
});
await handleAPIError(response);
@@ -374,34 +427,121 @@ export function useShakespeare() {
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Accumulate the full response from stream deltas
let content = '';
let finishReason = 'stop';
let responseId = '';
let responseModel = model;
let usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
const toolCalls: Map<number, ToolCallFunction> = new Map();
/** Process a single parsed SSE data object, accumulating deltas. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const processDelta = (parsed: any) => {
const delta = parsed.choices?.[0]?.delta;
if (!delta) return;
if (parsed.id) responseId = parsed.id;
if (parsed.model) responseModel = parsed.model;
if (parsed.choices?.[0]?.finish_reason) {
finishReason = parsed.choices[0].finish_reason;
}
if (delta.content) {
content += delta.content;
onChunk(delta.content);
}
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
const existing = toolCalls.get(idx);
if (!existing) {
toolCalls.set(idx, {
id: tc.id ?? '',
type: 'function',
function: {
name: tc.function?.name ?? '',
arguments: tc.function?.arguments ?? '',
},
});
} else {
if (tc.id) existing.id = tc.id;
if (tc.function?.name) existing.function.name += tc.function.name;
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
}
}
}
};
/** Try to parse and process a single SSE data payload string. */
const processSSEData = (data: string) => {
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
// Capture usage from the final chunk (which has choices: [] and real token counts)
if (parsed.usage?.prompt_tokens) {
usage = {
prompt_tokens: parsed.usage.prompt_tokens,
completion_tokens: parsed.usage.completion_tokens ?? 0,
total_tokens: parsed.usage.total_tokens ?? 0,
};
}
processDelta(parsed);
} catch {
// Malformed JSON — nothing to do
}
};
// Buffer for incomplete SSE lines that span across reader.read() boundaries.
let lineBuffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
const chunk = decoder.decode(value, { stream: true });
const combined = lineBuffer + chunk;
const segments = combined.split('\n');
// The last segment may be incomplete — save it for the next iteration
lineBuffer = segments.pop() ?? '';
for (const line of lines) {
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;
if (content) {
onChunk(content);
}
} catch {
// Ignore parsing errors for incomplete chunks
}
}
for (const line of segments) {
if (!line.startsWith('data: ')) continue;
processSSEData(line.slice(6));
}
}
// Process any remaining buffered line after the stream ends
if (lineBuffer.startsWith('data: ')) {
processSSEData(lineBuffer.slice(6));
}
} finally {
reader.releaseLock();
}
// Assemble the full response in the same shape as the non-streaming endpoint
const assembledToolCalls = toolCalls.size > 0
? Array.from(toolCalls.values())
: undefined;
return {
id: responseId,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: responseModel,
choices: [{
index: 0,
message: {
role: 'assistant',
content: content || undefined,
...(assembledToolCalls ? { tool_calls: assembledToolCalls } : {}),
},
finish_reason: finishReason,
}],
usage,
};
} catch (err) {
if (err instanceof RateLimitError) {
setRetryAfter(err.retryAfter);
@@ -414,7 +554,7 @@ export function useShakespeare() {
} finally {
setIsLoading(false);
}
}, [user]);
}, [user, apiUrl, buildAuthHeader]);
// ─── Credit balance (Shakespeare AI only) ───
@@ -424,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);
@@ -441,7 +576,7 @@ export function useShakespeare() {
} catch (err) {
throw new Error(formatError(err));
}
}, [user]);
}, [user, apiUrl, buildAuthHeader]);
// ─── Available models (merged from both providers) ───
@@ -454,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;
@@ -481,7 +612,7 @@ export function useShakespeare() {
} finally {
setIsLoading(false);
}
}, [user]);
}, [user, apiUrl, buildAuthHeader]);
return {
// State
+145
View File
@@ -0,0 +1,145 @@
import type { ChatMessage } from '@/hooks/useShakespeare';
/** Minimal profile fields injected into the system prompt so the AI knows who it's talking to. */
export interface UserIdentity {
/** The user's npub (bech32 public key). */
npub: string;
/** The user's hex public key. */
pubkey: string;
/** Display name from kind 0 metadata. */
displayName?: string;
/** NIP-05 identifier (e.g. "alice@example.com"). */
nip05?: string;
/** Short bio / about text. */
about?: string;
}
/**
* Build the AI chat system prompt.
*
* `{{SAVED_FEEDS}}` is replaced with a list of the user's saved feed
* labels so the model knows which named feeds are available.
*
* `{{USER_IDENTITY}}` is replaced with a block describing the logged-in
* user so the AI can answer questions like "who am I?" or "show me my
* recent posts" without extra round-trips.
*
* If `customPrompt` is provided (from Settings), it replaces
* the entire base template. Placeholders are substituted in both cases.
*/
export function buildSystemPrompt(
customPrompt?: string,
savedFeedLabels?: string[],
userIdentity?: UserIdentity,
): ChatMessage {
const savedFeedsText = savedFeedLabels && savedFeedLabels.length > 0
? `**Saved feeds the user has created:** ${savedFeedLabels.map((l) => `"${l}"`).join(', ')}`
: '';
const userIdentityText = userIdentity ? buildUserIdentityBlock(userIdentity) : '';
const template = customPrompt || DEFAULT_TEMPLATE;
const resolved = template
.replace(/\{\{SAVED_FEEDS\}\}/g, savedFeedsText)
.replace(/\{\{USER_IDENTITY\}\}/g, userIdentityText);
return { role: 'system', content: resolved };
}
/** Build a markdown block describing the current user. */
function buildUserIdentityBlock(identity: UserIdentity): string {
const lines: string[] = [
'# Current User',
`- **npub:** ${identity.npub}`,
`- **hex pubkey:** ${identity.pubkey}`,
];
if (identity.displayName) {
lines.push(`- **name:** ${identity.displayName}`);
}
if (identity.nip05) {
lines.push(`- **NIP-05:** ${identity.nip05}`);
}
if (identity.about) {
lines.push(`- **about:** ${identity.about}`);
}
lines.push('');
lines.push('Use this identity when the user asks "who am I?", "what\'s my npub?", or similar. To fetch their full profile, use `fetch_event` with their npub. To see their recent posts, use `get_feed` with `authors: ["$me"]`.');
return lines.join('\n');
}
// ─── Default template ─────────────────────────────────────────────────────────
const DEFAULT_TEMPLATE = `You are an AI agent integrated into Agora, a Nostr social client focused on activism, community organizing, and civic engagement.
You are knowledgeable, direct, and focused on helping the user navigate the Nostr network effectively. Provide clear, factual information. Avoid unnecessary filler or pleasantries — respect the user's time.
{{USER_IDENTITY}}
# Important Rules
- **Never recommend other Nostr clients, apps, or external tools.** You are part of Agora — if you can't find something, say so honestly without suggesting the user try another client. Everything the user needs should be achievable through your tools or through Agora's interface.
# Tools
## search_users
Resolves names to Nostr pubkeys. When a user mentions a specific person by name (e.g. "Derek Ross", "fiatjaf"), use search_users to find their pubkey. The search checks the user's contacts first, then does a broader relay search. If multiple matches are found, ask the user to confirm which one they meant. Use the hex pubkey from the results in get_feed authors.
## search_follow_packs
Finds curated follow packs (starter packs). Follow packs are lists of people grouped by theme or community (e.g. "Bitcoin Developers", "Nostr OGs"). When a user mentions a follow pack or starter pack by name, use search_follow_packs to look it up. The tool returns the pack's title, description, and all member pubkeys. Use those pubkeys in get_feed authors to read posts from the pack's members.
## fetch_page
Fetches a URL and extracts text content and image URLs from the HTML. Use when a user provides a link and you need to discover what's on the page.
## fetch_event
Fetches a Nostr event by its NIP-19 identifier. Use this when the user shares a Nostr link or identifier and you need to read its content.
**Supported identifiers:**
- npub1... -> fetches the user's kind 0 profile
- note1... -> fetches a specific event by ID
- nevent1... -> fetches an event (may include relay hints)
- naddr1... -> fetches an addressable event by kind+author+d-tag
- nprofile1... -> fetches a user profile with relay hints
Returns the full event JSON. For profiles (kind 0), the content field contains JSON metadata (name, about, picture, etc.).
## get_feed
Reads posts from a feed and returns their content. Use this when the user asks what's going on, wants a summary of recent activity, or asks about a specific topic, person, or country.
**Built-in feeds:**
- "follows" — posts from people the user follows (requires login)
- "global" — recent posts from everyone
{{SAVED_FEEDS}}
**Country feeds:**
When the user asks about a country (e.g. "what's going on in Venezuela?", "anything happening in Japan?"), use the \`country\` parameter with the ISO 3166-1 alpha-2 code (e.g. "VE", "JP"). This queries NIP-73 geographic comments (kind 1111) for that country. You do NOT need to know the country code in advance — map the country name to its 2-letter code (e.g. Venezuela = VE, Brazil = BR, United States = US, Japan = JP, Germany = DE).
**Ad-hoc queries:**
When no existing feed matches, build a query using:
- kinds: event kinds (default [1] for text notes; use [20] for photos, [30023] for articles, etc.)
- authors: "$me", "$contacts", or hex pubkeys from search_users
- search: NIP-50 full-text search
- hashtag: filter by hashtag
**Time window:**
- hours: how far back to look (default 12). Use 1-6 for "what's happening right now", 12-24 for "today", 168 for "this week"
- Set hours to 0 to disable the time window entirely — useful for "what was X's latest post?" or "show me their most recent note" where the post could be from any time
**Workflow:**
1. Determine the best feed source: named feed, country code, or ad-hoc query
2. Call get_feed with appropriate parameters
3. Summarize the results — highlight key topics, interesting conversations, and notable posts
4. Be conversational; don't just list posts, synthesize what's going on
**Examples:**
- "what are my friends talking about?" -> get_feed(feed_name: "follows")
- "what's going on in Venezuela?" -> get_feed(country: "VE")
- "anything about bitcoin today?" -> get_feed(search: "bitcoin", hours: 24)
- "what's #nostr been like this week?" -> get_feed(hashtag: "nostr", hours: 168)
- "what was fiatjaf's latest post?" -> search_users("fiatjaf") then get_feed(authors: ["<hex>"], hours: 0, limit: 1)`;
/** The raw default template with placeholders (for display in settings). */
export const DEFAULT_SYSTEM_PROMPT_TEMPLATE = DEFAULT_TEMPLATE;
+30
View File
@@ -0,0 +1,30 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { ToolResult } from '@/lib/tools/Tool';
// Re-export ToolResult so existing consumers can import from here.
export type { ToolResult };
// ─── Message Types ───
export interface DisplayMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool_result';
content: string;
timestamp: Date;
toolCalls?: ToolCall[];
/** For tool_result messages: the tool_call_id this result corresponds to. */
toolCallId?: string;
/** A Nostr event published by a tool, rendered inline in the chat. */
nostrEvent?: NostrEvent;
/** When set, the message is a client-generated notice rather than model output. Determines visual styling:
* - `'info'`: muted informational notice (e.g. /tools listing, unknown command)
* - `'error'`: destructive-styled warning (e.g. capacity exhausted, unexpected error) */
noticeVariant?: 'info' | 'error';
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
result?: string;
}
+8
View File
@@ -264,6 +264,10 @@ 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(),
});
// ─── BuildConfigSchema (build-time app config) ───────────────────────
@@ -372,4 +376,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(),
});
+2
View File
@@ -4,6 +4,7 @@ import {
BookOpen,
Bell,
Bookmark,
Bot,
CalendarDays,
Camera,
Clapperboard,
@@ -151,6 +152,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
{ id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText },
{ id: "help", label: "Help", path: "/help", icon: LifeBuoy },
{ id: "agent", label: "Agent", path: "/agent", icon: Bot },
// Content types
{ id: "actions", label: "Actions", path: "/actions", icon: Zap },
{ id: "events", label: "Events", path: "/events", icon: CalendarDays },
+3 -3
View File
@@ -92,14 +92,14 @@ export const WIDGET_DEFINITIONS: WidgetDefinition[] = [
},
{
id: 'ai-chat',
label: 'AI Chat',
description: 'Chat with Shakespeare AI',
label: 'Agent',
description: 'Chat with your AI agent',
icon: Bot,
defaultHeight: 300,
minHeight: 200,
maxHeight: 700,
category: 'personal',
href: '/ai-chat',
href: '/agent',
fillHeight: true,
},
+97
View File
@@ -0,0 +1,97 @@
import { z } from 'zod';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
identifier: z.string().describe('NIP-19 identifier (npub1..., note1..., nevent1..., naddr1..., nprofile1...).'),
});
type Params = z.infer<typeof inputSchema>;
export const FetchEventTool: Tool<Params> = {
description: `Fetch a Nostr event by its NIP-19 identifier. Supports npub (fetches kind 0 profile), nprofile, note (fetches event by ID), nevent, and naddr (fetches addressable event by kind+author+d-tag).
Use this when the user shares a Nostr identifier and you need to read its content — for example, to see what a note says, look up a user's profile, or read an article.
Returns the full event JSON including kind, content, tags, pubkey, and timestamp.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const identifier = args.identifier.trim();
if (!identifier) {
return { result: JSON.stringify({ error: 'A NIP-19 identifier is required.' }) };
}
let decoded: nip19.DecodedResult;
try {
decoded = nip19.decode(identifier);
} catch {
return { result: JSON.stringify({ error: `Invalid NIP-19 identifier: ${identifier}` }) };
}
if (decoded.type === 'nsec') {
return { result: JSON.stringify({ error: 'nsec identifiers are not supported for security reasons.' }) };
}
let event: NostrEvent | undefined;
switch (decoded.type) {
case 'npub': {
const events = await ctx.nostr.query(
[{ kinds: [0], authors: [decoded.data], limit: 1 }],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
case 'nprofile': {
const events = await ctx.nostr.query(
[{ kinds: [0], authors: [decoded.data.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
case 'note': {
const events = await ctx.nostr.query(
[{ ids: [decoded.data] }],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
case 'nevent': {
const events = await ctx.nostr.query(
[{ ids: [decoded.data.id] }],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
case 'naddr': {
const events = await ctx.nostr.query(
[{
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
'#d': [decoded.data.identifier],
limit: 1,
}],
{ signal: AbortSignal.timeout(8000) },
);
event = events[0];
break;
}
default:
return { result: JSON.stringify({ error: `Unsupported identifier type: ${(decoded as { type: string }).type}` }) };
}
if (!event) {
return { result: JSON.stringify({ error: 'No event found for the provided identifier.' }) };
}
return { result: JSON.stringify(event) };
},
};
+76
View File
@@ -0,0 +1,76 @@
import { z } from 'zod';
import { proxyUrl } from '@/lib/proxyUrl';
import { sanitizeToolFetchUrl } from './sanitizeToolFetchUrl';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
url: z.string().describe('The URL to fetch (e.g. "https://example.com/page").'),
});
type Params = z.infer<typeof inputSchema>;
export const FetchPageTool: Tool<Params> = {
description: `Fetch a web page and extract its content. Returns the page text and a list of image URLs found on the page. Use this when the user provides a URL and wants to know what's on the page.
The page is fetched through a CORS proxy so it works in the browser. Images are extracted from <img> tags in the HTML. Relative URLs are resolved to absolute URLs.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const url = sanitizeToolFetchUrl(args.url.trim());
if (!url) {
return { result: JSON.stringify({ error: 'A valid public HTTPS URL is required.' }) };
}
let html: string;
try {
const proxied = proxyUrl({ template: ctx.config.corsProxy, url });
const response = await fetch(proxied, { signal: AbortSignal.timeout(30_000) });
if (!response.ok) {
return { result: JSON.stringify({ error: `Fetch failed: ${response.status} ${response.statusText}` }) };
}
html = await response.text();
} catch (err) {
return { result: JSON.stringify({ error: `Failed to fetch "${url}": ${err instanceof Error ? err.message : 'Unknown error'}` }) };
}
const doc = new DOMParser().parseFromString(html, 'text/html');
const imgs = Array.from(doc.querySelectorAll('img'));
const baseUrl = new URL(url);
const imageUrls: string[] = [];
for (const img of imgs) {
const src = img.getAttribute('src');
if (!src) continue;
try {
const absolute = new URL(src, baseUrl).href;
if (!/\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)(\?.*)?$/i.test(absolute)) continue;
// Filter extracted URLs through the same fetch-safe check so that
// malicious pages cannot inject private-network URLs into the result
// list (which typically flows into downstream tool calls).
if (sanitizeToolFetchUrl(absolute)) {
imageUrls.push(absolute);
}
} catch {
// Skip malformed URLs.
}
}
const uniqueImages = [...new Set(imageUrls)];
const title = doc.querySelector('title')?.textContent?.trim() || '';
return {
result: JSON.stringify({
success: true,
title,
image_count: uniqueImages.length,
images: uniqueImages.slice(0, 100),
text_preview: doc.body?.textContent?.slice(0, 500)?.trim() || '',
}),
};
},
};
+286
View File
@@ -0,0 +1,286 @@
import { z } from 'zod';
import { nip19 } from 'nostr-tools';
import { DITTO_RELAYS } from '@/lib/appRelays';
import { fetchContactPubkeys } from './helpers';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
feed_name: z.string().optional().describe('Name of an existing feed: "follows", "global", or a saved feed label.'),
kinds: z.array(z.number()).optional().describe('Event kind numbers to filter (e.g. [1] for text notes, [20] for photos, [30023] for articles).'),
authors: z.array(z.string()).optional().describe('Author filter. Use "$me" for the logged-in user, "$contacts" for their follow list, or hex pubkeys.'),
search: z.string().optional().describe('Full-text search query (NIP-50).'),
hashtag: z.string().optional().describe('Filter by hashtag (without the # symbol).'),
country: z.string().optional().describe('ISO 3166-1 alpha-2 country code (e.g. "VE", "US", "BR"). Queries NIP-73 geographic comments (kind 1111) for that country.'),
hours: z.number().optional().describe('How many hours back to look. Default 12. Use 0 to disable the time window entirely (useful for "latest post by X" queries where the post could be from any time).'),
limit: z.number().optional().describe('Maximum number of posts to return. Default 50, max 100.'),
});
type Params = z.infer<typeof inputSchema>;
export const GetFeedTool: Tool<Params> = {
description: `Read posts from a feed and return their content. Use this when the user asks what people are talking about, wants a summary of recent activity, or asks about a specific topic or country.
You can reference an existing feed by name or build a query on the fly:
**Named feeds:**
- "follows" — posts from people the user follows
- "global" — recent posts from everyone
- Any saved feed label the user has created (check the system prompt for available feeds)
**Ad-hoc queries:**
- kinds: event kinds to include (default: [1] for text notes)
- authors: who to include — "$me", "$contacts", or hex pubkeys
- search: full-text NIP-50 search query
- hashtag: filter by hashtag (without #)
- country: ISO 3166-1 alpha-2 country code (e.g. "VE", "US") — queries the country activity feed (kind 1111 geographic comments)
**Time window:**
- hours: how far back to look (default: 12). Set to 0 to disable the time window (for "latest post by X" queries)
When the user asks about a country (e.g. "what's going on in Venezuela?"), use the country parameter. When they ask about their friends or follows, use feed_name "follows". When they ask about a topic, use search or hashtag.
After receiving results, summarize the key topics, conversations, and notable posts for the user.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const feedName = (args.feed_name ?? '').trim().toLowerCase();
const country = (args.country ?? '').trim().toUpperCase();
const hours = args.hours === 0 ? 0 : Math.max(1, args.hours ?? 12);
const limit = Math.min(Math.max(1, args.limit ?? 50), 100);
const sinceTimestamp = hours > 0 ? Math.floor(Date.now() / 1000) - hours * 3600 : undefined;
const contactPubkeys = await fetchContactPubkeys(ctx);
const resolved = resolveFilter(args, ctx, { feedName, country, limit, sinceTimestamp, contactPubkeys });
if ('error' in resolved) {
return { result: JSON.stringify(resolved) };
}
const { filter, needsDittoRelay, feedLabel } = resolved;
const store = needsDittoRelay ? ctx.nostr.group(DITTO_RELAYS) : ctx.nostr;
const events = await store.query(
[filter],
{ signal: AbortSignal.timeout(10000) },
);
const sorted = events.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
if (sorted.length === 0) {
return {
result: JSON.stringify({
success: true,
feed: feedLabel,
hours,
post_count: 0,
data: hours > 0
? `No posts found in the "${feedLabel}" feed in the past ${hours} hours.`
: `No posts found in the "${feedLabel}" feed.`,
}),
};
}
const text = await formatEvents(sorted, feedLabel, hours, ctx);
return {
result: JSON.stringify({
success: true,
feed: feedLabel,
hours,
post_count: sorted.length,
data: text,
}),
};
},
};
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Resolve author variables ($me, $contacts) to concrete pubkeys. */
function resolveAuthors(
authors: string[],
userPubkey: string | undefined,
contactPubkeys: string[],
): string[] {
return authors.flatMap((a) => {
if (a === '$me') return userPubkey ? [userPubkey] : [];
if (a === '$contacts') return contactPubkeys;
// Treat $follows the same as $contacts (saved feeds may use this form)
if (a === '$follows') return contactPubkeys;
return [a];
});
}
interface ResolveContext {
feedName: string;
country: string;
limit: number;
sinceTimestamp: number | undefined;
contactPubkeys: string[];
}
type ResolvedFilter =
| { filter: NostrFilter; needsDittoRelay: boolean; feedLabel: string }
| { error: string; available_feeds?: string };
/** Build a base filter with optional `since`. */
function baseFilter(sinceTimestamp: number | undefined, limit: number): NostrFilter {
const f: NostrFilter = { limit };
if (sinceTimestamp !== undefined) f.since = sinceTimestamp;
return f;
}
/** Build the Nostr filter from the tool arguments. */
function resolveFilter(
args: Params, ctx: ToolContext,
{ feedName, country, limit, sinceTimestamp, contactPubkeys }: ResolveContext,
): ResolvedFilter {
// Country query — NIP-73 geographic comments
if (country) {
// Validate as ISO 3166-1 alpha-2 (2 uppercase letters)
if (!/^[A-Z]{2}$/.test(country)) {
return { error: `Invalid country code "${country}". Use a 2-letter ISO 3166-1 alpha-2 code (e.g. "US", "VE", "JP").` };
}
return {
filter: { ...baseFilter(sinceTimestamp, limit), kinds: [1111], '#I': [`iso3166:${country}`] } as NostrFilter,
needsDittoRelay: false,
feedLabel: `country: ${country}`,
};
}
// Named feed: follows
if (feedName === 'follows') {
if (!ctx.user) return { error: 'Must be logged in to read the follows feed.' };
const authors = [ctx.user.pubkey, ...contactPubkeys];
if (authors.length <= 1) return { error: 'The user is not following anyone yet.' };
return { filter: { ...baseFilter(sinceTimestamp, limit), kinds: [1], authors }, needsDittoRelay: false, feedLabel: 'follows' };
}
// Named feed: global
if (feedName === 'global') {
return { filter: { ...baseFilter(sinceTimestamp, limit), kinds: [1] }, needsDittoRelay: false, feedLabel: 'global' };
}
// Named feed: user saved feed
if (feedName) {
const match = ctx.savedFeeds.find((f) => f.label.toLowerCase() === feedName);
if (!match) {
const available = ctx.savedFeeds.map((f) => f.label).join(', ');
return {
error: `No saved feed named "${args.feed_name}".`,
available_feeds: available ? `follows, global, ${available}` : 'follows, global',
};
}
try {
const sf = match.filter as Record<string, unknown>;
const filter: NostrFilter = baseFilter(sinceTimestamp, limit);
let needsDittoRelay = false;
if (Array.isArray(sf.kinds)) filter.kinds = sf.kinds as number[];
if (typeof sf.search === 'string') {
filter.search = sf.search;
// NIP-50 extensions (sort:, protocol:, etc.) require Ditto relay
if (/sort:|protocol:|media:|language:/.test(sf.search)) needsDittoRelay = true;
}
if (Array.isArray(sf.authors)) {
const resolved = resolveAuthors(sf.authors as string[], ctx.user?.pubkey, contactPubkeys);
if (resolved.length > 0) filter.authors = resolved;
}
// Carry over any tag filters (e.g. #t, #p)
for (const [key, value] of Object.entries(sf)) {
if (key.startsWith('#') && Array.isArray(value)) {
(filter as Record<string, unknown>)[key] = value;
}
}
return { filter, needsDittoRelay, feedLabel: match.label };
} catch (err) {
return { error: `Failed to resolve saved feed "${match.label}": ${err instanceof Error ? err.message : 'Unknown error'}` };
}
}
// Ad-hoc query — build filter directly from tool args
const filter: NostrFilter = baseFilter(sinceTimestamp, limit);
let needsDittoRelay = false;
filter.kinds = args.kinds ?? [1];
if (args.authors) {
const resolved = resolveAuthors(args.authors, ctx.user?.pubkey, contactPubkeys);
if (resolved.length > 0) filter.authors = resolved;
}
if (args.search) {
filter.search = args.search;
if (/sort:|protocol:|media:|language:/.test(args.search)) needsDittoRelay = true;
}
if (args.hashtag?.trim()) {
(filter as Record<string, unknown>)['#t'] = [args.hashtag.trim().toLowerCase()];
}
const feedLabel = args.search ? `search: ${args.search}` : args.hashtag ? `#${args.hashtag}` : 'ad-hoc';
return { filter, needsDittoRelay, feedLabel };
}
/** Format events into a markdown summary with author display names. */
async function formatEvents(
sorted: NostrEvent[], feedLabel: string, hours: number, ctx: ToolContext,
): Promise<string> {
const uniquePubkeys = [...new Set(sorted.map((e) => e.pubkey))];
const profileMap = new Map<string, { name?: string; display_name?: string; nip05?: string }>();
try {
const profiles = await ctx.nostr.query(
[{ kinds: [0], authors: uniquePubkeys }],
{ signal: AbortSignal.timeout(5000) },
);
for (const p of profiles) {
try {
const meta = JSON.parse(p.content);
profileMap.set(p.pubkey, {
name: meta.name,
display_name: meta.display_name,
nip05: meta.nip05,
});
} catch {
// Skip invalid metadata
}
}
} catch {
// Profiles unavailable — continue with pubkey-only display
}
const formatTimeAgo = (ts: number): string => {
const seconds = Math.floor(Date.now() / 1000) - ts;
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
};
let text = hours > 0
? `## ${feedLabel} — past ${hours}h (${sorted.length} posts)\n\n`
: `## ${feedLabel} — all time (${sorted.length} posts)\n\n`;
for (const event of sorted) {
const profile = profileMap.get(event.pubkey);
const displayName = profile?.display_name || profile?.name || nip19.npubEncode(event.pubkey).slice(0, 16) + '...';
const hashtags = event.tags
.filter(([t]) => t === 't')
.map(([, v]) => `#${v}`)
.join(' ');
text += `**${displayName}** (${formatTimeAgo(event.created_at)}):\n`;
text += `${event.content.slice(0, 500)}${event.content.length > 500 ? '...' : ''}\n`;
if (hashtags) text += `Tags: ${hashtags}\n`;
text += '\n---\n\n';
}
return text;
}
+98
View File
@@ -0,0 +1,98 @@
import { z } from 'zod';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
query: z.string().describe('The follow pack title to search for (e.g. "bitcoin developers", "nostr OGs").'),
});
type Params = z.infer<typeof inputSchema>;
export const SearchFollowPacksTool: Tool<Params> = {
description: `Search for Nostr follow packs by title. Follow packs (kind 39089) are curated lists of people. Use this when the user mentions a follow pack or starter pack by name — for example, "bitcoin developers pack" or "nostr OGs".
Returns matching packs with their title, description, member count, and the hex pubkeys of all members. Use the returned pubkeys in get_feed authors to read posts from the pack's members.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const query = args.query.trim().toLowerCase();
if (!query) {
return { result: JSON.stringify({ error: 'A search query is required.' }) };
}
const filters: { kinds: number[]; limit: number; search?: string; authors?: string[] }[] = [
{ kinds: [39089], limit: 200 },
];
filters.push({ kinds: [39089], search: args.query, limit: 50 });
if (ctx.user) {
filters.push({ kinds: [39089], authors: [ctx.user.pubkey], limit: 50 });
}
const events = await ctx.nostr.query(
filters,
{ signal: AbortSignal.timeout(10000) },
);
// Deduplicate by event id
const seen = new Set<string>();
const uniqueEvents = events.filter((e) => {
if (seen.has(e.id)) return false;
seen.add(e.id);
return true;
});
interface PackMatch {
title: string;
description?: string;
member_count: number;
pubkeys: string[];
author: string;
}
const matches: PackMatch[] = [];
for (const event of uniqueEvents) {
const title = (event.tags.find(([t]) => t === 'title')?.[1]
?? event.tags.find(([t]) => t === 'name')?.[1]
?? '').trim();
if (!title) continue;
if (!title.toLowerCase().includes(query)) continue;
const description = event.tags.find(([t]) => t === 'description')?.[1]
?? event.tags.find(([t]) => t === 'summary')?.[1];
const pubkeys = event.tags
.filter(([t]) => t === 'p')
.map(([, pk]) => pk);
if (pubkeys.length === 0) continue;
matches.push({
title,
description: description ? description.slice(0, 150) : undefined,
member_count: pubkeys.length,
pubkeys,
author: event.pubkey,
});
}
matches.sort((a, b) => {
const aExact = a.title.toLowerCase() === query ? 1 : 0;
const bExact = b.title.toLowerCase() === query ? 1 : 0;
if (aExact !== bExact) return bExact - aExact;
return b.member_count - a.member_count;
});
const results = matches.slice(0, 5);
if (results.length === 0) {
return { result: JSON.stringify({ matches: [], message: `No follow packs found matching "${args.query}".` }) };
}
return { result: JSON.stringify({ matches: results }) };
},
};
+109
View File
@@ -0,0 +1,109 @@
import { z } from 'zod';
import { fetchContactPubkeys } from './helpers';
import type { Tool, ToolResult, ToolContext } from './Tool';
const inputSchema = z.object({
query: z.string().describe('The name or display name to search for (e.g. "Derek Ross", "fiatjaf", "jb55").'),
});
type Params = z.infer<typeof inputSchema>;
export const SearchUsersTool: Tool<Params> = {
description: `Search for Nostr users by name. Returns matching profiles with their pubkeys, display names, NIP-05 identifiers, and bios. Use this when you need to resolve a person's name to their Nostr pubkey — for example, when looking up a specific author's posts.
The search checks the user's follow list first (contacts), then falls back to a broader relay search. Results from contacts are prioritized since they're more likely to be the person the user means.`,
inputSchema,
async execute(args: Params, ctx: ToolContext): Promise<ToolResult> {
const query = args.query.trim().toLowerCase();
if (!query) {
return { result: JSON.stringify({ error: 'A search query is required.' }) };
}
interface ProfileMatch {
pubkey: string;
name?: string;
display_name?: string;
nip05?: string;
about?: string;
source: 'contacts' | 'relay';
}
const matches: ProfileMatch[] = [];
// Phase 1: Search user's contacts
const contactPubkeys = await fetchContactPubkeys(ctx);
if (contactPubkeys.length > 0) {
const metaEvents = await ctx.nostr.query(
[{ kinds: [0], authors: contactPubkeys }],
{ signal: AbortSignal.timeout(8000) },
);
for (const event of metaEvents) {
if (matches.length >= 5) break;
try {
const meta = JSON.parse(event.content);
const name = (meta.name || '').toLowerCase();
const displayName = (meta.display_name || '').toLowerCase();
const nip05 = (meta.nip05 || '').toLowerCase();
if (name.includes(query) || displayName.includes(query) || nip05.includes(query)) {
matches.push({
pubkey: event.pubkey,
name: meta.name,
display_name: meta.display_name,
nip05: meta.nip05,
about: meta.about ? meta.about.slice(0, 100) : undefined,
source: 'contacts',
});
}
} catch {
// Skip events with invalid metadata JSON
}
}
}
// Phase 2: NIP-50 relay search (if contacts didn't yield enough results)
if (matches.length < 3) {
try {
const searchEvents = await ctx.nostr.query(
[{ kinds: [0], search: args.query, limit: 10 }],
{ signal: AbortSignal.timeout(8000) },
);
const existingPubkeys = new Set(matches.map((m) => m.pubkey));
for (const event of searchEvents) {
if (existingPubkeys.has(event.pubkey)) continue;
try {
const meta = JSON.parse(event.content);
matches.push({
pubkey: event.pubkey,
name: meta.name,
display_name: meta.display_name,
nip05: meta.nip05,
about: meta.about ? meta.about.slice(0, 100) : undefined,
source: 'relay',
});
} catch {
// Skip events with invalid metadata JSON
}
}
} catch {
// NIP-50 search may not be supported by all relays
}
}
const results = matches.slice(0, 5);
if (results.length === 0) {
return { result: JSON.stringify({ matches: [], message: `No users found matching "${args.query}". The user may need to provide an npub or NIP-05 address.` }) };
}
return { result: JSON.stringify({ matches: results }) };
},
};
+52
View File
@@ -0,0 +1,52 @@
import type { z } from 'zod';
import type { NostrEvent } from '@nostrify/nostrify';
/** Result returned by a tool's execute method. */
export interface ToolResult {
/** JSON string returned to the AI as the tool result. */
result: string;
/** A Nostr event published by the tool, rendered inline in the chat. */
nostrEvent?: NostrEvent;
}
/** Tool interface — each tool defines its schema, description, and execution logic. */
export interface Tool<TParams = unknown> {
/** Human-readable description shown to the AI model. */
description: string;
/** Zod schema for validating and parsing tool arguments. */
inputSchema: z.ZodType<TParams>;
/** Execute the tool with validated arguments. */
execute(args: TParams, ctx: ToolContext): Promise<ToolResult>;
}
/**
* Runtime context injected into every tool execution.
*
* Holds the dependencies that come from React hooks (nostr, user, config, etc.)
* so that Tool classes remain plain objects without hook coupling.
*/
export interface ToolContext {
/** Nostr protocol client for querying events. */
nostr: {
query: (filters: import('@nostrify/nostrify').NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]>;
group: (relays: string[]) => {
query: (filters: import('@nostrify/nostrify').NostrFilter[], opts?: { signal?: AbortSignal }) => Promise<NostrEvent[]>;
};
};
/** Currently logged-in user, or undefined if not logged in. */
user?: {
pubkey: string;
};
/** App configuration values. */
config: {
corsProxy: string;
};
/** Saved feed definitions. */
savedFeeds: Array<{
id: string;
label: string;
filter: Record<string, unknown>;
vars: Array<{ name: string; tagName: string; pointer: string }>;
createdAt: number;
}>;
}
+17
View File
@@ -0,0 +1,17 @@
import type { ToolContext } from './Tool';
/** Fetch the logged-in user's contact list pubkeys (kind 3 `p` tags). */
export async function fetchContactPubkeys(ctx: ToolContext): Promise<string[]> {
if (!ctx.user) return [];
try {
const contactEvents = await ctx.nostr.query(
[{ kinds: [3], authors: [ctx.user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(5000) },
);
return contactEvents[0]?.tags
.filter(([t]) => t === 'p')
.map(([, pk]) => pk) ?? [];
} catch {
return [];
}
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Validate that a URL is safe for the app or CORS proxy to fetch.
*
* This is stricter than `sanitizeUrl()` (which only checks for HTTPS and is
* used for rendering event-sourced URLs in the DOM). This function additionally
* rejects URLs targeting localhost, private networks, link-local addresses,
* cloud metadata endpoints, and other non-public destinations.
*
* Returns the normalised `href` when allowed, or `undefined` when blocked.
*
* Limitations (documented for follow-up):
* - Does not resolve DNS, so public hostnames that resolve to private IPs
* are not caught. The CORS proxy must enforce its own server-side checks.
* - Does not follow redirects; a public URL that 3xx-redirects to a private
* target is not blocked here.
*/
export function sanitizeToolFetchUrl(raw: string | undefined | null): string | undefined {
if (!raw) return undefined;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return undefined;
}
if (parsed.protocol !== 'https:') return undefined;
// Reject URL credentials — no legitimate fetch target needs them.
if (parsed.username || parsed.password) return undefined;
const hostname = parsed.hostname;
// Reject localhost variants.
if (
hostname === 'localhost' ||
hostname.endsWith('.localhost')
) {
return undefined;
}
// Reject .local (mDNS) and .internal TLDs.
if (
hostname.endsWith('.local') ||
hostname.endsWith('.internal')
) {
return undefined;
}
// Reject single-label hostnames (no dot) — likely internal names.
if (!hostname.includes('.')) return undefined;
// Check IPv4 literals (after new URL() normalization, always dotted-decimal).
if (isBlockedIpv4(hostname)) return undefined;
// Check IPv6 literals (URL.hostname strips brackets in browsers).
if (hostname.startsWith('[') || hostname.includes(':')) {
const bare = hostname.replace(/^\[|\]$/g, '');
if (isBlockedIpv6(bare)) return undefined;
}
return parsed.href;
}
// ─── IPv4 ─────────────────────────────────────────────────────────────────────
/** Match a dotted-decimal IPv4 address. */
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
function isBlockedIpv4(hostname: string): boolean {
const m = IPV4_RE.exec(hostname);
if (!m) return false;
const a = parseInt(m[1], 10);
const b = parseInt(m[2], 10);
// 0.0.0.0/8
if (a === 0) return true;
// 10.0.0.0/8
if (a === 10) return true;
// 100.64.0.0/10 (Carrier-grade NAT)
if (a === 100 && b >= 64 && b <= 127) return true;
// 127.0.0.0/8 (loopback)
if (a === 127) return true;
// 169.254.0.0/16 (link-local, cloud metadata)
if (a === 169 && b === 254) return true;
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.0.0.0/24 (IETF protocol assignments)
if (a === 192 && b === 0 && parseInt(m[3], 10) === 0) return true;
// 192.168.0.0/16
if (a === 192 && b === 168) return true;
// 198.18.0.0/15 (benchmark)
if (a === 198 && (b === 18 || b === 19)) return true;
// 224.0.0.0/4 (multicast)
if (a >= 224 && a <= 239) return true;
// 240.0.0.0/4 (reserved)
if (a >= 240) return true;
return false;
}
// ─── IPv6 ─────────────────────────────────────────────────────────────────────
function isBlockedIpv6(addr: string): boolean {
const lower = addr.toLowerCase();
// ::1 (loopback)
if (lower === '::1') return true;
// :: (unspecified)
if (lower === '::') return true;
// fc00::/7 — unique local (ULA)
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
// fe80::/10 — link-local
if (lower.startsWith('fe80')) return true;
// ff00::/8 — multicast
if (lower.startsWith('ff')) return true;
// IPv4-mapped IPv6: ::ffff:A.B.C.D or ::ffff:HHHH:HHHH
// URL.hostname normalises these to e.g. "::ffff:7f00:1"
// Check both the hex form and the mixed-notation form.
const ffffPrefix = '::ffff:';
if (lower.startsWith(ffffPrefix)) {
const suffix = lower.slice(ffffPrefix.length);
// Mixed notation: ::ffff:127.0.0.1
if (IPV4_RE.test(suffix)) {
return isBlockedIpv4(suffix);
}
// Hex notation: ::ffff:7f00:1 → convert to IPv4 and check.
const hexParts = suffix.split(':');
if (hexParts.length === 2) {
const hi = parseInt(hexParts[0], 16);
const lo = parseInt(hexParts[1], 16);
if (!isNaN(hi) && !isNaN(lo)) {
const ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
return isBlockedIpv4(ipv4);
}
}
}
return false;
}
+28
View File
@@ -0,0 +1,28 @@
import type { Tool } from './Tool';
/** OpenAI-compatible function-calling tool definition. */
export interface OpenAITool {
type: 'function';
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
/**
* Convert a Tool<T> to OpenAI's function-calling format.
*
* Uses Zod's `.toJSONSchema()` (available since zod v4 / zod-to-json-schema)
* to derive the JSON Schema from the tool's inputSchema.
*/
export function toolToOpenAI<T>(name: string, tool: Tool<T>): OpenAITool {
return {
type: 'function',
function: {
name,
description: tool.description,
parameters: tool.inputSchema.toJSONSchema() as Record<string, unknown>,
},
};
}
+44
View File
@@ -0,0 +1,44 @@
/** Maximum tool result size in bytes (50 KiB). */
const MAX_RESULT_BYTES = 50 * 1024;
/** Maximum tool result size in lines. */
const MAX_RESULT_LINES = 2000;
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', { fatal: false });
/**
* Truncate a tool result string if it exceeds size limits.
*
* Follows the same pattern as Shakespeare: when output is too large,
* replace it with a truncation notice so the AI knows to ask for a
* smaller result (e.g. fewer posts, shorter time window).
*/
export function truncateToolResult(result: string): string {
const encoded = encoder.encode(result);
const lines = result.split('\n').length;
if (encoded.length <= MAX_RESULT_BYTES && lines <= MAX_RESULT_LINES) {
return result;
}
// Truncate to the byte limit using actual byte boundaries.
// TextDecoder with fatal:false gracefully handles a slice that lands
// in the middle of a multi-byte character (replaces the partial char).
let truncated = result;
if (encoded.length > MAX_RESULT_BYTES) {
truncated = decoder.decode(encoded.slice(0, MAX_RESULT_BYTES));
}
if (truncated.split('\n').length > MAX_RESULT_LINES) {
truncated = truncated.split('\n').slice(0, MAX_RESULT_LINES).join('\n');
}
const notice = [
'\n\n---',
`[Output truncated: original was ${encoded.length.toLocaleString()} bytes / ${lines.toLocaleString()} lines, ` +
`limits are ${MAX_RESULT_BYTES.toLocaleString()} bytes / ${MAX_RESULT_LINES.toLocaleString()} lines]`,
'Try requesting fewer results (e.g. smaller limit, shorter time window).',
].join('\n');
return truncated + notice;
}
+391 -513
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -106,6 +106,10 @@ export function TestApp({ children }: TestAppProps) {
imageQuality: 'compressed',
sandboxDomain: 'iframe.diy',
sidebarWidgets: [],
aiBaseURL: 'https://ai.shakespeare.diy/v1',
aiApiKey: '',
aiModel: 'grok-4.1-fast',
aiSystemPrompt: '',
};
return (