@albionlabs/chat-widget 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.
@@ -0,0 +1,387 @@
1
+ import { writable, get } from 'svelte/store';
2
+ import { fetchNpv, fetchOrderbook, fetchTokenMetadata, fetchTrades, lookupToken, setGatewayConfig } from '../services/gateway-api.js';
3
+ const initial = {
4
+ messages: [],
5
+ connected: false,
6
+ reconnecting: false,
7
+ sessionId: null,
8
+ loading: false,
9
+ thinkingStatus: null,
10
+ backendVersion: null
11
+ };
12
+ export const chat = writable(initial);
13
+ let ws = null;
14
+ let messageCounter = 0;
15
+ let streamingAssistantMessageId = null;
16
+ let reconnectAttempts = 0;
17
+ let reconnectTimer = null;
18
+ let lastGatewayUrl = null;
19
+ let lastToken = null;
20
+ let lastApiKey = null;
21
+ let intentionalClose = false;
22
+ const MAX_RECONNECT_ATTEMPTS = 10;
23
+ const BASE_RECONNECT_DELAY_MS = 1000;
24
+ const MAX_RECONNECT_DELAY_MS = 30000;
25
+ function getReconnectDelay() {
26
+ const delay = Math.min(BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY_MS);
27
+ // Add jitter (±25%) to avoid thundering herd
28
+ return delay * (0.75 + Math.random() * 0.5);
29
+ }
30
+ function scheduleReconnect() {
31
+ if (intentionalClose || !lastGatewayUrl || !lastToken)
32
+ return;
33
+ if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS)
34
+ return;
35
+ reconnectTimer = setTimeout(() => {
36
+ reconnectTimer = null;
37
+ if (intentionalClose || !lastGatewayUrl || !lastToken)
38
+ return;
39
+ reconnectAttempts++;
40
+ connectInternal(lastGatewayUrl, lastToken);
41
+ }, getReconnectDelay());
42
+ }
43
+ function clearReconnectTimer() {
44
+ if (reconnectTimer !== null) {
45
+ clearTimeout(reconnectTimer);
46
+ reconnectTimer = null;
47
+ }
48
+ }
49
+ function connectInternal(gatewayUrl, token) {
50
+ if (ws) {
51
+ ws.onclose = null;
52
+ ws.onerror = null;
53
+ ws.close();
54
+ }
55
+ chat.update((s) => ({ ...s, reconnecting: true }));
56
+ let url = `${gatewayUrl}/api/chat?token=${encodeURIComponent(token)}`;
57
+ if (lastApiKey) {
58
+ url += `&apiKey=${encodeURIComponent(lastApiKey)}`;
59
+ }
60
+ ws = new WebSocket(url);
61
+ ws.onopen = () => {
62
+ // Don't reset reconnectAttempts here – wait for the server's 'connected'
63
+ // message which confirms the agent is reachable. Otherwise the backoff
64
+ // resets on every TCP-level open and AGENT_UNAVAILABLE causes a tight loop.
65
+ };
66
+ ws.onmessage = (event) => {
67
+ try {
68
+ const msg = JSON.parse(event.data);
69
+ if (msg.type === 'connected') {
70
+ reconnectAttempts = 0;
71
+ chat.update((s) => ({
72
+ ...s,
73
+ connected: true,
74
+ reconnecting: false,
75
+ backendVersion: msg.version ?? null
76
+ }));
77
+ return;
78
+ }
79
+ if (msg.type === 'progress' && msg.status) {
80
+ chat.update((s) => ({
81
+ ...s,
82
+ thinkingStatus: msg.status ?? null,
83
+ sessionId: msg.sessionId ?? s.sessionId,
84
+ loading: true
85
+ }));
86
+ }
87
+ else if (msg.type === 'stream' && msg.delta) {
88
+ chat.update((s) => {
89
+ const messages = [...s.messages];
90
+ const now = Date.now();
91
+ const delta = msg.delta ?? '';
92
+ const idx = streamingAssistantMessageId
93
+ ? messages.findIndex((m) => m.id === streamingAssistantMessageId)
94
+ : -1;
95
+ if (idx >= 0) {
96
+ messages[idx] = {
97
+ ...messages[idx],
98
+ content: `${messages[idx].content}${delta}`,
99
+ timestamp: now
100
+ };
101
+ }
102
+ else {
103
+ streamingAssistantMessageId = `msg-${++messageCounter}`;
104
+ messages.push({
105
+ id: streamingAssistantMessageId,
106
+ role: 'assistant',
107
+ content: delta,
108
+ timestamp: now
109
+ });
110
+ }
111
+ return {
112
+ ...s,
113
+ messages,
114
+ thinkingStatus: null,
115
+ sessionId: msg.sessionId ?? s.sessionId,
116
+ loading: true
117
+ };
118
+ });
119
+ }
120
+ else if (msg.type === 'message' && msg.content) {
121
+ chat.update((s) => {
122
+ const role = msg.role ?? 'assistant';
123
+ const now = Date.now();
124
+ let messages;
125
+ if (!streamingAssistantMessageId) {
126
+ messages = [
127
+ ...s.messages,
128
+ {
129
+ id: `msg-${++messageCounter}`,
130
+ role,
131
+ content: msg.content ?? '',
132
+ timestamp: now
133
+ }
134
+ ];
135
+ }
136
+ else {
137
+ messages = s.messages.map((m) => m.id === streamingAssistantMessageId
138
+ ? { ...m, role, content: msg.content ?? m.content, timestamp: now }
139
+ : m);
140
+ }
141
+ return { ...s, messages, sessionId: msg.sessionId ?? s.sessionId, loading: false, thinkingStatus: null };
142
+ });
143
+ streamingAssistantMessageId = null;
144
+ }
145
+ else if (msg.type === 'error') {
146
+ // AGENT_UNAVAILABLE is sent right before the server closes the
147
+ // socket; the client will auto-reconnect so don't spam the UI.
148
+ if (msg.code === 'AGENT_UNAVAILABLE')
149
+ return;
150
+ const hint = msg.code === 'AGENT_ERROR'
151
+ ? ' You can retry by sending your message again.'
152
+ : '';
153
+ const errorMsg = {
154
+ id: `msg-${++messageCounter}`,
155
+ role: 'system',
156
+ content: `Error: ${msg.message ?? 'Unknown error'}${hint}`,
157
+ timestamp: Date.now()
158
+ };
159
+ chat.update((s) => ({
160
+ ...s,
161
+ messages: [...s.messages, errorMsg],
162
+ loading: false,
163
+ thinkingStatus: null
164
+ }));
165
+ streamingAssistantMessageId = null;
166
+ }
167
+ }
168
+ catch {
169
+ // ignore parse errors
170
+ }
171
+ };
172
+ ws.onclose = () => {
173
+ chat.update((s) => ({ ...s, connected: false, reconnecting: false, loading: false }));
174
+ ws = null;
175
+ streamingAssistantMessageId = null;
176
+ scheduleReconnect();
177
+ };
178
+ ws.onerror = () => {
179
+ chat.update((s) => ({ ...s, connected: false, reconnecting: false }));
180
+ };
181
+ }
182
+ export function connect(gatewayUrl, token, apiKey) {
183
+ intentionalClose = false;
184
+ reconnectAttempts = 0;
185
+ lastGatewayUrl = gatewayUrl;
186
+ lastToken = token;
187
+ lastApiKey = apiKey ?? null;
188
+ setGatewayConfig(gatewayUrl, token, apiKey);
189
+ clearReconnectTimer();
190
+ connectInternal(gatewayUrl, token);
191
+ // Reconnect when the browser regains network or tab visibility
192
+ if (typeof window !== 'undefined') {
193
+ window.addEventListener('online', reconnectIfDisconnected);
194
+ document.addEventListener('visibilitychange', handleVisibilityChange);
195
+ }
196
+ }
197
+ export function reconnect() {
198
+ if (!lastGatewayUrl || !lastToken)
199
+ return;
200
+ reconnectAttempts = 0;
201
+ clearReconnectTimer();
202
+ connectInternal(lastGatewayUrl, lastToken);
203
+ }
204
+ function reconnectIfDisconnected() {
205
+ if (intentionalClose)
206
+ return;
207
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
208
+ reconnectAttempts = 0;
209
+ clearReconnectTimer();
210
+ if (lastGatewayUrl && lastToken) {
211
+ connectInternal(lastGatewayUrl, lastToken);
212
+ }
213
+ }
214
+ }
215
+ function handleVisibilityChange() {
216
+ if (document.visibilityState === 'visible') {
217
+ reconnectIfDisconnected();
218
+ }
219
+ }
220
+ function appendMessage(role, content) {
221
+ const displayMsg = {
222
+ id: `msg-${++messageCounter}`,
223
+ role,
224
+ content,
225
+ timestamp: Date.now()
226
+ };
227
+ chat.update((s) => ({ ...s, messages: [...s.messages, displayMsg] }));
228
+ }
229
+ function parseSide(value) {
230
+ const normalized = (value ?? 'both').toLowerCase();
231
+ if (normalized === 'buy' || normalized === 'bid')
232
+ return 'buy';
233
+ if (normalized === 'sell' || normalized === 'ask')
234
+ return 'sell';
235
+ return 'both';
236
+ }
237
+ function parseDirectCommand(content) {
238
+ const trimmed = content.trim();
239
+ if (!trimmed.startsWith('/'))
240
+ return null;
241
+ const parts = trimmed.split(/\s+/);
242
+ const command = parts[0]?.toLowerCase();
243
+ switch (command) {
244
+ case '/token':
245
+ return parts[1] ? { kind: 'token', query: parts[1] } : { kind: 'help' };
246
+ case '/orderbook':
247
+ return parts[1]
248
+ ? {
249
+ kind: 'orderbook',
250
+ tokenAddress: parts[1],
251
+ side: parseSide(parts[2])
252
+ }
253
+ : { kind: 'help' };
254
+ case '/trades': {
255
+ if (!parts[1])
256
+ return { kind: 'help' };
257
+ const parsedLimit = parts[2] ? parseInt(parts[2], 10) : 20;
258
+ const limit = Number.isFinite(parsedLimit) ? parsedLimit : 20;
259
+ return { kind: 'trades', tokenAddress: parts[1], limit };
260
+ }
261
+ case '/metadata': {
262
+ if (!parts[1])
263
+ return { kind: 'help' };
264
+ const parsedLimit = parts[2] ? parseInt(parts[2], 10) : 1;
265
+ const limit = Number.isFinite(parsedLimit) ? parsedLimit : 1;
266
+ return { kind: 'metadata', tokenAddress: parts[1], limit };
267
+ }
268
+ case '/npv': {
269
+ if (parts.length < 4)
270
+ return { kind: 'help' };
271
+ const discountRate = Number(parts[1]);
272
+ const cashFlows = parts.slice(2).map((value) => Number(value));
273
+ if (!Number.isFinite(discountRate) || cashFlows.some((value) => !Number.isFinite(value))) {
274
+ return { kind: 'help' };
275
+ }
276
+ return { kind: 'npv', discountRate, cashFlows };
277
+ }
278
+ case '/help':
279
+ return { kind: 'help' };
280
+ default:
281
+ return null;
282
+ }
283
+ }
284
+ function directCommandHelpText() {
285
+ return [
286
+ 'Direct commands (LLM bypass):',
287
+ '/token <symbol|address>',
288
+ '/orderbook <tokenAddress> [buy|sell|both]',
289
+ '/trades <tokenAddress> [limit]',
290
+ '/metadata <tokenAddress> [limit]',
291
+ '/npv <discountRate> <cashFlow1> <cashFlow2> ...'
292
+ ].join('\n');
293
+ }
294
+ async function executeDirectCommand(command) {
295
+ switch (command.kind) {
296
+ case 'help':
297
+ return directCommandHelpText();
298
+ case 'token': {
299
+ const response = await lookupToken(command.query);
300
+ const { token } = response;
301
+ return `Token: ${token.symbol} (${token.name})\nAddress: ${token.address}\nDecimals: ${token.decimals}`;
302
+ }
303
+ case 'orderbook': {
304
+ const response = await fetchOrderbook(command.tokenAddress, command.side);
305
+ return [
306
+ `Orderbook (${command.side}) ${response.tokenAddress}`,
307
+ `Best bid: ${response.bestBid ?? 'n/a'}`,
308
+ `Best ask: ${response.bestAsk ?? 'n/a'}`,
309
+ `Spread: ${response.spread ?? 'n/a'}`,
310
+ `Counts: bids=${response.bidCount}, asks=${response.askCount}`
311
+ ].join('\n');
312
+ }
313
+ case 'trades': {
314
+ const response = await fetchTrades(command.tokenAddress, command.limit);
315
+ return `Trades ${response.tokenAddress}: total=${response.total}\n${response.display}`;
316
+ }
317
+ case 'metadata': {
318
+ const response = await fetchTokenMetadata(command.tokenAddress, command.limit);
319
+ const decoded = response.latest?.decodedData;
320
+ const preview = decoded && typeof decoded === 'object'
321
+ ? Object.entries(decoded)
322
+ .slice(0, 6)
323
+ .map(([key, value]) => `${key}: ${String(value)}`)
324
+ .join('\n')
325
+ : 'No decoded metadata available.';
326
+ return `Metadata ${response.address}\n${preview}`;
327
+ }
328
+ case 'npv': {
329
+ const response = await fetchNpv(command.cashFlows, command.discountRate);
330
+ return `NPV=${response.npv}${response.irr !== undefined ? `, IRR=${response.irr}` : ''}`;
331
+ }
332
+ default:
333
+ return 'Unsupported command.';
334
+ }
335
+ }
336
+ export function sendMessage(content) {
337
+ const trimmed = content.trim();
338
+ if (!trimmed)
339
+ return;
340
+ streamingAssistantMessageId = null;
341
+ appendMessage('user', trimmed);
342
+ chat.update((s) => ({ ...s, loading: true }));
343
+ const directCommand = parseDirectCommand(trimmed);
344
+ if (directCommand) {
345
+ void executeDirectCommand(directCommand)
346
+ .then((result) => {
347
+ appendMessage('assistant', result);
348
+ })
349
+ .catch((error) => {
350
+ appendMessage('system', `Error: ${error instanceof Error ? error.message : 'Request failed'}`);
351
+ })
352
+ .finally(() => {
353
+ chat.update((s) => ({ ...s, loading: false }));
354
+ });
355
+ return;
356
+ }
357
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
358
+ appendMessage('system', 'Error: Chat connection is not available');
359
+ chat.update((s) => ({ ...s, loading: false }));
360
+ return;
361
+ }
362
+ const state = get(chat);
363
+ const msg = {
364
+ type: 'message',
365
+ content: trimmed,
366
+ sessionId: state.sessionId ?? undefined
367
+ };
368
+ ws.send(JSON.stringify(msg));
369
+ }
370
+ export function disconnect() {
371
+ intentionalClose = true;
372
+ clearReconnectTimer();
373
+ if (typeof window !== 'undefined') {
374
+ window.removeEventListener('online', reconnectIfDisconnected);
375
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
376
+ }
377
+ if (ws) {
378
+ ws.close();
379
+ ws = null;
380
+ }
381
+ streamingAssistantMessageId = null;
382
+ lastGatewayUrl = null;
383
+ lastToken = null;
384
+ lastApiKey = null;
385
+ reconnectAttempts = 0;
386
+ chat.set(initial);
387
+ }
@@ -0,0 +1,9 @@
1
+ import type { TxSignRequestPayload, WalletProvider } from '../types.js';
2
+ export declare const walletProvider: import("svelte/store").Writable<WalletProvider | null>;
3
+ export declare function setWalletProvider(provider: WalletProvider | null): void;
4
+ export declare function clearWalletProvider(): void;
5
+ export declare function signTransactionRequest(request: TxSignRequestPayload): Promise<string>;
6
+ export declare function waitForTransactionConfirmation(txHash: string, { timeoutMs, pollIntervalMs }?: {
7
+ timeoutMs?: number;
8
+ pollIntervalMs?: number;
9
+ }): Promise<boolean>;
@@ -0,0 +1,90 @@
1
+ import { get, writable } from 'svelte/store';
2
+ export const walletProvider = writable(null);
3
+ export function setWalletProvider(provider) {
4
+ walletProvider.set(provider);
5
+ }
6
+ export function clearWalletProvider() {
7
+ walletProvider.set(null);
8
+ }
9
+ function isAddress(value) {
10
+ return /^0x[a-fA-F0-9]{40}$/.test(value);
11
+ }
12
+ function normalizeHexData(data) {
13
+ if (!data.startsWith('0x')) {
14
+ throw new Error('Transaction data must be a hex string');
15
+ }
16
+ if ((data.length - 2) % 2 !== 0) {
17
+ throw new Error('Transaction data has invalid hex length');
18
+ }
19
+ return data;
20
+ }
21
+ function toHexFromWei(value) {
22
+ const wei = value.trim() === '' ? 0n : BigInt(value);
23
+ return `0x${wei.toString(16)}`;
24
+ }
25
+ function toHexChainId(chainId) {
26
+ if (!Number.isInteger(chainId) || chainId <= 0) {
27
+ throw new Error('Invalid chainId for signing request');
28
+ }
29
+ return `0x${chainId.toString(16)}`;
30
+ }
31
+ function sleep(ms) {
32
+ return new Promise((resolve) => setTimeout(resolve, ms));
33
+ }
34
+ function isLikelyMinedReceipt(value) {
35
+ if (!value || typeof value !== 'object')
36
+ return false;
37
+ const candidate = value;
38
+ return typeof candidate.blockNumber === 'string' && candidate.blockNumber !== '0x0';
39
+ }
40
+ export async function signTransactionRequest(request) {
41
+ const provider = get(walletProvider);
42
+ if (!provider) {
43
+ throw new Error('Wallet provider is not available. Please reconnect Dynamic.');
44
+ }
45
+ if (request.kind !== 'evm_send_transaction') {
46
+ throw new Error(`Unsupported sign request kind: ${request.kind}`);
47
+ }
48
+ if (!isAddress(request.from)) {
49
+ throw new Error('Invalid signer address in sign request');
50
+ }
51
+ if (!isAddress(request.to)) {
52
+ throw new Error('Invalid target address in sign request');
53
+ }
54
+ const txRequest = {
55
+ from: request.from,
56
+ to: request.to,
57
+ data: normalizeHexData(request.data),
58
+ value: toHexFromWei(request.value ?? '0'),
59
+ chainId: toHexChainId(request.chainId)
60
+ };
61
+ const result = await provider.request({
62
+ method: 'eth_sendTransaction',
63
+ params: [txRequest]
64
+ });
65
+ if (typeof result !== 'string' || !result.startsWith('0x')) {
66
+ throw new Error('Wallet provider returned an invalid transaction hash');
67
+ }
68
+ return result;
69
+ }
70
+ export async function waitForTransactionConfirmation(txHash, { timeoutMs = 180_000, pollIntervalMs = 3_000 } = {}) {
71
+ const provider = get(walletProvider);
72
+ if (!provider) {
73
+ throw new Error('Wallet provider is not available. Please reconnect Dynamic.');
74
+ }
75
+ if (!/^0x[a-fA-F0-9]{64}$/.test(txHash)) {
76
+ throw new Error('Invalid transaction hash');
77
+ }
78
+ const deadline = Date.now() + timeoutMs;
79
+ while (Date.now() < deadline) {
80
+ const receipt = await provider.request({
81
+ method: 'eth_getTransactionReceipt',
82
+ params: [txHash]
83
+ });
84
+ if (isLikelyMinedReceipt(receipt)) {
85
+ return true;
86
+ }
87
+ await sleep(pollIntervalMs);
88
+ }
89
+ return false;
90
+ }
@@ -0,0 +1,44 @@
1
+ export interface DisplayMessage {
2
+ id: string;
3
+ role: 'user' | 'assistant' | 'system';
4
+ content: string;
5
+ timestamp: number;
6
+ }
7
+ export interface WalletProvider {
8
+ request: (args: {
9
+ method: string;
10
+ params?: unknown[];
11
+ }) => Promise<unknown>;
12
+ }
13
+ export interface TxSignRequestPayload {
14
+ kind: 'evm_send_transaction';
15
+ chainId: number;
16
+ from: string;
17
+ to: string;
18
+ data: string;
19
+ value: string;
20
+ summary?: {
21
+ to?: string;
22
+ valueWei?: string;
23
+ dataBytes?: number;
24
+ };
25
+ }
26
+ export interface ChatWidgetConfig {
27
+ gatewayUrl: string;
28
+ token?: string;
29
+ }
30
+ export interface FloatingChatWidgetConfig {
31
+ gatewayUrl: string;
32
+ apiKey: string;
33
+ position?: 'bottom-right' | 'bottom-left';
34
+ offset?: {
35
+ x: number;
36
+ y: number;
37
+ };
38
+ startOpen?: boolean;
39
+ }
40
+ export interface FloatingChatCallbacks {
41
+ onRequestWalletConnect?: () => void;
42
+ onOpen?: () => void;
43
+ onClose?: () => void;
44
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@albionlabs/chat-widget",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "svelte": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "svelte": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "peerDependencies": {
18
+ "svelte": "^5.0.0"
19
+ },
20
+ "devDependencies": {
21
+ "@sveltejs/package": "^2.3.0",
22
+ "svelte": "^5.0.0",
23
+ "svelte-check": "^4.0.0",
24
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
25
+ "typescript": "^5.9.3",
26
+ "vite": "^6.3.0",
27
+ "@quant-bot/config": "0.0.1"
28
+ },
29
+ "scripts": {
30
+ "build": "svelte-package",
31
+ "check": "svelte-check --tsconfig ./tsconfig.json",
32
+ "lint": "eslint src/"
33
+ }
34
+ }