@vibexnpm/talkx 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibexnpm/talkx",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "description": "TalkFlow SDK - Chat & WebRTC unified client",
6
6
  "main": "dist/talkflow-sdk.umd.js",
@@ -55,6 +55,21 @@ class ChatClient extends EventEmitter {
55
55
  this._assistantTypingUserId = '__assistant__';
56
56
  this._assistantTypingUserName = 'AI';
57
57
 
58
+ // AI 진행 표시(생각중/검색중/작성중) — assistant-stream 채널의 PHASE/DONE 수신 상태.
59
+ // roomId → { streamId, personaId, phase, timer }. typing 과 별개의 가산 레이어(서버가 둘 다 발행).
60
+ // 자동 해제 3경로: (1) DONE 수신 (2) 어시 메시지 도착 (3) TTL(expiresAt) 만료 — typing 타이머와 동형.
61
+ // 현재는 PM_BACKSTAGE(방당 단일 PM) 만 phase 를 발행하므로 방 단위 1개 진행으로 추적한다.
62
+ this._assistantProgress = new Map();
63
+ // phase → 기본 표시 라벨(설계 §2 — 서버는 의미만, 라벨 텍스트는 SDK 매핑). 소비자는 payload.phase 로 커스텀 가능.
64
+ this._assistantPhaseLabels = {
65
+ THINKING: '생각 중…',
66
+ SEARCHING: '검색 중…',
67
+ PLANNING: '기획 중…',
68
+ WRITING: '작성 중…'
69
+ };
70
+ // PHASE 의 expiresAt 누락/이상 시 fallback TTL (ms) — 서버 ttl(최소 30s) 보다 약간 길게.
71
+ this._assistantProgressFallbackTtlMs = 35000;
72
+
58
73
  // 메시지 dedup 보호 — 같은 messageId 의 중복 수신 차단.
59
74
  // 서버측 멱등 race 복구 / 네트워크 재전송 / WebSocket 재연결 직후 등 다양한 중복 시나리오 커버.
60
75
  //
@@ -1580,6 +1595,12 @@ class ChatClient extends EventEmitter {
1580
1595
  this._handleTypingEvent(roomId, event);
1581
1596
  });
1582
1597
 
1598
+ // AI 진행 표시(생각중/검색중/작성중) 구독 — typing 과 별개 채널(가산 UX). 구버전 SDK 는 미구독 → 기존 typing 만.
1599
+ const assistantStreamDestination = WebSocketPaths.getChatAssistantStreamDestination(roomId);
1600
+ await this.connectionManager.subscribe(assistantStreamDestination, (event) => {
1601
+ this._handleAssistantStreamEvent(roomId, event);
1602
+ });
1603
+
1583
1604
  // 멤버 변경 이벤트 — roomList 이벤트를 방 수준으로 변환하여 자동 emit
1584
1605
  // 서버가 members 배열(입장/퇴장 사용자들) + participantCount(정확한 인원수) 포함
