@manonero/chat-client-sdk 0.0.1-beta.1 → 0.0.1-beta.11
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/README.md +398 -138
- package/dist/ChatClient.d.ts +53 -4
- package/dist/ChatClient.d.ts.map +1 -1
- package/dist/ChatClient.js +37 -13
- package/dist/ChatClient.js.map +1 -1
- package/dist/http/BotApi.d.ts +4 -1
- package/dist/http/BotApi.d.ts.map +1 -1
- package/dist/http/BotApi.js +4 -1
- package/dist/http/BotApi.js.map +1 -1
- package/dist/http/ConversationApi.d.ts +8 -5
- package/dist/http/ConversationApi.d.ts.map +1 -1
- package/dist/http/ConversationApi.js +11 -11
- package/dist/http/ConversationApi.js.map +1 -1
- package/dist/http/FileApi.d.ts +9 -11
- package/dist/http/FileApi.d.ts.map +1 -1
- package/dist/http/FileApi.js +8 -12
- package/dist/http/FileApi.js.map +1 -1
- package/dist/http/SearchApi.d.ts +39 -0
- package/dist/http/SearchApi.d.ts.map +1 -0
- package/dist/http/SearchApi.js +44 -0
- package/dist/http/SearchApi.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/realtime/ChatHubClient.d.ts +29 -2
- package/dist/realtime/ChatHubClient.d.ts.map +1 -1
- package/dist/realtime/ChatHubClient.js +36 -5
- package/dist/realtime/ChatHubClient.js.map +1 -1
- package/dist/realtime/NotificationHubClient.d.ts +11 -0
- package/dist/realtime/NotificationHubClient.d.ts.map +1 -1
- package/dist/realtime/NotificationHubClient.js +7 -3
- package/dist/realtime/NotificationHubClient.js.map +1 -1
- package/dist/realtime/ReconnectionManager.d.ts +9 -1
- package/dist/realtime/ReconnectionManager.d.ts.map +1 -1
- package/dist/realtime/ReconnectionManager.js +12 -2
- package/dist/realtime/ReconnectionManager.js.map +1 -1
- package/dist/realtime/connectionOptions.d.ts +32 -0
- package/dist/realtime/connectionOptions.d.ts.map +1 -0
- package/dist/realtime/connectionOptions.js +55 -0
- package/dist/realtime/connectionOptions.js.map +1 -0
- package/dist/types/block.d.ts +1 -0
- package/dist/types/block.d.ts.map +1 -1
- package/dist/types/block.js.map +1 -1
- package/dist/types/bot.d.ts +64 -2
- package/dist/types/bot.d.ts.map +1 -1
- package/dist/types/broadcast.d.ts +50 -0
- package/dist/types/broadcast.d.ts.map +1 -0
- package/dist/types/broadcast.js +3 -0
- package/dist/types/broadcast.js.map +1 -0
- package/dist/types/chat-events.d.ts +21 -18
- package/dist/types/chat-events.d.ts.map +1 -1
- package/dist/types/common.d.ts +14 -4
- package/dist/types/common.d.ts.map +1 -1
- package/dist/types/conversation.d.ts +2 -2
- package/dist/types/conversation.d.ts.map +1 -1
- package/dist/types/participant.d.ts +5 -0
- package/dist/types/participant.d.ts.map +1 -1
- package/dist/types/search.d.ts +47 -0
- package/dist/types/search.d.ts.map +1 -0
- package/dist/types/search.js +3 -0
- package/dist/types/search.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,19 +13,20 @@
|
|
|
13
13
|
5. [Participant — ParticipantApi](#5-participant--participantapi)
|
|
14
14
|
6. [Conversation — ConversationApi](#6-conversation--conversationapi)
|
|
15
15
|
7. [Message — MessageApi](#7-message--messageapi)
|
|
16
|
-
8. [
|
|
17
|
-
9. [
|
|
18
|
-
10. [
|
|
19
|
-
11. [
|
|
20
|
-
12. [
|
|
21
|
-
13. [Real-time:
|
|
22
|
-
14. [
|
|
23
|
-
15. [
|
|
24
|
-
16. [
|
|
25
|
-
17. [
|
|
26
|
-
18. [
|
|
27
|
-
19. [
|
|
28
|
-
20. [
|
|
16
|
+
8. [Tìm kiếm — SearchApi](#8-tìm-kiếm--searchapi)
|
|
17
|
+
9. [File — FileApi](#9-file--fileapi)
|
|
18
|
+
10. [Bot — BotApi](#10-bot--botapi)
|
|
19
|
+
11. [Proxy — ProxyApi](#11-proxy--proxyapi)
|
|
20
|
+
12. [Health — HealthApi](#12-health--healthapi)
|
|
21
|
+
13. [Real-time: ChatHubClient](#13-real-time-chathubclient)
|
|
22
|
+
14. [Real-time: NotificationHubClient](#14-real-time-notificationhubclient)
|
|
23
|
+
15. [ReconnectionManager](#15-reconnectionmanager)
|
|
24
|
+
16. [Hệ thống Block (nội dung tin nhắn)](#16-hệ-thống-block-nội-dung-tin-nhắn)
|
|
25
|
+
17. [Toàn bộ kiểu dữ liệu (Types)](#17-toàn-bộ-kiểu-dữ-liệu-types)
|
|
26
|
+
18. [Xử lý lỗi — ChatApiError](#18-xử-lý-lỗi--chatapierror)
|
|
27
|
+
19. [TypedEventEmitter](#19-typedeventemitter)
|
|
28
|
+
20. [Lưu ý quan trọng](#20-lưu-ý-quan-trọng)
|
|
29
|
+
21. [Ví dụ đầy đủ](#21-ví-dụ-đầy-đủ)
|
|
29
30
|
|
|
30
31
|
---
|
|
31
32
|
|
|
@@ -37,6 +38,7 @@ ChatClient (facade chính)
|
|
|
37
38
|
├── participants → ParticipantApi (quản lý người dùng)
|
|
38
39
|
├── conversations → ConversationApi (quản lý cuộc hội thoại)
|
|
39
40
|
├── messages → MessageApi (tin nhắn)
|
|
41
|
+
├── search → SearchApi (tìm kiếm tổng hợp conversations + messages)
|
|
40
42
|
├── files → FileApi (upload/download file)
|
|
41
43
|
├── bots → BotApi (quản lý bot)
|
|
42
44
|
├── proxy → ProxyApi (proxy đến backend khác)
|
|
@@ -92,6 +94,9 @@ import type { HttpClientOptions } from '@manonero/chat-client-sdk';
|
|
|
92
94
|
// Event map types — dùng khi cần typed wrapper
|
|
93
95
|
import type { ChatHubEventMap, NotificationHubEventMap } from '@manonero/chat-client-sdk';
|
|
94
96
|
|
|
97
|
+
// Ephemeral broadcast (dùng chung cho cả hai hub) — generic theo kiểu của `data`
|
|
98
|
+
import type { EphemeralBroadcastDto, BroadcastSenderType } from '@manonero/chat-client-sdk';
|
|
99
|
+
|
|
95
100
|
// Hub options (khi dùng hub client độc lập, không qua ChatClient)
|
|
96
101
|
import type { ChatHubClientOptions, NotificationHubClientOptions } from '@manonero/chat-client-sdk';
|
|
97
102
|
|
|
@@ -187,6 +192,50 @@ const client = new ChatClient({
|
|
|
187
192
|
});
|
|
188
193
|
```
|
|
189
194
|
|
|
195
|
+
### Cấu hình transport SignalR (`connectionOptions`)
|
|
196
|
+
|
|
197
|
+
SDK mặc định kết nối thẳng qua WebSockets, bỏ qua bước negotiate:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
{
|
|
201
|
+
skipNegotiation: true,
|
|
202
|
+
transport: HttpTransportType.WebSockets,
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Mọi giá trị truyền vào `signalrOptions.connectionOptions` sẽ **ghi đè** mặc định trên (đây là toàn bộ `IHttpConnectionOptions` của `@microsoft/signalr`, nên có thể override cả `accessTokenFactory`, `headers`, `withCredentials`, `httpClient`, …):
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { HttpTransportType } from '@microsoft/signalr';
|
|
210
|
+
|
|
211
|
+
// Quay lại luồng negotiate tiêu chuẩn (ví dụ khi đứng sau proxy không hỗ trợ WS)
|
|
212
|
+
const client = new ChatClient({
|
|
213
|
+
baseUrl: 'https://chat-api.example.com',
|
|
214
|
+
signalrOptions: {
|
|
215
|
+
connectionOptions: {
|
|
216
|
+
skipNegotiation: false,
|
|
217
|
+
transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Có thể cấu hình riêng cho từng hub; giá trị riêng được merge đè lên `connectionOptions` chung:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const client = new ChatClient({
|
|
227
|
+
baseUrl: 'https://chat-api.example.com',
|
|
228
|
+
signalrOptions: {
|
|
229
|
+
connectionOptions: { withCredentials: false }, // áp dụng cho cả 2 hub
|
|
230
|
+
notificationConnectionOptions: { skipNegotiation: false }, // chỉ NotificationHub
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
> **Lưu ý:** `skipNegotiation: true` chỉ hợp lệ khi `transport` đúng bằng `HttpTransportType.WebSockets` — kể cả tổ hợp cờ như `WebSockets | LongPolling` cũng bị SignalR từ chối. Nếu bạn đổi `transport` sang giá trị khác mà không nói gì về `skipNegotiation`, SDK sẽ tự bỏ mặc định `skipNegotiation: true`; nếu không, lỗi sẽ xuất hiện dưới dạng `connect()` bị reject (SignalR kiểm tra trong `HttpConnection.startInternal`, không phải lúc `build()`).
|
|
236
|
+
>
|
|
237
|
+
> Một key mang giá trị `undefined` được coi là "không cấu hình" và vẫn giữ mặc định — nên `connectionOptions: { transport: config.transport }` với `config.transport` optional sẽ không vô tình xoá mất transport mặc định.
|
|
238
|
+
|
|
190
239
|
---
|
|
191
240
|
|
|
192
241
|
## 3. ChatClient — Facade chính
|
|
@@ -208,6 +257,9 @@ new ChatClient(options: ChatClientOptions)
|
|
|
208
257
|
| `tokenProvider` | `() => string \| null` | ❌ | Hàm lấy token từ bên ngoài |
|
|
209
258
|
| `requestTimeoutMs` | `number \| null` | ❌ | Timeout HTTP / upload tính bằng ms. Mặc định `30000`. Truyền `null` để tắt (hữu ích khi upload file lớn). |
|
|
210
259
|
| `signalrOptions.logLevel` | `LogLevel` | ❌ | Mức log cho SignalR (mặc định: `Warning`) |
|
|
260
|
+
| `signalrOptions.connectionOptions` | `IHttpConnectionOptions` | ❌ | Options truyền cho `withUrl()` của cả 2 hub. Ghi đè mặc định `skipNegotiation: true` + `transport: WebSockets` |
|
|
261
|
+
| `signalrOptions.chatConnectionOptions` | `IHttpConnectionOptions` | ❌ | Chỉ áp dụng cho ChatHub, merge đè lên `connectionOptions` |
|
|
262
|
+
| `signalrOptions.notificationConnectionOptions` | `IHttpConnectionOptions` | ❌ | Chỉ áp dụng cho NotificationHub, merge đè lên `connectionOptions` |
|
|
211
263
|
|
|
212
264
|
### Thuộc tính
|
|
213
265
|
|
|
@@ -217,6 +269,7 @@ new ChatClient(options: ChatClientOptions)
|
|
|
217
269
|
| `participants` | `ParticipantApi` | Quản lý participant |
|
|
218
270
|
| `conversations` | `ConversationApi` | Quản lý cuộc hội thoại |
|
|
219
271
|
| `messages` | `MessageApi` | Thao tác với tin nhắn |
|
|
272
|
+
| `search` | `SearchApi` | Tìm kiếm tổng hợp conversations + messages |
|
|
220
273
|
| `files` | `FileApi` | Upload/download file |
|
|
221
274
|
| `bots` | `BotApi` | Quản lý bot |
|
|
222
275
|
| `proxy` | `ProxyApi` | Proxy đến backend khác |
|
|
@@ -235,6 +288,8 @@ Cập nhật JWT token nội bộ. Có hiệu lực **ngay** với mọi HTTP re
|
|
|
235
288
|
|
|
236
289
|
> ℹ️ Nếu `tokenProvider` đã được cấu hình và vẫn trả về giá trị, provider tiếp tục được ưu tiên — `setToken()` chỉ ảnh hưởng khi provider trả `null`/empty.
|
|
237
290
|
|
|
291
|
+
> ⚠️ Truyền token **khác `null`** không đụng tới `currentUser` — SDK giả định đây là rotate token của **cùng một user** (đúng use case của `onTokenExpired`, xem mục 15). Nếu bạn dùng `setToken()` để **đổi sang user khác** mà không gọi `login*()` lại, `currentUser` sẽ vẫn trỏ về user cũ (sai). Muốn đổi user, hãy gọi lại `auth.login*()` (tự cập nhật cả token lẫn `currentUser`), hoặc `clearToken()`/`setToken(null)` trước.
|
|
292
|
+
|
|
238
293
|
```ts
|
|
239
294
|
client.setToken('new-jwt-token-here');
|
|
240
295
|
await client.refreshConnections(); // tuỳ chọn — chỉ khi muốn áp ngay cho SignalR
|
|
@@ -250,9 +305,11 @@ client.clearToken();
|
|
|
250
305
|
|
|
251
306
|
#### `refreshConnections(): Promise<void>`
|
|
252
307
|
|
|
253
|
-
|
|
308
|
+
Reconnect lại từng hub đang ở trạng thái `Connected` với token mới nhất. Hub đang Disconnected/Reconnecting được giữ nguyên. Dùng để áp token mới (sau `setToken()`) cho phiên SignalR đang chạy.
|
|
309
|
+
|
|
310
|
+
> ℹ️ ChatHub **giữ nguyên** các conversation đã join: SDK snapshot danh sách trước khi reconnect rồi tự `joinConversation` lại sau đó (best-effort), nên event theo hội thoại tiếp tục chảy qua lần xoay token. NotificationHub không có khái niệm join nên tự động nhận lại event sau khi reconnect.
|
|
254
311
|
|
|
255
|
-
>
|
|
312
|
+
> ℹ️ Nếu tại thời điểm gọi **không còn token nào khả dụng** (ví dụ vừa `setToken(null)`/`clearToken()` và không có `tokenProvider` nào trả về giá trị thay thế), hub đang `Connected` sẽ được **disconnect** thay vì reconnect — vì không có token hợp lệ để reconnect. Nhờ vậy gọi `refreshConnections()` ngay sau khi xoá token là an toàn, không throw.
|
|
256
313
|
|
|
257
314
|
```ts
|
|
258
315
|
client.setToken(newToken);
|
|
@@ -435,20 +492,24 @@ const conv = await client.conversations.create({
|
|
|
435
492
|
});
|
|
436
493
|
```
|
|
437
494
|
|
|
438
|
-
|
|
495
|
+
> ⚠️ **Đã gỡ bỏ `conversations.search()`** — endpoint backend `GET /api/conversations/search` không còn được proxy qua gateway (trả `404`). Dùng endpoint tìm kiếm tổng hợp `client.search.search()` thay thế — xem [mục 8 — SearchApi](#8-tìm-kiếm--searchapi).
|
|
439
496
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
```ts
|
|
443
|
-
const results = await client.conversations.search('team', 10);
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
### `getById(id: string): Promise<ConversationDto>`
|
|
497
|
+
### `getById(id, options?): Promise<ConversationDto>`
|
|
447
498
|
|
|
448
499
|
```ts
|
|
500
|
+
// Mặc định: bao gồm danh sách thành viên
|
|
449
501
|
const conv = await client.conversations.getById('conv-id-here');
|
|
502
|
+
|
|
503
|
+
// Bỏ danh sách thành viên để giảm payload — khi đó `participants` trả về []
|
|
504
|
+
const light = await client.conversations.getById('conv-id-here', {
|
|
505
|
+
includeParticipants: false,
|
|
506
|
+
});
|
|
450
507
|
```
|
|
451
508
|
|
|
509
|
+
| Param | Kiểu | Mặc định | Mô tả |
|
|
510
|
+
|-------|------|----------|-------|
|
|
511
|
+
| `options.includeParticipants` | `boolean` | `true` | Đặt `false` để bỏ mảng `participants` khỏi response (spec §6.4). Khi `false`, `participants` là `[]`. |
|
|
512
|
+
|
|
452
513
|
### `update(id, request): Promise<ConversationDto>`
|
|
453
514
|
|
|
454
515
|
```ts
|
|
@@ -536,7 +597,7 @@ if (page1.hasMore) {
|
|
|
536
597
|
```ts
|
|
537
598
|
interface ConversationDto {
|
|
538
599
|
id: string;
|
|
539
|
-
name
|
|
600
|
+
name?: string | null;
|
|
540
601
|
avatar?: MediaReference | null;
|
|
541
602
|
ownerId: string;
|
|
542
603
|
lastMessageAt?: string | null; // ISO 8601, null khi chưa có tin nhắn
|
|
@@ -661,12 +722,14 @@ const newer = await client.messages.getHistory('conv-id', {
|
|
|
661
722
|
|
|
662
723
|
### `search(conversationId, query, limit?): Promise<MessageDto[]>`
|
|
663
724
|
|
|
664
|
-
Full-text search trong cuộc hội thoại.
|
|
725
|
+
Full-text search **trong phạm vi một cuộc hội thoại** (`GET /api/conversations/{conversationId}/messages/search`, spec §7.3).
|
|
665
726
|
|
|
666
727
|
```ts
|
|
667
728
|
const results = await client.messages.search('conv-id', 'từ khóa tìm kiếm', 20);
|
|
668
729
|
```
|
|
669
730
|
|
|
731
|
+
> ℹ️ Để tìm kiếm **xuyên suốt tất cả** cuộc hội thoại và tin nhắn, dùng `client.search.search()` — xem [mục 8 — SearchApi](#8-tìm-kiếm--searchapi).
|
|
732
|
+
|
|
670
733
|
### `getById(messageId): Promise<MessageDto>`
|
|
671
734
|
|
|
672
735
|
```ts
|
|
@@ -773,7 +836,67 @@ interface EditMessageRequest {
|
|
|
773
836
|
|
|
774
837
|
---
|
|
775
838
|
|
|
776
|
-
## 8.
|
|
839
|
+
## 8. Tìm kiếm — SearchApi
|
|
840
|
+
|
|
841
|
+
**Truy cập:** `client.search`
|
|
842
|
+
|
|
843
|
+
Endpoint tìm kiếm tổng hợp do gateway cung cấp (`GET /api/search`, spec §6.3) — tìm **conversations**, **messages**, hoặc **cả hai** trong một request. Yêu cầu JWT.
|
|
844
|
+
|
|
845
|
+
> Endpoint này **thay thế** cho `GET /api/conversations/search` và `GET /api/messages/search` (global) của backend — hai endpoint đó không còn được proxy qua gateway. Việc tìm kiếm tin nhắn trong phạm vi **một** cuộc hội thoại vẫn dùng `client.messages.search()` (spec §7.3).
|
|
846
|
+
|
|
847
|
+
### `search(params): Promise<SearchResult>`
|
|
848
|
+
|
|
849
|
+
| Param | Kiểu | Mặc định | Mô tả |
|
|
850
|
+
|-------|------|----------|-------|
|
|
851
|
+
| `q` | `string` | — (**bắt buộc**) | Từ khóa tìm kiếm. Thiếu/rỗng → `400 Bad Request`. |
|
|
852
|
+
| `type` | `'MESSAGE' \| 'CONVERSATION' \| 'BOTH'` | `'BOTH'` | Phạm vi tìm kiếm. Giá trị khác → `400 Bad Request`. |
|
|
853
|
+
| `limit` | `number` | `20` | Số kết quả mỗi loại. |
|
|
854
|
+
| `cursor` | `string` | — | Phân trang — **chỉ áp dụng cho messages** (opaque `nextCursor` từ response trước). |
|
|
855
|
+
|
|
856
|
+
**Hành vi theo `type`:**
|
|
857
|
+
|
|
858
|
+
- `CONVERSATION` — tìm conversations; mỗi item kèm `lastMessage` (full `MessageDto`). `messages` trả về `null`.
|
|
859
|
+
- `MESSAGE` — tìm messages, **gom nhóm theo conversation**; mỗi nhóm kèm thông tin `conversation`. Phân trang (`nextCursor`/`hasMore`) phản ánh messages. `conversations` trả về `null`.
|
|
860
|
+
- `BOTH` — thực hiện cả hai (mặc định).
|
|
861
|
+
|
|
862
|
+
> Response **luôn có cả hai key** `conversations` và `messages`; phần không được yêu cầu bởi `type` sẽ là `null`.
|
|
863
|
+
|
|
864
|
+
```ts
|
|
865
|
+
// Tìm cả hai loại (mặc định)
|
|
866
|
+
const result = await client.search.search({ q: 'hello' });
|
|
867
|
+
|
|
868
|
+
// Conversations: kiểm tra lastMessage.isDeleted trước khi hiển thị
|
|
869
|
+
for (const conv of result.conversations ?? []) {
|
|
870
|
+
const preview = conv.lastMessage?.isDeleted
|
|
871
|
+
? 'Tin nhắn đã xóa'
|
|
872
|
+
: conv.lastMessage?.plainTextIndex ?? '';
|
|
873
|
+
console.log(conv.name, preview);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Messages: gom nhóm theo conversation
|
|
877
|
+
for (const group of result.messages?.groups ?? []) {
|
|
878
|
+
console.log('Trong:', group.conversation?.name ?? '(không rõ)');
|
|
879
|
+
for (const msg of group.messages) console.log(' -', msg.plainTextIndex);
|
|
880
|
+
}
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
```ts
|
|
884
|
+
// Chỉ tìm messages + phân trang
|
|
885
|
+
let page = await client.search.search({ q: 'hello', type: 'MESSAGE', limit: 20 });
|
|
886
|
+
while (page.messages?.hasMore) {
|
|
887
|
+
page = await client.search.search({
|
|
888
|
+
q: 'hello',
|
|
889
|
+
type: 'MESSAGE',
|
|
890
|
+
cursor: page.messages.nextCursor!, // opaque string — KHÔNG cast sang number
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
> ⚠️ Giống `client.conversations.list()`, backend **không** filter `isDeleted` cho `lastMessage` — client **phải** tự kiểm tra `lastMessage.isDeleted` trước khi hiển thị. Nếu batch fetch lỗi, `lastMessage` (của conversation) hoặc `conversation` (của nhóm message) có thể là `null` (degrade graceful).
|
|
896
|
+
|
|
897
|
+
---
|
|
898
|
+
|
|
899
|
+
## 9. File — FileApi
|
|
777
900
|
|
|
778
901
|
**Truy cập:** `client.files`
|
|
779
902
|
|
|
@@ -820,19 +943,9 @@ const result = await client.files.upload(uploadUrl, file, (loaded, total) => {
|
|
|
820
943
|
|
|
821
944
|
> Khi có `onProgress`, SDK dùng XHR thay vì fetch (vì fetch không hỗ trợ upload progress events).
|
|
822
945
|
|
|
823
|
-
### `getDownloadUrl(storageKey, signed
|
|
824
|
-
|
|
825
|
-
Tạo URL tải file tuyệt đối (dùng để hiển thị ảnh, tải xuống, v.v.).
|
|
826
|
-
|
|
827
|
-
```ts
|
|
828
|
-
// URL public (mặc định)
|
|
829
|
-
const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg');
|
|
830
|
-
// → "https://chat-api.example.com/api/files/01HXABCDEF/photo.jpg"
|
|
831
|
-
```
|
|
832
|
-
|
|
833
|
-
#### Signed URL (kiểm soát truy cập)
|
|
946
|
+
### `getDownloadUrl(storageKey, signed): string`
|
|
834
947
|
|
|
835
|
-
|
|
948
|
+
Tạo URL tải file tuyệt đối có chữ ký (dùng để hiển thị ảnh, tải xuống, v.v.). Theo spec §8.3, **cả `expires` lẫn `sig` đều bắt buộc** — không có public unsigned access.
|
|
836
949
|
|
|
837
950
|
```ts
|
|
838
951
|
const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
|
|
@@ -842,9 +955,9 @@ const url = client.files.getDownloadUrl('01HXABCDEF/photo.jpg', {
|
|
|
842
955
|
// → ".../api/files/01HXABCDEF/photo.jpg?expires=...&sig=abc123..."
|
|
843
956
|
```
|
|
844
957
|
|
|
845
|
-
> Server trả `400 Bad Request` (title `Invalid Signature`) nếu URL bị sửa hoặc đã hết hạn. `expires` và `sig`
|
|
958
|
+
> Server trả `400 Bad Request` (title `Invalid Signature`) nếu URL bị sửa hoặc đã hết hạn. `expires` và `sig` phải lấy từ nguồn server-issued (không tự ghép).
|
|
846
959
|
|
|
847
|
-
> **
|
|
960
|
+
> **Trong thực tế:** Server thường trả sẵn `url` trong `MediaReference` (đã gắn `expires` + `sig`) cho mọi media của tin nhắn / participant. Ưu tiên dùng `MediaReference.url` (xem [MediaReference](#mediareference)) — chỉ gọi `getDownloadUrl()` khi bạn có sẵn cặp `expires`/`sig` từ một response server khác (ví dụ: `downloadUrl` từ `POST /api/files/upload`).
|
|
848
961
|
|
|
849
962
|
> **Encoding:** `storageKey` có thể chứa `/` — SDK giữ `/` làm phân cách path; các ký tự đặc biệt khác (space, `?`, `#`, `%`...) tự động được percent-encode theo từng segment.
|
|
850
963
|
|
|
@@ -883,7 +996,7 @@ interface UploadResponse {
|
|
|
883
996
|
|
|
884
997
|
---
|
|
885
998
|
|
|
886
|
-
##
|
|
999
|
+
## 10. Bot — BotApi
|
|
887
1000
|
|
|
888
1001
|
**Truy cập:** `client.bots`
|
|
889
1002
|
|
|
@@ -916,17 +1029,31 @@ const bot = await client.bots.create({
|
|
|
916
1029
|
uniqueName: 'my-bot',
|
|
917
1030
|
fullName: 'My Assistant Bot',
|
|
918
1031
|
description: 'Trợ lý AI',
|
|
919
|
-
kafkaTopic: 'my-bot-topic',
|
|
1032
|
+
kafkaTopic: 'my-bot-topic', // optional — null/omit = không nhận event qua Kafka
|
|
1033
|
+
webhookUrl: 'https://bot.example.com/events', // optional — HTTP/2 NDJSON webhook
|
|
1034
|
+
deliveryPreference: 'Both', // optional — 'Both' | 'WebhookOnly' | 'KafkaOnly' (server default 'Both')
|
|
920
1035
|
listenAllGroupMessages: false,
|
|
921
1036
|
});
|
|
922
1037
|
```
|
|
923
1038
|
|
|
1039
|
+
> Bot có thể nhận outgoing event qua `kafkaTopic`, `webhookUrl`, cả hai, hoặc không cấu hình kênh nào. Cả hai field đều **optional** độc lập với nhau.
|
|
1040
|
+
>
|
|
1041
|
+
> `deliveryPreference` chỉ có hiệu lực khi bot có **cả** `kafkaTopic` lẫn `webhookUrl` được cấu hình. Bot chỉ có một kênh sẽ luôn dùng kênh đó bất kể preference.
|
|
1042
|
+
|
|
924
1043
|
### `update(id, request): Promise<BotDto>`
|
|
925
1044
|
|
|
926
1045
|
```ts
|
|
927
1046
|
await client.bots.update('bot-id', {
|
|
928
1047
|
fullName: 'Updated Bot Name',
|
|
929
|
-
|
|
1048
|
+
kafkaTopic: 'new-topic', // set/đổi topic
|
|
1049
|
+
webhookUrl: 'https://bot.example.com/events/v2', // set/đổi webhook
|
|
1050
|
+
deliveryPreference: 'WebhookOnly', // optional — bỏ qua/null = không đổi
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
// Để clear về null, dùng cờ riêng (mutually exclusive với việc set giá trị mới):
|
|
1054
|
+
await client.bots.update('bot-id', {
|
|
1055
|
+
clearKafkaTopic: true,
|
|
1056
|
+
clearWebhookUrl: true,
|
|
930
1057
|
});
|
|
931
1058
|
```
|
|
932
1059
|
|
|
@@ -948,17 +1075,22 @@ await client.bots.delete('bot-id');
|
|
|
948
1075
|
### Kiểu dữ liệu Bot
|
|
949
1076
|
|
|
950
1077
|
```ts
|
|
1078
|
+
// Kênh nhận outgoing event. Chỉ có hiệu lực khi bot có cả kafkaTopic lẫn webhookUrl.
|
|
1079
|
+
type BotDeliveryPreference = 'Both' | 'WebhookOnly' | 'KafkaOnly';
|
|
1080
|
+
|
|
951
1081
|
interface BotDto {
|
|
952
1082
|
id: string;
|
|
953
|
-
participantId: string;
|
|
1083
|
+
participantId: string; // Dùng để lấy name/avatar từ ParticipantApi
|
|
954
1084
|
description?: string | null;
|
|
955
1085
|
metadata?: Record<string, unknown> | null;
|
|
956
|
-
kafkaTopic
|
|
1086
|
+
kafkaTopic?: string | null; // null khi bot không cấu hình Kafka
|
|
1087
|
+
webhookUrl?: string | null; // HTTP/2 NDJSON webhook (chỉ MessageCreatedEvent); null khi không cấu hình
|
|
1088
|
+
deliveryPreference: BotDeliveryPreference; // Chỉ áp dụng khi cả kafkaTopic và webhookUrl đều được cấu hình
|
|
957
1089
|
isActive: boolean;
|
|
958
1090
|
listenAllGroupMessages: boolean;
|
|
959
|
-
createdBy: string;
|
|
960
|
-
createdAt: string;
|
|
961
|
-
updatedAt: string;
|
|
1091
|
+
createdBy: string; // ID của người tạo bot
|
|
1092
|
+
createdAt: string; // ISO 8601
|
|
1093
|
+
updatedAt: string; // ISO 8601
|
|
962
1094
|
}
|
|
963
1095
|
```
|
|
964
1096
|
|
|
@@ -968,29 +1100,35 @@ interface BotDto {
|
|
|
968
1100
|
|
|
969
1101
|
```ts
|
|
970
1102
|
interface CreateBotRequest {
|
|
971
|
-
uniqueName: string;
|
|
972
|
-
fullName: string;
|
|
1103
|
+
uniqueName: string; // max 50, [a-zA-Z0-9_]
|
|
1104
|
+
fullName: string; // max 100
|
|
973
1105
|
avatar?: MediaReference; // optional — avatar của bot
|
|
974
|
-
description?: string;
|
|
1106
|
+
description?: string; // max 500
|
|
975
1107
|
metadata?: Record<string, unknown>;
|
|
976
|
-
kafkaTopic
|
|
1108
|
+
kafkaTopic?: string | null; // optional — max 200, [a-zA-Z0-9._-]
|
|
1109
|
+
webhookUrl?: string | null; // optional — absolute http/https URI, max 2000
|
|
1110
|
+
deliveryPreference?: BotDeliveryPreference; // optional — server default 'Both'
|
|
977
1111
|
listenAllGroupMessages?: boolean; // Mặc định false
|
|
978
1112
|
}
|
|
979
1113
|
|
|
980
1114
|
interface UpdateBotRequest {
|
|
981
|
-
uniqueName?: string;
|
|
1115
|
+
uniqueName?: string;
|
|
982
1116
|
fullName?: string;
|
|
983
1117
|
avatar?: MediaReference;
|
|
984
1118
|
description?: string;
|
|
985
1119
|
metadata?: Record<string, unknown>;
|
|
986
1120
|
listenAllGroupMessages?: boolean;
|
|
987
|
-
//
|
|
1121
|
+
kafkaTopic?: string | null; // set/đổi topic (mutually exclusive với clearKafkaTopic)
|
|
1122
|
+
clearKafkaTopic?: boolean; // true → clear topic về null
|
|
1123
|
+
webhookUrl?: string | null; // set/đổi webhook (mutually exclusive với clearWebhookUrl)
|
|
1124
|
+
clearWebhookUrl?: boolean; // true → clear webhook về null
|
|
1125
|
+
deliveryPreference?: BotDeliveryPreference | null; // bỏ qua/null = không đổi
|
|
988
1126
|
}
|
|
989
1127
|
```
|
|
990
1128
|
|
|
991
1129
|
---
|
|
992
1130
|
|
|
993
|
-
##
|
|
1131
|
+
## 11. Proxy — ProxyApi
|
|
994
1132
|
|
|
995
1133
|
**Truy cập:** `client.proxy`
|
|
996
1134
|
|
|
@@ -1047,7 +1185,7 @@ const result = await client.proxy.request<unknown>('myservice', 'api/resource/12
|
|
|
1047
1185
|
|
|
1048
1186
|
---
|
|
1049
1187
|
|
|
1050
|
-
##
|
|
1188
|
+
## 12. Health — HealthApi
|
|
1051
1189
|
|
|
1052
1190
|
**Truy cập:** `client.health`
|
|
1053
1191
|
|
|
@@ -1067,7 +1205,7 @@ const ready = await client.health.ready(); // "Healthy"
|
|
|
1067
1205
|
|
|
1068
1206
|
---
|
|
1069
1207
|
|
|
1070
|
-
##
|
|
1208
|
+
## 13. Real-time: ChatHubClient
|
|
1071
1209
|
|
|
1072
1210
|
**Truy cập:** `client.realtime.chat`
|
|
1073
1211
|
|
|
@@ -1205,9 +1343,10 @@ client.realtime.chat.off('messageReceived', handler);
|
|
|
1205
1343
|
| `streamStarted` | `StreamStartedDto` | Bot stream bắt đầu |
|
|
1206
1344
|
| `streamStatusUpdated` | `StreamStatusUpdatedDto` | Trạng thái bot stream thay đổi |
|
|
1207
1345
|
| `streamChunkReceived` | `StreamChunkReceivedDto` | Nhận thêm chunk text từ bot |
|
|
1208
|
-
| `
|
|
1346
|
+
| `streamBlockReceived` | `StreamBlockReceivedDto` | Nhận inline content block (widget) đan xen giữa các chunk text, theo `sequence` |
|
|
1209
1347
|
| `streamCompleted` | `StreamCompletedDto` | Bot stream hoàn thành (kèm message đầy đủ) |
|
|
1210
1348
|
| `streamAborted` | `StreamAbortedDto` | Bot stream bị hủy |
|
|
1349
|
+
| `ephemeralBroadcast` | `EphemeralBroadcastDto` | Broadcast tạm thời (không lưu) nhắm `audience="viewers"` của conversation đang mở — `conversationId` luôn có giá trị |
|
|
1211
1350
|
| `error` | `HubErrorDto` | Lỗi từ server |
|
|
1212
1351
|
| `reconnecting` | `Error \| undefined` | Đang reconnect |
|
|
1213
1352
|
| `reconnected` | `string \| undefined` | Đã reconnect (connectionId mới) |
|
|
@@ -1222,7 +1361,7 @@ interface MessageUpdatedDto {
|
|
|
1222
1361
|
conversationId: string;
|
|
1223
1362
|
blocks: Block[];
|
|
1224
1363
|
plainTextContent: string | null;
|
|
1225
|
-
mentions?: Mention[];
|
|
1364
|
+
mentions?: Mention[] | null;
|
|
1226
1365
|
updatedAt: string; // ISO 8601
|
|
1227
1366
|
replyToMessageId?: string | null; // null nếu reply reference đã bị xóa
|
|
1228
1367
|
}
|
|
@@ -1298,25 +1437,33 @@ client.realtime.chat.on('messageThumbnailsReady', (dto) => {
|
|
|
1298
1437
|
Khi bot streaming, **không có** `messageReceived` — thay vào đó nhận chuỗi sự kiện sau:
|
|
1299
1438
|
|
|
1300
1439
|
```
|
|
1301
|
-
streamStarted → streamStatusUpdated* → streamChunkReceived* → streamCompleted
|
|
1302
|
-
|
|
1440
|
+
streamStarted → streamStatusUpdated* → [streamChunkReceived | streamBlockReceived]* → streamCompleted
|
|
1441
|
+
→ streamAborted
|
|
1303
1442
|
```
|
|
1304
1443
|
|
|
1444
|
+
`streamChunkReceived` (text) và `streamBlockReceived` (inline block/widget) dùng **chung** dãy số `sequence` (server gán, tăng dần từ 0). Client sắp xếp theo `sequence` để render đúng vị trí text↔block trong cùng một stream.
|
|
1445
|
+
|
|
1305
1446
|
```ts
|
|
1306
|
-
|
|
1447
|
+
// Mỗi phần tử là một text chunk hoặc một block, gắn với sequence để sắp xếp.
|
|
1448
|
+
const parts: { sequence: number; text?: string; block?: Block }[] = [];
|
|
1307
1449
|
|
|
1308
1450
|
client.realtime.chat.on('streamStarted', (dto) => {
|
|
1309
1451
|
console.log('Bot bắt đầu trả lời, streamId:', dto.streamId);
|
|
1310
1452
|
});
|
|
1311
1453
|
|
|
1312
1454
|
client.realtime.chat.on('streamChunkReceived', (dto) => {
|
|
1313
|
-
|
|
1314
|
-
// Hiển thị progressive:
|
|
1455
|
+
parts.push({ sequence: dto.sequence, text: dto.text });
|
|
1456
|
+
// Hiển thị progressive: sắp xếp theo sequence rồi nối lại
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
client.realtime.chat.on('streamBlockReceived', (dto) => {
|
|
1460
|
+
parts.push({ sequence: dto.sequence, block: dto.block });
|
|
1461
|
+
// Render block (card, choice, divider...) đúng vị trí theo sequence
|
|
1315
1462
|
});
|
|
1316
1463
|
|
|
1317
1464
|
client.realtime.chat.on('streamCompleted', (dto) => {
|
|
1318
1465
|
console.log('Tin nhắn hoàn chỉnh:', dto.message);
|
|
1319
|
-
|
|
1466
|
+
parts.length = 0; // reset
|
|
1320
1467
|
});
|
|
1321
1468
|
|
|
1322
1469
|
client.realtime.chat.on('streamAborted', (dto) => {
|
|
@@ -1324,9 +1471,44 @@ client.realtime.chat.on('streamAborted', (dto) => {
|
|
|
1324
1471
|
});
|
|
1325
1472
|
```
|
|
1326
1473
|
|
|
1474
|
+
**Giới hạn streaming (server enforce):**
|
|
1475
|
+
|
|
1476
|
+
| Giới hạn | Giá trị |
|
|
1477
|
+
|----------|---------|
|
|
1478
|
+
| Max chunk size | 4096 bytes |
|
|
1479
|
+
| Max chunks / stream | 2000 |
|
|
1480
|
+
| Max blocks / stream | 50 |
|
|
1481
|
+
| Max block size | 65536 bytes (64 KB) |
|
|
1482
|
+
| Max accumulated size | 1 MB |
|
|
1483
|
+
| Stream timeout | 120 giây |
|
|
1484
|
+
| Max stream duration | 300 giây |
|
|
1485
|
+
| Max concurrent streams / bot | 10 |
|
|
1486
|
+
|
|
1487
|
+
### Ephemeral Broadcast (tin real-time không lưu trữ)
|
|
1488
|
+
|
|
1489
|
+
Bot hoặc SYSTEM user có thể phát **broadcast tạm thời** (không lưu vào lịch sử, không có ack, không edit/delete) qua `POST /api/broadcasts`. Trên ChatHub, client nhận event `ephemeralBroadcast` khi broadcast nhắm `audience="viewers"` của conversation đang mở — khi đó `conversationId` luôn có giá trị.
|
|
1490
|
+
|
|
1491
|
+
`data` là một envelope JSON tự do; client diễn giải dựa trên `type`. Dùng generic `EphemeralBroadcastDto<TData>` để narrow kiểu của `data` khi cần (mặc định `unknown` để vẫn an toàn khi chưa khai báo).
|
|
1492
|
+
|
|
1493
|
+
```ts
|
|
1494
|
+
import type { EphemeralBroadcastDto } from '@manonero/chat-client-sdk';
|
|
1495
|
+
|
|
1496
|
+
interface PromoBanner { title: string; startsAt: string }
|
|
1497
|
+
|
|
1498
|
+
client.realtime.chat.on('ephemeralBroadcast', (b) => {
|
|
1499
|
+
// b.senderType: 'Bot' | 'System' ; b.conversationId luôn có giá trị trên ChatHub
|
|
1500
|
+
if (b.type === 'promo_banner') {
|
|
1501
|
+
const promo = b.data as PromoBanner; // narrow theo `type`
|
|
1502
|
+
showBanner(promo.title, promo.startsAt);
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
```
|
|
1506
|
+
|
|
1507
|
+
> Cùng event `ephemeralBroadcast` (và cùng kiểu `EphemeralBroadcastDto`) cũng có trên NotificationHub cho các audience cấp user — xem [mục 14](#14-real-time-notificationhubclient). Định nghĩa kiểu đầy đủ ở [mục 17](#17-toàn-bộ-kiểu-dữ-liệu-types).
|
|
1508
|
+
|
|
1327
1509
|
---
|
|
1328
1510
|
|
|
1329
|
-
##
|
|
1511
|
+
## 14. Real-time: NotificationHubClient
|
|
1330
1512
|
|
|
1331
1513
|
**Truy cập:** `client.realtime.notifications`
|
|
1332
1514
|
|
|
@@ -1370,6 +1552,7 @@ await client.realtime.notifications.disconnect();
|
|
|
1370
1552
|
| `participantLeft` | `ParticipantLeftDto` | Thành viên rời/bị xóa — gửi cho tất cả participant còn lại **và** chính người vừa rời (để sync xóa khỏi các tab khác) |
|
|
1371
1553
|
| `conversationPinned` | `ConversationPinnedDto` | Conversation được pin |
|
|
1372
1554
|
| `conversationUnpinned` | `ConversationUnpinnedDto` | Conversation được unpin |
|
|
1555
|
+
| `ephemeralBroadcast` | `EphemeralBroadcastDto` | Broadcast tạm thời (không lưu) — audience `members`/`all`/`users`; broadcast toàn cục có `conversationId: null` |
|
|
1373
1556
|
| `reconnecting` | `Error \| undefined` | Đang reconnect |
|
|
1374
1557
|
| `reconnected` | `string \| undefined` | Đã reconnect |
|
|
1375
1558
|
| `disconnected` | `Error \| undefined` | Mất kết nối |
|
|
@@ -1440,9 +1623,24 @@ interface ConversationUnpinnedDto {
|
|
|
1440
1623
|
}
|
|
1441
1624
|
```
|
|
1442
1625
|
|
|
1626
|
+
### Ephemeral Broadcast trên NotificationHub
|
|
1627
|
+
|
|
1628
|
+
Giống ChatHub, NotificationHub cũng phát event `ephemeralBroadcast` (cùng kiểu `EphemeralBroadcastDto`) — nhưng cho các audience **cấp user**: `audience="members"` (mọi thành viên conversation), `audience="all"` (mọi user online), hoặc `audience="users"` (danh sách userId chỉ định). Với broadcast toàn cục, `conversationId` là `null`.
|
|
1629
|
+
|
|
1630
|
+
```ts
|
|
1631
|
+
client.realtime.notifications.on('ephemeralBroadcast', (b) => {
|
|
1632
|
+
if (b.conversationId === null && b.type === 'system_alert') {
|
|
1633
|
+
// Broadcast toàn cục — vd. cảnh báo bảo trì hệ thống
|
|
1634
|
+
showGlobalAlert(b.data);
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
```
|
|
1638
|
+
|
|
1639
|
+
> Định nghĩa đầy đủ `EphemeralBroadcastDto` ở [mục 17](#17-toàn-bộ-kiểu-dữ-liệu-types).
|
|
1640
|
+
|
|
1443
1641
|
---
|
|
1444
1642
|
|
|
1445
|
-
##
|
|
1643
|
+
## 15. ReconnectionManager
|
|
1446
1644
|
|
|
1447
1645
|
Class quản lý reconnect thủ công khi cả `withAutomaticReconnect` của SignalR đã thất bại và hub bị `disconnected`. Cung cấp exponential backoff và xử lý token hết hạn.
|
|
1448
1646
|
|
|
@@ -1470,13 +1668,19 @@ manager.stop();
|
|
|
1470
1668
|
|
|
1471
1669
|
**Chiến lược backoff:** 3 lần thử với delay 2s → 5s → 10s.
|
|
1472
1670
|
|
|
1671
|
+
> **Không có event kết thúc:** nếu cả 3 lần thử đều thất bại, manager **dừng im lặng** — không phát thêm event nào. Một `connect()` thất bại không mở được kết nối nên `onclose`/`disconnected` **không** fire lại; host chỉ nhận đúng 1 lần `disconnected` (lần kích hoạt reconnect). Muốn biết reconnect đã bỏ cuộc, hãy tự giám sát qua event `disconnected` kết hợp getter `state` của hub.
|
|
1672
|
+
|
|
1473
1673
|
**Phát hiện token hết hạn:** heuristic — match (case-insensitive) các keyword `401`, `unauthorized`, `expired`, `invalid_token`, `invalid token` trong message của error.
|
|
1474
1674
|
|
|
1675
|
+
> **⚠️ Giới hạn dưới transport mặc định:** SDK mặc định `skipNegotiation: true` + `transport: WebSockets`. Khi token hết hạn, 401 xảy ra ở bước bắt tay WebSocket — mà trình duyệt **không** expose HTTP status ra cho script, nên `err.message` chỉ là chuỗi chung chung ("WebSocket failed to connect…") không chứa keyword nào ở trên, và `onTokenExpired` **có thể không được gọi**. Không danh sách keyword nào khắc phục được (status đơn giản không nằm trong message). Vì vậy:
|
|
1676
|
+
> - Muốn xử lý hết hạn **tất định**, hãy **refresh chủ động** dựa trên `AuthResponse.expiresAt` (đã có sẵn từ lúc login) — đừng trông chờ vào drop-detection.
|
|
1677
|
+
> - Heuristic chỉ fire đáng tin khi bạn tự override `skipNegotiation: false` (lúc đó bước negotiate trả HTTP 401 đọc được).
|
|
1678
|
+
|
|
1475
1679
|
**Bỏ qua close chủ động:** Cả `ChatHubClient` và `NotificationHubClient` expose getter `intentionallyClosed`. Trước khi tear down trong `disconnect()` (kể cả khi gọi qua `client.disconnect()` / `client.logout()`), cờ này được set `true`. Manager kiểm tra cờ ngay đầu handler và **bỏ qua** lần `disconnected` đó — không tự reconnect ngược ý người dùng. Cờ được reset về `false` ở đầu lần `connect()` kế tiếp.
|
|
1476
1680
|
|
|
1477
1681
|
---
|
|
1478
1682
|
|
|
1479
|
-
##
|
|
1683
|
+
## 16. Hệ thống Block (nội dung tin nhắn)
|
|
1480
1684
|
|
|
1481
1685
|
`Block` là **discriminated union** — mỗi block phân biệt bằng field `$type`.
|
|
1482
1686
|
|
|
@@ -1518,6 +1722,7 @@ interface ImageBlock {
|
|
|
1518
1722
|
height: number | null;
|
|
1519
1723
|
caption: string | null;
|
|
1520
1724
|
fileSizeBytes: number | null;
|
|
1725
|
+
mimeType: string | null; // vd. 'image/jpeg', 'image/png', 'image/svg+xml'
|
|
1521
1726
|
}
|
|
1522
1727
|
|
|
1523
1728
|
interface VideoBlock {
|
|
@@ -1635,6 +1840,32 @@ for (const block of message.blocks) {
|
|
|
1635
1840
|
|
|
1636
1841
|
Danh sách đầy đủ: `isTextBlock`, `isImageBlock`, `isVideoBlock`, `isAudioBlock`, `isFileBlock`, `isLinkPreviewBlock`, `isEmbedBlock`, `isLocationBlock`, `isContactBlock`, `isChoiceBlock`, `isCardBlock`, `isCarouselBlock`, `isDividerBlock`, `isCustomBlock`.
|
|
1637
1842
|
|
|
1843
|
+
### Serialization helpers — `withTypeFirst` / `normalizeBlocksForSend`
|
|
1844
|
+
|
|
1845
|
+
Server dùng polymorphic deserializer yêu cầu **`$type` phải là property đầu tiên** trong mỗi block JSON (spec §13). SDK đã tự gọi `normalizeBlocksForSend()` nội bộ trong `MessageApi.send/edit` và `ChatHubClient.sendMessage/editMessage`, nên **bạn KHÔNG cần gọi thủ công** trong luồng thông thường.
|
|
1846
|
+
|
|
1847
|
+
Hai helper này được export public chỉ dành cho power users muốn:
|
|
1848
|
+
- Tự xây dựng API module riêng dùng `HttpClient` trực tiếp.
|
|
1849
|
+
- Tạo block từ nguồn dữ liệu mà thứ tự property bị xáo trộn (vd. clone qua `structuredClone` rồi spread).
|
|
1850
|
+
|
|
1851
|
+
```ts
|
|
1852
|
+
import { withTypeFirst, normalizeBlocksForSend } from '@manonero/chat-client-sdk';
|
|
1853
|
+
import type { Block, TextBlock } from '@manonero/chat-client-sdk';
|
|
1854
|
+
|
|
1855
|
+
// withTypeFirst<T>(block: T): T — chuẩn hoá 1 block đơn lẻ
|
|
1856
|
+
const normalized: TextBlock = withTypeFirst({
|
|
1857
|
+
content: 'Xin chào',
|
|
1858
|
+
format: 'Plain',
|
|
1859
|
+
plainText: 'Xin chào',
|
|
1860
|
+
$type: 'text', // có thể nằm ở cuối khi tạo block — sẽ được đưa lên đầu
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
// normalizeBlocksForSend<T extends Block>(blocks: T[]): T[] — áp dụng cho mảng
|
|
1864
|
+
const blocks: Block[] = normalizeBlocksForSend(rawBlocks);
|
|
1865
|
+
```
|
|
1866
|
+
|
|
1867
|
+
> `CarouselBlock` là block duy nhất chứa nested blocks (`cards: CardBlock[]`); `withTypeFirst` đệ quy nên rule `$type` đứng đầu giữ vững ở mọi cấp.
|
|
1868
|
+
|
|
1638
1869
|
### Ví dụ tạo block
|
|
1639
1870
|
|
|
1640
1871
|
```ts
|
|
@@ -1656,6 +1887,7 @@ const imageBlock: ImageBlock = {
|
|
|
1656
1887
|
height: 1080,
|
|
1657
1888
|
caption: 'Ảnh mô tả',
|
|
1658
1889
|
fileSizeBytes: 204800,
|
|
1890
|
+
mimeType: 'image/jpeg',
|
|
1659
1891
|
};
|
|
1660
1892
|
|
|
1661
1893
|
// Gửi tin nhắn kèm file
|
|
@@ -1696,7 +1928,7 @@ interface CardField {
|
|
|
1696
1928
|
}
|
|
1697
1929
|
```
|
|
1698
1930
|
|
|
1699
|
-
### ChoiceMode / ChoiceOption
|
|
1931
|
+
### ChoiceMode / ChoiceOption
|
|
1700
1932
|
|
|
1701
1933
|
```ts
|
|
1702
1934
|
type ChoiceMode = 'Single' | 'Multiple';
|
|
@@ -1706,16 +1938,10 @@ interface ChoiceOption {
|
|
|
1706
1938
|
value: string;
|
|
1707
1939
|
selected: boolean;
|
|
1708
1940
|
}
|
|
1709
|
-
|
|
1710
|
-
interface ChoiceBlock {
|
|
1711
|
-
$type: 'choice';
|
|
1712
|
-
prompt: string; // Câu hỏi hiển thị cho user
|
|
1713
|
-
options: ChoiceOption[];
|
|
1714
|
-
mode: ChoiceMode;
|
|
1715
|
-
submitted: boolean; // true sau khi user đã submit lựa chọn
|
|
1716
|
-
}
|
|
1717
1941
|
```
|
|
1718
1942
|
|
|
1943
|
+
> `ChoiceBlock` đã được định nghĩa đầy đủ ở mục [Interfaces tham chiếu đầy đủ](#interfaces-tham-chiếu-đầy-đủ).
|
|
1944
|
+
|
|
1719
1945
|
### QuickReply
|
|
1720
1946
|
|
|
1721
1947
|
Nút bấm nhanh hiển thị bên dưới tin nhắn bot:
|
|
@@ -1743,7 +1969,7 @@ interface Mention {
|
|
|
1743
1969
|
|
|
1744
1970
|
---
|
|
1745
1971
|
|
|
1746
|
-
##
|
|
1972
|
+
## 17. Toàn bộ kiểu dữ liệu (Types)
|
|
1747
1973
|
|
|
1748
1974
|
### CursorPaginatedResult (cursor-based)
|
|
1749
1975
|
|
|
@@ -1766,12 +1992,39 @@ interface PagedResult<T> {
|
|
|
1766
1992
|
}
|
|
1767
1993
|
```
|
|
1768
1994
|
|
|
1995
|
+
### SearchResult (GET /api/search — §8)
|
|
1996
|
+
|
|
1997
|
+
```ts
|
|
1998
|
+
type SearchType = 'MESSAGE' | 'CONVERSATION' | 'BOTH';
|
|
1999
|
+
|
|
2000
|
+
interface SearchResult {
|
|
2001
|
+
// null khi type=MESSAGE. Mỗi item có `lastMessage` đã hydrate —
|
|
2002
|
+
// kiểm tra lastMessage.isDeleted trước khi hiển thị.
|
|
2003
|
+
conversations: ConversationListItemDto[] | null;
|
|
2004
|
+
// null khi type=CONVERSATION.
|
|
2005
|
+
messages: SearchMessagesResult | null;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
interface SearchMessagesResult {
|
|
2009
|
+
groups: SearchMessageGroup[];
|
|
2010
|
+
nextCursor: string | null; // opaque — chỉ phân trang cho messages
|
|
2011
|
+
hasMore: boolean;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
interface SearchMessageGroup {
|
|
2015
|
+
// null nếu batch fetch conversation lỗi (degrade graceful).
|
|
2016
|
+
// `participants` luôn là [] (fetch với includeParticipants=false).
|
|
2017
|
+
conversation: ConversationDto | null;
|
|
2018
|
+
messages: MessageDto[];
|
|
2019
|
+
}
|
|
2020
|
+
```
|
|
2021
|
+
|
|
1769
2022
|
### MediaReference
|
|
1770
2023
|
|
|
1771
2024
|
```ts
|
|
1772
2025
|
interface MediaReference {
|
|
1773
|
-
storageKey?: string; // Key lưu trữ nội bộ — dùng FileApi.getDownloadUrl(storageKey) để tạo URL tuyệt đối
|
|
1774
|
-
url?: string; // URL do server cấp sẵn (xem chi tiết bên dưới)
|
|
2026
|
+
storageKey?: string | null; // Key lưu trữ nội bộ — dùng FileApi.getDownloadUrl(storageKey, { expires, sig }) để tạo URL tuyệt đối có chữ ký. `null` khi reference là external URL.
|
|
2027
|
+
url?: string | null; // URL do server cấp sẵn (xem chi tiết bên dưới). `null` ở request body khi chỉ gửi storageKey.
|
|
1775
2028
|
}
|
|
1776
2029
|
```
|
|
1777
2030
|
|
|
@@ -1782,22 +2035,20 @@ Trường `url` có hai dạng tuỳ cấu hình server:
|
|
|
1782
2035
|
| **Relative signed URL** | `/api/files/abc/photo.jpg?expires=...&sig=...` | Phải prepend `baseUrl` trước khi dùng |
|
|
1783
2036
|
| **Absolute external URL** | `https://cdn.example.com/photo.jpg` | Dùng trực tiếp |
|
|
1784
2037
|
|
|
1785
|
-
> **Quy tắc resolve:**
|
|
2038
|
+
> **Quy tắc resolve:** Luôn ưu tiên `url` — nó đã được server gắn signature (`expires` + `sig`). Nếu chỉ có `storageKey` mà không có `url`, bạn cần cặp `expires`/`sig` từ nguồn khác để gọi `getDownloadUrl(storageKey, { expires, sig })` (server bắt buộc signed access — gọi unsigned sẽ bị từ chối với `400 Invalid Signature`).
|
|
1786
2039
|
|
|
1787
2040
|
```ts
|
|
1788
|
-
// Helper chuẩn để resolve MediaReference → URL tuyệt đối
|
|
2041
|
+
// Helper chuẩn để resolve MediaReference → URL tuyệt đối.
|
|
2042
|
+
// Server gần như luôn trả sẵn `url` cho mọi MediaReference của tin nhắn/participant,
|
|
2043
|
+
// nên trường hợp chỉ có storageKey là bất thường và nên log để debug.
|
|
1789
2044
|
function resolveMediaRef(
|
|
1790
2045
|
ref: MediaReference | null | undefined,
|
|
1791
2046
|
baseUrl: string,
|
|
1792
|
-
getDownloadUrl: (key: string) => string,
|
|
1793
2047
|
): string | null {
|
|
1794
|
-
if (!ref) return null;
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
}
|
|
1799
|
-
if (ref.storageKey) return getDownloadUrl(ref.storageKey);
|
|
1800
|
-
return null;
|
|
2048
|
+
if (!ref?.url) return null;
|
|
2049
|
+
// url có thể là relative signed URL ("/api/files/...?sig=...")
|
|
2050
|
+
// hoặc absolute external URL ("https://...")
|
|
2051
|
+
return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
|
|
1801
2052
|
}
|
|
1802
2053
|
```
|
|
1803
2054
|
|
|
@@ -1902,23 +2153,15 @@ interface StreamChunkReceivedDto {
|
|
|
1902
2153
|
messageId: string;
|
|
1903
2154
|
conversationId: string;
|
|
1904
2155
|
text: string; // Chunk text để append vào placeholder
|
|
1905
|
-
|
|
2156
|
+
sequence: number; // Số thứ tự server gán (zero-based), chung dãy với StreamBlockReceivedDto
|
|
1906
2157
|
}
|
|
1907
2158
|
|
|
1908
|
-
|
|
1909
|
-
// cùng cửa sổ Streaming:ChunkCoalesceWindowMs (mặc định 50ms) thành 1 batch.
|
|
1910
|
-
// Client cài cả hai handler vì server có thể gửi bất kỳ event nào.
|
|
1911
|
-
// Append theo thứ tự parts[i].chunkIndex tăng dần.
|
|
1912
|
-
interface StreamChunkPart {
|
|
1913
|
-
text: string;
|
|
1914
|
-
chunkIndex: number;
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
interface StreamChunkBatchReceivedDto {
|
|
2159
|
+
interface StreamBlockReceivedDto {
|
|
1918
2160
|
streamId: string;
|
|
1919
2161
|
messageId: string;
|
|
1920
2162
|
conversationId: string;
|
|
1921
|
-
|
|
2163
|
+
block: Block; // Inline content block (card, choice, divider...)
|
|
2164
|
+
sequence: number; // Số thứ tự server gán, chung dãy với StreamChunkReceivedDto
|
|
1922
2165
|
}
|
|
1923
2166
|
|
|
1924
2167
|
interface StreamCompletedDto {
|
|
@@ -1939,6 +2182,29 @@ interface StreamAbortedDto {
|
|
|
1939
2182
|
}
|
|
1940
2183
|
```
|
|
1941
2184
|
|
|
2185
|
+
### EphemeralBroadcastDto (dùng chung — ChatHub & NotificationHub)
|
|
2186
|
+
|
|
2187
|
+
Broadcast tạm thời (**KHÔNG lưu trữ**) do bot hoặc SYSTEM user phát qua `POST /api/broadcasts`. Cùng một payload xuất hiện trên **cả hai hub** với event `ephemeralBroadcast`:
|
|
2188
|
+
|
|
2189
|
+
- **ChatHub** — `audience="viewers"` của conversation đang mở (`conversationId` có giá trị).
|
|
2190
|
+
- **NotificationHub** — `audience="members" | "all" | "users"` (broadcast toàn cục → `conversationId: null`).
|
|
2191
|
+
|
|
2192
|
+
```ts
|
|
2193
|
+
// Sender của broadcast — luôn là bot hoặc SYSTEM user (không bao giờ là User thường)
|
|
2194
|
+
type BroadcastSenderType = 'Bot' | 'System';
|
|
2195
|
+
|
|
2196
|
+
interface EphemeralBroadcastDto<TData = unknown> {
|
|
2197
|
+
type: string; // Khóa do người gửi đặt — client tự diễn giải `data` theo giá trị này
|
|
2198
|
+
senderId: string; // participantId của bot, hoặc literal "SYSTEM"
|
|
2199
|
+
senderType: BroadcastSenderType; // 'Bot' | 'System'
|
|
2200
|
+
conversationId: string | null; // null khi broadcast toàn cục
|
|
2201
|
+
data: TData | null; // Envelope JSON tự do; null khi người gửi bỏ trống
|
|
2202
|
+
sentAt: string; // UTC ISO 8601
|
|
2203
|
+
}
|
|
2204
|
+
```
|
|
2205
|
+
|
|
2206
|
+
> `TData` cho phép narrow kiểu của `data` theo `type` (mặc định `unknown` để dùng an toàn khi chưa khai báo). Xem ví dụ ở [mục 13](#13-real-time-chathubclient) (ChatHub) và [mục 14](#14-real-time-notificationhubclient) (NotificationHub).
|
|
2207
|
+
|
|
1942
2208
|
### Ack types (return value của Hub methods)
|
|
1943
2209
|
|
|
1944
2210
|
Mỗi Hub method client→server trả về một ack object với `success: boolean` và optional error fields:
|
|
@@ -1987,7 +2253,7 @@ interface ReactionAck {
|
|
|
1987
2253
|
|
|
1988
2254
|
---
|
|
1989
2255
|
|
|
1990
|
-
##
|
|
2256
|
+
## 18. Xử lý lỗi — ChatApiError
|
|
1991
2257
|
|
|
1992
2258
|
Tất cả lỗi HTTP đều throw `ChatApiError` (kế thừa `Error`).
|
|
1993
2259
|
|
|
@@ -2034,7 +2300,7 @@ try {
|
|
|
2034
2300
|
|------------|------|-------|
|
|
2035
2301
|
| `status` | `number` | HTTP status code |
|
|
2036
2302
|
| `title` | `string` | Tiêu đề lỗi — giá trị `ErrorType` enum: `Validation`, `NotFound`, `Conflict`, `Forbidden`, `Unauthorized`, `Unexpected`. Auth endpoints trả `Bad Request`, v.v. |
|
|
2037
|
-
| `detail` | `string \| undefined` | Mô tả của lỗi
|
|
2303
|
+
| `detail` | `string \| undefined` | Mô tả chi tiết, human-readable của lỗi (RFC 7807 `detail`) |
|
|
2038
2304
|
| `type` | `string \| undefined` | URI tham chiếu RFC 7807 `type` (nếu server gửi). `undefined` cho proxy errors và lỗi sinh nội bộ (timeout/network/parse) |
|
|
2039
2305
|
| `errors` | `ProblemError[] \| undefined` | Mảng tất cả lỗi chi tiết — có khi lỗi đi qua handler (ErrorOr path). `undefined` cho lỗi middleware/filter, proxy errors, và lỗi sinh nội bộ |
|
|
2040
2306
|
| `traceId` | `string \| undefined` | W3C trace ID để correlate log. Tự động thêm bởi framework. `undefined` cho proxy errors và lỗi sinh nội bộ |
|
|
@@ -2110,6 +2376,8 @@ const chatHub = new ChatHubClient({
|
|
|
2110
2376
|
hubUrl: 'https://chat-api.example.com/hubs/chat',
|
|
2111
2377
|
tokenProvider,
|
|
2112
2378
|
logLevel: LogLevel.Warning, // optional
|
|
2379
|
+
// optional — ghi đè mặc định skipNegotiation: true + transport: WebSockets
|
|
2380
|
+
connectionOptions: { skipNegotiation: false },
|
|
2113
2381
|
});
|
|
2114
2382
|
|
|
2115
2383
|
// Khởi tạo NotificationHub độc lập
|
|
@@ -2139,7 +2407,7 @@ manager.start();
|
|
|
2139
2407
|
|
|
2140
2408
|
---
|
|
2141
2409
|
|
|
2142
|
-
##
|
|
2410
|
+
## 19. TypedEventEmitter
|
|
2143
2411
|
|
|
2144
2412
|
SDK dùng `TypedEventEmitter<TEventMap>` nội bộ cho cả hai hub. Giao diện public của nó được expose qua `.on()` và `.off()`.
|
|
2145
2413
|
|
|
@@ -2169,7 +2437,7 @@ unsubs.forEach(fn => fn());
|
|
|
2169
2437
|
|
|
2170
2438
|
---
|
|
2171
2439
|
|
|
2172
|
-
##
|
|
2440
|
+
## 20. Lưu ý quan trọng
|
|
2173
2441
|
|
|
2174
2442
|
### Cursor pagination — KHÔNG cast sang number
|
|
2175
2443
|
|
|
@@ -2187,8 +2455,10 @@ const cursor = Number(result.nextCursor); // BUG!
|
|
|
2187
2455
|
- `setToken()` hoạt động ngay cho **HTTP requests**.
|
|
2188
2456
|
- SignalR đọc token khi **connect()**, không phải mid-session.
|
|
2189
2457
|
- Để áp token mới ngay cho hub đang Connected: `setToken(newToken)` → `await refreshConnections()`.
|
|
2190
|
-
-
|
|
2458
|
+
- `refreshConnections()` tự `joinConversation()` lại cho ChatHub sau reconnect (best-effort) — không cần tự làm lại thủ công. Hoặc dùng `ReconnectionManager` để tự động khôi phục toàn bộ vòng đời kết nối.
|
|
2191
2459
|
- Khi `tokenProvider` được cấu hình, provider luôn được hỏi trước. `setToken()` chỉ là fallback cho trường hợp provider trả `null`/empty.
|
|
2460
|
+
- `setToken(token)` với `token` khác `null` **không** cập nhật `currentUser` — chỉ dùng cho rotate token của cùng user. Đổi user: gọi lại `auth.login*()` hoặc `clearToken()`/`setToken(null)` trước.
|
|
2461
|
+
- `refreshConnections()` không throw nếu không còn token khả dụng — hub `Connected` sẽ bị disconnect thay vì reconnect.
|
|
2192
2462
|
|
|
2193
2463
|
### REST vs SignalR — field name khác nhau
|
|
2194
2464
|
|
|
@@ -2212,34 +2482,25 @@ const block: ImageBlock = {
|
|
|
2212
2482
|
};
|
|
2213
2483
|
```
|
|
2214
2484
|
|
|
2215
|
-
**Khi hiển thị tin nhắn nhận từ server:** server trả về `MediaReference` có
|
|
2485
|
+
**Khi hiển thị tin nhắn nhận từ server:** server trả về `MediaReference` có sẵn `url` đã được ký (`expires` + `sig`). Dùng helper resolve sau:
|
|
2216
2486
|
|
|
2217
2487
|
```ts
|
|
2218
2488
|
// resolve MediaReference → URL tuyệt đối để dùng trong <img src>, <a href>, v.v.
|
|
2219
2489
|
function resolveMediaRef(
|
|
2220
2490
|
ref: MediaReference | null | undefined,
|
|
2221
2491
|
baseUrl: string,
|
|
2222
|
-
getDownloadUrl: (key: string) => string,
|
|
2223
2492
|
): string | null {
|
|
2224
|
-
if (!ref) return null;
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
|
|
2229
|
-
}
|
|
2230
|
-
if (ref.storageKey) return getDownloadUrl(ref.storageKey);
|
|
2231
|
-
return null;
|
|
2493
|
+
if (!ref?.url) return null;
|
|
2494
|
+
// url có thể là relative signed URL ("/api/files/...?sig=...")
|
|
2495
|
+
// hoặc absolute external URL ("https://...")
|
|
2496
|
+
return ref.url.startsWith('/') ? baseUrl.replace(/\/$/, '') + ref.url : ref.url;
|
|
2232
2497
|
}
|
|
2233
2498
|
|
|
2234
2499
|
// Ví dụ dùng với ImageBlock:
|
|
2235
|
-
const displayUrl = resolveMediaRef(
|
|
2236
|
-
block.source,
|
|
2237
|
-
'https://chat-api.example.com',
|
|
2238
|
-
client.files.getDownloadUrl.bind(client.files),
|
|
2239
|
-
);
|
|
2500
|
+
const displayUrl = resolveMediaRef(block.source, 'https://chat-api.example.com');
|
|
2240
2501
|
```
|
|
2241
2502
|
|
|
2242
|
-
> **Tại sao không
|
|
2503
|
+
> **Tại sao không dùng `getDownloadUrl(storageKey, ...)`?** Server bắt buộc signed access (spec §8.3), và `getDownloadUrl()` yêu cầu bạn cung cấp cặp `expires`/`sig`. Trong khi đó, `MediaReference.url` đã được server gắn sẵn signature — không cần thao tác thêm.
|
|
2243
2504
|
|
|
2244
2505
|
> **Tại sao không dùng `url` trực tiếp?** Server trả `url` dạng relative (`/api/files/...`), không phải absolute. Dùng trực tiếp trong `<img src>` sẽ resolve về origin của frontend thay vì API server nếu hai service chạy trên host/port khác nhau.
|
|
2245
2506
|
|
|
@@ -2255,7 +2516,7 @@ console.log(participant.fullName); // Tên hiển thị của bot
|
|
|
2255
2516
|
|
|
2256
2517
|
---
|
|
2257
2518
|
|
|
2258
|
-
##
|
|
2519
|
+
## 21. Ví dụ đầy đủ
|
|
2259
2520
|
|
|
2260
2521
|
### Ví dụ 1: Chat app cơ bản
|
|
2261
2522
|
|
|
@@ -2367,7 +2628,9 @@ async function loadMoreMessages(conversationId: string) {
|
|
|
2367
2628
|
### Ví dụ 3: Xử lý bot streaming
|
|
2368
2629
|
|
|
2369
2630
|
```ts
|
|
2370
|
-
|
|
2631
|
+
// Mỗi part là text hoặc block, gắn sequence (server gán) để sắp xếp đúng vị trí.
|
|
2632
|
+
type StreamPart = { sequence: number; text?: string; block?: Block };
|
|
2633
|
+
const streamBuffers = new Map<string, StreamPart[]>();
|
|
2371
2634
|
|
|
2372
2635
|
client.realtime.chat.on('streamStarted', (dto) => {
|
|
2373
2636
|
streamBuffers.set(dto.streamId, []);
|
|
@@ -2378,24 +2641,21 @@ client.realtime.chat.on('streamStatusUpdated', (dto) => {
|
|
|
2378
2641
|
updateStreamStatus(dto.messageId, dto.status); // e.g. "thinking", "searching"
|
|
2379
2642
|
});
|
|
2380
2643
|
|
|
2381
|
-
// Append một
|
|
2382
|
-
function
|
|
2383
|
-
const
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2644
|
+
// Append một part (text hoặc block) vào buffer, sắp theo sequence rồi flush ra UI
|
|
2645
|
+
function appendPart(streamId: string, messageId: string, part: StreamPart) {
|
|
2646
|
+
const parts = streamBuffers.get(streamId) ?? [];
|
|
2647
|
+
parts.push(part);
|
|
2648
|
+
parts.sort((a, b) => a.sequence - b.sequence);
|
|
2649
|
+
streamBuffers.set(streamId, parts);
|
|
2650
|
+
renderStreamingParts(messageId, parts); // render text + block đúng thứ tự
|
|
2387
2651
|
}
|
|
2388
2652
|
|
|
2389
2653
|
client.realtime.chat.on('streamChunkReceived', (dto) => {
|
|
2390
|
-
|
|
2654
|
+
appendPart(dto.streamId, dto.messageId, { sequence: dto.sequence, text: dto.text });
|
|
2391
2655
|
});
|
|
2392
2656
|
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
client.realtime.chat.on('streamChunkBatchReceived', (dto) => {
|
|
2396
|
-
for (const part of dto.parts) {
|
|
2397
|
-
appendChunk(dto.streamId, dto.messageId, part.text);
|
|
2398
|
-
}
|
|
2657
|
+
client.realtime.chat.on('streamBlockReceived', (dto) => {
|
|
2658
|
+
appendPart(dto.streamId, dto.messageId, { sequence: dto.sequence, block: dto.block });
|
|
2399
2659
|
});
|
|
2400
2660
|
|
|
2401
2661
|
client.realtime.chat.on('streamCompleted', (dto) => {
|