Skip to content

Commit 204d08f

Browse files
Add Nebula AI chat feature with streaming and context support
Co-authored-by: joaquim.verges <[email protected]>
1 parent 409ae17 commit 204d08f

File tree

13 files changed

+1777
-1
lines changed

13 files changed

+1777
-1
lines changed

apps/playground-web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"class-variance-authority": "^0.7.1",
1919
"clsx": "^2.1.1",
2020
"date-fns": "4.1.0",
21+
"fetch-event-stream": "^1.0.1",
2122
"lucide-react": "0.525.0",
2223
"next": "15.3.5",
2324
"next-themes": "^0.4.6",
@@ -31,6 +32,7 @@
3132
"react-pick-color": "^2.0.0",
3233
"server-only": "^0.0.1",
3334
"shiki": "1.27.0",
35+
"sonner": "^1.7.1",
3436
"tailwind-merge": "^2.6.0",
3537
"thirdweb": "workspace:*",
3638
"use-debounce": "^10.0.5",
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import { stream } from "fetch-event-stream";
2+
import type { NebulaTxData, NebulaUserMessage } from "./types";
3+
4+
// Mock URL for playground - you'll need to configure this
5+
const NEBULA_URL = process.env.NEXT_PUBLIC_NEBULA_URL || "https://nebula-api.thirdweb-dev.com";
6+
7+
export type NebulaContext = {
8+
chainIds: string[] | null;
9+
walletAddress: string | null;
10+
networks: "mainnet" | "testnet" | "all" | null;
11+
};
12+
13+
export type NebulaSwapData = {
14+
action: string;
15+
transaction: {
16+
chainId: number;
17+
to: `0x${string}`;
18+
data: `0x${string}`;
19+
};
20+
to: {
21+
address: `0x${string}`;
22+
amount: string;
23+
chain_id: number;
24+
decimals: number;
25+
symbol: string;
26+
};
27+
from: {
28+
address: `0x${string}`;
29+
amount: string;
30+
chain_id: number;
31+
decimals: number;
32+
symbol: string;
33+
};
34+
intent: {
35+
amount: string;
36+
destinationChainId: number;
37+
destinationTokenAddress: `0x${string}`;
38+
originChainId: number;
39+
originTokenAddress: `0x${string}`;
40+
receiver: `0x${string}`;
41+
sender: `0x${string}`;
42+
};
43+
};
44+
45+
export async function promptNebula(params: {
46+
message: NebulaUserMessage;
47+
sessionId: string;
48+
authToken: string;
49+
handleStream: (res: ChatStreamedResponse) => void;
50+
abortController: AbortController;
51+
context: undefined | NebulaContext;
52+
}) {
53+
const body: Record<string, string | boolean | object> = {
54+
messages: [params.message],
55+
session_id: params.sessionId,
56+
stream: true,
57+
};
58+
59+
if (params.context) {
60+
body.context = {
61+
chain_ids: params.context.chainIds || [],
62+
networks: params.context.networks,
63+
wallet_address: params.context.walletAddress,
64+
};
65+
}
66+
67+
const events = await stream(`${NEBULA_URL}/chat`, {
68+
body: JSON.stringify(body),
69+
headers: {
70+
Authorization: `Bearer ${params.authToken}`,
71+
"Content-Type": "application/json",
72+
},
73+
method: "POST",
74+
signal: params.abortController.signal,
75+
});
76+
77+
for await (const _event of events) {
78+
if (!_event.data) {
79+
continue;
80+
}
81+
82+
const event = _event as ChatStreamedEvent;
83+
84+
switch (event.event) {
85+
case "delta": {
86+
params.handleStream({
87+
data: {
88+
v: JSON.parse(event.data).v,
89+
},
90+
event: "delta",
91+
});
92+
break;
93+
}
94+
95+
case "presence": {
96+
params.handleStream({
97+
data: JSON.parse(event.data),
98+
event: "presence",
99+
});
100+
break;
101+
}
102+
103+
case "image": {
104+
const data = JSON.parse(event.data) as {
105+
data: {
106+
width: number;
107+
height: number;
108+
url: string;
109+
};
110+
request_id: string;
111+
};
112+
113+
params.handleStream({
114+
data: data.data,
115+
event: "image",
116+
request_id: data.request_id,
117+
});
118+
break;
119+
}
120+
121+
case "action": {
122+
const data = JSON.parse(event.data);
123+
124+
if (data.type === "sign_transaction") {
125+
try {
126+
const parsedTxData = JSON.parse(data.data) as NebulaTxData;
127+
params.handleStream({
128+
data: parsedTxData,
129+
event: "action",
130+
request_id: data.request_id,
131+
type: "sign_transaction",
132+
});
133+
} catch (e) {
134+
console.error("failed to parse action data", e, { event });
135+
}
136+
}
137+
138+
if (data.type === "sign_swap") {
139+
try {
140+
const swapData = JSON.parse(data.data) as NebulaSwapData;
141+
params.handleStream({
142+
data: swapData,
143+
event: "action",
144+
request_id: data.request_id,
145+
type: "sign_swap",
146+
});
147+
} catch (e) {
148+
console.error("failed to parse action data", e, { event });
149+
}
150+
}
151+
152+
break;
153+
}
154+
155+
case "error": {
156+
const data = JSON.parse(event.data) as {
157+
code: number;
158+
error: {
159+
message: string;
160+
};
161+
};
162+
163+
params.handleStream({
164+
data: {
165+
code: data.code,
166+
errorMessage: data.error.message,
167+
},
168+
event: "error",
169+
});
170+
break;
171+
}
172+
173+
case "init": {
174+
const data = JSON.parse(event.data);
175+
params.handleStream({
176+
data: {
177+
request_id: data.request_id,
178+
session_id: data.session_id,
179+
},
180+
event: "init",
181+
});
182+
break;
183+
}
184+
185+
case "context": {
186+
const data = JSON.parse(event.data) as {
187+
data: string;
188+
request_id: string;
189+
session_id: string;
190+
};
191+
192+
const contextData = JSON.parse(data.data) as {
193+
wallet_address: string;
194+
chain_ids: number[];
195+
networks: NebulaContext["networks"];
196+
};
197+
198+
params.handleStream({
199+
data: contextData,
200+
event: "context",
201+
});
202+
break;
203+
}
204+
205+
case "ping": {
206+
break;
207+
}
208+
209+
default: {
210+
console.warn("unhandled event", event);
211+
}
212+
}
213+
}
214+
}
215+
216+
type ChatStreamedResponse =
217+
| {
218+
event: "init";
219+
data: {
220+
session_id: string;
221+
request_id: string;
222+
};
223+
}
224+
| {
225+
event: "presence";
226+
data: {
227+
session_id: string;
228+
request_id: string;
229+
source: "user" | "reviewer" | (string & {});
230+
data: string;
231+
};
232+
}
233+
| {
234+
event: "delta";
235+
data: {
236+
v: string;
237+
};
238+
}
239+
| {
240+
event: "action";
241+
type: "sign_transaction";
242+
data: NebulaTxData;
243+
request_id: string;
244+
}
245+
| {
246+
event: "action";
247+
type: "sign_swap";
248+
data: NebulaSwapData;
249+
request_id: string;
250+
}
251+
| {
252+
event: "image";
253+
data: {
254+
width: number;
255+
height: number;
256+
url: string;
257+
};
258+
request_id: string;
259+
}
260+
| {
261+
event: "context";
262+
data: {
263+
wallet_address: string;
264+
chain_ids: number[];
265+
networks: NebulaContext["networks"];
266+
};
267+
}
268+
| {
269+
event: "error";
270+
data: {
271+
code: number;
272+
errorMessage: string;
273+
};
274+
};
275+
276+
type ChatStreamedEvent =
277+
| {
278+
event: "init";
279+
data: string;
280+
}
281+
| {
282+
event: "presence";
283+
data: string;
284+
}
285+
| {
286+
event: "delta";
287+
data: string;
288+
}
289+
| {
290+
event: "image";
291+
data: string;
292+
}
293+
| {
294+
event: "action";
295+
type: "sign_transaction" | "sign_swap";
296+
data: string;
297+
}
298+
| {
299+
event: "context";
300+
data: string;
301+
}
302+
| {
303+
event: "error";
304+
data: string;
305+
}
306+
| {
307+
event: "ping";
308+
data: string;
309+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
type FetchWithKeyOptions = {
2+
endpoint: string;
3+
authToken: string;
4+
timeout?: number;
5+
} & (
6+
| {
7+
method: "POST" | "PUT";
8+
body: Record<string, unknown>;
9+
}
10+
| {
11+
method: "GET" | "DELETE";
12+
}
13+
);
14+
15+
export async function fetchWithAuthToken(options: FetchWithKeyOptions) {
16+
const timeout = options.timeout || 30000;
17+
18+
const controller = new AbortController();
19+
const timeoutId = setTimeout(() => controller.abort(), timeout);
20+
21+
try {
22+
const response = await fetch(options.endpoint, {
23+
body: "body" in options ? JSON.stringify(options.body) : undefined,
24+
headers: {
25+
Accept: "application/json",
26+
Authorization: `Bearer ${options.authToken}`,
27+
"Content-Type": "application/json",
28+
},
29+
method: options.method,
30+
signal: controller.signal,
31+
});
32+
33+
if (!response.ok) {
34+
if (response.status === 504) {
35+
throw new Error("Request timed out. Please try again.");
36+
}
37+
38+
throw new Error(`HTTP error! status: ${response.status}`);
39+
}
40+
41+
return response;
42+
} catch (error: unknown) {
43+
if (error instanceof Error && error.name === "AbortError") {
44+
throw new Error("Request timed out. Please try again.");
45+
}
46+
throw error;
47+
} finally {
48+
clearTimeout(timeoutId);
49+
}
50+
}

0 commit comments

Comments
 (0)