Compare commits
5 Commits
main
...
feat/ai-chat
| Author | SHA1 | Date | |
|---|---|---|---|
| 344d3a0049 | |||
| f6f76d08d4 | |||
| 07f0e7d9b9 | |||
| 9671da4267 | |||
| f4875266a6 |
@@ -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: '',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ const DEFAULT_SIDEBAR_ORDER: string[] = [
|
||||
'feed',
|
||||
'notifications',
|
||||
'communities',
|
||||
'agent',
|
||||
'profile',
|
||||
'settings',
|
||||
];
|
||||
|
||||
+191
-60
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
|
||||
@@ -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) };
|
||||
},
|
||||
};
|
||||
@@ -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() || '',
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }) };
|
||||
},
|
||||
};
|
||||
@@ -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 }) };
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user