1585
1606
  const memberJoinedHandler = (event) => {
@@ -1609,6 +1630,7 @@ class ChatClient extends EventEmitter {
1609
1630
  chatDestination,
1610
1631
  readDestination,
1611
1632
  typingDestination,
1633
+ assistantStreamDestination,
1612
1634
  memberJoinedHandler,
1613
1635
  memberLeftHandler,
1614
1636
  subscribedAt: new Date()
@@ -1634,6 +1656,9 @@ class ChatClient extends EventEmitter {
1634
1656
  if (subscription.typingDestination) {
1635
1657
  this.connectionManager.unsubscribe(subscription.typingDestination);
1636
1658
  }
1659
+ if (subscription.assistantStreamDestination) {
1660
+ this.connectionManager.unsubscribe(subscription.assistantStreamDestination);
1661
+ }
1637
1662
 
1638
1663
  // 멤버 변경 리스너 해제
1639
1664
  if (subscription.memberJoinedHandler) {
@@ -1646,6 +1671,8 @@ class ChatClient extends EventEmitter {
1646
1671
  // 타이핑 타이머 정리 (outgoing + incoming assistant 둘 다)
1647
1672
  this._clearTypingTimer(roomId);
1648
1673
  this._clearAssistantTypingTimer(roomId);
1674
+ // AI 진행 표시 상태/타이머 정리 (emit 없이 — 구독 해제 중)
1675
+ this._clearAssistantProgress(roomId, false);
1649
1676
 
1650
1677
  // 현재 보고 있는 방이면 activeRoom 해제
1651
1678
  if (this._activeRoomId === roomId) {
@@ -1855,6 +1882,9 @@ class ChatClient extends EventEmitter {
1855
1882
  typing: false,
1856
1883
  senderType: 'ASSISTANT'
1857
1884
  });
1885
+ // AI 진행 표시도 해제 — 최종 메시지 도착 = 진행 종료(설계 §3.1·§4.1, 메시지=진실).
1886
+ // DONE 이 유실/지연돼도 메시지로 수렴. active:false emit.
1887
+ this._clearAssistantProgress(roomId, true, 'message');
1858
1888
  }
1859
1889
 
1860
1890
  // 상대방 메시지일 때만 newMessage + 자동 읽음 처리
@@ -2161,6 +2191,141 @@ class ChatClient extends EventEmitter {
2161
2191
  }
2162
2192
  }
2163
2193
 
2194
+ /**
2195
+ * AI 진행 표시(assistant-stream) 이벤트 수신 처리 — PHASE(단계)/DELTA(토큰 청크)/DONE(종료) 분기.
2196
+ *
2197
+ * <p>typing 과 별개의 가산 레이어 — UI 는 {@code active:true} 면 {@code text}(토큰 스트림 누적) 우선,
2198
+ * 없으면 {@code label}(단계) 표시, {@code active:false} 면 해제. PHASE→DELTA 흐름으로 "작성 중" 라벨이
2199
+ * 점진 렌더 텍스트로 자연 전환된다. 서버가 typing 도 이중발행하므로 구버전 SDK 는 본 채널 미구독 → 기존
2200
+ * typing 만으로 정상 동작(graceful).</p>
2201
+ * @private
2202
+ */
2203
+ _handleAssistantStreamEvent(roomId, event) {
2204
+ if (!event || !event.type) return;
2205
+
2206
+ if (event.type === 'PHASE') {
2207
+ const phase = event.phase || null;
2208
+ const label = (phase && this._assistantPhaseLabels[phase]) || null;
2209
+ // 같은 stream 의 PHASE 재발행(B-3 synthesis heartbeat 등)이면 누적 텍스트 보존, 새 stream 이면 리셋.
2210
+ const prev = this._assistantProgress.get(roomId);
2211
+ const text = (prev && prev.streamId === event.streamId) ? (prev.text || '') : '';
2212
+ // 진행 상태 갱신 + TTL 재무장(heartbeat 재수신마다 연장). 같은 방의 이전 타이머는 reset.
2213
+ this._startAssistantProgressTimer(roomId, event, { phase, text });
2214
+ this.emit('assistantProgress', {
2215
+ roomId,
2216
+ streamId: event.streamId || null,
2217
+ personaId: event.personaId || null,
2218
+ phase,
2219
+ label,
2220
+ active: true,
2221
+ text: text || null
2222
+ });
2223
+ return;
2224
+ }
2225
+
2226
+ if (event.type === 'DELTA') {
2227
+ // 토큰 청크(B) — 누적 텍스트에 이어붙여 점진 렌더용으로 emit. WRITING 단계에서 흐른다.
2228
+ const prev = this._assistantProgress.get(roomId);
2229
+ // stale DELTA 무시 — 이전 stream 의 늦은 토큰이 새 stream 표시를 오염시키지 않게(DONE 가드와 동형).
2230
+ if (prev?.streamId && event.streamId && prev.streamId !== event.streamId) {
2231
+ this.logger.debug(`Stale assistant DELTA ignored: room=${roomId}, delta=${event.streamId}, active=${prev.streamId}`);
2232
+ return;
2233
+ }
2234
+ const phase = (prev && prev.phase) || 'WRITING';
2235
+ const text = (prev ? (prev.text || '') : '') + (event.delta || '');
2236
+ this._startAssistantProgressTimer(roomId, event, { phase, text });
2237
+ this.emit('assistantProgress', {
2238
+ roomId,
2239
+ streamId: event.streamId || null,
2240
+ personaId: event.personaId || null,
2241
+ phase,
2242
+ label: this._assistantPhaseLabels[phase] || null,
2243
+ active: true,
2244
+ text, // 지금까지 누적된 전체 텍스트(버블 렌더용)
2245
+ delta: event.delta || null, // 이번 청크(append 렌더 선호 시)
2246
+ seq: typeof event.seq === 'number' ? event.seq : null
2247
+ });
2248
+ return;
2249
+ }
2250
+
2251
+ if (event.type === 'DONE') {
2252
+ // 종료(성공·실패 공통) — 상태/타이머 해제 + active:false emit(status/messageId 동봉, 권위 신호).
2253
+ const state = this._assistantProgress.get(roomId);
2254
+ // 늦게 도착한 이전 stream 의 DONE 무시 — 다음 stream 의 PHASE 이후 도착 시 새 진행 표시를 끄는 race 차단.
2255
+ // 메시지/assistant-stream 채널은 ordering 이 묶여 있지 않다. streamId 가 둘 다 있고 다를 때만 stale 판정
2256
+ // (UI 계약이 "active 만 보면 됨"이므로 SDK 가 stale false 를 막는다).
2257
+ if (state?.streamId && event.streamId && state.streamId !== event.streamId) {
2258
+ this.logger.debug(`Stale assistant DONE ignored: room=${roomId}, done=${event.streamId}, active=${state.streamId}`);
2259
+ return;
2260
+ }
2261
+ // 메시지 도착으로 먼저 해제됐어도 DONE 은 한 번 더 발행될 수 있으나 active:false 는 idempotent(설계 §4.1).
2262
+ if (state && state.timer) clearTimeout(state.timer);
2263
+ this._assistantProgress.delete(roomId);
2264
+ this.emit('assistantProgress', {
2265
+ roomId,
2266
+ streamId: event.streamId || null,
2267
+ personaId: event.personaId || null,
2268
+ phase: null,
2269
+ label: null,
2270
+ active: false,
2271
+ status: event.status || null,
2272
+ messageId: event.messageId || null
2273
+ });
2274
+ }
2275
+ // 그 외 미지 type 은 무시(forward-compat).
2276
+ }
2277
+
2278
+ /**
2279
+ * AI 진행 상태 갱신 + TTL 타이머 재무장 — {@code expiresAt}(epochMillis) 기반, 누락/이상 시 fallback.
2280
+ * {@code fields}={phase, text}(PHASE/DELTA 가 해소해 전달 — DELTA 는 직전 phase 유지 + 누적 text).
2281
+ * 만료 = DONE·메시지 유실로 진행이 끊긴 것 → UI 가 영구 표시에 갇히지 않게 {@code active:false} 자동 emit.
2282
+ * @private
2283
+ */
2284
+ _startAssistantProgressTimer(roomId, event, fields) {
2285
+ const prev = this._assistantProgress.get(roomId);
2286
+ if (prev && prev.timer) clearTimeout(prev.timer);
2287
+
2288
+ let ttl = this._assistantProgressFallbackTtlMs;
2289
+ if (typeof event.expiresAt === 'number') {
2290
+ // 비정상값 방어 — [5s, 120s] 로 clamp(과거 timestamp / 과대 TTL 모두 차단).
2291
+ ttl = Math.min(120000, Math.max(5000, event.expiresAt - Date.now()));
2292
+ }
2293
+ const timer = setTimeout(() => {
2294
+ this._clearAssistantProgress(roomId, true, 'timeout');
2295
+ this.logger.debug(`Assistant progress auto-cleared (timeout): room=${roomId}`);
2296
+ }, ttl);
2297
+ this._assistantProgress.set(roomId, {
2298
+ streamId: event.streamId || null,
2299
+ personaId: event.personaId || null,
2300
+ phase: fields.phase,
2301
+ text: fields.text || '',
2302
+ timer
2303
+ });
2304
+ }
2305
+
2306
+ /**
2307
+ * AI 진행 표시 상태/타이머 해제. {@code emit=true} 면 {@code active:false} 를 발행(메시지 도착/타임아웃 해제용).
2308
+ * 구독 해제 시엔 {@code emit=false} 로 조용히 정리.
2309
+ * @private
2310
+ */
2311
+ _clearAssistantProgress(roomId, emit, reason) {
2312
+ const state = this._assistantProgress.get(roomId);
2313
+ if (!state) return;
2314
+ if (state.timer) clearTimeout(state.timer);
2315
+ this._assistantProgress.delete(roomId);
2316
+ if (emit) {
2317
+ this.emit('assistantProgress', {
2318
+ roomId,
2319
+ streamId: state.streamId || null,
2320
+ personaId: state.personaId || null,
2321
+ phase: null,
2322
+ label: null,
2323
+ active: false,
2324
+ reason: reason || null
2325
+ });
2326
+ }
2327
+ }
2328
+
2164
2329
  // ==================== 유틸리티 ====================
2165
2330
 
2166
2331
  /**
@@ -2208,6 +2373,10 @@ class ChatClient extends EventEmitter {
2208
2373
  this._assistantTypingTimers.forEach((timer) => clearTimeout(timer));
2209
2374
  this._assistantTypingTimers.clear();
2210
2375
 
2376
+ // AI 진행 표시 상태/타이머도 전체 cleanup (구독 외 경로로 남은 roomId 방어).
2377
+ this._assistantProgress.forEach((state) => state.timer && clearTimeout(state.timer));
2378
+ this._assistantProgress.clear();
2379
+
2211
2380
  // dedup bucket 전체 clear — unsubscribeRoom 이 방별로 정리하지만 구독 외 경로로 누적된 bucket
2212
2381
  // (예: 구독 안 한 방의 push/listener) 이 있을 수 있어 안전하게 명시 정리.
2213
2382
  this._seenChatMessageIdsByRoom.clear();
package/src/constants.js CHANGED
@@ -319,6 +319,8 @@ export const WebSocketPaths = {
319
319
  getChatDestination: (roomId) => `/topic/chat/${roomId}`,
320
320
  getChatReadDestination: (roomId) => `/topic/chat/${roomId}/read`,
321
321
  getChatTypingDestination: (roomId) => `/topic/chat/${roomId}/typing`,
322
+ // AI 진행 표시(생각중/검색중/작성중) + 토큰 스트리밍 — typing 과 별개 채널(가산 UX 레이어).
323
+ getChatAssistantStreamDestination: (roomId) => `/topic/chat/${roomId}/assistant-stream`,
322
324
 
323
325
  // Room list (카톡 스타일 리스트 실시간 업데이트)
324
326
  // 서버가 convertAndSendToUser(userId, "/queue/rooms", event) 로 전송
@@ -15,6 +15,7 @@ const CHAT_EVENT_MAP = [
15
15
  ['messageTranslated', 'messageTranslated'],
16
16
  ['messageRead', 'messageRead'],
17
17
  ['typing', 'typing'],
18
+ ['assistantProgress', 'assistantProgress'],
18
19
  ['memberJoined', 'memberJoined'],
19
20
  ['memberLeft', 'memberLeft'],
20
21
  ['roomSubscribed', 'roomSubscribed'],
package/types/index.d.ts CHANGED
@@ -378,6 +378,7 @@ export const WebSocketPaths: {
378
378
  getChatDestination(roomId: string): string;
379
379
  getChatReadDestination(roomId: string): string;
380
380
  getChatTypingDestination(roomId: string): string;
381
+ getChatAssistantStreamDestination(roomId: string): string;
381
382
  readonly ROOM_LIST_USER_DESTINATION: string;
382
383
  getWebRTCDestination(roomId: string): string;
383
384
  getWebRTCUserDestination(): string;
@@ -735,6 +736,42 @@ export interface TypingEvent {
735
736
  senderType: 'USER' | 'ASSISTANT';
736
737
  }
737
738
 
739
+ /**
740
+ * AI 진행 표시 이벤트 — `/topic/chat/{roomId}/assistant-stream` 의 PHASE/DONE.
741
+ *
742
+ * typing 과 별개의 가산 UX 레이어(설계 ASSISTANT_PROGRESS_STREAMING_DESIGN.md). 서버가 typing 도 이중발행하므로
743
+ * 구버전 SDK 는 본 이벤트를 못 받아도 기존 typing 으로 정상 동작한다. UI 는 `active:true` 면 `label`(또는 `phase`)
744
+ * 로 단계를 표시하고, `active:false`(DONE·어시 메시지 도착·TTL 만료) 면 해제한다.
745
+ *
746
+ * 현재는 PM_BACKSTAGE(방당 단일 PM) 만 phase 를 발행 — 멘션/버튼/14인 경로는 본 이벤트 없이 typing 만.
747
+ */
748
+ export interface AssistantProgressEvent {
749
+ /** 채팅방 ID. */
750
+ roomId: string;
751
+ /** 어시 응답 1회분 식별(멀티 응답/중복 dedup). 일부 종료 emit 에선 null. */
752
+ streamId: string | null;
753
+ /** 진행 주체 페르소나 ID. */
754
+ personaId: string | null;
755
+ /** 진행 단계 — `active:true` 일 때. 종료 시 null. */
756
+ phase: 'THINKING' | 'SEARCHING' | 'PLANNING' | 'WRITING' | null;
757
+ /** phase 의 기본 표시 라벨(예: "생각 중…"). SDK 매핑 — 커스텀하려면 `phase` 사용. 종료 시 null. */
758
+ label: string | null;
759
+ /** true=진행중(PHASE/DELTA) / false=종료(DONE·메시지 도착·TTL). */
760
+ active: boolean;
761
+ /** 토큰 스트림 누적 텍스트(B) — DELTA 수신 시 지금까지 모인 전체. 버블에 그대로 렌더. PHASE-only/종료 시 null. */
762
+ text?: string | null;
763
+ /** 이번 토큰 청크(B) — append 렌더를 선호할 때. DELTA 수신 시만. */
764
+ delta?: string | null;
765
+ /** 토큰 청크 순서/누락 감지(B) — DELTA 수신 시만. */
766
+ seq?: number | null;
767
+ /** 종료(DONE) 결과 — DONE 수신 시만. */
768
+ status?: 'SUCCEEDED' | 'FAILED' | 'CANCELLED' | 'TIMEOUT' | null;
769
+ /** 종료(DONE, SUCCEEDED) 시 영속된 최종 메시지 ID. */
770
+ messageId?: string | null;
771
+ /** 종료 사유 — 'message'(어시 메시지 도착) / 'timeout'(TTL). DONE 수신 해제엔 없음. */
772
+ reason?: 'message' | 'timeout' | null;
773
+ }
774
+
738
775
  // ============================================================================
739
776
  // Options — constructor & method
740
777
  // ============================================================================
@@ -1056,7 +1093,7 @@ export interface RetentionCleanupPayload {
1056
1093
  * TalkFlowClient 이벤트 맵 — 런타임 {@code TalkFlowClient.js} 의 {@code this.emit(...)} 호출 기반.
1057
1094
  *
1058
1095
  * <p><b>Connection 관련</b>: stateChange, connected, disconnected, reconnecting, connectionError, tokenSet, loggedOut</p>
1059
- * <p><b>Chat 관련 (ChatClient 이벤트를 이 이름으로 re-emit)</b>: chatMessage, newChatMessage, messageUpdated, messageDeleted, reactionChanged, linkPreviewAttached, messageRead, typing, memberJoined, memberLeft, roomSubscribed, roomUnsubscribed, roomListSubscribed, roomListUnsubscribed, roomListUpdate, roomListMessage, roomListCreated, roomListJoined, roomListLeft, roomListSelfLeft, roomListRoomUpdated, retentionCleanup</p>
1096
+ * <p><b>Chat 관련 (ChatClient 이벤트를 이 이름으로 re-emit)</b>: chatMessage, newChatMessage, messageUpdated, messageDeleted, reactionChanged, linkPreviewAttached, messageRead, typing, assistantProgress, memberJoined, memberLeft, roomSubscribed, roomUnsubscribed, roomListSubscribed, roomListUnsubscribed, roomListUpdate, roomListMessage, roomListCreated, roomListJoined, roomListLeft, roomListSelfLeft, roomListRoomUpdated, retentionCleanup</p>
1060
1097
  * <p><b>Push 관련</b>: pushEnabled, pushFailed, pushNotification</p>
1061
1098
  * <p><b>WebRTC 관련</b>: localStreamStarted, localStreamStopped, remoteTrack, screenShareStarted, screenShareEnded, deviceChange, mediaStateChanged, callStarted, callEnded, callRequested, callAccepted, callRejected, callCancelled, callInvitation, callBusy, incomingCall, incomingCallWhileBusy, userJoined, userLeft, participantLeft, participantMediaState, peerConnected, peerDisconnected, peerClosed, webrtcError</p>
1062
1099
  */
@@ -1085,6 +1122,7 @@ export interface TalkFlowClientEvents {
1085
1122
  messageTranslated: ChatMessageReceivedPayload;
1086
1123
  messageRead: MessageReadEventPayload;
1087
1124
  typing: TypingEvent;
1125
+ assistantProgress: AssistantProgressEvent;
1088
1126
  memberJoined: MemberChangePayload;
1089
1127
  memberLeft: MemberChangePayload;
1090
1128
  roomSubscribed: { roomId: string };
@@ -1198,6 +1236,7 @@ export interface ChatClientEvents {
1198
1236
  retentionCleanup: RetentionCleanupPayload;
1199
1237
  messageRead: MessageReadEventPayload;
1200
1238
  typing: TypingEvent;
1239
+ assistantProgress: AssistantProgressEvent;
1201
1240
  }
1202
1241
 
1203
1242
  /**