create-fedi-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +11113 -0
- package/dist/templates/base/.env.example +5 -0
- package/dist/templates/base/app/demo/page.tsx +25 -0
- package/dist/templates/base/app/globals.css +95 -0
- package/dist/templates/base/app/layout.tsx +39 -0
- package/dist/templates/base/app/page.tsx +83 -0
- package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
- package/dist/templates/base/components/providers.tsx +63 -0
- package/dist/templates/base/env.ts +10 -0
- package/dist/templates/base/hooks/useFediInternal.ts +41 -0
- package/dist/templates/base/lib/fedi-types.ts +96 -0
- package/dist/templates/base/lib/fedi.ts +18 -0
- package/dist/templates/base/lib/nostr/hooks.ts +52 -0
- package/dist/templates/base/lib/nostr/index.ts +9 -0
- package/dist/templates/base/lib/nostr/mock.ts +60 -0
- package/dist/templates/base/lib/nostr/provider.tsx +64 -0
- package/dist/templates/base/lib/utils.ts +3 -0
- package/dist/templates/base/lib/webln/hooks.ts +67 -0
- package/dist/templates/base/lib/webln/index.ts +12 -0
- package/dist/templates/base/lib/webln/mock.ts +96 -0
- package/dist/templates/base/lib/webln/provider.tsx +52 -0
- package/dist/templates/base/next.config.ts +3 -0
- package/dist/templates/base/package.json +40 -0
- package/dist/templates/base/proxy.ts +8 -0
- package/dist/templates/base/tsconfig.json +20 -0
- package/dist/templates/base/vitest.config.ts +6 -0
- package/dist/templates/base/vitest.setup.ts +40 -0
- package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
- package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
- package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
- package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-assistant/module.json +48 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
- package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
- package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
- package/dist/templates/modules/ai-chat-gated/module.json +62 -0
- package/dist/templates/modules/ai-rules/.cursorrules +8 -0
- package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
- package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
- package/dist/templates/modules/ai-rules/module.json +20 -0
- package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
- package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
- package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
- package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
- package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
- package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
- package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
- package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
- package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
- package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
- package/dist/templates/modules/database/env.supabase.ts +24 -0
- package/dist/templates/modules/database/env.turso.ts +23 -0
- package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
- package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
- package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
- package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
- package/dist/templates/modules/database/module.json +110 -0
- package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
- package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
- package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
- package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
- package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
- package/dist/templates/modules/ecash-balance/module.json +14 -0
- package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
- package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
- package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
- package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
- package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
- package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
- package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
- package/dist/templates/modules/lnurl/module.json +27 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
- package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
- package/dist/templates/modules/multispend-demo/module.json +18 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
- package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
- package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
- package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
- package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
- package/dist/templates/modules/nostr-feed/module.json +25 -0
- package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
- package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
- package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
- package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
- package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
- package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
- package/dist/templates/modules/nostr-identity/module.json +15 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
- package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
- package/dist/templates/modules/payment-gated-content/module.json +24 -0
- package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
- package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
- package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
- package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
- package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
- package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
- package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
- package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
- package/dist/templates/modules/webln-payments/module.json +17 -0
- package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
- package/package.json +29 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { AssistantDemoClient } from './AssistantDemoClient';
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: 'AI Assistant',
|
|
6
|
+
description:
|
|
7
|
+
'Free AI assistant demo for Fedi Mini Apps. Configure a system prompt, stream replies via the Vercel AI SDK, and copy messages to clipboard.',
|
|
8
|
+
openGraph: {
|
|
9
|
+
title: 'AI Assistant',
|
|
10
|
+
description:
|
|
11
|
+
'Free AI assistant demo for Fedi Mini Apps. Configure a system prompt, stream replies via the Vercel AI SDK, and copy messages to clipboard.',
|
|
12
|
+
},
|
|
13
|
+
twitter: {
|
|
14
|
+
card: 'summary',
|
|
15
|
+
title: 'AI Assistant',
|
|
16
|
+
description:
|
|
17
|
+
'Free AI assistant demo for Fedi Mini Apps. Configure a system prompt, stream replies via the Vercel AI SDK, and copy messages to clipboard.',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default function AssistantDemoPage() {
|
|
22
|
+
return <AssistantDemoClient />;
|
|
23
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import ReactMarkdown from 'react-markdown';
|
|
4
|
+
import type { UIMessage } from 'ai';
|
|
5
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { useAssistant } from './AssistantProvider';
|
|
7
|
+
|
|
8
|
+
function getMessageText(message: UIMessage): string {
|
|
9
|
+
return message.parts
|
|
10
|
+
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
11
|
+
.map((part) => part.text)
|
|
12
|
+
.join('');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface IAssistantMessageProps {
|
|
16
|
+
message: UIMessage;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Single chat bubble with Markdown rendering and copy-to-clipboard.
|
|
21
|
+
*/
|
|
22
|
+
function AssistantMessage({ message }: IAssistantMessageProps) {
|
|
23
|
+
const isUser = message.role === 'user';
|
|
24
|
+
const text = getMessageText(message);
|
|
25
|
+
const [copied, setCopied] = useState(false);
|
|
26
|
+
|
|
27
|
+
async function handleCopy() {
|
|
28
|
+
if (!text) return;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await navigator.clipboard.writeText(text);
|
|
32
|
+
setCopied(true);
|
|
33
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
34
|
+
} catch {
|
|
35
|
+
setCopied(false);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className={`group flex ${isUser ? 'justify-end' : 'justify-start'}`}
|
|
42
|
+
data-role={message.role}
|
|
43
|
+
>
|
|
44
|
+
<div
|
|
45
|
+
className="relative max-w-[85%] rounded-xl px-3 py-2.5 text-sm leading-[1.65]"
|
|
46
|
+
style={{
|
|
47
|
+
background: isUser ? 'var(--color-accent-dim)' : 'var(--color-surface-2)',
|
|
48
|
+
color: 'var(--color-text)',
|
|
49
|
+
borderRadius: 'var(--radius-lg)',
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{text && (
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={handleCopy}
|
|
56
|
+
className="absolute right-2 top-2 rounded px-1.5 py-0.5 text-[10px] font-semibold opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus:opacity-100"
|
|
57
|
+
style={{
|
|
58
|
+
background: 'var(--color-surface-1)',
|
|
59
|
+
color: 'var(--color-text-muted)',
|
|
60
|
+
}}
|
|
61
|
+
aria-label={copied ? 'Copied' : 'Copy message'}
|
|
62
|
+
>
|
|
63
|
+
{copied ? 'Copied' : 'Copy'}
|
|
64
|
+
</button>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{isUser ? (
|
|
68
|
+
<p className="whitespace-pre-wrap pr-10">{text}</p>
|
|
69
|
+
) : (
|
|
70
|
+
<div
|
|
71
|
+
className="prose prose-invert prose-sm max-w-none pr-10 prose-p:my-1 prose-pre:my-2 prose-pre:rounded-md prose-pre:bg-[var(--color-surface-1)] prose-code:text-[var(--color-accent)] prose-code:before:content-none prose-code:after:content-none"
|
|
72
|
+
style={{ color: 'var(--color-text)' }}
|
|
73
|
+
>
|
|
74
|
+
<ReactMarkdown>{text || '…'}</ReactMarkdown>
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface IAssistantProps {
|
|
83
|
+
/** Placeholder shown when the conversation is empty. */
|
|
84
|
+
emptyPlaceholder?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Free AI chat interface with streaming replies, Markdown, and copy-to-clipboard.
|
|
89
|
+
* Must be rendered inside `AssistantProvider`.
|
|
90
|
+
*/
|
|
91
|
+
export function Assistant({
|
|
92
|
+
emptyPlaceholder = 'Ask anything. Replies stream in real time.',
|
|
93
|
+
}: IAssistantProps) {
|
|
94
|
+
const { messages, sendMessage, status, error, setMessages } = useAssistant();
|
|
95
|
+
const [input, setInput] = useState('');
|
|
96
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
97
|
+
|
|
98
|
+
const isStreaming = status === 'streaming' || status === 'submitted';
|
|
99
|
+
const canSend = input.trim().length > 0 && !isStreaming;
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
|
103
|
+
}, [messages, isStreaming]);
|
|
104
|
+
|
|
105
|
+
async function handleSend() {
|
|
106
|
+
const text = input.trim();
|
|
107
|
+
if (!text || isStreaming) return;
|
|
108
|
+
|
|
109
|
+
setInput('');
|
|
110
|
+
await sendMessage({ text });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function handleClear() {
|
|
114
|
+
setMessages([]);
|
|
115
|
+
setInput('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="flex min-h-[420px] flex-col gap-4">
|
|
120
|
+
<div
|
|
121
|
+
ref={scrollRef}
|
|
122
|
+
className="flex-1 space-y-3 overflow-y-auto rounded-xl p-3"
|
|
123
|
+
style={{
|
|
124
|
+
background: 'var(--color-surface-1)',
|
|
125
|
+
border: '1px solid var(--color-border)',
|
|
126
|
+
borderRadius: 'var(--radius-lg)',
|
|
127
|
+
maxHeight: '50dvh',
|
|
128
|
+
}}
|
|
129
|
+
aria-live="polite"
|
|
130
|
+
aria-label="Assistant messages"
|
|
131
|
+
>
|
|
132
|
+
{messages.length === 0 && (
|
|
133
|
+
<p className="text-center text-sm leading-[1.65]" style={{ color: 'var(--color-text-muted)' }}>
|
|
134
|
+
{emptyPlaceholder}
|
|
135
|
+
</p>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{messages.map((message) => (
|
|
139
|
+
<AssistantMessage key={message.id} message={message} />
|
|
140
|
+
))}
|
|
141
|
+
|
|
142
|
+
{isStreaming && (
|
|
143
|
+
<p
|
|
144
|
+
className="text-xs font-mono"
|
|
145
|
+
style={{ color: 'var(--color-text-subtle)' }}
|
|
146
|
+
aria-live="polite"
|
|
147
|
+
>
|
|
148
|
+
Assistant is typing…
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{error && (
|
|
154
|
+
<p className="text-xs" style={{ color: 'var(--color-error, #ef4444)' }} role="alert">
|
|
155
|
+
{error.message ?? 'Something went wrong'}
|
|
156
|
+
</p>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<div className="space-y-3">
|
|
160
|
+
<label htmlFor="assistant-input" className="sr-only">
|
|
161
|
+
Message
|
|
162
|
+
</label>
|
|
163
|
+
<textarea
|
|
164
|
+
id="assistant-input"
|
|
165
|
+
value={input}
|
|
166
|
+
onChange={(event) => setInput(event.target.value)}
|
|
167
|
+
placeholder="Ask anything…"
|
|
168
|
+
rows={3}
|
|
169
|
+
disabled={isStreaming}
|
|
170
|
+
className="w-full resize-none rounded-lg px-3 py-2.5 text-sm leading-[1.65] outline-none transition-opacity duration-200 disabled:opacity-50"
|
|
171
|
+
style={{
|
|
172
|
+
background: 'var(--color-surface-2)',
|
|
173
|
+
color: 'var(--color-text)',
|
|
174
|
+
border: '1px solid var(--color-border)',
|
|
175
|
+
borderRadius: 'var(--radius-md)',
|
|
176
|
+
}}
|
|
177
|
+
onKeyDown={(event) => {
|
|
178
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
if (canSend) void handleSend();
|
|
181
|
+
}
|
|
182
|
+
}}
|
|
183
|
+
/>
|
|
184
|
+
|
|
185
|
+
<div className="flex gap-2">
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={() => void handleSend()}
|
|
189
|
+
disabled={!canSend}
|
|
190
|
+
className="inline-flex flex-1 items-center justify-center rounded-lg px-4 py-2.5 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
|
|
191
|
+
style={{
|
|
192
|
+
background: 'var(--color-surface-2)',
|
|
193
|
+
color: 'var(--color-text)',
|
|
194
|
+
border: '1px solid var(--color-border)',
|
|
195
|
+
borderRadius: 'var(--radius-md)',
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
Send
|
|
199
|
+
</button>
|
|
200
|
+
{messages.length > 0 && (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={handleClear}
|
|
204
|
+
disabled={isStreaming}
|
|
205
|
+
className="inline-flex items-center justify-center rounded-lg px-4 py-2.5 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
|
|
206
|
+
style={{
|
|
207
|
+
background: 'var(--color-surface-1)',
|
|
208
|
+
color: 'var(--color-text-muted)',
|
|
209
|
+
border: '1px solid var(--color-border)',
|
|
210
|
+
borderRadius: 'var(--radius-md)',
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
Clear
|
|
214
|
+
</button>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useChat } from '@ai-sdk/react';
|
|
4
|
+
import { DefaultChatTransport } from 'ai';
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from 'react';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_SYSTEM_PROMPT =
|
|
15
|
+
'You are a helpful assistant inside a Fedi Mini App. Keep answers concise and practical.';
|
|
16
|
+
|
|
17
|
+
type TAssistantContext = ReturnType<typeof useChat>;
|
|
18
|
+
|
|
19
|
+
const AssistantContext = createContext<TAssistantContext | null>(null);
|
|
20
|
+
|
|
21
|
+
interface IAssistantProviderProps {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
/** Custom system prompt sent with each request. */
|
|
24
|
+
systemPrompt?: string;
|
|
25
|
+
/** API route path. Defaults to `/api/assistant`. */
|
|
26
|
+
api?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Provides Vercel AI SDK `useChat()` state to descendant assistant UI components.
|
|
31
|
+
*/
|
|
32
|
+
export function AssistantProvider({
|
|
33
|
+
children,
|
|
34
|
+
systemPrompt = DEFAULT_SYSTEM_PROMPT,
|
|
35
|
+
api = '/api/assistant',
|
|
36
|
+
}: IAssistantProviderProps) {
|
|
37
|
+
const systemPromptRef = useRef(systemPrompt);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
systemPromptRef.current = systemPrompt;
|
|
41
|
+
}, [systemPrompt]);
|
|
42
|
+
|
|
43
|
+
const transport = useMemo(
|
|
44
|
+
() =>
|
|
45
|
+
new DefaultChatTransport({
|
|
46
|
+
api,
|
|
47
|
+
prepareSendMessagesRequest: ({ messages }) => ({
|
|
48
|
+
body: {
|
|
49
|
+
messages,
|
|
50
|
+
systemPrompt: systemPromptRef.current,
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
}),
|
|
54
|
+
[api],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const chat = useChat({ transport });
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<AssistantContext.Provider value={chat}>{children}</AssistantContext.Provider>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Access assistant chat state from an `AssistantProvider` subtree. */
|
|
65
|
+
export function useAssistant(): TAssistantContext {
|
|
66
|
+
const context = useContext(AssistantContext);
|
|
67
|
+
if (!context) {
|
|
68
|
+
throw new Error('useAssistant must be used within an AssistantProvider');
|
|
69
|
+
}
|
|
70
|
+
return context;
|
|
71
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
2
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
3
|
+
import { createGroq } from '@ai-sdk/groq';
|
|
4
|
+
import { createOllama } from 'ollama-ai-provider';
|
|
5
|
+
import type { LanguageModel } from 'ai';
|
|
6
|
+
|
|
7
|
+
export type TAiProvider = 'anthropic' | 'openai' | 'groq' | 'ollama';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MODELS: Record<TAiProvider, string> = {
|
|
10
|
+
anthropic: 'claude-sonnet-4-0',
|
|
11
|
+
openai: 'gpt-4o',
|
|
12
|
+
groq: 'llama-3.3-70b-versatile',
|
|
13
|
+
ollama: 'llama3.2',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function resolveProvider(): TAiProvider {
|
|
17
|
+
const provider = process.env.AI_PROVIDER?.toLowerCase();
|
|
18
|
+
if (provider === 'anthropic' || provider === 'openai' || provider === 'groq' || provider === 'ollama') {
|
|
19
|
+
return provider;
|
|
20
|
+
}
|
|
21
|
+
return 'anthropic';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveModel(provider: TAiProvider): string {
|
|
25
|
+
return process.env.AI_MODEL?.trim() || DEFAULT_MODELS[provider];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns a Vercel AI SDK language model based on `AI_PROVIDER` and related env vars.
|
|
30
|
+
*/
|
|
31
|
+
export function getLanguageModel(): LanguageModel {
|
|
32
|
+
const provider = resolveProvider();
|
|
33
|
+
const modelId = resolveModel(provider);
|
|
34
|
+
const apiKey = process.env.AI_API_KEY;
|
|
35
|
+
const baseURL = process.env.AI_BASE_URL;
|
|
36
|
+
|
|
37
|
+
switch (provider) {
|
|
38
|
+
case 'anthropic':
|
|
39
|
+
return createAnthropic(apiKey ? { apiKey } : undefined)(modelId);
|
|
40
|
+
case 'openai':
|
|
41
|
+
return createOpenAI(apiKey ? { apiKey } : undefined)(modelId);
|
|
42
|
+
case 'groq':
|
|
43
|
+
return createGroq(apiKey ? { apiKey } : undefined)(modelId);
|
|
44
|
+
case 'ollama':
|
|
45
|
+
return createOllama(baseURL ? { baseURL } : undefined)(modelId) as unknown as LanguageModel;
|
|
46
|
+
default:
|
|
47
|
+
return createAnthropic(apiKey ? { apiKey } : undefined)(modelId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-assistant",
|
|
3
|
+
"description": "Free AI assistant chat powered by Vercel AI SDK",
|
|
4
|
+
"dependencies": [
|
|
5
|
+
"ai",
|
|
6
|
+
"@ai-sdk/react",
|
|
7
|
+
"react-markdown",
|
|
8
|
+
"@ai-sdk/anthropic",
|
|
9
|
+
"@ai-sdk/openai",
|
|
10
|
+
"@ai-sdk/groq",
|
|
11
|
+
"ollama-ai-provider"
|
|
12
|
+
],
|
|
13
|
+
"devDependencies": [],
|
|
14
|
+
"files": [
|
|
15
|
+
{ "src": "lib/ai/providers.ts", "dest": "lib/ai/providers.ts", "merge": "add" },
|
|
16
|
+
{ "src": "components/ai/AssistantProvider.tsx", "dest": "components/ai/AssistantProvider.tsx", "merge": "add" },
|
|
17
|
+
{ "src": "components/ai/Assistant.tsx", "dest": "components/ai/Assistant.tsx", "merge": "add" },
|
|
18
|
+
{ "src": "app/api/assistant/route.ts", "dest": "app/api/assistant/route.ts", "merge": "add" },
|
|
19
|
+
{ "src": "app/demo/assistant/page.tsx", "dest": "app/demo/assistant/page.tsx", "merge": "add" },
|
|
20
|
+
{ "src": "app/demo/assistant/AssistantDemoClient.tsx", "dest": "app/demo/assistant/AssistantDemoClient.tsx", "merge": "add" }
|
|
21
|
+
],
|
|
22
|
+
"envVars": [
|
|
23
|
+
{
|
|
24
|
+
"key": "AI_PROVIDER",
|
|
25
|
+
"description": "AI provider — anthropic | openai | groq | ollama",
|
|
26
|
+
"example": "anthropic",
|
|
27
|
+
"required": true
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"key": "AI_API_KEY",
|
|
31
|
+
"description": "Provider API key (not required for ollama)",
|
|
32
|
+
"example": "sk-ant-...",
|
|
33
|
+
"required": true
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"key": "AI_MODEL",
|
|
37
|
+
"description": "Specific model name (e.g. claude-sonnet-4-6)",
|
|
38
|
+
"example": "claude-sonnet-4-6",
|
|
39
|
+
"required": false
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"key": "AI_BASE_URL",
|
|
43
|
+
"description": "Custom base URL for Ollama or self-hosted endpoints",
|
|
44
|
+
"example": "http://localhost:11434",
|
|
45
|
+
"required": false
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { generateChatInvoice } from '../../../../lib/chat-payment';
|
|
3
|
+
|
|
4
|
+
export async function POST(request: Request) {
|
|
5
|
+
let body: { memo?: string };
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
body = (await request.json()) as typeof body;
|
|
9
|
+
} catch {
|
|
10
|
+
body = {};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const invoice = await generateChatInvoice(body.memo);
|
|
14
|
+
return NextResponse.json(invoice);
|
|
15
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { convertToModelMessages, streamText, type UIMessage } from 'ai';
|
|
2
|
+
import { consumeChatPayment } from '../../../lib/chat-payment';
|
|
3
|
+
import { getLanguageModel } from '../../../lib/ai/providers';
|
|
4
|
+
|
|
5
|
+
export const maxDuration = 60;
|
|
6
|
+
|
|
7
|
+
type TChatRequestBody = {
|
|
8
|
+
messages?: UIMessage[];
|
|
9
|
+
paymentId?: string;
|
|
10
|
+
preimage?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Streaming AI chat endpoint. Requires a valid, single-use Lightning payment per request.
|
|
15
|
+
*/
|
|
16
|
+
export async function POST(request: Request) {
|
|
17
|
+
let body: TChatRequestBody;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
body = (await request.json()) as TChatRequestBody;
|
|
21
|
+
} catch {
|
|
22
|
+
return new Response('Invalid JSON body', { status: 400 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const paymentId =
|
|
26
|
+
body.paymentId ?? request.headers.get('x-payment-id') ?? undefined;
|
|
27
|
+
const preimage =
|
|
28
|
+
body.preimage ?? request.headers.get('x-payment-preimage') ?? undefined;
|
|
29
|
+
|
|
30
|
+
if (!paymentId || !preimage) {
|
|
31
|
+
return new Response('Payment required', { status: 402 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const payment = await consumeChatPayment(paymentId, preimage);
|
|
35
|
+
if (!payment.valid) {
|
|
36
|
+
return new Response(payment.reason, { status: 402 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const messages = body.messages;
|
|
40
|
+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
41
|
+
return new Response('messages array is required', { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const result = streamText({
|
|
46
|
+
model: getLanguageModel(),
|
|
47
|
+
messages: await convertToModelMessages(messages),
|
|
48
|
+
system:
|
|
49
|
+
'You are a helpful assistant inside a Fedi Mini App. Keep answers concise and practical.',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return result.toUIMessageStreamResponse();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
const message = err instanceof Error ? err.message : 'AI request failed';
|
|
55
|
+
return new Response(message, { status: 500 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { GatedChat } from '../../../components/ai/GatedChat';
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'AI Chat (Gated)',
|
|
7
|
+
description:
|
|
8
|
+
'Pay-per-message AI chat demo: each prompt costs sats via Lightning. Invoice, WebLN payment, then streaming assistant replies powered by the Vercel AI SDK.',
|
|
9
|
+
openGraph: {
|
|
10
|
+
title: 'AI Chat (Gated)',
|
|
11
|
+
description:
|
|
12
|
+
'Pay-per-message AI chat demo: each prompt costs sats via Lightning. Invoice, WebLN payment, then streaming assistant replies powered by the Vercel AI SDK.',
|
|
13
|
+
},
|
|
14
|
+
twitter: {
|
|
15
|
+
card: 'summary',
|
|
16
|
+
title: 'AI Chat (Gated)',
|
|
17
|
+
description:
|
|
18
|
+
'Pay-per-message AI chat demo: each prompt costs sats via Lightning. Invoice, WebLN payment, then streaming assistant replies powered by the Vercel AI SDK.',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default function AiChatDemoPage() {
|
|
23
|
+
const satsPerMessage =
|
|
24
|
+
process.env.NEXT_PUBLIC_SATS_PER_MESSAGE ??
|
|
25
|
+
process.env.AI_SATS_PER_MESSAGE ??
|
|
26
|
+
'10';
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
|
|
30
|
+
<div
|
|
31
|
+
className="mx-auto w-full max-w-[390px] px-4 pt-6"
|
|
32
|
+
style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
|
|
33
|
+
>
|
|
34
|
+
<Link
|
|
35
|
+
href="/demo"
|
|
36
|
+
className="mb-6 inline-block text-xs text-[var(--color-text-muted)] transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
37
|
+
>
|
|
38
|
+
← back
|
|
39
|
+
</Link>
|
|
40
|
+
|
|
41
|
+
<header className="mb-8 space-y-2">
|
|
42
|
+
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
|
|
43
|
+
Gated AI chat
|
|
44
|
+
</h1>
|
|
45
|
+
<p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
46
|
+
The flagship pay-per-prompt module. Each message costs{' '}
|
|
47
|
+
{Number.parseInt(satsPerMessage, 10).toLocaleString()} sats. Your app generates a
|
|
48
|
+
BOLT11 via <code className="font-mono text-xs">/api/chat/invoice</code>, the user pays
|
|
49
|
+
with WebLN, and the server verifies the preimage before calling{' '}
|
|
50
|
+
<code className="font-mono text-xs">streamText()</code>.
|
|
51
|
+
</p>
|
|
52
|
+
</header>
|
|
53
|
+
|
|
54
|
+
<GatedChat />
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import ReactMarkdown from 'react-markdown';
|
|
4
|
+
import type { UIMessage } from 'ai';
|
|
5
|
+
|
|
6
|
+
interface IChatMessageProps {
|
|
7
|
+
message: UIMessage;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getMessageText(message: UIMessage): string {
|
|
11
|
+
return message.parts
|
|
12
|
+
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
13
|
+
.map((part) => part.text)
|
|
14
|
+
.join('');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders a single chat message with Markdown support for assistant replies.
|
|
19
|
+
*/
|
|
20
|
+
export function ChatMessage({ message }: IChatMessageProps) {
|
|
21
|
+
const isUser = message.role === 'user';
|
|
22
|
+
const text = getMessageText(message);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
|
|
27
|
+
data-role={message.role}
|
|
28
|
+
>
|
|
29
|
+
<div
|
|
30
|
+
className="max-w-[85%] rounded-xl px-3 py-2.5 text-sm leading-[1.65]"
|
|
31
|
+
style={{
|
|
32
|
+
background: isUser ? 'var(--color-accent-dim)' : 'var(--color-surface-2)',
|
|
33
|
+
color: isUser ? 'var(--color-text)' : 'var(--color-text)',
|
|
34
|
+
borderRadius: 'var(--radius-lg)',
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
{isUser ? (
|
|
38
|
+
<p className="whitespace-pre-wrap">{text}</p>
|
|
39
|
+
) : (
|
|
40
|
+
<div
|
|
41
|
+
className="prose prose-invert prose-sm max-w-none prose-p:my-1 prose-pre:my-2 prose-pre:rounded-md prose-pre:bg-[var(--color-surface-1)] prose-code:text-[var(--color-accent)] prose-code:before:content-none prose-code:after:content-none"
|
|
42
|
+
style={{ color: 'var(--color-text)' }}
|
|
43
|
+
>
|
|
44
|
+
<ReactMarkdown>{text || '…'}</ReactMarkdown>
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|