@thorprovider/medusa-extended 1.11.2 → 1.11.3

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.
@@ -1421,3 +1421,95 @@ describe('DropshipperClient.deleteCustomerAddress', () => {
1421
1421
  expect(result.id).toBe('addr_1')
1422
1422
  })
1423
1423
  })
1424
+
1425
+ // ---------------------------------------------------------------------------
1426
+ // AI Assistant — streaming (chatAssistantStream)
1427
+ // ---------------------------------------------------------------------------
1428
+
1429
+ describe('DropshipperClient.chatAssistantStream', () => {
1430
+ it('normalizes a text/event-stream UI-message stream into ChatStreamEvents', async () => {
1431
+ const sse = [
1432
+ 'data: {"type":"start","messageId":"m1","messageMetadata":{"intent":"catalog_search"}}\n\n',
1433
+ 'data: {"type":"text-delta","delta":"Encontr","id":"m1"}\n\n',
1434
+ 'data: {"type":"text-delta","delta":"é 2 productos","id":"m1"}\n\n',
1435
+ 'data: {"type":"finish","messageId":"m1","messageMetadata":{"intent":"catalog_search","sources":[{"product_id":"p1","title":"Zapato","handle":"zapato","thumbnail":null,"sale_price":100,"cost_price":60,"margin_percent":40,"available_quantity":5,"variants_summary":[]}]}}\n\n',
1436
+ 'data: [DONE]\n\n',
1437
+ ].join('')
1438
+
1439
+ server.use(
1440
+ http.post(`${BASE}/admin/thor/dropshipper/ai/chat`, ({ request }) => {
1441
+ requireBearerToken(request)
1442
+ return new HttpResponse(sse, {
1443
+ headers: { 'Content-Type': 'text/event-stream' },
1444
+ })
1445
+ }),
1446
+ )
1447
+
1448
+ const stream = await client().chatAssistantStream({
1449
+ messages: [{ role: 'user', content: 'productos con margen' }],
1450
+ })
1451
+
1452
+ const reader = stream.getReader()
1453
+ const events = []
1454
+ while (true) {
1455
+ const { done, value } = await reader.read()
1456
+ if (done) break
1457
+ events.push(value)
1458
+ }
1459
+
1460
+ expect(events).toEqual([
1461
+ { type: 'start', intent: 'catalog_search' },
1462
+ { type: 'text-delta', delta: 'Encontr' },
1463
+ { type: 'text-delta', delta: 'é 2 productos' },
1464
+ {
1465
+ type: 'finish',
1466
+ intent: 'catalog_search',
1467
+ sources: [
1468
+ {
1469
+ product_id: 'p1',
1470
+ title: 'Zapato',
1471
+ handle: 'zapato',
1472
+ thumbnail: null,
1473
+ sale_price: 100,
1474
+ cost_price: 60,
1475
+ margin_percent: 40,
1476
+ available_quantity: 5,
1477
+ variants_summary: [],
1478
+ },
1479
+ ],
1480
+ },
1481
+ ])
1482
+ })
1483
+
1484
+ it('synthesizes a one-shot stream from a plain JSON greeting/other response', async () => {
1485
+ server.use(
1486
+ http.post(`${BASE}/admin/thor/dropshipper/ai/chat`, ({ request }) => {
1487
+ requireBearerToken(request)
1488
+ return HttpResponse.json({
1489
+ answer: '¡Hola! ¿En qué puedo ayudarte?',
1490
+ intent: 'greeting',
1491
+ model_used: 'gpt-4o',
1492
+ sources: [],
1493
+ })
1494
+ }),
1495
+ )
1496
+
1497
+ const stream = await client().chatAssistantStream({
1498
+ messages: [{ role: 'user', content: 'Hola' }],
1499
+ })
1500
+
1501
+ const reader = stream.getReader()
1502
+ const events = []
1503
+ while (true) {
1504
+ const { done, value } = await reader.read()
1505
+ if (done) break
1506
+ events.push(value)
1507
+ }
1508
+
1509
+ expect(events).toEqual([
1510
+ { type: 'start', intent: 'greeting' },
1511
+ { type: 'text-delta', delta: '¡Hola! ¿En qué puedo ayudarte?' },
1512
+ { type: 'finish', intent: 'greeting', sources: [] },
1513
+ ])
1514
+ })
1515
+ })
@@ -158,6 +158,8 @@ import type {
158
158
  // AI Assistant Chat
159
159
  ChatRequest,
160
160
  ChatResponse,
161
+ ChatIntent,
162
+ ChatSource,
161
163
  // Profile
162
164
  GetMyProfileResponse,
163
165
  // Notifications
@@ -178,6 +180,41 @@ import { fetchBackendWithResilience } from './request-resilience';
178
180
 
179
181
  type FetchMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
180
182
 
183
+ // ---------------------------------------------------------------------------
184
+ // AI Assistant — streaming chat events
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * One event from the streamed AI assistant chat (`chatAssistantStream`).
189
+ *
190
+ * The backend returns a UI-message stream (SSE over `text/event-stream`) for
191
+ * catalog searches, and a plain JSON `ChatResponse` for greetings/other
192
+ * intents. `chatAssistantStream` normalizes both into this single event type so
193
+ * consumers can render incremental text and update the "thinking" label as the
194
+ * model progresses without knowing the transport details.
195
+ */
196
+ export type ChatStreamEvent =
197
+ | {
198
+ type: 'start';
199
+ /** Semantic intent as soon as the backend resolves it. */
200
+ intent?: ChatIntent;
201
+ }
202
+ | {
203
+ type: 'text-delta';
204
+ /** A chunk of the assistant's answer, appended to the bubble. */
205
+ delta: string;
206
+ }
207
+ | {
208
+ type: 'finish';
209
+ intent: ChatIntent;
210
+ /** Products referenced by the answer (empty for greetings/other). */
211
+ sources: ChatSource[];
212
+ }
213
+ | {
214
+ type: 'error';
215
+ errorText: string;
216
+ };
217
+
181
218
  /**
182
219
  * Configuration required to instantiate the dropshipper client.
183
220
  */
@@ -330,6 +367,78 @@ export class DropshipperClient {
330
367
  );
331
368
  }
