@thorprovider/medusa-extended 1.11.2 → 1.11.5
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.mts +40 -2
- package/dist/index.d.ts +40 -2
- package/dist/index.js +180 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +180 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/dropshipper.test.ts +92 -0
- package/src/dropshipper.ts +259 -0
- package/src/index.ts +1 -1
package/src/dropshipper.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/dropshipper.ts
CHANGED
|
@@ -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,106 @@ 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
|
+
// This endpoint performs two LLM round-trips plus channel-scoped retrieval
|
|
398
|
+
// BEFORE it starts streaming (headers only flush when Stage 3 begins), so
|
|
399
|
+
// the default 10s timeout aborts legitimate catalog searches. Use a larger
|
|
400
|
+
// per-attempt timeout; once headers arrive the timer is cleared and the
|
|
401
|
+
// streamed body is unaffected. POST is a mutation, so on timeout/network it
|
|
402
|
+
// won't be retried — maxRetries:1 only guards against redundant 5xx/red work.
|
|
403
|
+
response = await fetchBackendWithResilience(url, init, {
|
|
404
|
+
timeoutMs: 60_000,
|
|
405
|
+
maxRetries: 1,
|
|
406
|
+
});
|
|
407
|
+
} catch (networkError: any) {
|
|
408
|
+
throw new ProviderAPIError(
|
|
409
|
+
`[dropshipper] Network error on POST /admin/thor/dropshipper/ai/chat: ${networkError.message}`,
|
|
410
|
+
'NETWORK_ERROR',
|
|
411
|
+
networkError,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!response.ok) {
|
|
416
|
+
let message = `HTTP ${response.status}`;
|
|
417
|
+
let errorCode: string | undefined;
|
|
418
|
+
let requestId: string | undefined;
|
|
419
|
+
let details: string | undefined;
|
|
420
|
+
try {
|
|
421
|
+
const json: any = await response.json();
|
|
422
|
+
message = json?.message ?? message;
|
|
423
|
+
errorCode = json?.error_code ?? json?.type;
|
|
424
|
+
requestId = json?.request_id;
|
|
425
|
+
details = json?.details;
|
|
426
|
+
} catch {
|
|
427
|
+
// ignore parse errors
|
|
428
|
+
}
|
|
429
|
+
this.logger.error(
|
|
430
|
+
`[dropshipper] POST /admin/thor/dropshipper/ai/chat failed status=${response.status} message=${message}` +
|
|
431
|
+
(errorCode ? ` error_code=${errorCode}` : '') +
|
|
432
|
+
(requestId ? ` request_id=${requestId}` : '') +
|
|
433
|
+
(details ? ` details=${details}` : '')
|
|
434
|
+
);
|
|
435
|
+
throw new ProviderAPIError(
|
|
436
|
+
`[dropshipper] POST /admin/thor/dropshipper/ai/chat failed: ${message}`,
|
|
437
|
+
`DROPSHIPPER_API_${response.status}`,
|
|
438
|
+
response.status,
|
|
439
|
+
{
|
|
440
|
+
message,
|
|
441
|
+
status: response.status,
|
|
442
|
+
errorCode,
|
|
443
|
+
requestId,
|
|
444
|
+
details,
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
450
|
+
if (contentType.includes('text/event-stream') && response.body) {
|
|
451
|
+
return mapUiMessageStreamToEvents(response.body, this.logger);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Non-streamed reply (greeting/other): synthesize a one-shot event stream.
|
|
455
|
+
const data: ChatResponse = await response.json();
|
|
456
|
+
return new ReadableStream<ChatStreamEvent>({
|
|
457
|
+
start(controller) {
|
|
458
|
+
controller.enqueue({ type: 'start', intent: data.intent });
|
|
459
|
+
controller.enqueue({ type: 'text-delta', delta: data.answer });
|
|
460
|
+
controller.enqueue({
|
|
461
|
+
type: 'finish',
|
|
462
|
+
intent: data.intent,
|
|
463
|
+
sources: data.sources ?? [],
|
|
464
|
+
});
|
|
465
|
+
controller.close();
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
333
470
|
// -------------------------------------------------------------------------
|
|
334
471
|
// Notifications
|
|
335
472
|
// -------------------------------------------------------------------------
|
|
@@ -1768,3 +1905,125 @@ export class DropshipperClient {
|
|
|
1768
1905
|
|
|
1769
1906
|
// Re-export types used by callers so they don't need to import from @thorprovider/types directly.
|
|
1770
1907
|
export type { DropshipperCategory, DropshipperPromotion, NotificationData, PriceUpdatedNotificationData, ProductAssignedNotificationData, StockChangeNotificationData, ManualAdminNotificationData, RawNotification, GetNotificationsOptions, GetNotificationsResponse, ChatRequest, ChatResponse, ChatMessage, ChatIntent, ChatSource, ChatSourceVariant } from '@thorprovider/types';
|
|
1908
|
+
|
|
1909
|
+
// ---------------------------------------------------------------------------
|
|
1910
|
+
// AI Assistant — streaming helpers
|
|
1911
|
+
// ---------------------------------------------------------------------------
|
|
1912
|
+
|
|
1913
|
+
/**
|
|
1914
|
+
* Yields one parsed JSON object per `data:` line of a Server-Sent Events body.
|
|
1915
|
+
*/
|
|
1916
|
+
async function* parseSse(
|
|
1917
|
+
stream: ReadableStream<Uint8Array>,
|
|
1918
|
+
logger?: ReturnType<typeof createLogger>,
|
|
1919
|
+
): AsyncGenerator<unknown> {
|
|
1920
|
+
const reader = stream.getReader();
|
|
1921
|
+
const decoder = new TextDecoder();
|
|
1922
|
+
let buffer = '';
|
|
1923
|
+
let frameIndex = 0;
|
|
1924
|
+
try {
|
|
1925
|
+
while (true) {
|
|
1926
|
+
const { done, value } = await reader.read();
|
|
1927
|
+
if (done) break;
|
|
1928
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1929
|
+
const lines = buffer.split(/\r?\n/);
|
|
1930
|
+
buffer = lines.pop() ?? '';
|
|
1931
|
+
for (const line of lines) {
|
|
1932
|
+
const trimmed = line.trim();
|
|
1933
|
+
if (!trimmed.startsWith('data:')) continue;
|
|
1934
|
+
const payload = trimmed.slice(5).trim();
|
|
1935
|
+
if (!payload || payload === '[DONE]') continue;
|
|
1936
|
+
frameIndex += 1;
|
|
1937
|
+
try {
|
|
1938
|
+
yield JSON.parse(payload);
|
|
1939
|
+
} catch (err: any) {
|
|
1940
|
+
logger?.warn(
|
|
1941
|
+
`[dropshipper] sse-malformed-frame frame=${frameIndex} payload=${payload.slice(0, 200)}`
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
const tail = buffer.trim();
|
|
1947
|
+
if (tail.startsWith('data:')) {
|
|
1948
|
+
const payload = tail.slice(5).trim();
|
|
1949
|
+
if (payload && payload !== '[DONE]') {
|
|
1950
|
+
frameIndex += 1;
|
|
1951
|
+
try {
|
|
1952
|
+
yield JSON.parse(payload);
|
|
1953
|
+
} catch (err: any) {
|
|
1954
|
+
logger?.warn(
|
|
1955
|
+
`[dropshipper] sse-malformed-frame frame=${frameIndex} payload=${payload.slice(0, 200)}`
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
} finally {
|
|
1961
|
+
reader.releaseLock();
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/**
|
|
1966
|
+
* Normalize the backend's UI-message stream (SSE of `UIMessageChunk` frames)
|
|
1967
|
+
* into the consumer-friendly `ChatStreamEvent` type.
|
|
1968
|
+
*/
|
|
1969
|
+
function mapUiMessageStreamToEvents(
|
|
1970
|
+
stream: ReadableStream<Uint8Array>,
|
|
1971
|
+
logger?: ReturnType<typeof createLogger>,
|
|
1972
|
+
): ReadableStream<ChatStreamEvent> {
|
|
1973
|
+
return new ReadableStream<ChatStreamEvent>({
|
|
1974
|
+
async start(controller) {
|
|
1975
|
+
let eventCount = 0;
|
|
1976
|
+
for await (const raw of parseSse(stream, logger)) {
|
|
1977
|
+
const chunk = raw as {
|
|
1978
|
+
type?: string;
|
|
1979
|
+
messageId?: string;
|
|
1980
|
+
delta?: string;
|
|
1981
|
+
errorText?: string;
|
|
1982
|
+
messageMetadata?: { intent?: ChatIntent; sources?: ChatSource[] };
|
|
1983
|
+
};
|
|
1984
|
+
switch (chunk.type) {
|
|
1985
|
+
case 'start':
|
|
1986
|
+
controller.enqueue({
|
|
1987
|
+
type: 'start',
|
|
1988
|
+
intent: chunk.messageMetadata?.intent,
|
|
1989
|
+
});
|
|
1990
|
+
eventCount += 1;
|
|
1991
|
+
break;
|
|
1992
|
+
case 'text-delta':
|
|
1993
|
+
if (chunk.delta !== undefined) {
|
|
1994
|
+
controller.enqueue({ type: 'text-delta', delta: chunk.delta });
|
|
1995
|
+
eventCount += 1;
|
|
1996
|
+
}
|
|
1997
|
+
break;
|
|
1998
|
+
case 'finish':
|
|
1999
|
+
controller.enqueue({
|
|
2000
|
+
type: 'finish',
|
|
2001
|
+
intent: chunk.messageMetadata?.intent ?? 'other',
|
|
2002
|
+
sources: chunk.messageMetadata?.sources ?? [],
|
|
2003
|
+
});
|
|
2004
|
+
eventCount += 1;
|
|
2005
|
+
break;
|
|
2006
|
+
case 'error':
|
|
2007
|
+
logger?.warn(
|
|
2008
|
+
`[dropshipper] chat-stream-error errorText=${chunk.errorText}`
|
|
2009
|
+
);
|
|
2010
|
+
controller.enqueue({
|
|
2011
|
+
type: 'error',
|
|
2012
|
+
errorText: chunk.errorText ?? 'Error inesperado del asistente.',
|
|
2013
|
+
});
|
|
2014
|
+
eventCount += 1;
|
|
2015
|
+
break;
|
|
2016
|
+
default:
|
|
2017
|
+
logger?.debug(
|
|
2018
|
+
`[dropshipper] chat-stream-unknown-frame type=${chunk.type}`
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
logger?.debug(`[dropshipper] chat-stream-completed events=${eventCount}`);
|
|
2023
|
+
controller.close();
|
|
2024
|
+
},
|
|
2025
|
+
cancel() {
|
|
2026
|
+
stream.cancel?.();
|
|
2027
|
+
},
|
|
2028
|
+
});
|
|
2029
|
+
}
|
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
|
|