@vibexnpm/talkx 3.2.2 → 4.0.1
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.ts +174 -30
- package/dist/talkflow-sdk.esm.js +1 -1
- package/dist/talkflow-sdk.esm.js.map +1 -1
- package/dist/talkflow-sdk.standalone.js +1 -1
- package/dist/talkflow-sdk.standalone.js.map +1 -1
- package/dist/talkflow-sdk.umd.js +1 -1
- package/dist/talkflow-sdk.umd.js.map +1 -1
- package/package.json +5 -3
- package/src/TalkFlowClient.js +23 -2
- package/src/chat/ChatClient.js +116 -85
- package/src/constants.js +36 -0
- package/src/index.js +1 -0
- package/src/talkflow/delegates.js +6 -0
- package/types/__typetests__/singleMessageAttachment.types.ts +68 -0
- package/types/__typetests__/tsconfig.json +12 -0
- package/types/index.d.ts +174 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibexnpm/talkx",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "TalkFlow SDK - Chat & WebRTC unified client",
|
|
6
6
|
"main": "dist/talkflow-sdk.umd.js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"build:types": "copy types\\index.d.ts dist\\index.d.ts",
|
|
18
18
|
"dev": "rollup -c -w",
|
|
19
19
|
"lint": "eslint src/",
|
|
20
|
-
"test": "jest"
|
|
20
|
+
"test": "jest && npm run typecheck",
|
|
21
|
+
"typecheck": "tsc -p types/__typetests__/tsconfig.json"
|
|
21
22
|
},
|
|
22
23
|
"repository": {
|
|
23
24
|
"type": "git",
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
"@rollup/plugin-terser": "^0.4.4",
|
|
49
50
|
"babel-jest": "^29.7.0",
|
|
50
51
|
"jest": "^29.7.0",
|
|
51
|
-
"rollup": "^4.12.0"
|
|
52
|
+
"rollup": "^4.12.0",
|
|
53
|
+
"typescript": "^5.9.3"
|
|
52
54
|
}
|
|
53
55
|
}
|
package/src/TalkFlowClient.js
CHANGED
|
@@ -289,7 +289,15 @@ class TalkFlowClient extends EventEmitter {
|
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
/**
|
|
292
|
-
* 사용자 정보
|
|
292
|
+
* 사용자 정보 수정.
|
|
293
|
+
*
|
|
294
|
+
* 서버는 갱신된 프로필이 담긴 **새 JWT** 를 응답에 함께 실어 준다(`/auth` 와 같은 계약).
|
|
295
|
+
* 이걸 채택하지 않으면 옛 토큰의 name/profileUrl 클레임이 계속 나가, 서버가 그 클레임을 읽는
|
|
296
|
+
* 경로에서 낡은 값이 최대 7일(토큰 TTL) 동안 보인다.
|
|
297
|
+
*
|
|
298
|
+
* 같은 사용자의 토큰 교체라 `setToken` 은 재연결을 일으키지 않는다 — userId 가 바뀔 때만
|
|
299
|
+
* 재초기화하고, 여기서는 STOMP connectHeaders 만 갱신된다.
|
|
300
|
+
*
|
|
293
301
|
* @param {Object} userData
|
|
294
302
|
* @param {string} [userData.nickname] - 닉네임 (max 100자)
|
|
295
303
|
* @param {string} [userData.profileImageUrl] - 프로필 이미지 URL (max 500자)
|
|
@@ -299,7 +307,20 @@ class TalkFlowClient extends EventEmitter {
|
|
|
299
307
|
*/
|
|
300
308
|
async updateMyInfo(userData) {
|
|
301
309
|
this._checkToken();
|
|
302
|
-
|
|
310
|
+
const response = await this.apiClient.put('/api/v1/users/update', userData);
|
|
311
|
+
|
|
312
|
+
if (response?.data?.accessToken) {
|
|
313
|
+
// 토큰 교체 실패로 수정 자체를 실패시키지 않는다 — 서버 반영은 이미 끝났고,
|
|
314
|
+
// 옛 토큰도 유효(낡았을 뿐)하므로 여기서 throw 하면 호출자가 이미 적용된 수정을
|
|
315
|
+
// 다시 시도하게 된다. authenticate() 는 토큰이 없으면 아무것도 못 하므로 계약이 다르다.
|
|
316
|
+
try {
|
|
317
|
+
await this.setToken(response.data.accessToken);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
this.logger.warn('Profile updated but token refresh failed; keeping the old token', error);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return response;
|
|
303
324
|
}
|
|
304
325
|
|
|
305
326
|
/**
|
package/src/chat/ChatClient.js
CHANGED
|
@@ -1331,8 +1331,8 @@ class ChatClient extends EventEmitter {
|
|
|
1331
1331
|
* 설계된다. 서버 Unique 제약 + pre-check 로 중복 저장이 차단되며, 재시도 히트 시
|
|
1332
1332
|
* {@code duplicate: true} 로 기존 결과가 그대로 반환된다.</p>
|
|
1333
1333
|
*
|
|
1334
|
-
* <p
|
|
1335
|
-
*
|
|
1334
|
+
* <p><b>전송 1회 = 메시지 1건</b>: 첨부가 몇 개든 한 메시지로 저장되며 응답 {@code messages} 는 길이 1이다.
|
|
1335
|
+
* {@code chatMessageType} 은 렌더러 힌트로, 첨부가 동질이면 그 카테고리, 혼재면 {@code MIXED} 다.</p>
|
|
1336
1336
|
*
|
|
1337
1337
|
* <p>답글: {@code data.replyToMessageId} 에 원본 메시지의 서버 {@code messageId} 를 지정.
|
|
1338
1338
|
* 서버가 원본을 조회해서 snapshot 을 생성 후 저장하며, 응답/수신 메시지의 {@code replyTo}
|
|
@@ -1341,12 +1341,15 @@ class ChatClient extends EventEmitter {
|
|
|
1341
1341
|
* @param {string} roomId - 채팅방 ID
|
|
1342
1342
|
* @param {Object} data
|
|
1343
1343
|
* @param {string} [data.message] - 메시지 내용 (message 또는 fileInfos 중 하나 필수)
|
|
1344
|
-
* @param {Object[]} [data.fileInfos] - 파일 메타데이터
|
|
1345
|
-
*
|
|
1344
|
+
* @param {Object[]} [data.fileInfos] - 파일 메타데이터 배열. 각 원소는 <b>UUID {@code fileId} 필수</b>
|
|
1345
|
+
* ({@link #uploadFile} 반환값은 이미 포함). 외부 스토리지 직접 업로드 연동은 직접 채워야 한다.
|
|
1346
1346
|
* @param {string} [data.replyToMessageId] - 답글 대상 원본 메시지 ID (옵션)
|
|
1347
|
+
* @param {Object} [data.decisionResponse] - 의사결정 카드의 선택 — {@code { optionId }} (옵션).
|
|
1348
|
+
* 카드 버튼을 누르면 <b>평범한 답글</b>로 보낸다: {@code replyToMessageId} = 카드의 messageId,
|
|
1349
|
+
* {@code message} = 고른 선택지의 {@code label}, 여기에 {@code optionId}. 전용 endpoint 는 없다.
|
|
1347
1350
|
* @returns {Promise<{duplicate: boolean, messages: Object[]}>}
|
|
1348
1351
|
* - {@code duplicate}: 멱등 재시도로 기존 결과를 반환한 경우 {@code true}
|
|
1349
|
-
* - {@code messages}: 저장된 메시지
|
|
1352
|
+
* - {@code messages}: 저장된 메시지 배열. {@code messageId} 는 서버가 부여한 ID.
|
|
1350
1353
|
*
|
|
1351
1354
|
* @example
|
|
1352
1355
|
* // 일반 메시지
|
|
@@ -1361,13 +1364,22 @@ class ChatClient extends EventEmitter {
|
|
|
1361
1364
|
* });
|
|
1362
1365
|
*
|
|
1363
1366
|
* @example
|
|
1364
|
-
* //
|
|
1367
|
+
* // 의사결정 카드의 선택 — 답글 하나로 나간다 (새 API 없음)
|
|
1368
|
+
* const option = card.decisionRequest.options[0];
|
|
1369
|
+
* await chat.sendMessage('room-id', {
|
|
1370
|
+
* message: option.label, // 폴백 가독성 — 카드를 못 그리는 화면에서도 대화가 읽힌다
|
|
1371
|
+
* replyToMessageId: card.messageId,
|
|
1372
|
+
* decisionResponse: { optionId: option.id }
|
|
1373
|
+
* });
|
|
1374
|
+
*
|
|
1375
|
+
* @example
|
|
1376
|
+
* // 첨부 3장 + 캡션 — 메시지 1건
|
|
1365
1377
|
* const { messages } = await chat.sendMessage('room-id', {
|
|
1366
1378
|
* message: '사진 보냅니다',
|
|
1367
|
-
* fileInfos: [f0, f1, f2]
|
|
1368
|
-
* separateFiles: true
|
|
1379
|
+
* fileInfos: [f0, f1, f2] // uploadFile 결과 (fileId 포함)
|
|
1369
1380
|
* });
|
|
1370
|
-
* // messages.length ===
|
|
1381
|
+
* // messages.length === 1 · messages[0].chatMessageType === 'IMAGE'
|
|
1382
|
+
* // messages[0].fileMetaDataList.length === 3
|
|
1371
1383
|
*/
|
|
1372
1384
|
async sendMessage(roomId, data) {
|
|
1373
1385
|
const messageId = this._generateMessageId();
|
|
@@ -1382,12 +1394,12 @@ class ChatClient extends EventEmitter {
|
|
|
1382
1394
|
* 예측 {@code messageIds} 배열을 동기 반환한다. 호출자는 promise 가 resolve 되기 전에
|
|
1383
1395
|
* 미리 placeholder 메시지를 UI 에 그려둘 수 있고, resolve 후 같은 ID 로 안전하게 교체할 수 있다.</p>
|
|
1384
1396
|
*
|
|
1385
|
-
* <p
|
|
1386
|
-
*
|
|
1387
|
-
* {@code messages.map(m => m.messageId)} 와 길이/순서가 정확히 일치한다.</p>
|
|
1397
|
+
* <p>전송 1회 = 메시지 1건이므로 {@code messageIds} 는 <b>항상 {@code [baseMessageId]} 하나</b>다.
|
|
1398
|
+
* 서버 분류 규칙을 mirror 해 여러 ID 를 예측하던 로직은 제거됐다.</p>
|
|
1388
1399
|
*
|
|
1389
|
-
* <p><b
|
|
1390
|
-
*
|
|
1400
|
+
* <p><b>전환 기간 주의</b>: 서버가 아직 병합 저장을 켜지 않았다면 한 요청이 여러 row 로 저장되어
|
|
1401
|
+
* {@code optimistic.predictionMatched} 가 {@code false} 가 된다. 오류가 아니라 전환 기간의 정상
|
|
1402
|
+
* 상태이며, 이때는 응답의 {@code actualMessageIds} 로 재구성해야 한다.</p>
|
|
1391
1403
|
*
|
|
1392
1404
|
* @param {string} roomId - 채팅방 ID
|
|
1393
1405
|
* @param {Object} data - {@link #sendMessage} 와 동일한 페이로드
|
|
@@ -1407,7 +1419,8 @@ class ChatClient extends EventEmitter {
|
|
|
1407
1419
|
*/
|
|
1408
1420
|
sendMessageOptimistic(roomId, data) {
|
|
1409
1421
|
const baseMessageId = this._generateMessageId();
|
|
1410
|
-
|
|
1422
|
+
// 전송 1회 = 메시지 1건 — 예측할 것이 없다. 서버 분류 규칙을 mirror 하던 예측기는 제거됐다.
|
|
1423
|
+
const messageIds = [baseMessageId];
|
|
1411
1424
|
|
|
1412
1425
|
const promise = this._sendMessageWithId(roomId, baseMessageId, data)
|
|
1413
1426
|
.then((result) => this._attachOptimisticMetadata(messageIds, result));
|
|
@@ -1430,14 +1443,18 @@ class ChatClient extends EventEmitter {
|
|
|
1430
1443
|
// 메시지 전송 시 타이핑 표시 자동 중단
|
|
1431
1444
|
this.stopTyping(roomId);
|
|
1432
1445
|
|
|
1433
|
-
// separateFiles 는
|
|
1434
|
-
//
|
|
1446
|
+
// separateFiles 는 더 이상 보내지 않는다 — 전송 1회 = 메시지 1건이 계약이다.
|
|
1447
|
+
// 서버는 이 필드를 병합 저장 플래그가 OFF 인 동안에만(legacy 재현용) 읽으며, 값이 없으면
|
|
1448
|
+
// 기본 true 로 동작한다. 즉 OFF 서버에서는 여전히 N건이 저장되고, 그 불일치는
|
|
1449
|
+
// optimistic 의 predictionMatched:false 로 드러난다(설계 §5).
|
|
1435
1450
|
const response = await this.apiClient.post(`/api/v1/chat-messages/${roomId}/send`, {
|
|
1436
1451
|
messageId,
|
|
1437
1452
|
message: data.message,
|
|
1438
1453
|
fileInfos: data.fileInfos,
|
|
1439
|
-
|
|
1440
|
-
|
|
1454
|
+
replyToMessageId: data.replyToMessageId,
|
|
1455
|
+
// 의사결정 카드의 선택. 🔴 이 줄이 없으면 호출자가 넘긴 값이 <b>조용히 유실</b>된다 —
|
|
1456
|
+
// 이 payload 는 화이트리스트라 data 에 있어도 전달되지 않는다.
|
|
1457
|
+
decisionResponse: data.decisionResponse
|
|
1441
1458
|
});
|
|
1442
1459
|
|
|
1443
1460
|
const result = this._unwrapSuccessResponse(response);
|
|
@@ -1542,7 +1559,6 @@ class ChatClient extends EventEmitter {
|
|
|
1542
1559
|
* @param {File|Blob|Array<File|Blob>} files - 단일 파일 또는 파일 배열
|
|
1543
1560
|
* @param {Object} [options]
|
|
1544
1561
|
* @param {string} [options.message] - 함께 보낼 텍스트 본문
|
|
1545
|
-
* @param {boolean} [options.separateFiles] - 파일별 개별 메시지 분리 여부 (서버 설정)
|
|
1546
1562
|
* @param {string} [options.replyToMessageId] - 답글 대상 원본 메시지의 서버 {@code messageId}
|
|
1547
1563
|
* @param {Function} [options.onUploadProgress] - {@code ({loaded,total,percent,fileIndex}) => void} — 파일별 진행률
|
|
1548
1564
|
* @param {AbortSignal} [options.signal] - 취소 시그널 — 업로드 중에만 유효
|
|
@@ -1571,7 +1587,6 @@ class ChatClient extends EventEmitter {
|
|
|
1571
1587
|
return this.sendMessage(roomId, {
|
|
1572
1588
|
message: options.message,
|
|
1573
1589
|
fileInfos,
|
|
1574
|
-
separateFiles: options.separateFiles,
|
|
1575
1590
|
replyToMessageId: options.replyToMessageId
|
|
1576
1591
|
});
|
|
1577
1592
|
}
|
|
@@ -1616,11 +1631,7 @@ class ChatClient extends EventEmitter {
|
|
|
1616
1631
|
const fileArray = this._normalizeFileArray(files);
|
|
1617
1632
|
|
|
1618
1633
|
const baseMessageId = this._generateMessageId();
|
|
1619
|
-
const messageIds =
|
|
1620
|
-
baseMessageId,
|
|
1621
|
-
this._predictGroupCount(fileArray, options.separateFiles !== false),
|
|
1622
|
-
this._hasTextMessage(options.message)
|
|
1623
|
-
);
|
|
1634
|
+
const messageIds = [baseMessageId]; // 전송 1회 = 메시지 1건
|
|
1624
1635
|
|
|
1625
1636
|
const promise = (async () => {
|
|
1626
1637
|
const metas = await this._uploadFiles(roomId, fileArray, options);
|
|
@@ -1628,7 +1639,6 @@ class ChatClient extends EventEmitter {
|
|
|
1628
1639
|
const result = await this._sendMessageWithId(roomId, baseMessageId, {
|
|
1629
1640
|
message: options.message,
|
|
1630
1641
|
fileInfos,
|
|
1631
|
-
separateFiles: options.separateFiles,
|
|
1632
1642
|
replyToMessageId: options.replyToMessageId
|
|
1633
1643
|
});
|
|
1634
1644
|
return this._attachOptimisticMetadata(messageIds, result);
|
|
@@ -1740,6 +1750,44 @@ class ChatClient extends EventEmitter {
|
|
|
1740
1750
|
return this.sendMessageOptimistic(roomId, { message, replyToMessageId });
|
|
1741
1751
|
}
|
|
1742
1752
|
|
|
1753
|
+
/**
|
|
1754
|
+
* 의사결정 카드의 선택 전송 (간편 버전).
|
|
1755
|
+
*
|
|
1756
|
+
* <p>세 가지를 <b>한 번에 맞춰</b> 보낸다 — 셋 중 하나만 빠져도 계약이 깨지므로 직접 조립하기보다
|
|
1757
|
+
* 이 메서드를 쓰는 편이 안전하다:</p>
|
|
1758
|
+
* <ul>
|
|
1759
|
+
* <li>{@code replyToMessageId} = 카드 messageId — 어느 질문에 답한 것인지</li>
|
|
1760
|
+
* <li>{@code message} = 고른 선택지의 {@code label} — <b>가독 폴백</b>. 빈 본문이면 대화에
|
|
1761
|
+
* 빈 칸이 남아 나중에 읽을 때 무엇을 골랐는지 알 수 없다</li>
|
|
1762
|
+
* <li>{@code decisionResponse.optionId} — 서버가 보는 <b>권위</b> 값</li>
|
|
1763
|
+
* </ul>
|
|
1764
|
+
*
|
|
1765
|
+
* <p>전용 endpoint 가 아니라 <b>평범한 답글</b>이다. 그래서 선택이 대화에 메시지로 남고, 카드를
|
|
1766
|
+
* 못 그리는 화면에서도 흐름이 읽힌다.</p>
|
|
1767
|
+
*
|
|
1768
|
+
* @param {string} roomId - 채팅방 ID
|
|
1769
|
+
* @param {Object} card - 카드 메시지 ({@code decisionRequest} 를 가진 그 메시지)
|
|
1770
|
+
* @param {Object} option - 사용자가 고른 {@code card.decisionRequest.options[i]}
|
|
1771
|
+
* @returns {Promise<{duplicate: boolean, messages: Object[]}>}
|
|
1772
|
+
*
|
|
1773
|
+
* @example
|
|
1774
|
+
* const card = messages.find(m => m.decisionRequest);
|
|
1775
|
+
* await chat.sendDecisionChoice('room-id', card, card.decisionRequest.options[1]);
|
|
1776
|
+
*/
|
|
1777
|
+
async sendDecisionChoice(roomId, card, option) {
|
|
1778
|
+
if (!card || !card.messageId) {
|
|
1779
|
+
throw new Error('sendDecisionChoice: card with messageId is required');
|
|
1780
|
+
}
|
|
1781
|
+
if (!option || !option.id) {
|
|
1782
|
+
throw new Error('sendDecisionChoice: option with id is required');
|
|
1783
|
+
}
|
|
1784
|
+
return this.sendMessage(roomId, {
|
|
1785
|
+
message: option.label,
|
|
1786
|
+
replyToMessageId: card.messageId,
|
|
1787
|
+
decisionResponse: { optionId: option.id }
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1743
1791
|
/**
|
|
1744
1792
|
* 메시지 삭제.
|
|
1745
1793
|
*
|
|
@@ -1811,20 +1859,23 @@ class ChatClient extends EventEmitter {
|
|
|
1811
1859
|
}
|
|
1812
1860
|
|
|
1813
1861
|
/**
|
|
1814
|
-
* MIME 타입 →
|
|
1815
|
-
*
|
|
1862
|
+
* MIME 타입 → {@code ChatMessageType} 카테고리 (서버 {@code AttachmentTypeClassifier} mirror).
|
|
1863
|
+
*
|
|
1864
|
+
* <p><b>공개 API</b> — legacy {@code GALLERY} 메시지의 실제 카테고리를 복원하는 데 쓴다
|
|
1865
|
+
* ({@link #resolveMessageCategory}). 서버 규칙이 바뀌면 본 메서드도 동기화해야 한다.</p>
|
|
1816
1866
|
*
|
|
1817
|
-
* @private
|
|
1818
1867
|
* @param {string} mimeType
|
|
1819
|
-
* @returns {
|
|
1868
|
+
* @returns {'IMAGE'|'VIDEO'|'AUDIO'|'DOCUMENT'|'FILE'}
|
|
1820
1869
|
*/
|
|
1821
|
-
|
|
1870
|
+
classifyFileType(mimeType) {
|
|
1822
1871
|
const mime = this._baseMimeType(mimeType);
|
|
1823
1872
|
if (mime.startsWith('image/')) return 'IMAGE';
|
|
1824
1873
|
if (mime.startsWith('video/')) return 'VIDEO';
|
|
1825
1874
|
if (mime.startsWith('audio/')) return 'AUDIO';
|
|
1826
|
-
// 평문(txt/md)도 DOCUMENT — 서버가 온디맨드 번역·PM 문서 인식 대상으로 삼는 기준과 동일해야
|
|
1875
|
+
// 평문(txt/md)도 DOCUMENT — 서버가 온디맨드 번역·PM 문서 인식 대상으로 삼는 기준과 동일해야 한다.
|
|
1876
|
+
// pre-2007 Office(.doc/.xls/.ppt)도 서버가 OLE 매직까지 검증해 DOCUMENT 로 받는다.
|
|
1827
1877
|
if (mime.includes('pdf') || mime.includes('document')
|
|
1878
|
+
|| mime.includes('msword') || mime.includes('ms-excel') || mime.includes('ms-powerpoint')
|
|
1828
1879
|
|| PLAIN_TEXT_DOCUMENT_MIME_TYPES.has(mime)) {
|
|
1829
1880
|
return 'DOCUMENT';
|
|
1830
1881
|
}
|
|
@@ -1832,66 +1883,46 @@ class ChatClient extends EventEmitter {
|
|
|
1832
1883
|
}
|
|
1833
1884
|
|
|
1834
1885
|
/**
|
|
1835
|
-
*
|
|
1836
|
-
* <p>{@code separateFiles=true} 면 파일 개수, {@code false} 면 타입별 distinct 개수.
|
|
1837
|
-
* File 객체 ({@code .type}) / FileMetaData 객체 ({@code .fileType}) 양쪽 입력 모두 처리.</p>
|
|
1838
|
-
*
|
|
1886
|
+
* 하위호환 별칭 — {@link #classifyFileType} 를 쓸 것.
|
|
1839
1887
|
* @private
|
|
1840
|
-
* @
|
|
1841
|
-
* @param {boolean} separateFiles
|
|
1842
|
-
* @returns {number}
|
|
1888
|
+
* @deprecated
|
|
1843
1889
|
*/
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
if (separateFiles) return fileArray.length;
|
|
1847
|
-
|
|
1848
|
-
// 입력 순서 보존하면서 distinct 타입 카운트 (서버 LinkedHashMap 동작과 동일)
|
|
1849
|
-
const typesSeen = new Set();
|
|
1850
|
-
for (const file of fileArray) {
|
|
1851
|
-
const mime = file && (file.type || file.fileType) || '';
|
|
1852
|
-
typesSeen.add(this._classifyFileType(mime));
|
|
1853
|
-
}
|
|
1854
|
-
return typesSeen.size;
|
|
1890
|
+
_classifyFileType(mimeType) {
|
|
1891
|
+
return this.classifyFileType(mimeType);
|
|
1855
1892
|
}
|
|
1856
1893
|
|
|
1857
1894
|
/**
|
|
1858
|
-
*
|
|
1859
|
-
* <p>규칙 ({@code SendMessageService.java:102-106, 197}):
|
|
1860
|
-
* {@code expectedMessageCount = (hasMessage?1:0) + groupCount}, 1 이면 base, 2 이상이면
|
|
1861
|
-
* 텍스트=base / 파일=base#0..#(N-1).</p>
|
|
1895
|
+
* 메시지의 <b>렌더 카테고리</b>를 해석한다 — legacy {@code GALLERY} 복원용 공개 헬퍼.
|
|
1862
1896
|
*
|
|
1863
|
-
* @
|
|
1864
|
-
* @
|
|
1865
|
-
* @
|
|
1866
|
-
* @
|
|
1867
|
-
* @returns {string[]} 응답 messages 와 같은 순서
|
|
1868
|
-
*/
|
|
1869
|
-
_predictMessageIds(baseMessageId, groupCount, hasMessage) {
|
|
1870
|
-
const expectedCount = (hasMessage ? 1 : 0) + groupCount;
|
|
1871
|
-
if (expectedCount === 0) return [];
|
|
1872
|
-
if (expectedCount === 1) return [baseMessageId];
|
|
1873
|
-
|
|
1874
|
-
const ids = [];
|
|
1875
|
-
if (hasMessage) ids.push(baseMessageId);
|
|
1876
|
-
for (let i = 0; i < groupCount; i++) {
|
|
1877
|
-
ids.push(`${baseMessageId}#${i}`);
|
|
1878
|
-
}
|
|
1879
|
-
return ids;
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
/**
|
|
1883
|
-
* {@code SendMessageData} 페이로드로부터 messageIds 예측.
|
|
1884
|
-
* <p>{@link #sendMessageOptimistic} 가 사용 — {@code data.fileInfos} 가 있으면 그걸로
|
|
1885
|
-
* group count 예측, 없으면 텍스트 단건으로 처리.</p>
|
|
1897
|
+
* <p>서버는 이제 첨부가 동질이면 그 카테고리를, 혼재면 {@code MIXED} 를 보낸다. 그러나 이 변경
|
|
1898
|
+
* <b>이전에 저장된 메시지는 복수 첨부면 타입 무관 전부 {@code GALLERY}</b> 다 — 동영상 2개도, PDF 2개도
|
|
1899
|
+
* {@code GALLERY} 다. 따라서 <b>{@code GALLERY} 를 {@code IMAGE} 로 매핑하면 안 된다.</b>
|
|
1900
|
+
* 이 메서드가 {@code fileMetaDataList} 의 MIME 으로 실제 카테고리를 복원한다.</p>
|
|
1886
1901
|
*
|
|
1887
|
-
* @
|
|
1902
|
+
* @param {Object} message - 서버 메시지 ({@code chatMessageType} + {@code fileMetaDataList})
|
|
1903
|
+
* @returns {string} 'TEXT'|'IMAGE'|'VIDEO'|'AUDIO'|'DOCUMENT'|'FILE'|'MIXED'|'SYSTEM'
|
|
1904
|
+
*
|
|
1905
|
+
* @example
|
|
1906
|
+
* // 렌더러 선택 — GALLERY 분기를 따로 두지 않아도 된다
|
|
1907
|
+
* switch (client.resolveMessageCategory(message)) {
|
|
1908
|
+
* case 'IMAGE': return <ImageGrid files={message.fileMetaDataList} />;
|
|
1909
|
+
* case 'VIDEO': return <VideoList files={message.fileMetaDataList} />;
|
|
1910
|
+
* case 'MIXED': return <MixedAttachmentList files={message.fileMetaDataList} />;
|
|
1911
|
+
* default: return <FileList files={message.fileMetaDataList} />;
|
|
1912
|
+
* }
|
|
1888
1913
|
*/
|
|
1889
|
-
|
|
1890
|
-
const
|
|
1891
|
-
const
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1914
|
+
resolveMessageCategory(message) {
|
|
1915
|
+
const type = message && message.chatMessageType;
|
|
1916
|
+
const files = (message && message.fileMetaDataList) || [];
|
|
1917
|
+
// GALLERY 만 복원 대상 — 나머지는 서버가 이미 정확한 카테고리를 보냈다.
|
|
1918
|
+
if (type !== 'GALLERY') {
|
|
1919
|
+
return type || 'TEXT';
|
|
1920
|
+
}
|
|
1921
|
+
if (files.length === 0) {
|
|
1922
|
+
return 'FILE';
|
|
1923
|
+
}
|
|
1924
|
+
const categories = new Set(files.map(f => this.classifyFileType((f && f.fileType) || '')));
|
|
1925
|
+
return categories.size === 1 ? categories.values().next().value : 'MIXED';
|
|
1895
1926
|
}
|
|
1896
1927
|
|
|
1897
1928
|
/**
|
package/src/constants.js
CHANGED
|
@@ -95,9 +95,45 @@ export const ChatMessageType = {
|
|
|
95
95
|
FILE: 'FILE',
|
|
96
96
|
VIDEO: 'VIDEO',
|
|
97
97
|
AUDIO: 'AUDIO',
|
|
98
|
+
DOCUMENT: 'DOCUMENT',
|
|
99
|
+
/**
|
|
100
|
+
* 서로 다른 카테고리의 첨부가 섞인 메시지 — 예: 이미지 2 + PDF 1.
|
|
101
|
+
* <p>전송 1회 = 메시지 1건 계약에서 새로 생긴 값. 클라이언트가 모르면 렌더가 깨진다.</p>
|
|
102
|
+
*/
|
|
103
|
+
MIXED: 'MIXED',
|
|
104
|
+
/**
|
|
105
|
+
* @deprecated 복수 첨부(타입 무관)를 뜻하던 <b>과거</b> 값. 신규 저장에서는 생성되지 않지만
|
|
106
|
+
* 기존 데이터에 남아 있다. 동영상·문서 복수도 전부 이 값이므로 <b>IMAGE 로 매핑하면 안 되고</b>,
|
|
107
|
+
* {@code ChatClient#resolveMessageCategory} 로 fileMetaDataList 의 MIME 에서 복원해야 한다.
|
|
108
|
+
*/
|
|
109
|
+
GALLERY: 'GALLERY',
|
|
98
110
|
SYSTEM: 'SYSTEM'
|
|
99
111
|
};
|
|
100
112
|
|
|
113
|
+
/**
|
|
114
|
+
* AI 의사결정 카드({@code message.decisionRequest}) 렌더 상한 — 서버가 강제하는 값의 사본.
|
|
115
|
+
*
|
|
116
|
+
* <p>🔴 <b>카드는 새 {@code chatMessageType} 이 아니다.</b> 카드 메시지의 타입은 그냥 {@code TEXT} 이고,
|
|
117
|
+
* 선택 요청은 {@code decisionRequest} <b>필드</b>로 온다. 그래서 필드를 모르는 렌더러는 <b>평범한 텍스트
|
|
118
|
+
* 메시지로 그리면 되고 그게 정상 동작</b>이다 — {@code content} 에 질문과 후보가 사람이 읽는 형태로
|
|
119
|
+
* 들어 있다.</p>
|
|
120
|
+
*
|
|
121
|
+
* <p>🔴 반대로 카드를 그린다면 {@code content} 를 <b>다시 그리지 마라</b> — 버튼과 본문에 같은 질문·후보가
|
|
122
|
+
* 두 번 보인다. 둘 중 하나만 그린다.</p>
|
|
123
|
+
*
|
|
124
|
+
* <p>클라가 이 값으로 <b>검증할 필요는 없다</b>(서버가 이미 강제한다). 레이아웃 계산용 참고값이다.</p>
|
|
125
|
+
*/
|
|
126
|
+
export const DecisionCardLimits = {
|
|
127
|
+
/** 선택지 최소 개수 — 1개면 물을 이유가 없어 서버가 카드를 만들지 않는다. */
|
|
128
|
+
MIN_OPTIONS: 2,
|
|
129
|
+
/** 선택지 최대 개수 — 그 이상은 선택이 아니라 목록이다. */
|
|
130
|
+
MAX_OPTIONS: 5,
|
|
131
|
+
/** 라벨 표시 길이(초과분은 서버가 말줄임 처리해서 보낸다). */
|
|
132
|
+
LABEL_MAX_LENGTH: 80,
|
|
133
|
+
/** 질문 표시 길이. */
|
|
134
|
+
QUESTION_MAX_LENGTH: 200
|
|
135
|
+
};
|
|
136
|
+
|
|
101
137
|
/**
|
|
102
138
|
* 메시지 발신자 타입 — 서버 {@code SenderType} 과 일치.
|
|
103
139
|
*
|
package/src/index.js
CHANGED
|
@@ -24,9 +24,15 @@ const CHAT_DELEGATE_METHODS = [
|
|
|
24
24
|
'sendTextMessageOptimistic',
|
|
25
25
|
'sendReply',
|
|
26
26
|
'sendReplyOptimistic',
|
|
27
|
+
// AI 의사결정 카드의 선택 — 답글·라벨 본문·optionId 를 한 번에 맞춰 보낸다.
|
|
28
|
+
'sendDecisionChoice',
|
|
27
29
|
'uploadFile',
|
|
28
30
|
'sendFileMessage',
|
|
29
31
|
'sendFileMessageOptimistic',
|
|
32
|
+
// 첨부 분류 — legacy GALLERY 복원용 공개 헬퍼. 타입 선언이 최상위 클라이언트에도 있으므로
|
|
33
|
+
// 여기에 없으면 talkflow.resolveMessageCategory() 가 런타임 undefined 가 된다(타입이 거짓말).
|
|
34
|
+
'classifyFileType',
|
|
35
|
+
'resolveMessageCategory',
|
|
30
36
|
'editMessage',
|
|
31
37
|
'deleteMessage',
|
|
32
38
|
'markAsRead',
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript 소비자 컴파일 검증 — 타입 선언이 <b>실제로 컴파일되는지</b>.
|
|
3
|
+
*
|
|
4
|
+
* <p>런타임 테스트(jest)는 `.d.ts` 를 전혀 보지 않으므로, 존재하지 않는 타입을 반환 타입으로 쓰거나
|
|
5
|
+
* 값(const)을 타입 위치에 쓰는 오류를 못 잡는다. S2 에서 실제로 `resolveMessageCategory` 가
|
|
6
|
+
* 값인 `ChatMessageType` 을 반환 타입으로 선언해 소비자 컴파일이 깨졌다.</p>
|
|
7
|
+
*
|
|
8
|
+
* <p>실행: {@code npm run typecheck}</p>
|
|
9
|
+
*/
|
|
10
|
+
import type TalkFlowClient from '../index';
|
|
11
|
+
import type {
|
|
12
|
+
ChatClient,
|
|
13
|
+
ChatMessage,
|
|
14
|
+
ChatMessageTypeValue,
|
|
15
|
+
FileMetaData,
|
|
16
|
+
SendFileInfo
|
|
17
|
+
} from '../index';
|
|
18
|
+
import { ChatMessageType } from '../index';
|
|
19
|
+
|
|
20
|
+
declare const chat: ChatClient;
|
|
21
|
+
declare const talkflow: TalkFlowClient;
|
|
22
|
+
declare const message: ChatMessage;
|
|
23
|
+
|
|
24
|
+
// 1) 렌더 카테고리 해석 — 반환은 값이 아니라 union 타입이어야 한다.
|
|
25
|
+
const category: ChatMessageTypeValue = chat.resolveMessageCategory(message);
|
|
26
|
+
const viaTopLevel: ChatMessageTypeValue = talkflow.resolveMessageCategory(message);
|
|
27
|
+
|
|
28
|
+
// 2) 신규 상수가 런타임 선언에 있어야 한다.
|
|
29
|
+
const mixed: 'MIXED' = ChatMessageType.MIXED;
|
|
30
|
+
const gallery: 'GALLERY' = ChatMessageType.GALLERY;
|
|
31
|
+
const document: 'DOCUMENT' = ChatMessageType.DOCUMENT;
|
|
32
|
+
|
|
33
|
+
// 3) MIXED 를 포함한 exhaustive 분기가 성립해야 한다.
|
|
34
|
+
function render(c: ChatMessageTypeValue): string {
|
|
35
|
+
switch (c) {
|
|
36
|
+
case 'TEXT': return 'text';
|
|
37
|
+
case 'IMAGE': return 'image';
|
|
38
|
+
case 'VIDEO': return 'video';
|
|
39
|
+
case 'AUDIO': return 'audio';
|
|
40
|
+
case 'DOCUMENT': return 'document';
|
|
41
|
+
case 'FILE': return 'file';
|
|
42
|
+
case 'MIXED': return 'mixed';
|
|
43
|
+
case 'GALLERY': return 'legacy';
|
|
44
|
+
case 'SYSTEM': return 'system';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 4) uploadFile 결과는 fileId 를 보장하므로 그대로 전송에 넘길 수 있다.
|
|
49
|
+
async function uploadThenSend(): Promise<void> {
|
|
50
|
+
const meta: SendFileInfo = await chat.uploadFile('room', new File([], 'a.png'));
|
|
51
|
+
await chat.sendMessage('room', { message: '캡션', fileInfos: [meta] });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 5) optimistic 핸들 — messageIds 는 문자열 배열.
|
|
55
|
+
function optimistic(): void {
|
|
56
|
+
const { baseMessageId, messageIds, promise } = chat.sendMessageOptimistic('room', { message: 'hi' });
|
|
57
|
+
void baseMessageId;
|
|
58
|
+
const ids: string[] = messageIds;
|
|
59
|
+
void ids;
|
|
60
|
+
void promise;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 6) 수신 메시지의 FileMetaData 는 legacy 를 위해 fileId 가 optional 이다.
|
|
64
|
+
function legacyResponseFile(f: FileMetaData): string | undefined {
|
|
65
|
+
return f.fileId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { category, viaTopLevel, mixed, gallery, document, render, uploadThenSend, optimistic, legacyResponseFile };
|