332
369
 
370
+ /**
371
+ * Stream the AI assistant's reply.
372
+ *
373
+ * Unlike `chatAssistant`, this surfaces the model's progress: the backend
374
+ * resolves the intent first and then streams the answer token by token
375
+ * (catalog searches) or returns the full answer in one shot (greetings/other).
376
+ * The response is normalized into a single `ReadableStream<ChatStreamEvent>`
377
+ * so callers can show a live "thinking" label and render text incrementally.
378
+ *
379
+ * `POST /admin/thor/dropshipper/ai/chat`
380
+ */
381
+ async chatAssistantStream(body: ChatRequest): Promise<ReadableStream<ChatStreamEvent>> {
382
+ const url = `${this.baseUrl}/admin/thor/dropshipper/ai/chat`;
383
+ this.logger.debug('[dropshipper] POST /admin/thor/dropshipper/ai/chat (stream)');
384
+
385
+ const init: RequestInit = {
386
+ method: 'POST',
387
+ headers: {
388
+ Authorization: `Bearer ${this.token}`,
389
+ 'Content-Type': 'application/json',
390
+ Accept: 'text/event-stream',
391
+ },
392
+ body: JSON.stringify(body),
393
+ };
394
+
395
+ let response: Response;
396
+ try {
397
+ response = await fetchBackendWithResilience(url, init);
398
+ } catch (networkError: any) {
399
+ throw new ProviderAPIError(
400
+ `[dropshipper] Network error on POST /admin/thor/dropshipper/ai/chat: ${networkError.message}`,
401
+ 'NETWORK_ERROR',
402
+ networkError,
403
+ );
404
+ }
405
+
406
+ if (!response.ok) {
407
+ let message = `HTTP ${response.status}`;
408
+ try {
409
+ const json = await response.json();
410
+ message = json?.message ?? message;
411
+ } catch {
412
+ // ignore parse errors
413
+ }
414
+ throw new ProviderAPIError(
415
+ `[dropshipper] POST /admin/thor/dropshipper/ai/chat failed: ${message}`,
416
+ `DROPSHIPPER_API_${response.status}`,
417
+ response.status,
418
+ );
419
+ }
420
+
421
+ const contentType = response.headers.get('content-type') ?? '';
422
+ if (contentType.includes('text/event-stream') && response.body) {
423
+ return mapUiMessageStreamToEvents(response.body);
424
+ }
425
+
426
+ // Non-streamed reply (greeting/other): synthesize a one-shot event stream.
427
+ const data: ChatResponse = await response.json();
428
+ return new ReadableStream<ChatStreamEvent>({
429
+ start(controller) {
430
+ controller.enqueue({ type: 'start', intent: data.intent });
431
+ controller.enqueue({ type: 'text-delta', delta: data.answer });
432
+ controller.enqueue({
433
+ type: 'finish',
434
+ intent: data.intent,
435
+ sources: data.sources ?? [],
436
+ });
437
+ controller.close();
438
+ },
439
+ });
440
+ }
441
+
333
442
  // -------------------------------------------------------------------------
334
443
  // Notifications
335
444
  // -------------------------------------------------------------------------
