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,181 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useChat } from '@ai-sdk/react';
|
|
4
|
+
import { DefaultChatTransport } from 'ai';
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { ChatMessage } from './ChatMessage';
|
|
7
|
+
import { PaymentGate, type TChatPaymentProof } from './PaymentGate';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SATS = 10;
|
|
10
|
+
|
|
11
|
+
function getClientSatsPerMessage(): number {
|
|
12
|
+
const raw = process.env.NEXT_PUBLIC_SATS_PER_MESSAGE ?? String(DEFAULT_SATS);
|
|
13
|
+
const parsed = Number.parseInt(raw, 10);
|
|
14
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_SATS;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Full pay-per-message AI chat: message list, input, Lightning payment gate, streaming replies.
|
|
19
|
+
*/
|
|
20
|
+
export function GatedChat() {
|
|
21
|
+
const satsPerMessage = getClientSatsPerMessage();
|
|
22
|
+
const [input, setInput] = useState('');
|
|
23
|
+
const [pendingText, setPendingText] = useState<string | null>(null);
|
|
24
|
+
const [gateError, setGateError] = useState<string | null>(null);
|
|
25
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
|
|
27
|
+
const transport = useMemo(
|
|
28
|
+
() => new DefaultChatTransport({ api: '/api/chat' }),
|
|
29
|
+
[],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const { messages, sendMessage, status, error } = useChat({ transport });
|
|
33
|
+
|
|
34
|
+
const isStreaming = status === 'streaming' || status === 'submitted';
|
|
35
|
+
const canSend = input.trim().length > 0 && !isStreaming && pendingText === null;
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
|
39
|
+
}, [messages, isStreaming]);
|
|
40
|
+
|
|
41
|
+
async function handlePaymentConfirmed(proof: TChatPaymentProof) {
|
|
42
|
+
const text = pendingText ?? input.trim();
|
|
43
|
+
if (!text) return;
|
|
44
|
+
|
|
45
|
+
setGateError(null);
|
|
46
|
+
setInput('');
|
|
47
|
+
setPendingText(null);
|
|
48
|
+
|
|
49
|
+
await sendMessage(
|
|
50
|
+
{ text },
|
|
51
|
+
{
|
|
52
|
+
body: {
|
|
53
|
+
paymentId: proof.paymentId,
|
|
54
|
+
preimage: proof.preimage,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handlePrepareSend() {
|
|
61
|
+
const text = input.trim();
|
|
62
|
+
if (!text || isStreaming) return;
|
|
63
|
+
setGateError(null);
|
|
64
|
+
setPendingText(text);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleCancelPending() {
|
|
68
|
+
setPendingText(null);
|
|
69
|
+
setGateError(null);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="flex min-h-[420px] flex-col gap-4">
|
|
74
|
+
<div
|
|
75
|
+
ref={scrollRef}
|
|
76
|
+
className="flex-1 space-y-3 overflow-y-auto rounded-xl p-3"
|
|
77
|
+
style={{
|
|
78
|
+
background: 'var(--color-surface-1)',
|
|
79
|
+
border: '1px solid var(--color-border)',
|
|
80
|
+
borderRadius: 'var(--radius-lg)',
|
|
81
|
+
maxHeight: '50dvh',
|
|
82
|
+
}}
|
|
83
|
+
aria-live="polite"
|
|
84
|
+
aria-label="Chat messages"
|
|
85
|
+
>
|
|
86
|
+
{messages.length === 0 && (
|
|
87
|
+
<p className="text-center text-sm leading-[1.65]" style={{ color: 'var(--color-text-muted)' }}>
|
|
88
|
+
Each message costs {satsPerMessage.toLocaleString()} sats. Pay via Lightning, then the
|
|
89
|
+
assistant streams a reply.
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{messages.map((message) => (
|
|
94
|
+
<ChatMessage key={message.id} message={message} />
|
|
95
|
+
))}
|
|
96
|
+
|
|
97
|
+
{isStreaming && (
|
|
98
|
+
<p
|
|
99
|
+
className="text-xs font-mono"
|
|
100
|
+
style={{ color: 'var(--color-text-subtle)' }}
|
|
101
|
+
aria-live="polite"
|
|
102
|
+
>
|
|
103
|
+
Assistant is typing…
|
|
104
|
+
</p>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{(error || gateError) && (
|
|
109
|
+
<p className="text-xs" style={{ color: 'var(--color-error, #ef4444)' }} role="alert">
|
|
110
|
+
{gateError ?? error?.message ?? 'Something went wrong'}
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<div className="space-y-3">
|
|
115
|
+
<label htmlFor="gated-chat-input" className="sr-only">
|
|
116
|
+
Message
|
|
117
|
+
</label>
|
|
118
|
+
<textarea
|
|
119
|
+
id="gated-chat-input"
|
|
120
|
+
value={input}
|
|
121
|
+
onChange={(event) => setInput(event.target.value)}
|
|
122
|
+
placeholder="Ask anything…"
|
|
123
|
+
rows={3}
|
|
124
|
+
disabled={isStreaming || pendingText !== null}
|
|
125
|
+
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"
|
|
126
|
+
style={{
|
|
127
|
+
background: 'var(--color-surface-2)',
|
|
128
|
+
color: 'var(--color-text)',
|
|
129
|
+
border: '1px solid var(--color-border)',
|
|
130
|
+
borderRadius: 'var(--radius-md)',
|
|
131
|
+
}}
|
|
132
|
+
onKeyDown={(event) => {
|
|
133
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
if (canSend) handlePrepareSend();
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
{pendingText !== null ? (
|
|
141
|
+
<div className="space-y-2">
|
|
142
|
+
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
|
143
|
+
Pay to send: “{pendingText.slice(0, 80)}
|
|
144
|
+
{pendingText.length > 80 ? '…' : ''}”
|
|
145
|
+
</p>
|
|
146
|
+
<PaymentGate
|
|
147
|
+
amountSats={satsPerMessage}
|
|
148
|
+
memo={`AI chat message`}
|
|
149
|
+
disabled={isStreaming}
|
|
150
|
+
onPaymentConfirmed={handlePaymentConfirmed}
|
|
151
|
+
onError={setGateError}
|
|
152
|
+
/>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={handleCancelPending}
|
|
156
|
+
className="w-full text-xs font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
157
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
158
|
+
>
|
|
159
|
+
Edit message
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
) : (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={handlePrepareSend}
|
|
166
|
+
disabled={!canSend}
|
|
167
|
+
className="inline-flex w-full 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"
|
|
168
|
+
style={{
|
|
169
|
+
background: 'var(--color-surface-2)',
|
|
170
|
+
color: 'var(--color-text)',
|
|
171
|
+
border: '1px solid var(--color-border)',
|
|
172
|
+
borderRadius: 'var(--radius-md)',
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
Continue to payment
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { PayButton } from '../webln/PayButton';
|
|
5
|
+
import { formatSats } from '../../lib/payment-history';
|
|
6
|
+
|
|
7
|
+
export type TChatPaymentProof = {
|
|
8
|
+
paymentId: string;
|
|
9
|
+
preimage: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type TInvoiceResponse = {
|
|
13
|
+
paymentId: string;
|
|
14
|
+
invoice: string;
|
|
15
|
+
amountSats: number;
|
|
16
|
+
memo: string;
|
|
17
|
+
devPreimage?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface IPaymentGateProps {
|
|
21
|
+
amountSats: number;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
memo?: string;
|
|
24
|
+
onPaymentConfirmed: (proof: TChatPaymentProof) => void;
|
|
25
|
+
onError?: (message: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type TGateStep = 'idle' | 'loading' | 'ready' | 'error';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pay-to-send gate: fetches a server invoice, then wraps WebLN payment around submission.
|
|
32
|
+
*/
|
|
33
|
+
export function PaymentGate({
|
|
34
|
+
amountSats,
|
|
35
|
+
disabled = false,
|
|
36
|
+
memo,
|
|
37
|
+
onPaymentConfirmed,
|
|
38
|
+
onError,
|
|
39
|
+
}: IPaymentGateProps) {
|
|
40
|
+
const [step, setStep] = useState<TGateStep>('idle');
|
|
41
|
+
const [invoiceData, setInvoiceData] = useState<TInvoiceResponse | null>(null);
|
|
42
|
+
const [error, setError] = useState<string | null>(null);
|
|
43
|
+
|
|
44
|
+
const reset = useCallback(() => {
|
|
45
|
+
setStep('idle');
|
|
46
|
+
setInvoiceData(null);
|
|
47
|
+
setError(null);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const requestInvoice = useCallback(async () => {
|
|
51
|
+
setStep('loading');
|
|
52
|
+
setError(null);
|
|
53
|
+
|
|
54
|
+
const res = await fetch('/api/chat/invoice', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ memo: memo ?? `AI chat (${formatSats(amountSats)})` }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const message = 'Could not create invoice';
|
|
62
|
+
setError(message);
|
|
63
|
+
setStep('error');
|
|
64
|
+
onError?.(message);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const data = (await res.json()) as TInvoiceResponse;
|
|
69
|
+
setInvoiceData(data);
|
|
70
|
+
setStep('ready');
|
|
71
|
+
return data;
|
|
72
|
+
}, [amountSats, memo, onError]);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (
|
|
76
|
+
process.env.NODE_ENV !== 'development' ||
|
|
77
|
+
!invoiceData?.devPreimage ||
|
|
78
|
+
step !== 'ready'
|
|
79
|
+
) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const timer = window.setTimeout(() => {
|
|
84
|
+
onPaymentConfirmed({
|
|
85
|
+
paymentId: invoiceData.paymentId,
|
|
86
|
+
preimage: invoiceData.devPreimage!,
|
|
87
|
+
});
|
|
88
|
+
reset();
|
|
89
|
+
}, 5000);
|
|
90
|
+
|
|
91
|
+
return () => window.clearTimeout(timer);
|
|
92
|
+
}, [invoiceData, step, onPaymentConfirmed, reset]);
|
|
93
|
+
|
|
94
|
+
async function handlePrepare() {
|
|
95
|
+
if (disabled || step === 'loading') return;
|
|
96
|
+
await requestInvoice();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handlePaid(preimage: string) {
|
|
100
|
+
if (!invoiceData) return;
|
|
101
|
+
onPaymentConfirmed({ paymentId: invoiceData.paymentId, preimage });
|
|
102
|
+
reset();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (step === 'idle') {
|
|
106
|
+
return (
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={handlePrepare}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
className="inline-flex w-full 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"
|
|
112
|
+
style={{
|
|
113
|
+
background: 'var(--color-accent)',
|
|
114
|
+
color: 'var(--color-primary-foreground)',
|
|
115
|
+
borderRadius: 'var(--radius-md)',
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
Pay {formatSats(amountSats)} to send
|
|
119
|
+
</button>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (step === 'loading') {
|
|
124
|
+
return (
|
|
125
|
+
<p
|
|
126
|
+
className="text-center text-xs font-mono"
|
|
127
|
+
style={{ color: 'var(--color-text-subtle)' }}
|
|
128
|
+
aria-live="polite"
|
|
129
|
+
>
|
|
130
|
+
Generating invoice…
|
|
131
|
+
</p>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className="space-y-2">
|
|
137
|
+
{invoiceData && (
|
|
138
|
+
<PayButton
|
|
139
|
+
invoice={invoiceData.invoice}
|
|
140
|
+
amountSats={invoiceData.amountSats}
|
|
141
|
+
memo={invoiceData.memo}
|
|
142
|
+
onSuccess={handlePaid}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{process.env.NODE_ENV === 'development' && step === 'ready' && (
|
|
147
|
+
<p className="text-center text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
|
148
|
+
Simulated payment in 5 seconds (dev only)
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{error && (
|
|
153
|
+
<p className="text-xs" style={{ color: 'var(--color-error, #ef4444)' }} role="alert">
|
|
154
|
+
{error}
|
|
155
|
+
</p>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={reset}
|
|
161
|
+
className="w-full text-xs font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
162
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
163
|
+
>
|
|
164
|
+
Cancel
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -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,161 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
|
|
3
|
+
export type TChatPaymentStatus = 'pending' | 'paid' | 'consumed' | 'expired';
|
|
4
|
+
|
|
5
|
+
export type TChatPaymentRecord = {
|
|
6
|
+
id: string;
|
|
7
|
+
invoice: string;
|
|
8
|
+
preimage: string;
|
|
9
|
+
amountSats: number;
|
|
10
|
+
memo: string;
|
|
11
|
+
status: TChatPaymentStatus;
|
|
12
|
+
createdAt: number;
|
|
13
|
+
paidAt: number | null;
|
|
14
|
+
consumedAt: number | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const payments = new Map<string, TChatPaymentRecord>();
|
|
18
|
+
const INVOICE_TTL_MS = 15 * 60 * 1000;
|
|
19
|
+
|
|
20
|
+
function generateId(): string {
|
|
21
|
+
return randomBytes(16).toString('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function generatePreimage(): string {
|
|
25
|
+
return randomBytes(32).toString('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildMockInvoice(amountSats: number, paymentId: string): string {
|
|
29
|
+
const amountPart = amountSats.toString(16).padStart(6, '0');
|
|
30
|
+
return `lnbc${amountPart}n1p${paymentId.slice(0, 40)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reads the per-message price from env (default 10 sats).
|
|
35
|
+
*/
|
|
36
|
+
export function getSatsPerMessage(): number {
|
|
37
|
+
const raw = process.env.AI_SATS_PER_MESSAGE ?? '10';
|
|
38
|
+
const parsed = Number.parseInt(raw, 10);
|
|
39
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 10;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type TChatInvoiceResult = {
|
|
43
|
+
paymentId: string;
|
|
44
|
+
invoice: string;
|
|
45
|
+
amountSats: number;
|
|
46
|
+
memo: string;
|
|
47
|
+
/** Present in development to simulate wallet payment without WebLN */
|
|
48
|
+
devPreimage?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates a pending chat payment invoice for one AI message.
|
|
53
|
+
*/
|
|
54
|
+
export async function generateChatInvoice(memo?: string): Promise<TChatInvoiceResult> {
|
|
55
|
+
const amountSats = getSatsPerMessage();
|
|
56
|
+
const id = generateId();
|
|
57
|
+
const preimage = generatePreimage();
|
|
58
|
+
|
|
59
|
+
const record: TChatPaymentRecord = {
|
|
60
|
+
id,
|
|
61
|
+
invoice: buildMockInvoice(amountSats, id),
|
|
62
|
+
preimage,
|
|
63
|
+
amountSats,
|
|
64
|
+
memo: memo ?? `AI chat message (${amountSats} sats)`,
|
|
65
|
+
status: 'pending',
|
|
66
|
+
createdAt: Date.now(),
|
|
67
|
+
paidAt: null,
|
|
68
|
+
consumedAt: null,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
payments.set(id, record);
|
|
72
|
+
|
|
73
|
+
const result: TChatInvoiceResult = {
|
|
74
|
+
paymentId: id,
|
|
75
|
+
invoice: record.invoice,
|
|
76
|
+
amountSats: record.amountSats,
|
|
77
|
+
memo: record.memo,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (process.env.NODE_ENV === 'development') {
|
|
81
|
+
result.devPreimage = preimage;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function getPaymentById(id: string): Promise<TChatPaymentRecord | null> {
|
|
88
|
+
const record = payments.get(id);
|
|
89
|
+
if (!record) return null;
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
record.status === 'pending' &&
|
|
93
|
+
Date.now() - record.createdAt > INVOICE_TTL_MS
|
|
94
|
+
) {
|
|
95
|
+
record.status = 'expired';
|
|
96
|
+
payments.set(id, record);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return record;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type TVerifyChatPaymentResult =
|
|
103
|
+
| { valid: true; record: TChatPaymentRecord }
|
|
104
|
+
| { valid: false; reason: string };
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verifies a Lightning preimage and marks the payment as paid (not yet consumed).
|
|
108
|
+
*/
|
|
109
|
+
export async function verifyChatPayment(
|
|
110
|
+
paymentId: string,
|
|
111
|
+
preimage: string,
|
|
112
|
+
): Promise<TVerifyChatPaymentResult> {
|
|
113
|
+
const record = await getPaymentById(paymentId);
|
|
114
|
+
if (!record) {
|
|
115
|
+
return { valid: false, reason: 'Payment not found' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (record.status === 'expired') {
|
|
119
|
+
return { valid: false, reason: 'Invoice expired' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (record.status === 'consumed') {
|
|
123
|
+
return { valid: false, reason: 'Payment already used' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (record.status === 'paid') {
|
|
127
|
+
return { valid: true, record };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (record.preimage !== preimage) {
|
|
131
|
+
return { valid: false, reason: 'Invalid payment proof' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
record.status = 'paid';
|
|
135
|
+
record.paidAt = Date.now();
|
|
136
|
+
payments.set(paymentId, record);
|
|
137
|
+
return { valid: true, record };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Confirms a paid invoice is valid and marks it consumed for a single AI request.
|
|
142
|
+
*/
|
|
143
|
+
export async function consumeChatPayment(
|
|
144
|
+
paymentId: string,
|
|
145
|
+
preimage: string,
|
|
146
|
+
): Promise<TVerifyChatPaymentResult> {
|
|
147
|
+
const verified = await verifyChatPayment(paymentId, preimage);
|
|
148
|
+
if (!verified.valid) {
|
|
149
|
+
return verified;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const record = verified.record;
|
|
153
|
+
if (record.status === 'consumed') {
|
|
154
|
+
return { valid: false, reason: 'Payment already used' };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
record.status = 'consumed';
|
|
158
|
+
record.consumedAt = Date.now();
|
|
159
|
+
payments.set(paymentId, record);
|
|
160
|
+
return { valid: true, record };
|
|
161
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-chat-gated",
|
|
3
|
+
"description": "AI chat interface locked behind a Lightning micropayment per message",
|
|
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/chat-payment.ts", "dest": "lib/chat-payment.ts", "merge": "add" },
|
|
16
|
+
{ "src": "lib/ai/providers.ts", "dest": "lib/ai/providers.ts", "merge": "add" },
|
|
17
|
+
{ "src": "components/ai/ChatMessage.tsx", "dest": "components/ai/ChatMessage.tsx", "merge": "add" },
|
|
18
|
+
{ "src": "components/ai/PaymentGate.tsx", "dest": "components/ai/PaymentGate.tsx", "merge": "add" },
|
|
19
|
+
{ "src": "components/ai/GatedChat.tsx", "dest": "components/ai/GatedChat.tsx", "merge": "add" },
|
|
20
|
+
{ "src": "app/api/chat/route.ts", "dest": "app/api/chat/route.ts", "merge": "add" },
|
|
21
|
+
{ "src": "app/api/chat/invoice/route.ts", "dest": "app/api/chat/invoice/route.ts", "merge": "add" },
|
|
22
|
+
{ "src": "app/demo/ai-chat/page.tsx", "dest": "app/demo/ai-chat/page.tsx", "merge": "add" }
|
|
23
|
+
],
|
|
24
|
+
"envVars": [
|
|
25
|
+
{
|
|
26
|
+
"key": "AI_PROVIDER",
|
|
27
|
+
"description": "AI provider — anthropic | openai | groq | ollama",
|
|
28
|
+
"example": "anthropic",
|
|
29
|
+
"required": true
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"key": "AI_API_KEY",
|
|
33
|
+
"description": "Provider API key (not required for ollama)",
|
|
34
|
+
"example": "sk-ant-...",
|
|
35
|
+
"required": true
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"key": "AI_MODEL",
|
|
39
|
+
"description": "Specific model name (e.g. claude-sonnet-4-0)",
|
|
40
|
+
"example": "claude-sonnet-4-0",
|
|
41
|
+
"required": false
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"key": "AI_BASE_URL",
|
|
45
|
+
"description": "Custom base URL for Ollama or self-hosted endpoints",
|
|
46
|
+
"example": "http://localhost:11434",
|
|
47
|
+
"required": false
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"key": "AI_SATS_PER_MESSAGE",
|
|
51
|
+
"description": "Lightning price in sats for each AI message (server-side)",
|
|
52
|
+
"example": "10",
|
|
53
|
+
"required": false
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"key": "NEXT_PUBLIC_SATS_PER_MESSAGE",
|
|
57
|
+
"description": "Same price exposed to the client UI",
|
|
58
|
+
"example": "10",
|
|
59
|
+
"required": false
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} — Cursor Context
|
|
2
|
+
|
|
3
|
+
This is a Fedi Mini App built with create-fedi-app.
|
|
4
|
+
Full context is in `.ai/rules/`. Read OVERVIEW.md first.
|
|
5
|
+
|
|
6
|
+
Key constraint: this app runs inside Fedi's in-app browser (WebView).
|
|
7
|
+
window.webln and window.nostr are injected by Fedi — do not install
|
|
8
|
+
external wallet libraries. Always check for undefined before calling them.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} — GitHub Copilot Context
|
|
2
|
+
|
|
3
|
+
This is a Fedi Mini App built with create-fedi-app.
|
|
4
|
+
Full context is in `.ai/rules/`. Read OVERVIEW.md first.
|
|
5
|
+
|
|
6
|
+
Key constraint: this app runs inside Fedi's in-app browser (WebView).
|
|
7
|
+
window.webln and window.nostr are injected by Fedi — do not install
|
|
8
|
+
external wallet libraries. Always check for undefined before calling them.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} — Claude Code Context
|
|
2
|
+
|
|
3
|
+
This is a Fedi Mini App built with create-fedi-app.
|
|
4
|
+
Full context is in `.ai/rules/`. Read OVERVIEW.md first.
|
|
5
|
+
|
|
6
|
+
Key constraint: this app runs inside Fedi's in-app browser (WebView).
|
|
7
|
+
window.webln and window.nostr are injected by Fedi — do not install
|
|
8
|
+
external wallet libraries. Always check for undefined before calling them.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-rules",
|
|
3
|
+
"description": "Agent-readable context files for AI coding tools",
|
|
4
|
+
"dependencies": [],
|
|
5
|
+
"devDependencies": [],
|
|
6
|
+
"files": [
|
|
7
|
+
{ "src": "CLAUDE.md", "dest": ".ai/CLAUDE.md", "merge": "add" },
|
|
8
|
+
{ "src": ".cursorrules", "dest": ".ai/.cursorrules", "merge": "add" },
|
|
9
|
+
{ "src": ".github/copilot-instructions.md", "dest": ".ai/.github/copilot-instructions.md", "merge": "add" },
|
|
10
|
+
{ "src": "rules/OVERVIEW.md", "dest": ".ai/rules/OVERVIEW.md", "merge": "replace" },
|
|
11
|
+
{ "src": "rules/webln.md", "dest": ".ai/rules/webln.md", "merge": "add" },
|
|
12
|
+
{ "src": "rules/nostr.md", "dest": ".ai/rules/nostr.md", "merge": "add" },
|
|
13
|
+
{ "src": "rules/fedi-api.md", "dest": ".ai/rules/fedi-api.md", "merge": "add" },
|
|
14
|
+
{ "src": "rules/design-system.md", "dest": ".ai/rules/design-system.md", "merge": "add" },
|
|
15
|
+
{ "src": "rules/patterns.md", "dest": ".ai/rules/patterns.md", "merge": "add" },
|
|
16
|
+
{ "src": "rules/architecture.md", "dest": ".ai/rules/architecture.md", "merge": "add" },
|
|
17
|
+
{ "src": "rules/testing.md", "dest": ".ai/rules/testing.md", "merge": "add" }
|
|
18
|
+
],
|
|
19
|
+
"envVars": []
|
|
20
|
+
}
|