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.
Files changed (133) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +11113 -0
  3. package/dist/templates/base/.env.example +5 -0
  4. package/dist/templates/base/app/demo/page.tsx +25 -0
  5. package/dist/templates/base/app/globals.css +95 -0
  6. package/dist/templates/base/app/layout.tsx +39 -0
  7. package/dist/templates/base/app/page.tsx +83 -0
  8. package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
  9. package/dist/templates/base/components/providers.tsx +63 -0
  10. package/dist/templates/base/env.ts +10 -0
  11. package/dist/templates/base/hooks/useFediInternal.ts +41 -0
  12. package/dist/templates/base/lib/fedi-types.ts +96 -0
  13. package/dist/templates/base/lib/fedi.ts +18 -0
  14. package/dist/templates/base/lib/nostr/hooks.ts +52 -0
  15. package/dist/templates/base/lib/nostr/index.ts +9 -0
  16. package/dist/templates/base/lib/nostr/mock.ts +60 -0
  17. package/dist/templates/base/lib/nostr/provider.tsx +64 -0
  18. package/dist/templates/base/lib/utils.ts +3 -0
  19. package/dist/templates/base/lib/webln/hooks.ts +67 -0
  20. package/dist/templates/base/lib/webln/index.ts +12 -0
  21. package/dist/templates/base/lib/webln/mock.ts +96 -0
  22. package/dist/templates/base/lib/webln/provider.tsx +52 -0
  23. package/dist/templates/base/next.config.ts +3 -0
  24. package/dist/templates/base/package.json +40 -0
  25. package/dist/templates/base/proxy.ts +8 -0
  26. package/dist/templates/base/tsconfig.json +20 -0
  27. package/dist/templates/base/vitest.config.ts +6 -0
  28. package/dist/templates/base/vitest.setup.ts +40 -0
  29. package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
  30. package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
  31. package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
  32. package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
  33. package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
  34. package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
  35. package/dist/templates/modules/ai-assistant/module.json +48 -0
  36. package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
  37. package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
  38. package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
  39. package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
  40. package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
  41. package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
  42. package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
  43. package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
  44. package/dist/templates/modules/ai-chat-gated/module.json +62 -0
  45. package/dist/templates/modules/ai-rules/.cursorrules +8 -0
  46. package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
  47. package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
  48. package/dist/templates/modules/ai-rules/module.json +20 -0
  49. package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
  50. package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
  51. package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
  52. package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
  53. package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
  54. package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
  55. package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
  56. package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
  57. package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
  58. package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
  59. package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
  60. package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
  61. package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
  62. package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
  63. package/dist/templates/modules/database/env.supabase.ts +24 -0
  64. package/dist/templates/modules/database/env.turso.ts +23 -0
  65. package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
  66. package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
  67. package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
  68. package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
  69. package/dist/templates/modules/database/module.json +110 -0
  70. package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
  71. package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
  72. package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
  73. package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
  74. package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
  75. package/dist/templates/modules/ecash-balance/module.json +14 -0
  76. package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
  77. package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
  78. package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
  79. package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
  80. package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
  81. package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
  82. package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
  83. package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
  84. package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
  85. package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
  86. package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
  87. package/dist/templates/modules/lnurl/module.json +27 -0
  88. package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
  89. package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
  90. package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
  91. package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
  92. package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
  93. package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
  94. package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
  95. package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
  96. package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
  97. package/dist/templates/modules/multispend-demo/module.json +18 -0
  98. package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
  99. package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
  100. package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
  101. package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
  102. package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
  103. package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
  104. package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
  105. package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
  106. package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
  107. package/dist/templates/modules/nostr-feed/module.json +25 -0
  108. package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
  109. package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
  110. package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
  111. package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
  112. package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
  113. package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
  114. package/dist/templates/modules/nostr-identity/module.json +15 -0
  115. package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
  116. package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
  117. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
  118. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
  119. package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
  120. package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
  121. package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
  122. package/dist/templates/modules/payment-gated-content/module.json +24 -0
  123. package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
  124. package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
  125. package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
  126. package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
  127. package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
  128. package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
  129. package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
  130. package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
  131. package/dist/templates/modules/webln-payments/module.json +17 -0
  132. package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
  133. 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
+ }