@@ -1768,3 +1877,103 @@ export class DropshipperClient {
1768
1877
 
1769
1878
  // Re-export types used by callers so they don't need to import from @thorprovider/types directly.
1770
1879
  export type { DropshipperCategory, DropshipperPromotion, NotificationData, PriceUpdatedNotificationData, ProductAssignedNotificationData, StockChangeNotificationData, ManualAdminNotificationData, RawNotification, GetNotificationsOptions, GetNotificationsResponse, ChatRequest, ChatResponse, ChatMessage, ChatIntent, ChatSource, ChatSourceVariant } from '@thorprovider/types';
1880
+
1881
+ // ---------------------------------------------------------------------------
1882
+ // AI Assistant — streaming helpers
1883
+ // ---------------------------------------------------------------------------
1884
+
1885
+ /**
1886
+ * Yields one parsed JSON object per `data:` line of a Server-Sent Events body.
1887
+ */
1888
+ async function* parseSse(
1889
+ stream: ReadableStream<Uint8Array>,
1890
+ ): AsyncGenerator<unknown> {
1891
+ const reader = stream.getReader();
1892
+ const decoder = new TextDecoder();
1893
+ let buffer = '';
1894
+ try {
1895
+ while (true) {
1896
+ const { done, value } = await reader.read();
1897
+ if (done) break;
1898
+ buffer += decoder.decode(value, { stream: true });
1899
+ const lines = buffer.split(/\r?\n/);
1900
+ buffer = lines.pop() ?? '';
1901
+ for (const line of lines) {
1902
+ const trimmed = line.trim();
1903
+ if (!trimmed.startsWith('data:')) continue;
1904
+ const payload = trimmed.slice(5).trim();
1905
+ if (!payload || payload === '[DONE]') continue;
1906
+ try {
1907
+ yield JSON.parse(payload);
1908
+ } catch {
1909
+ // Skip malformed frames.
1910
+ }
1911
+ }
1912
+ }
1913
+ const tail = buffer.trim();
1914
+ if (tail.startsWith('data:')) {
1915
+ const payload = tail.slice(5).trim();
1916
+ if (payload && payload !== '[DONE]') {
1917
+ try {
1918
+ yield JSON.parse(payload);
1919
+ } catch {
1920
+ // Ignore trailing partial frame.
1921
+ }
1922
+ }
1923
+ }
1924
+ } finally {
1925
+ reader.releaseLock();
1926
+ }
1927
+ }
1928
+
1929
+ /**
1930
+ * Normalize the backend's UI-message stream (SSE of `UIMessageChunk` frames)
1931
+ * into the consumer-friendly `ChatStreamEvent` type.
1932
+ */
1933
+ function mapUiMessageStreamToEvents(
1934
+ stream: ReadableStream<Uint8Array>,
1935
+ ): ReadableStream<ChatStreamEvent> {
1936
+ return new ReadableStream<ChatStreamEvent>({
1937
+ async start(controller) {
1938
+ for await (const raw of parseSse(stream)) {
1939
+ const chunk = raw as {
1940
+ type?: string;
1941
+ messageId?: string;
1942
+ delta?: string;
1943
+ errorText?: string;
1944
+ messageMetadata?: { intent?: ChatIntent; sources?: ChatSource[] };
1945
+ };
1946
+ switch (chunk.type) {
1947
+ case 'start':
1948
+ controller.enqueue({
1949
+ type: 'start',
1950
+ intent: chunk.messageMetadata?.intent,
1951
+ });
1952
+ break;
1953
+ case 'text-delta':
1954
+ if (chunk.delta !== undefined) {
1955
+ controller.enqueue({ type: 'text-delta', delta: chunk.delta });
1956
+ }
1957
+ break;
1958
+ case 'finish':
1959
+ controller.enqueue({
1960
+ type: 'finish',
1961
+ intent: chunk.messageMetadata?.intent ?? 'other',
1962
+ sources: chunk.messageMetadata?.sources ?? [],
1963
+ });
1964
+ break;
1965
+ case 'error':
1966
+ controller.enqueue({
1967
+ type: 'error',
1968
+ errorText: chunk.errorText ?? 'Error inesperado del asistente.',
1969
+ });
1970
+ break;
1971
+ }
1972
+ }
1973
+ controller.close();
1974
+ },
1975
+ cancel() {
1976
+ stream.cancel?.();
1977
+ },
1978
+ });
1979
+ }
package/src/index.ts CHANGED
@@ -7,5 +7,5 @@ export { MedusaAdminClient } from './admin';
7
7
  export type { MedusaAdminClientConfig } from './admin';
8
8
 
9
9
  export { DropshipperClient } from './dropshipper';
10
- export type { DropshipperClientConfig } from './dropshipper';
10
+ export type { DropshipperClientConfig, ChatStreamEvent } from './dropshipper';
11
11