@vibexnpm/talkx 4.0.1 → 5.2.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/dist/index.d.ts +292 -8
- 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 +1 -1
- package/src/TalkFlowClient.js +3 -0
- package/src/chat/ChatClient.js +757 -81
- package/src/talkflow/delegates.js +10 -1
- package/src/talkflow/eventForwarding.js +1 -0
- package/src/talkflow/session.js +1 -0
- package/src/utils/ApiClient.js +8 -4
- package/types/index.d.ts +292 -8
package/src/chat/ChatClient.js
CHANGED
|
@@ -70,11 +70,73 @@ class ChatClient extends EventEmitter {
|
|
|
70
70
|
this._assistantTypingUserId = '__assistant__';
|
|
71
71
|
this._assistantTypingUserName = 'AI';
|
|
72
72
|
|
|
73
|
-
// AI 진행 표시(생각중/검색중/작성중) — assistant-stream 채널의 PHASE/DONE 수신 상태.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
73
|
+
// AI 진행 표시(생각중/검색중/작성중) — assistant-stream 채널의 PHASE/DELTA/DONE 수신 상태.
|
|
74
|
+
//
|
|
75
|
+
// roomId → Map(streamId → { personaId, phase, text, lastSeq, textAvailable, updatedAt, timer })
|
|
76
|
+
//
|
|
77
|
+
// ⚠️ v5.0.0 에서 <b>방 단위 1슬롯 → stream 단위 다중 슬롯</b>으로 바꿨다(설계 §7.1). 한 방에 동시
|
|
78
|
+
// 스트림이 <b>실재</b>하기 때문이다 — 어시 리스너가 @Async 이고 방 단위 직렬화 락이 없어서, PM 방에
|
|
79
|
+
// 트리거가 겹치면 각각 streamId 를 발급받아 동시에 산다. 1슬롯이면 나중 것이 앞 것을 덮어 한쪽이
|
|
80
|
+
// 통째로 사라진다(그게 v4.x 의 "stale 무시" 가드가 하던 일이다).
|
|
81
|
+
//
|
|
82
|
+
// 자동 해제는 **2경로**다: (1) DONE 수신 (2) TTL(expiresAt) 만료.
|
|
83
|
+
// ⚠️ 어시 메시지 도착은 해제 경로가 <b>아니다</b>(v4.2.0, 설계 §7.3) — 서버가 MESSAGE_CREATED 를
|
|
84
|
+
// DONE 보다 먼저 보내 그 시점엔 messageId → streamId 를 알 수 없다.
|
|
85
|
+
//
|
|
86
|
+
// lastSeq/textAvailable = seq 가드용 커서(설계 §5.5.0). 커서 초기값은 -1 이다 — 첫 청크가 seq=0.
|
|
77
87
|
this._assistantProgress = new Map();
|
|
88
|
+
|
|
89
|
+
// 종료된 streamId 의 묘비 — roomId → Map(streamId → expiresAt).
|
|
90
|
+
//
|
|
91
|
+
// <b>다중 슬롯 전환의 대가다.</b> 1슬롯 시절엔 "streamId 가 다르면 버린다"가 늦은 이벤트를 막는
|
|
92
|
+
// race 차단 장치였는데, 슬롯이 여러 개가 되면 그 가드가 사라진다. 그러면 DONE 으로 닫힌 스트림의
|
|
93
|
+
// 늦은 DELTA 가 <b>새 슬롯을 만들어 유령 버블로 되살아난다</b>. 서버 gate 가 terminal 직후 제거를
|
|
94
|
+
// 금지하고 TTL 로만 회수하는 것과 같은 이유이고 같은 해법이다(설계 §7.2).
|
|
95
|
+
//
|
|
96
|
+
// ⚠️ 등록 트리거는 <b>DONE 하나뿐</b>이다. 클라 TTL 만료는 "표시가 낡았다"이지 서버의 확정 종료가
|
|
97
|
+
// 아니라서, 그걸로 묘비를 세우면 뒤늦게 오는 정상 DELTA 가 영영 막힌다.
|
|
98
|
+
//
|
|
99
|
+
// 회수는 <b>lazy</b> 다 — 만료분은 같은 방에 다음 DONE 이 오거나 그 streamId 이벤트가 올 때 지워진다.
|
|
100
|
+
// 별도 타이머를 두지 않는 이유: 항목이 문자열+숫자뿐이고 방 구독을 끊으면 방 단위로 통째 삭제되므로
|
|
101
|
+
// 상한이 자연히 걸린다. 타이머 하나를 더 관리하는 비용이 이득보다 크다.
|
|
102
|
+
this._assistantStreamTombstones = new Map();
|
|
103
|
+
this._assistantTombstoneTtlMs = 120000;
|
|
104
|
+
// ⚠️ <b>상한이 아니라 경보 임계</b>다. 넘어도 유효한 묘비를 지우지 않는다(지우면 유령이 되살아난다).
|
|
105
|
+
this._assistantTombstoneWarnThreshold = 32;
|
|
106
|
+
/** 임계 초과 경보를 방마다 1회만 내기 위한 표식 — 넘은 동안 DONE 마다 짖지 않게. @private */
|
|
107
|
+
this._assistantTombstoneWarned = new Set();
|
|
108
|
+
|
|
109
|
+
// 슬롯 갱신 순번 — "가장 최근에 갱신된 stream" 판정용.
|
|
110
|
+
// ⚠️ 벽시계(Date.now)로는 안 된다. 밀리초 해상도라 연속 갱신이 <b>동률</b>이 되고, 동률이면
|
|
111
|
+
// 순회 순서가 승자를 정해 <b>다른 stream 의 본문이 방 버블에 뜬다</b>. 단조 카운터가 유일하게 안전하다.
|
|
112
|
+
this._assistantSlotSeq = 0;
|
|
113
|
+
|
|
114
|
+
// 방별 세대 카운터 — 구독/해제마다 증가. 스냅샷 응답이 "아직 같은 세대인가"를 판정하는 축이다
|
|
115
|
+
// (늦게 도착한 응답이 이미 떠난 방의 슬롯을 되살리는 것을 막는다, 설계 §6.1-a).
|
|
116
|
+
this._assistantRoomGeneration = new Map();
|
|
117
|
+
|
|
118
|
+
// ── 스냅샷 재개 (설계 §6.1, R-4) ──
|
|
119
|
+
//
|
|
120
|
+
// 스트림은 Pub/Sub 라 <b>구독 시점 이후만</b> 온다. 재입장·중간입장 사용자가 앞부분을 받는 유일한
|
|
121
|
+
// 경로가 서버 스냅샷이고, 그걸 라이브와 이어붙이는 순서가 이 세 자료구조에 걸려 있다.
|
|
122
|
+
//
|
|
123
|
+
// roomId → { generation, events: [] } : 스냅샷 도착 전 라이브 이벤트 보류함.
|
|
124
|
+
//
|
|
125
|
+
// ⚠️ <b>구독보다 먼저</b> 켜야 한다. 구독 호출과 보류 플래그 사이에 DELTA 가 오면 그건 보류되지
|
|
126
|
+
// 않고 그대로 렌더돼, 스냅샷 병합 전에 화면이 잘린 본문으로 먼저 그려진다.
|
|
127
|
+
//
|
|
128
|
+
// ⚠️ 큐는 <b>방 단위 단일 배열</b>이다. stream 별로 쪼개면 A0→B0→A1 로 도착한 것이 A0→A1→B0 로
|
|
129
|
+
// 풀려 "가장 최근 stream" 판정이 뒤집히고 방 집계 버블이 엉뚱한 본문을 보여준다 — R-3 이 단조
|
|
130
|
+
// 카운터로 세운 정합성을 drain 이 다시 깨는 자리다.
|
|
131
|
+
//
|
|
132
|
+
// ⚠️ 세대를 함께 들고 있어야 한다. 안 그러면 이전 세대의 늦은 응답이 <b>현재 세대의 보류분을
|
|
133
|
+
// 통째로 삭제</b>한다.
|
|
134
|
+
this._assistantSnapshotPending = new Map();
|
|
135
|
+
// roomId → { generation, task } : 진행 중인 스냅샷 요청. <b>같은 세대일 때만</b> 재사용한다 —
|
|
136
|
+
// 다른 세대 요청을 재사용하면 그 응답이 세대 불일치로 버려지면서 이번 요청까지 함께 사라진다.
|
|
137
|
+
this._assistantSnapshotInFlight = new Map();
|
|
138
|
+
// 스냅샷 재개 사용 여부 — 서버가 구버전이면 404/빈 배열이라 자동으로 무해하지만, 끄고 싶을 때를 위해.
|
|
139
|
+
this.assistantStreamReplay = options.assistantStreamReplay !== false;
|
|
78
140
|
// phase → 기본 표시 라벨(설계 §2 — 서버는 의미만, 라벨 텍스트는 SDK 매핑). 소비자는 payload.phase 로 커스텀 가능.
|
|
79
141
|
this._assistantPhaseLabels = {
|
|
80
142
|
THINKING: '생각 중…',
|
|
@@ -134,13 +196,20 @@ class ChatClient extends EventEmitter {
|
|
|
134
196
|
_installSyncTriggers() {
|
|
135
197
|
if (!this.connectionManager || typeof this.connectionManager.on !== 'function') return;
|
|
136
198
|
|
|
199
|
+
// ⚠️ 어시 스트림 재개는 <b>메시지 갭 복구와 별도 경로</b>다(설계 §7.4). syncAfterReconnect 는
|
|
200
|
+
// syncOnReconnect 옵션 + anchor 있는 방이라는 두 겹 필터가 걸려 있어서, 거기 얹으면 옵션이
|
|
201
|
+
// 꺼져 있거나 그 방에서 메시지를 받은 적이 없을 때 replay 가 조용히 빠진다.
|
|
137
202
|
this._onReconnectedForSync = ({ attempts } = {}) => {
|
|
138
203
|
this.syncAfterReconnect(`reconnected:${attempts ?? 0}`)
|
|
139
204
|
.catch((error) => this.logger.warn('Gap sync after reconnect failed:', error));
|
|
205
|
+
this.resumeAssistantStreams(`reconnected:${attempts ?? 0}`)
|
|
206
|
+
.catch((error) => this.logger.warn('Assistant stream replay after reconnect failed:', error));
|
|
140
207
|
};
|
|
141
208
|
this._onResumedForSync = ({ reason } = {}) => {
|
|
142
209
|
this.syncAfterReconnect(`resumed:${reason ?? 'unknown'}`)
|
|
143
210
|
.catch((error) => this.logger.warn('Gap sync after resume failed:', error));
|
|
211
|
+
this.resumeAssistantStreams(`resumed:${reason ?? 'unknown'}`)
|
|
212
|
+
.catch((error) => this.logger.warn('Assistant stream replay after resume failed:', error));
|
|
144
213
|
};
|
|
145
214
|
|
|
146
215
|
this.connectionManager.on('reconnected', this._onReconnectedForSync);
|
|
@@ -948,6 +1017,222 @@ class ChatClient extends EventEmitter {
|
|
|
948
1017
|
return this.apiClient.post(`/api/v1/rooms/group/${roomId}/unpin`);
|
|
949
1018
|
}
|
|
950
1019
|
|
|
1020
|
+
// ── 투표 ────────────────────────────────────────────────────────────────
|
|
1021
|
+
//
|
|
1022
|
+
// 투표 서버는 클러스터 내부에만 있어 클라이언트가 직접 부를 수 없다. 모든 호출은 채팅 서버를 거치고,
|
|
1023
|
+
// 신원(userId/projectId)은 채팅 서버가 인증 결과로 채워 넣는다.
|
|
1024
|
+
//
|
|
1025
|
+
// 🔴 실패는 서버가 투표 서버의 code 를 <b>그대로</b> 내려준다 — {@code error.response.code} 로 분기한다
|
|
1026
|
+
// (VOTE_ALREADY_CLOSED / VOTE_NOT_EDITABLE / VOTE_MODIFY_FORBIDDEN / CONFLICT ...).
|
|
1027
|
+
// 409 는 세 종류이고 대응이 다르다: ALREADY_CLOSED·NOT_EDITABLE 은 재시도해도 같고,
|
|
1028
|
+
// CONFLICT 만 <b>재조회 후</b> 재전송하면 성공한다.
|
|
1029
|
+
//
|
|
1030
|
+
// 🔴 결과 마스킹은 <b>호출자마다</b> 계산된다. 목록에는 집계가 없고, 단건 조회/접수 응답에만 있다.
|
|
1031
|
+
// 같은 투표라도 다른 사용자가 보면 가려지므로 응답을 공유 캐시에 넣으면 안 된다.
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* 투표 생성.
|
|
1035
|
+
*
|
|
1036
|
+
* <p>🔴 <b>{@code idempotencyKey} 는 필수다.</b> SDK 가 대신 만들지 않는다 — 만들면 그 값이 호출자에게
|
|
1037
|
+
* 남지 않아, 응답만 유실된 타임아웃에서 <b>복구가 불가능</b>하다(다음 호출이 새 키를 만들어 투표가
|
|
1038
|
+
* 두 개 생긴다). 호출자가 키를 소유하고, 같은 시도의 재시도에는 <b>같은 키</b>를 넘긴다 —
|
|
1039
|
+
* 그러면 서버가 기존 투표를 돌려준다(응답도 201, {@code voteId} 도 같다).</p>
|
|
1040
|
+
*
|
|
1041
|
+
* @param {string} roomId - 채팅방 ID
|
|
1042
|
+
* @param {Object} vote - 투표 폼
|
|
1043
|
+
* @param {string} vote.question - 질문 (최대 500자)
|
|
1044
|
+
* @param {'SINGLE'|'MULTIPLE'} vote.type
|
|
1045
|
+
* @param {number} vote.maxSelections - 양수. {@code SINGLE} 이면 1
|
|
1046
|
+
* @param {'TEXT'|'TEXT_WITH_IMAGE'} vote.optionType - {@code 'IMAGE'} 라는 값은 없다
|
|
1047
|
+
* @param {number} vote.closeAt - 마감 시각 (epoch millis). 과거·현재 불가 · 최대 30일 뒤
|
|
1048
|
+
* @param {Array<{id: string, content: string, order: number, imageUrl?: string}>} vote.options - 2~10개.
|
|
1049
|
+
* {@code imageUrl} 은 {@link #uploadVoteImage} 가 돌려준 값을 <b>가공 없이</b> 넣는다
|
|
1050
|
+
* @param {Object} options
|
|
1051
|
+
* @param {string} options.idempotencyKey - <b>필수.</b> 재시도에는 반드시 같은 값을 넘긴다
|
|
1052
|
+
* @returns {Promise<Object>} 생성된 투표 (집계 포함)
|
|
1053
|
+
* @throws {Error} {@code idempotencyKey} 가 없으면 서버를 호출하지 않고 즉시 실패한다
|
|
1054
|
+
* @example
|
|
1055
|
+
* const key = crypto.randomUUID();
|
|
1056
|
+
* const vote = await chat.createVote(roomId, {
|
|
1057
|
+
* question: '점심 뭐 먹지', type: 'SINGLE', optionType: 'TEXT', maxSelections: 1,
|
|
1058
|
+
* closeAt: Date.now() + 86_400_000,
|
|
1059
|
+
* options: [{ id: 'opt-1', content: '삼겹살', order: 0 }, { id: 'opt-2', content: '국밥', order: 1 }]
|
|
1060
|
+
* }, { idempotencyKey: key });
|
|
1061
|
+
*/
|
|
1062
|
+
async createVote(roomId, vote, options = {}) {
|
|
1063
|
+
if (!roomId) {
|
|
1064
|
+
throw new Error('roomId is required');
|
|
1065
|
+
}
|
|
1066
|
+
const { idempotencyKey } = options;
|
|
1067
|
+
if (!idempotencyKey) {
|
|
1068
|
+
// SDK 가 만들어 주면 호출자는 그 값을 볼 수 없다 — 타임아웃으로 응답만 유실됐을 때
|
|
1069
|
+
// 같은 키로 재시도할 방법이 사라지고, 다음 호출이 투표를 하나 더 만든다.
|
|
1070
|
+
throw new Error(
|
|
1071
|
+
'createVote: options.idempotencyKey is required — '
|
|
1072
|
+
+ 'generate it once per vote and reuse the same value on retry '
|
|
1073
|
+
+ '(재시도에 같은 키를 넘겨야 투표가 중복 생성되지 않는다).'
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
const response = await this.apiClient.post(
|
|
1077
|
+
`/api/v1/rooms/${roomId}/votes`,
|
|
1078
|
+
vote,
|
|
1079
|
+
undefined,
|
|
1080
|
+
{ 'X-Idempotency-Key': idempotencyKey }
|
|
1081
|
+
);
|
|
1082
|
+
return this._unwrapSuccessResponse(response);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* 방의 투표 목록 (최신순).
|
|
1087
|
+
*
|
|
1088
|
+
* <p>목록 항목에는 선택지별 집계도 내 선택도 없다 — 참여자 수({@code responseCount})만 있다.
|
|
1089
|
+
* 결과를 보려면 {@link #getVote} 를 쓴다.</p>
|
|
1090
|
+
*
|
|
1091
|
+
* @param {string} roomId - 채팅방 ID
|
|
1092
|
+
* @param {Object} [options]
|
|
1093
|
+
* @param {string} [options.cursor] - 이전 응답의 {@code nextCursor} 를 <b>그대로</b>. 불투명 문자열이라 파싱하지 않는다
|
|
1094
|
+
* @param {number} [options.limit=20] - 최대 100. 초과분은 서버가 조용히 100 으로 보정한다(에러 아님)
|
|
1095
|
+
* @returns {Promise<{items: Array<Object>, nextCursor: ?string}>} {@code items} 가 비면 끝
|
|
1096
|
+
*/
|
|
1097
|
+
async getVotes(roomId, options = {}) {
|
|
1098
|
+
const { cursor, limit } = options;
|
|
1099
|
+
const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes`, { cursor, limit });
|
|
1100
|
+
return this._unwrapSuccessResponse(response);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* 투표 단건 + 집계.
|
|
1105
|
+
*
|
|
1106
|
+
* <p>{@code revealed: false} 일 때의 {@code count}/{@code ratio} 0 은 「참여자가 없다」가 아니라
|
|
1107
|
+
* <b>서버가 가린 값</b>이다. 결과 공개 조건은 「내가 응답했다」 또는 「마감됐다」. 비공개 상태에서도
|
|
1108
|
+
* {@code myOptionIds} 는 채워진다(재투표 화면용).</p>
|
|
1109
|
+
*
|
|
1110
|
+
* <p>{@code ratio} 의 분모는 응답자 수이지 총 선택 수가 아니다 — 복수 선택에서 <b>합이 100을 넘는 것이
|
|
1111
|
+
* 정상</b>이고 화면이 「복수 응답」임을 표기해야 한다.</p>
|
|
1112
|
+
*
|
|
1113
|
+
* <p>마감 여부는 {@code status} 만으로 정해지지 않는다. 서버 자동 마감이 미구현이라
|
|
1114
|
+
* {@code status: 'OPEN'} 인데 {@code closeAt} 이 지난 투표가 있고 그건 마감된 것으로 동작한다 —
|
|
1115
|
+
* <b>{@code status} 와 {@code closeAt} 을 함께</b> 봐야 한다.</p>
|
|
1116
|
+
*
|
|
1117
|
+
* @param {string} roomId - 채팅방 ID
|
|
1118
|
+
* @param {string} voteId - 투표 ID
|
|
1119
|
+
* @returns {Promise<Object>}
|
|
1120
|
+
*/
|
|
1121
|
+
async getVote(roomId, voteId) {
|
|
1122
|
+
const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/votes/${voteId}`);
|
|
1123
|
+
return this._unwrapSuccessResponse(response);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* 투표 수정 — <b>부분 수정이 아니다.</b> 등록 폼 전체를 보낸다({@link #createVote} 와 같은 타입).
|
|
1128
|
+
*
|
|
1129
|
+
* <p>{@code maxSelections}↔{@code type}, {@code optionType}↔{@code imageUrl} 이 서로 묶여 있어
|
|
1130
|
+
* 한쪽만 바꾸면 판독 불가능한 투표가 만들어지기 때문이다.</p>
|
|
1131
|
+
*
|
|
1132
|
+
* <p>조건 둘: <b>개설자만</b> · <b>참여자가 0명일 때만</b>. 표가 들어오면
|
|
1133
|
+
* {@code VOTE_NOT_EDITABLE}(409), 개설자가 아니면 {@code VOTE_MODIFY_FORBIDDEN}(403).
|
|
1134
|
+
* 선택지 {@code id} 를 이전 값과 맞출 필요는 없다 — 참여자가 0명이라 어긋날 응답이 없다.</p>
|
|
1135
|
+
*
|
|
1136
|
+
* @param {string} roomId - 채팅방 ID
|
|
1137
|
+
* @param {string} voteId - 투표 ID
|
|
1138
|
+
* @param {Object} vote - 투표 폼 (생성과 동일)
|
|
1139
|
+
* @returns {Promise<Object>}
|
|
1140
|
+
*/
|
|
1141
|
+
async updateVote(roomId, voteId, vote) {
|
|
1142
|
+
const response = await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}`, vote);
|
|
1143
|
+
return this._unwrapSuccessResponse(response);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* 투표 삭제 — 개설자면 된다. 마감 여부도 참여자 유무도 보지 않는다.
|
|
1148
|
+
*
|
|
1149
|
+
* <p>채팅에 이미 게시된 투표 카드는 <b>그대로 남는다</b>(서버가 발행된 메시지를 고칠 수 없다).
|
|
1150
|
+
* 원본 카드를 누르면 404 가 오고, 이 404 를 「취소된 투표」로 표시하는 것은 화면 몫이다.
|
|
1151
|
+
* 삭제 사실은 별도의 취소 카드 메시지로도 방에 발행된다.</p>
|
|
1152
|
+
*
|
|
1153
|
+
* @param {string} roomId - 채팅방 ID
|
|
1154
|
+
* @param {string} voteId - 투표 ID
|
|
1155
|
+
* @returns {Promise<void>} 본문 없음 (204)
|
|
1156
|
+
*/
|
|
1157
|
+
async deleteVote(roomId, voteId) {
|
|
1158
|
+
await this.apiClient.delete(`/api/v1/rooms/${roomId}/votes/${voteId}`);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* 수동 마감 (개설자만).
|
|
1163
|
+
*
|
|
1164
|
+
* <p>본문이 없으므로 마감 후 결과를 보려면 {@link #getVote} 를 다시 호출한다.
|
|
1165
|
+
* 이미 마감이면 {@code VOTE_ALREADY_CLOSED}(409), 개설자가 아니면 {@code VOTE_CLOSE_FORBIDDEN}(403)
|
|
1166
|
+
* — 수정의 403 과 code 가 다르다.</p>
|
|
1167
|
+
*
|
|
1168
|
+
* @param {string} roomId - 채팅방 ID
|
|
1169
|
+
* @param {string} voteId - 투표 ID
|
|
1170
|
+
* @returns {Promise<void>} 본문 없음 (204)
|
|
1171
|
+
*/
|
|
1172
|
+
async closeVote(roomId, voteId) {
|
|
1173
|
+
await this.apiClient.patch(`/api/v1/rooms/${roomId}/votes/${voteId}/close`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* 투표 접수 · 재투표 — <b>같은 메서드</b>다.
|
|
1178
|
+
*
|
|
1179
|
+
* <p>재투표는 새 자원을 만들지 않고 기존 응답을 덮어쓴다. 클라이언트가 최초/재투표를 구분해 보낼 필요가
|
|
1180
|
+
* 없고, 같은 값을 여러 번 보내도 결과가 같다. 응답에 <b>갱신된 집계가 함께</b> 오므로 별도 폴링이 필요 없다.</p>
|
|
1181
|
+
*
|
|
1182
|
+
* <p>빈 배열은 「투표 취소」로 해석되지 않는다 — 투표를 취소하는 경로는 없다(400).</p>
|
|
1183
|
+
*
|
|
1184
|
+
* <p>여러 규칙을 동시에 위반해도 서버는 <b>먼저 걸린 하나만</b> 응답한다. 하나를 고쳐 재시도하면
|
|
1185
|
+
* 다음 위반이 드러날 수 있어, 모든 오류를 한 번에 수집하는 화면은 성립하지 않는다.</p>
|
|
1186
|
+
*
|
|
1187
|
+
* @param {string} roomId - 채팅방 ID
|
|
1188
|
+
* @param {string} voteId - 투표 ID
|
|
1189
|
+
* @param {string[]} optionIds - 고른 선택지. 빈 배열 불가
|
|
1190
|
+
* @returns {Promise<Object>} 갱신된 투표 (집계 포함)
|
|
1191
|
+
*/
|
|
1192
|
+
async submitVote(roomId, voteId, optionIds) {
|
|
1193
|
+
const response = await this.apiClient.put(
|
|
1194
|
+
`/api/v1/rooms/${roomId}/votes/${voteId}/responses`,
|
|
1195
|
+
{ optionIds }
|
|
1196
|
+
);
|
|
1197
|
+
return this._unwrapSuccessResponse(response);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* 선택지 이미지 업로드.
|
|
1202
|
+
*
|
|
1203
|
+
* <p>돌려받은 {@code imageUrl} 을 <b>가공 없이 그대로</b> 생성·수정 본문의 {@code options[].imageUrl} 에
|
|
1204
|
+
* 넣는다 — 서버가 문자열로 비교하고, 이 API 가 발급한 URL 만 통과한다. <b>채팅 첨부 URL 은 쓸 수 없다</b>
|
|
1205
|
+
* (같은 버킷이라도 거절된다).</p>
|
|
1206
|
+
*
|
|
1207
|
+
* <p>형식은 매직넘버로 판정한다(PNG · JPEG · GIF · WEBP) — 신고된 Content-Type 과 확장자는 쓰이지 않으므로
|
|
1208
|
+
* {@code .png} 로 이름 붙인 텍스트 파일은 거절된다. 10MB 를 넘으면 {@code VOTE_IMAGE_TOO_LARGE}.</p>
|
|
1209
|
+
*
|
|
1210
|
+
* <p>{@code optionType: 'TEXT_WITH_IMAGE'} 는 「이미지를 쓸 수 있다」는 상한이지 「모두 이미지다」가 아니다 —
|
|
1211
|
+
* 이미지가 있는 선택지와 없는 선택지를 섞어도 되고 하나도 없어도 된다. 다만 {@code imageUrl} 이
|
|
1212
|
+
* <b>빈 문자열</b>이면 거절된다(없으면 키를 아예 빼거나 {@code null}).</p>
|
|
1213
|
+
*
|
|
1214
|
+
* @param {string} roomId - 채팅방 ID
|
|
1215
|
+
* @param {File|Blob} file - 이미지 파일
|
|
1216
|
+
* @param {Object} [options]
|
|
1217
|
+
* @param {Function} [options.onProgress] - {@code ({loaded, total, percent}) => void}
|
|
1218
|
+
* @param {AbortSignal} [options.signal] - 취소용
|
|
1219
|
+
* @returns {Promise<{imageUrl: string}>}
|
|
1220
|
+
*/
|
|
1221
|
+
async uploadVoteImage(roomId, file, options = {}) {
|
|
1222
|
+
if (!roomId) {
|
|
1223
|
+
throw new Error('roomId is required');
|
|
1224
|
+
}
|
|
1225
|
+
if (!file) {
|
|
1226
|
+
throw new Error('file is required');
|
|
1227
|
+
}
|
|
1228
|
+
const response = await this.apiClient.upload(
|
|
1229
|
+
`/api/v1/rooms/${roomId}/votes/images`,
|
|
1230
|
+
file,
|
|
1231
|
+
{ onProgress: options.onProgress, signal: options.signal }
|
|
1232
|
+
);
|
|
1233
|
+
return this._unwrapSuccessResponse(response);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
951
1236
|
/**
|
|
952
1237
|
* 이모지 리액션 토글.
|
|
953
1238
|
* <p>한 사용자는 메시지당 하나의 이모지만 보유.
|
|
@@ -2084,10 +2369,20 @@ class ChatClient extends EventEmitter {
|
|
|
2084
2369
|
});
|
|
2085
2370
|
|
|
2086
2371
|
// AI 진행 표시(생각중/검색중/작성중) 구독 — typing 과 별개 채널(가산 UX). 구버전 SDK 는 미구독 → 기존 typing 만.
|
|
2372
|
+
//
|
|
2373
|
+
// ⚠️ 보류함을 <b>구독보다 먼저</b> 연다(설계 §6.1 ①). 구독 호출과 보류 플래그 사이에 DELTA 가
|
|
2374
|
+
// 도착하면 그건 보류되지 않고 그대로 렌더돼, 스냅샷 병합 전에 잘린 본문이 화면에 먼저 그려진다.
|
|
2375
|
+
const snapshotGeneration = this.assistantStreamReplay ? this._armAssistantSnapshot(roomId) : null;
|
|
2087
2376
|
const assistantStreamDestination = WebSocketPaths.getChatAssistantStreamDestination(roomId);
|
|
2088
2377
|
await this.connectionManager.subscribe(assistantStreamDestination, (event) => {
|
|
2089
2378
|
this._handleAssistantStreamEvent(roomId, event);
|
|
2090
2379
|
});
|
|
2380
|
+
if (snapshotGeneration !== null) {
|
|
2381
|
+
// 구독 직후 스냅샷 — 응답을 기다리지 않는다. 구독은 이미 걸렸으므로 그 사이 이벤트는 보류함에 쌓이고,
|
|
2382
|
+
// 병합이 끝나면 순서대로 풀린다. 실패해도 보류분은 풀리므로 라이브만으로 계속 동작한다.
|
|
2383
|
+
this._loadAssistantSnapshot(roomId, snapshotGeneration)
|
|
2384
|
+
.catch((error) => this.logger.warn(`Assistant snapshot merge failed: room=${roomId}`, error));
|
|
2385
|
+
}
|
|
2091
2386
|
|
|
2092
2387
|
// Rolling 누적 요약 완료 알림 구독 — refreshSummary 완료/실패를 summaryUpdate 이벤트로 통지(transient).
|
|
2093
2388
|
const summaryDestination = WebSocketPaths.getChatSummaryDestination(roomId);
|
|
@@ -2169,8 +2464,8 @@ class ChatClient extends EventEmitter {
|
|
|
2169
2464
|
// 타이핑 타이머 정리 (outgoing + incoming assistant 둘 다)
|
|
2170
2465
|
this._clearTypingTimer(roomId);
|
|
2171
2466
|
this._clearAssistantTypingTimer(roomId);
|
|
2172
|
-
// AI 진행 표시
|
|
2173
|
-
this._clearAssistantProgress(roomId
|
|
2467
|
+
// AI 진행 표시 상태/타이머/묘비 정리 + 세대 증가 (emit 없이 — 구독 해제 중)
|
|
2468
|
+
this._clearAssistantProgress(roomId);
|
|
2174
2469
|
|
|
2175
2470
|
// 현재 보고 있는 방이면 activeRoom 해제
|
|
2176
2471
|
if (this._activeRoomId === roomId) {
|
|
@@ -2386,9 +2681,16 @@ class ChatClient extends EventEmitter {
|
|
|
2386
2681
|
typing: false,
|
|
2387
2682
|
senderType: 'ASSISTANT'
|
|
2388
2683
|
});
|
|
2389
|
-
// AI 진행
|
|
2390
|
-
//
|
|
2391
|
-
|
|
2684
|
+
// ⚠️ AI 진행 표시(assistantProgress)는 <b>여기서 끄지 않는다</b>
|
|
2685
|
+
// (ASSISTANT_STREAM_REPLAY_DESIGN §7.3).
|
|
2686
|
+
//
|
|
2687
|
+
// 서버 발행 순서가 메시지 → DONE 이라(ChatMessagePublicationWorkflow 가 MESSAGE_CREATED 를
|
|
2688
|
+
// 먼저 내고, InterveneInChatService 가 그 뒤에 DONE(streamId, messageId) 을 낸다) 이 시점의
|
|
2689
|
+
// 클라는 <b>messageId → streamId 관계를 아직 모른다</b>. 그래서 "이 메시지에 해당하는 stream 만
|
|
2690
|
+
// 끈다" 가 불가능하고, 방 단위로 끄면 같은 방의 <b>다른 살아 있는 stream</b> 까지 사라진다.
|
|
2691
|
+
//
|
|
2692
|
+
// 종료 SSOT 는 DONE 이고, DONE 유실 대비 안전망은 TTL(_startAssistantProgressTimer) 이다.
|
|
2693
|
+
// DONE 은 메시지 post 직후에 나오므로 표시가 늦게 꺼지는 체감도 사실상 없다.
|
|
2392
2694
|
}
|
|
2393
2695
|
|
|
2394
2696
|
// 상대방 메시지일 때만 newMessage + 자동 읽음 처리
|
|
@@ -2713,76 +3015,128 @@ class ChatClient extends EventEmitter {
|
|
|
2713
3015
|
* 없으면 {@code label}(단계) 표시, {@code active:false} 면 해제. PHASE→DELTA 흐름으로 "작성 중" 라벨이
|
|
2714
3016
|
* 점진 렌더 텍스트로 자연 전환된다. 서버가 typing 도 이중발행하므로 구버전 SDK 는 본 채널 미구독 → 기존
|
|
2715
3017
|
* typing 만으로 정상 동작(graceful).</p>
|
|
3018
|
+
*
|
|
3019
|
+
* <p><b>seq 가드</b>(ASSISTANT_STREAM_REPLAY_DESIGN §5.5.0/§7.1-a): 스트림은 Pub/Sub 라 <b>구독 시점
|
|
3020
|
+
* 이후만</b> 수신된다. 방을 나갔다 재입장하거나 WS 가 순단되면 앞부분을 받은 적이 없는데, 예전 구현은
|
|
3021
|
+
* 그 상태에서 도착한 청크를 그대로 누적본으로 렌더해 <b>잘린 본문</b>을 보여줬다. 이제 {@code seq} 로
|
|
3022
|
+
* "앞이 비었다"를 스스로 판정해 본문 대신 {@code label} 로 물러선다({@code textAvailable=false}).
|
|
3023
|
+
* 최종 메시지가 도착하면 어차피 본문이 확정되므로, 잘린 본문을 보여주는 것보다 라벨이 낫다.</p>
|
|
2716
3024
|
* @private
|
|
2717
3025
|
*/
|
|
2718
3026
|
_handleAssistantStreamEvent(roomId, event) {
|
|
2719
3027
|
if (!event || !event.type) return;
|
|
2720
3028
|
|
|
3029
|
+
const streamId = event.streamId || null;
|
|
3030
|
+
if (!streamId) {
|
|
3031
|
+
// streamId 없는 이벤트는 슬롯을 키잉할 수 없다. 서버 계약상 always non-null 이라 도달하지 않는다.
|
|
3032
|
+
this.logger.debug(`Assistant stream event without streamId ignored: room=${roomId}, type=${event.type}`);
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
// 종료된 stream 의 늦은 이벤트 — 되살리지 않는다(§7.2). DONE 자체도 중복 도착하면 여기서 걸린다.
|
|
3036
|
+
if (this._isAssistantStreamClosed(roomId, streamId)) {
|
|
3037
|
+
this.logger.debug(`Assistant event for closed stream ignored: room=${roomId}, stream=${streamId}, type=${event.type}`);
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
// 스냅샷 대기 중이면 보류 — 병합 뒤에 seq 규칙으로 이어붙인다(§6.1). 렌더는 아직 하지 않는다.
|
|
3041
|
+
//
|
|
3042
|
+
// ⚠️ <b>DONE 만은 즉시 처리한다</b>(§6.2). 큐에 넣으면 스냅샷의 ACTIVE 가 먼저 적용돼
|
|
3043
|
+
// {@code active:true} 버블이 한 번 떴다가 곧바로 꺼진다. DONE 은 "서버가 끝났다고 명시한" 신호라
|
|
3044
|
+
// 지금 tombstone 을 세워야 스냅샷이 그 stream 을 되살리지 못한다. 같은 stream 의 이미 보류된
|
|
3045
|
+
// PHASE/DELTA 는 drain 때 그 tombstone 에 걸려 자연히 무시된다.
|
|
3046
|
+
const pending = this._assistantSnapshotPending.get(roomId);
|
|
3047
|
+
if (pending && event.type !== 'DONE') {
|
|
3048
|
+
pending.events.push(event);
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
|
|
2721
3052
|
if (event.type === 'PHASE') {
|
|
3053
|
+
// PHASE 에는 seq 가 없다 — <b>시각이 유일한 순서 축</b>이다. 스냅샷의 phaseUpdatedAt 보다
|
|
3054
|
+
// 오래된 pending PHASE 를 적용하면 라벨이 WRITING → SEARCHING 으로 역행한다(§6.2).
|
|
3055
|
+
const prevSlot = this._assistantSlot(roomId, streamId);
|
|
3056
|
+
const createdAt = typeof event.createdAt === 'number' ? event.createdAt : null;
|
|
3057
|
+
// ⚠️ 등호도 stale 이다. 계약이 "더 최신일 때만 적용"이라 같은 시각이면 적용하면 안 된다 —
|
|
3058
|
+
// 같은 밀리초에 발행된 pending PHASE 가 스냅샷 phase 를 덮어 라벨이 역행한다.
|
|
3059
|
+
if (prevSlot && createdAt !== null && createdAt <= (prevSlot.phaseUpdatedAt || 0)) {
|
|
3060
|
+
this.logger.debug(`Stale assistant PHASE ignored: room=${roomId}, stream=${streamId}, createdAt=${createdAt}, applied=${prevSlot.phaseUpdatedAt}`);
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
2722
3063
|
const phase = event.phase || null;
|
|
2723
|
-
|
|
2724
|
-
//
|
|
2725
|
-
|
|
2726
|
-
const
|
|
2727
|
-
|
|
2728
|
-
this._startAssistantProgressTimer(roomId, event, { phase, text });
|
|
2729
|
-
this.emit('assistantProgress', {
|
|
2730
|
-
roomId,
|
|
2731
|
-
streamId: event.streamId || null,
|
|
2732
|
-
personaId: event.personaId || null,
|
|
3064
|
+
// 같은 stream 의 PHASE 재발행(B-3 synthesis heartbeat 등)이면 누적 상태를 보존한다.
|
|
3065
|
+
// ⚠️ 새 stream 의 커서는 <b>-1</b> 로 연다. DeltaCoalescer 의 첫 청크가 seq=0 이라 0 으로 열면
|
|
3066
|
+
// 첫 글자가 "중복"으로 버려진다 (설계 §5.5.0).
|
|
3067
|
+
const prev = prevSlot;
|
|
3068
|
+
const slot = this._setAssistantSlot(roomId, streamId, event, {
|
|
2733
3069
|
phase,
|
|
2734
|
-
|
|
3070
|
+
phaseUpdatedAt: createdAt !== null ? createdAt : (prev ? prev.phaseUpdatedAt : 0),
|
|
3071
|
+
text: prev ? (prev.text || '') : '',
|
|
3072
|
+
lastSeq: prev ? this._seqOf(prev.lastSeq) : -1,
|
|
3073
|
+
textAvailable: prev ? prev.textAvailable !== false : true
|
|
3074
|
+
});
|
|
3075
|
+
this._emitAssistantStream(roomId, streamId, slot, {
|
|
2735
3076
|
active: true,
|
|
2736
|
-
text: text || null
|
|
3077
|
+
text: slot.textAvailable ? (slot.text || null) : null
|
|
2737
3078
|
});
|
|
2738
3079
|
return;
|
|
2739
3080
|
}
|
|
2740
3081
|
|
|
2741
3082
|
if (event.type === 'DELTA') {
|
|
2742
3083
|
// 토큰 청크(B) — 누적 텍스트에 이어붙여 점진 렌더용으로 emit. WRITING 단계에서 흐른다.
|
|
2743
|
-
const prev = this.
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
3084
|
+
const prev = this._assistantSlot(roomId, streamId);
|
|
3085
|
+
const phase = (prev && prev.phase) || 'WRITING';
|
|
3086
|
+
const seq = typeof event.seq === 'number' ? event.seq : null;
|
|
3087
|
+
|
|
3088
|
+
let lastSeq = prev ? this._seqOf(prev.lastSeq) : -1;
|
|
3089
|
+
let textAvailable = prev ? prev.textAvailable !== false : true;
|
|
3090
|
+
let text = prev ? (prev.text || '') : '';
|
|
3091
|
+
|
|
3092
|
+
// ── seq 취급 규칙 (설계 §5.5.0 SSOT — 소비자 열. 서버 스냅샷 병합도 같은 표를 쓴다) ──
|
|
3093
|
+
// seq <= lastSeq : 중복·지연 도착. <b>장애가 아니다</b> — 조용히 무시.
|
|
3094
|
+
// seq == lastSeq + 1 : 정상 append.
|
|
3095
|
+
// seq > lastSeq + 1 : 실제 gap. 앞이 비었으니 본문을 포기하고 라벨로 물러선다.
|
|
3096
|
+
// 슬롯이 없는 상태의 late join(재입장 직후 seq>0)도 lastSeq=-1 기준으로 마지막 분기에 걸린다.
|
|
3097
|
+
//
|
|
3098
|
+
// ⚠️ <b>커서 판정은 degrade 여부와 무관하다.</b> textAvailable=false 가 멈추는 것은 <b>본문 누적
|
|
3099
|
+
// 뿐</b>이다. 커서까지 멈추면 (1) 이후 중복이 걸러지지 않아 같은 청크가 다시 emit 되고,
|
|
3100
|
+
// (2) lastSeq 가 옛 값에 갇혀 R-4 스냅샷 병합이 잘못된 지점에서 절단한다.
|
|
3101
|
+
if (seq === null) {
|
|
3102
|
+
// 서버가 seq 를 안 실은 경우(구버전 서버) — 판정 불가라 기존 동작(그대로 append)을 유지한다.
|
|
3103
|
+
if (textAvailable) text += event.delta || '';
|
|
3104
|
+
} else if (seq <= lastSeq) {
|
|
3105
|
+
this.logger.debug(`Duplicate assistant DELTA ignored: room=${roomId}, stream=${streamId}, seq=${seq}, lastSeq=${lastSeq}`);
|
|
2747
3106
|
return;
|
|
3107
|
+
} else if (seq === lastSeq + 1) {
|
|
3108
|
+
// 포기한 슬롯이면 커서만 전진시키고 본문은 비운 채 둔다 — 베이스가 없는데 이어붙이면 오염이다.
|
|
3109
|
+
if (textAvailable) text += event.delta || '';
|
|
3110
|
+
lastSeq = seq;
|
|
3111
|
+
} else {
|
|
3112
|
+
this.logger.debug(`Assistant DELTA gap — text dropped: room=${roomId}, stream=${streamId}, seq=${seq}, lastSeq=${lastSeq}`);
|
|
3113
|
+
textAvailable = false;
|
|
3114
|
+
text = '';
|
|
3115
|
+
lastSeq = seq;
|
|
2748
3116
|
}
|
|
2749
|
-
|
|
2750
|
-
const
|
|
2751
|
-
this.
|
|
2752
|
-
this.emit('assistantProgress', {
|
|
2753
|
-
roomId,
|
|
2754
|
-
streamId: event.streamId || null,
|
|
2755
|
-
personaId: event.personaId || null,
|
|
2756
|
-
phase,
|
|
2757
|
-
label: this._assistantPhaseLabels[phase] || null,
|
|
3117
|
+
|
|
3118
|
+
const slot = this._setAssistantSlot(roomId, streamId, event, { phase, text, lastSeq, textAvailable });
|
|
3119
|
+
this._emitAssistantStream(roomId, streamId, slot, {
|
|
2758
3120
|
active: true,
|
|
2759
|
-
text,
|
|
2760
|
-
delta: event.delta || null,
|
|
2761
|
-
seq
|
|
3121
|
+
text: slot.textAvailable ? slot.text : null, // 누적 전체(버블 렌더용)
|
|
3122
|
+
delta: slot.textAvailable ? (event.delta || null) : null, // 이번 청크(append 렌더 선호 시)
|
|
3123
|
+
seq
|
|
2762
3124
|
});
|
|
2763
3125
|
return;
|
|
2764
3126
|
}
|
|
2765
3127
|
|
|
2766
3128
|
if (event.type === 'DONE') {
|
|
2767
|
-
// 종료(성공·실패 공통) —
|
|
2768
|
-
const
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
//
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
// 메시지 도착으로 먼저 해제됐어도 DONE 은 한 번 더 발행될 수 있으나 active:false 는 idempotent(설계 §4.1).
|
|
2777
|
-
if (state && state.timer) clearTimeout(state.timer);
|
|
2778
|
-
this._assistantProgress.delete(roomId);
|
|
2779
|
-
this.emit('assistantProgress', {
|
|
2780
|
-
roomId,
|
|
2781
|
-
streamId: event.streamId || null,
|
|
2782
|
-
personaId: event.personaId || null,
|
|
3129
|
+
// 종료(성공·실패 공통) — <b>이 stream 만</b> 닫는다. 같은 방의 다른 stream 은 계속 산다.
|
|
3130
|
+
const slot = this._assistantSlot(roomId, streamId);
|
|
3131
|
+
if (slot && slot.timer) clearTimeout(slot.timer);
|
|
3132
|
+
this._deleteAssistantSlot(roomId, streamId);
|
|
3133
|
+
// 서버가 "끝났다"고 명시한 유일한 신호 — 묘비는 여기서만 세운다(§7.2).
|
|
3134
|
+
this._tombstoneAssistantStream(roomId, streamId);
|
|
3135
|
+
this._emitAssistantStream(roomId, streamId, slot, {
|
|
3136
|
+
active: false,
|
|
3137
|
+
personaId: event.personaId || (slot && slot.personaId) || null,
|
|
2783
3138
|
phase: null,
|
|
2784
3139
|
label: null,
|
|
2785
|
-
active: false,
|
|
2786
3140
|
status: event.status || null,
|
|
2787
3141
|
messageId: event.messageId || null
|
|
2788
3142
|
});
|
|
@@ -2790,6 +3144,236 @@ class ChatClient extends EventEmitter {
|
|
|
2790
3144
|
// 그 외 미지 type 은 무시(forward-compat).
|
|
2791
3145
|
}
|
|
2792
3146
|
|
|
3147
|
+
/**
|
|
3148
|
+
* 진행 중 스트림 스냅샷을 받아 라이브와 병합한다 (설계 §6.1, R-4).
|
|
3149
|
+
*
|
|
3150
|
+
* <pre>
|
|
3151
|
+
* 0. gen 발급 + pending ON ← 구독보다 <b>먼저</b> (호출자가 한다)
|
|
3152
|
+
* 1. SUBSCRIBE ← 호출자가 한다
|
|
3153
|
+
* 2. GET /assistant/streams
|
|
3154
|
+
* 3. 세대 검증 — 다르면 응답 통째 폐기
|
|
3155
|
+
* 4. 병합: 스냅샷을 베이스로, 보류분을 §5.5.0 규칙으로 이어붙임
|
|
3156
|
+
* 5. pending OFF + 렌더
|
|
3157
|
+
* </pre>
|
|
3158
|
+
*
|
|
3159
|
+
* <p><b>왜 구독이 먼저인가</b>: 반대로 하면 스냅샷 생성과 구독 사이에 발행된 청크가 <b>유실</b>된다.
|
|
3160
|
+
* 구독을 먼저 걸면 최악이 중복인데, 중복은 {@code seq} 로 정확히 잘라낼 수 있다.</p>
|
|
3161
|
+
*
|
|
3162
|
+
* <p><b>실패해도 보류분은 반드시 푼다</b> — 안 그러면 라이브 이벤트가 큐에 갇혀 진행 표시가 통째로
|
|
3163
|
+
* 멈춘다. 스냅샷 실패는 "복구 포기"일 뿐 채팅을 막지 않는다(§6.2).</p>
|
|
3164
|
+
* @private
|
|
3165
|
+
*/
|
|
3166
|
+
async _loadAssistantSnapshot(roomId, generation) {
|
|
3167
|
+
// 같은 <b>세대</b>의 요청이 이미 떠 있으면 재사용한다. 다른 세대 것은 재사용하면 안 된다 —
|
|
3168
|
+
// 그 응답은 세대 불일치로 폐기되는데, 재사용했다면 이번 세대의 요청까지 함께 사라진다(그리고
|
|
3169
|
+
// 이번 세대가 쌓아둔 보류분이 통째로 버려진다).
|
|
3170
|
+
const inFlight = this._assistantSnapshotInFlight.get(roomId);
|
|
3171
|
+
if (inFlight && inFlight.generation === generation) return inFlight.task;
|
|
3172
|
+
|
|
3173
|
+
const task = (async () => {
|
|
3174
|
+
let streams = null;
|
|
3175
|
+
try {
|
|
3176
|
+
const response = await this.apiClient.get(`/api/v1/rooms/${roomId}/assistant/streams`);
|
|
3177
|
+
const data = this._unwrapSuccessResponse(response);
|
|
3178
|
+
streams = (data && Array.isArray(data.streams)) ? data.streams : [];
|
|
3179
|
+
} catch (error) {
|
|
3180
|
+
// 구버전 서버(404)·네트워크·5xx 모두 여기로 온다. 사용자에게 노출하지 않는다.
|
|
3181
|
+
this.logger.warn(`Assistant stream snapshot failed (live-only fallback): room=${roomId}`, error);
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
if (this._assistantGeneration(roomId) !== generation) {
|
|
3185
|
+
// 응답 전에 방을 떠났거나 재구독했다 — 이 스냅샷은 이미 다른 세대의 것이다.
|
|
3186
|
+
// ⚠️ <b>내 세대의 보류함만</b> 정리한다. 무조건 지우면 그 사이 열린 현재 세대의 보류분이
|
|
3187
|
+
// 통째로 사라져 라이브 이벤트가 증발한다.
|
|
3188
|
+
this.logger.debug(`Assistant snapshot discarded (stale generation): room=${roomId}`);
|
|
3189
|
+
this._deleteAssistantPendingIfGeneration(roomId, generation);
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
if (streams) this._applyAssistantSnapshot(roomId, streams);
|
|
3193
|
+
this._drainAssistantPending(roomId, generation);
|
|
3194
|
+
})();
|
|
3195
|
+
|
|
3196
|
+
this._assistantSnapshotInFlight.set(roomId, { generation, task });
|
|
3197
|
+
try {
|
|
3198
|
+
await task;
|
|
3199
|
+
} finally {
|
|
3200
|
+
// 내가 등록한 항목일 때만 지운다 — 그 사이 새 세대가 자기 요청을 올려놨을 수 있다.
|
|
3201
|
+
const current = this._assistantSnapshotInFlight.get(roomId);
|
|
3202
|
+
if (current && current.generation === generation) {
|
|
3203
|
+
this._assistantSnapshotInFlight.delete(roomId);
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
/** @private */
|
|
3209
|
+
_deleteAssistantPendingIfGeneration(roomId, generation) {
|
|
3210
|
+
const pending = this._assistantSnapshotPending.get(roomId);
|
|
3211
|
+
if (pending && pending.generation === generation) {
|
|
3212
|
+
this._assistantSnapshotPending.delete(roomId);
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
/** 스냅샷을 슬롯에 심는다 — 보류분을 풀기 <b>전에</b> 끝나야 베이스가 선다. @private */
|
|
3217
|
+
_applyAssistantSnapshot(roomId, streams) {
|
|
3218
|
+
streams.forEach((s) => {
|
|
3219
|
+
const streamId = s && s.streamId;
|
|
3220
|
+
if (!streamId) return;
|
|
3221
|
+
// 이미 DONE 을 라이브로 받아 닫은 stream — 스냅샷이 그걸 되살리면 안 된다(§6.2).
|
|
3222
|
+
if (this._isAssistantStreamClosed(roomId, streamId)) return;
|
|
3223
|
+
|
|
3224
|
+
if (s.status === 'DONE') {
|
|
3225
|
+
// 유예 구간에 걸린 종료분. 진행 표시를 켜지 않고 최종 메시지로 바로 확정시킨다.
|
|
3226
|
+
this._tombstoneAssistantStream(roomId, streamId);
|
|
3227
|
+
this._emitAssistantStream(roomId, streamId, null, {
|
|
3228
|
+
active: false, phase: null, label: null, personaId: s.personaId || null,
|
|
3229
|
+
status: s.doneStatus || null, messageId: s.messageId || null, resumed: true
|
|
3230
|
+
});
|
|
3231
|
+
return;
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
const textAvailable = s.textAvailable !== false;
|
|
3235
|
+
const slot = this._setAssistantSlot(roomId, streamId, {
|
|
3236
|
+
personaId: s.personaId, expiresAt: undefined
|
|
3237
|
+
}, {
|
|
3238
|
+
phase: s.phase || null,
|
|
3239
|
+
phaseUpdatedAt: typeof s.phaseUpdatedAt === 'number' ? s.phaseUpdatedAt : 0,
|
|
3240
|
+
text: textAvailable ? (s.text || '') : '',
|
|
3241
|
+
lastSeq: this._seqOf(s.lastSeq),
|
|
3242
|
+
textAvailable,
|
|
3243
|
+
resumed: true
|
|
3244
|
+
});
|
|
3245
|
+
this._emitAssistantStream(roomId, streamId, slot, {
|
|
3246
|
+
active: true,
|
|
3247
|
+
text: slot.textAvailable ? (slot.text || null) : null,
|
|
3248
|
+
resumed: true
|
|
3249
|
+
});
|
|
3250
|
+
});
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
/**
|
|
3254
|
+
* 보류분을 <b>도착 순서 그대로</b> 흘려보낸다. 이 시점부터 §5.5.0 규칙이 스냅샷 베이스 위에서 돈다.
|
|
3255
|
+
*
|
|
3256
|
+
* <p>순서가 중요하다 — stream 별로 몰아서 풀면 방 집계의 "가장 최근 stream" 판정이 실제 도착 순서와
|
|
3257
|
+
* 달라져 엉뚱한 본문이 버블에 뜬다.</p>
|
|
3258
|
+
* @private
|
|
3259
|
+
*/
|
|
3260
|
+
_drainAssistantPending(roomId, generation) {
|
|
3261
|
+
const pending = this._assistantSnapshotPending.get(roomId);
|
|
3262
|
+
if (!pending) return;
|
|
3263
|
+
if (generation !== undefined && pending.generation !== generation) return;
|
|
3264
|
+
this._assistantSnapshotPending.delete(roomId); // 먼저 지워야 재귀로 다시 큐에 들어가지 않는다
|
|
3265
|
+
pending.events.forEach((event) => this._handleAssistantStreamEvent(roomId, event));
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
/** @private */
|
|
3269
|
+
_assistantGeneration(roomId) {
|
|
3270
|
+
return this._assistantRoomGeneration.get(roomId) || 0;
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
/** 세대를 넘기고 보류함을 연다 — <b>구독 직전</b>에 부른다(§6.1 ①). @private */
|
|
3274
|
+
_armAssistantSnapshot(roomId) {
|
|
3275
|
+
const generation = this._assistantGeneration(roomId) + 1;
|
|
3276
|
+
this._assistantRoomGeneration.set(roomId, generation);
|
|
3277
|
+
const previous = this._assistantSnapshotPending.get(roomId);
|
|
3278
|
+
// ⚠️ <b>보류분은 세대를 넘겨 이어받는다.</b> 세대는 <b>스냅샷 응답</b>의 유효성을 가르는 축이지
|
|
3279
|
+
// 큐 내용의 소유권이 아니다. 큐에 있는 건 아직 렌더되지 않은 <b>정상 라이브 이벤트</b>라, 새 세대를
|
|
3280
|
+
// 빈 배열로 열면 그대로 유실된다(GET 대기 중 재개가 한 번 더 불리면 그 사이 도착한 DELTA 가 통째로
|
|
3281
|
+
// 사라진다). 방을 떠날 때 버리는 건 별개 경로다({@link #_clearAssistantProgress}).
|
|
3282
|
+
this._assistantSnapshotPending.set(roomId, {
|
|
3283
|
+
generation,
|
|
3284
|
+
events: previous ? previous.events : []
|
|
3285
|
+
});
|
|
3286
|
+
return generation;
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
/**
|
|
3290
|
+
* 재연결·복귀 시 <b>구독 중인 모든 방</b>의 스냅샷을 다시 받는다 (설계 §7.4).
|
|
3291
|
+
*
|
|
3292
|
+
* <p>⚠️ {@link #syncAfterReconnect} 에 얹으면 안 된다. 그쪽은 {@code syncOnReconnect} 옵션과
|
|
3293
|
+
* <b>anchor 가 있는 방</b>이라는 두 겹 필터가 걸려 있어서, 옵션이 꺼져 있거나 그 방에서 메시지를 받은
|
|
3294
|
+
* 적이 없으면 replay 가 조용히 빠진다. 어시 재개는 메시지 갭 복구와 발동 조건이 다르다.</p>
|
|
3295
|
+
*/
|
|
3296
|
+
async resumeAssistantStreams(reason = 'manual') {
|
|
3297
|
+
if (!this.assistantStreamReplay) return;
|
|
3298
|
+
const rooms = Array.from(this.subscribedRooms.keys());
|
|
3299
|
+
if (rooms.length === 0) return;
|
|
3300
|
+
this.logger.debug(`Assistant stream replay: rooms=${rooms.length}, reason=${reason}`);
|
|
3301
|
+
await Promise.all(rooms.map((roomId) => {
|
|
3302
|
+
const generation = this._armAssistantSnapshot(roomId);
|
|
3303
|
+
return this._loadAssistantSnapshot(roomId, generation)
|
|
3304
|
+
.catch((error) => this.logger.warn(`Assistant stream replay failed: room=${roomId}`, error));
|
|
3305
|
+
}));
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
/** 방의 stream 슬롯 맵 — 없으면 생성. @private */
|
|
3309
|
+
_assistantSlots(roomId) {
|
|
3310
|
+
let slots = this._assistantProgress.get(roomId);
|
|
3311
|
+
if (!slots) {
|
|
3312
|
+
slots = new Map();
|
|
3313
|
+
this._assistantProgress.set(roomId, slots);
|
|
3314
|
+
}
|
|
3315
|
+
return slots;
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
/** @private */
|
|
3319
|
+
_assistantSlot(roomId, streamId) {
|
|
3320
|
+
const slots = this._assistantProgress.get(roomId);
|
|
3321
|
+
return slots ? slots.get(streamId) : undefined;
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
/** @private */
|
|
3325
|
+
_deleteAssistantSlot(roomId, streamId) {
|
|
3326
|
+
const slots = this._assistantProgress.get(roomId);
|
|
3327
|
+
if (!slots) return;
|
|
3328
|
+
slots.delete(streamId);
|
|
3329
|
+
if (slots.size === 0) this._assistantProgress.delete(roomId);
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
/** 종료된 stream 인가 — 늦은 이벤트가 슬롯을 되살리는 것을 막는다(§7.2). @private */
|
|
3333
|
+
_isAssistantStreamClosed(roomId, streamId) {
|
|
3334
|
+
const graves = this._assistantStreamTombstones.get(roomId);
|
|
3335
|
+
if (!graves) return false;
|
|
3336
|
+
const expiresAt = graves.get(streamId);
|
|
3337
|
+
if (expiresAt === undefined) return false;
|
|
3338
|
+
if (expiresAt <= Date.now()) {
|
|
3339
|
+
graves.delete(streamId); // 만료된 묘비는 회수 — 그 뒤엔 새 stream 취급이 맞다.
|
|
3340
|
+
return false;
|
|
3341
|
+
}
|
|
3342
|
+
return true;
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
/**
|
|
3346
|
+
* 묘비 등록 — <b>회수는 시간으로만</b> 한다.
|
|
3347
|
+
*
|
|
3348
|
+
* <p>⚠️ 임계를 넘어도 <b>아직 유효한 묘비는 지우지 않는다.</b> 지우는 순간 그 stream 의 늦은 DELTA 가
|
|
3349
|
+
* 슬롯을 되살려 유령 버블이 되고, 그건 "DONE 이후 절대 되살리지 않는다"는 이 장치의 존재 이유 자체를
|
|
3350
|
+
* 깬다. 서버 gate 가 상한 초과 시에도 pruning 대신 경보만 내는 것과 <b>같은 규칙</b>이다 —
|
|
3351
|
+
* 계약 파손이 메모리보다 훨씬 비싸다(항목당 문자열+숫자다).</p>
|
|
3352
|
+
* @private
|
|
3353
|
+
*/
|
|
3354
|
+
_tombstoneAssistantStream(roomId, streamId) {
|
|
3355
|
+
let graves = this._assistantStreamTombstones.get(roomId);
|
|
3356
|
+
if (!graves) {
|
|
3357
|
+
graves = new Map();
|
|
3358
|
+
this._assistantStreamTombstones.set(roomId, graves);
|
|
3359
|
+
}
|
|
3360
|
+
const now = Date.now();
|
|
3361
|
+
graves.forEach((expiresAt, id) => {
|
|
3362
|
+
if (expiresAt <= now) graves.delete(id); // 만료분만 회수(lazy)
|
|
3363
|
+
});
|
|
3364
|
+
graves.set(streamId, now + this._assistantTombstoneTtlMs);
|
|
3365
|
+
|
|
3366
|
+
// 경보는 임계를 <b>넘는 순간 1회</b>만 — 넘은 동안 DONE 마다 짖으면 로그가 신호를 덮는다.
|
|
3367
|
+
if (graves.size > this._assistantTombstoneWarnThreshold) {
|
|
3368
|
+
if (!this._assistantTombstoneWarned.has(roomId)) {
|
|
3369
|
+
this._assistantTombstoneWarned.add(roomId);
|
|
3370
|
+
this.logger.warn(`Assistant tombstones above threshold — NOT pruning (TTL only): room=${roomId}, size=${graves.size}, threshold=${this._assistantTombstoneWarnThreshold}`);
|
|
3371
|
+
}
|
|
3372
|
+
} else {
|
|
3373
|
+
this._assistantTombstoneWarned.delete(roomId);
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
|
|
2793
3377
|
/**
|
|
2794
3378
|
* Rolling 누적 요약 완료 알림(summary 채널) 수신 처리 — status 를 그대로 {@code summaryUpdate} 로 emit.
|
|
2795
3379
|
*
|
|
@@ -2816,13 +3400,24 @@ class ChatClient extends EventEmitter {
|
|
|
2816
3400
|
}
|
|
2817
3401
|
|
|
2818
3402
|
/**
|
|
2819
|
-
*
|
|
2820
|
-
*
|
|
2821
|
-
* 만료 = DONE·메시지 유실로 진행이 끊긴 것 → UI 가 영구 표시에 갇히지 않게 {@code active:false} 자동 emit.
|
|
3403
|
+
* seq 커서 정규화 — 값이 없으면 {@code -1}(DELTA 0건). 첫 청크가 {@code seq=0} 이라 기본값이 0 이면
|
|
3404
|
+
* 첫 글자를 중복으로 버린다(설계 §5.5.0).
|
|
2822
3405
|
* @private
|
|
2823
3406
|
*/
|
|
2824
|
-
|
|
2825
|
-
|
|
3407
|
+
_seqOf(value) {
|
|
3408
|
+
return typeof value === 'number' ? value : -1;
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
/**
|
|
3412
|
+
* stream 슬롯 갱신 + TTL 타이머 재무장 — {@code expiresAt}(epochMillis) 기반, 누락/이상 시 fallback.
|
|
3413
|
+
*
|
|
3414
|
+
* <p>만료 = DONE 유실로 진행이 끊긴 것 → UI 가 영구 표시에 갇히지 않게 {@code active:false} 를 자동
|
|
3415
|
+
* emit 한다. ⚠️ 만료는 <b>묘비를 세우지 않는다</b> — "표시가 낡았다"는 것이지 서버의 확정 종료가
|
|
3416
|
+
* 아니라서, 뒤늦게 정상 DELTA 가 오면 다시 살아나야 맞다(§7.2).</p>
|
|
3417
|
+
* @private
|
|
3418
|
+
*/
|
|
3419
|
+
_setAssistantSlot(roomId, streamId, event, fields) {
|
|
3420
|
+
const prev = this._assistantSlot(roomId, streamId);
|
|
2826
3421
|
if (prev && prev.timer) clearTimeout(prev.timer);
|
|
2827
3422
|
|
|
2828
3423
|
let ttl = this._assistantProgressFallbackTtlMs;
|
|
@@ -2831,39 +3426,114 @@ class ChatClient extends EventEmitter {
|
|
|
2831
3426
|
ttl = Math.min(120000, Math.max(5000, event.expiresAt - Date.now()));
|
|
2832
3427
|
}
|
|
2833
3428
|
const timer = setTimeout(() => {
|
|
2834
|
-
this.
|
|
2835
|
-
this.logger.debug(`Assistant progress auto-cleared (timeout): room=${roomId}`);
|
|
3429
|
+
this._expireAssistantStream(roomId, streamId);
|
|
2836
3430
|
}, ttl);
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
personaId: event.personaId || null,
|
|
3431
|
+
|
|
3432
|
+
const slot = {
|
|
3433
|
+
personaId: event.personaId || (prev && prev.personaId) || null,
|
|
2840
3434
|
phase: fields.phase,
|
|
3435
|
+
// 마지막으로 <b>적용된</b> PHASE 의 발행 시각 — pending PHASE 역행 판정 커서(§6.2).
|
|
3436
|
+
phaseUpdatedAt: fields.phaseUpdatedAt !== undefined
|
|
3437
|
+
? fields.phaseUpdatedAt
|
|
3438
|
+
: (prev ? (prev.phaseUpdatedAt || 0) : 0),
|
|
2841
3439
|
text: fields.text || '',
|
|
3440
|
+
lastSeq: this._seqOf(fields.lastSeq),
|
|
3441
|
+
textAvailable: fields.textAvailable !== false,
|
|
3442
|
+
resumed: fields.resumed === true || (prev ? prev.resumed === true : false),
|
|
3443
|
+
updatedAt: Date.now(),
|
|
3444
|
+
order: ++this._assistantSlotSeq, // 최신 판정 축 — updatedAt 은 진단용일 뿐이다
|
|
2842
3445
|
timer
|
|
3446
|
+
};
|
|
3447
|
+
this._assistantSlots(roomId).set(streamId, slot);
|
|
3448
|
+
return slot;
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
/** TTL 만료 — 이 stream 만 끈다. 묘비는 세우지 않는다. @private */
|
|
3452
|
+
_expireAssistantStream(roomId, streamId) {
|
|
3453
|
+
const slot = this._assistantSlot(roomId, streamId);
|
|
3454
|
+
if (!slot) return;
|
|
3455
|
+
if (slot.timer) clearTimeout(slot.timer);
|
|
3456
|
+
this._deleteAssistantSlot(roomId, streamId);
|
|
3457
|
+
this.logger.debug(`Assistant progress auto-cleared (timeout): room=${roomId}, stream=${streamId}`);
|
|
3458
|
+
this._emitAssistantStream(roomId, streamId, slot, {
|
|
3459
|
+
active: false, phase: null, label: null, reason: 'timeout'
|
|
2843
3460
|
});
|
|
2844
3461
|
}
|
|
2845
3462
|
|
|
2846
3463
|
/**
|
|
2847
|
-
*
|
|
2848
|
-
*
|
|
3464
|
+
* {@code assistantProgress}(stream 단위) + {@code assistantRoomProgress}(방 단위 집계)를 함께 발행한다.
|
|
3465
|
+
*
|
|
3466
|
+
* <p><b>왜 둘인가</b>: 진짜 상태는 stream 단위지만, 기존 프론트는 "방마다 임시 버블 하나"를 전제로
|
|
3467
|
+
* 짜여 있다. 그 코드에 stream 단위 {@code active:false} 가 들어가면 <b>같은 방의 다른 살아 있는
|
|
3468
|
+
* 버블까지 숨긴다</b>. 그래서 이행용 다리로 방 단위 집계를 별도 이벤트로 함께 낸다(설계 §7.5 (b)).
|
|
3469
|
+
* 프론트는 리스너 이름만 바꿔 기존 렌더를 유지하다가, 준비되면 stream 단위로 넘어오면 된다.</p>
|
|
2849
3470
|
* @private
|
|
2850
3471
|
*/
|
|
2851
|
-
|
|
2852
|
-
const
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
3472
|
+
_emitAssistantStream(roomId, streamId, slot, overrides) {
|
|
3473
|
+
const phase = 'phase' in overrides ? overrides.phase : (slot ? slot.phase : null);
|
|
3474
|
+
this.emit('assistantProgress', {
|
|
3475
|
+
roomId,
|
|
3476
|
+
streamId,
|
|
3477
|
+
personaId: slot ? slot.personaId : null,
|
|
3478
|
+
phase,
|
|
3479
|
+
label: phase ? (this._assistantPhaseLabels[phase] || null) : null,
|
|
3480
|
+
textAvailable: slot ? slot.textAvailable : true,
|
|
3481
|
+
lastSeq: slot ? slot.lastSeq : -1,
|
|
3482
|
+
...overrides
|
|
3483
|
+
});
|
|
3484
|
+
this._emitAssistantRoomProgress(roomId);
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
/**
|
|
3488
|
+
* 방 단위 집계 — 살아 있는 stream 이 하나라도 있으면 {@code active:true}.
|
|
3489
|
+
* 표시 내용은 <b>가장 최근에 갱신된</b> stream 을 따른다(단일 버블 UI 가 자연스럽게 최신을 보여준다).
|
|
3490
|
+
* @private
|
|
3491
|
+
*/
|
|
3492
|
+
_emitAssistantRoomProgress(roomId) {
|
|
3493
|
+
const slots = this._assistantProgress.get(roomId);
|
|
3494
|
+
const alive = slots ? Array.from(slots.entries()) : [];
|
|
3495
|
+
if (alive.length === 0) {
|
|
3496
|
+
this.emit('assistantRoomProgress', {
|
|
3497
|
+
roomId, active: false, streamCount: 0,
|
|
3498
|
+
streamId: null, personaId: null, phase: null, label: null, text: null, textAvailable: true
|
|
2865
3499
|
});
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
const [streamId, slot] = alive.reduce((a, b) => (b[1].order > a[1].order ? b : a));
|
|
3503
|
+
this.emit('assistantRoomProgress', {
|
|
3504
|
+
roomId,
|
|
3505
|
+
active: true,
|
|
3506
|
+
streamCount: alive.length,
|
|
3507
|
+
streamId,
|
|
3508
|
+
personaId: slot.personaId,
|
|
3509
|
+
phase: slot.phase,
|
|
3510
|
+
label: slot.phase ? (this._assistantPhaseLabels[slot.phase] || null) : null,
|
|
3511
|
+
text: slot.textAvailable ? (slot.text || null) : null,
|
|
3512
|
+
textAvailable: slot.textAvailable
|
|
3513
|
+
});
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
/**
|
|
3517
|
+
* 방의 모든 stream 상태/타이머 정리 — 구독 해제·destroy 용. 조용히 지운다(emit 없음).
|
|
3518
|
+
*
|
|
3519
|
+
* <p>묘비도 함께 지운다. 방을 떠나면 되살아날 유령 자체가 없고, 재입장은 새 세대이기 때문이다.</p>
|
|
3520
|
+
* @private
|
|
3521
|
+
*/
|
|
3522
|
+
_clearAssistantProgress(roomId) {
|
|
3523
|
+
const slots = this._assistantProgress.get(roomId);
|
|
3524
|
+
if (slots) {
|
|
3525
|
+
slots.forEach((slot) => slot.timer && clearTimeout(slot.timer));
|
|
3526
|
+
this._assistantProgress.delete(roomId);
|
|
2866
3527
|
}
|
|
3528
|
+
this._assistantStreamTombstones.delete(roomId);
|
|
3529
|
+
// 경고 표식도 묘비와 <b>같은 수명</b>이어야 한다. 남겨두면 재구독 후 임계를 다시 넘어도
|
|
3530
|
+
// "이미 알렸다"로 판정해 경고가 영영 안 나오고, Set 에 죽은 roomId 가 쌓인다.
|
|
3531
|
+
this._assistantTombstoneWarned.delete(roomId);
|
|
3532
|
+
// 보류함도 비운다 — 방을 떠났는데 큐가 남아 있으면 재구독 때 옛 이벤트가 섞여 들어간다.
|
|
3533
|
+
// (진행 중 스냅샷 응답은 아래 세대 증가로 무효화되므로 별도 취소가 필요 없다.)
|
|
3534
|
+
this._assistantSnapshotPending.delete(roomId);
|
|
3535
|
+
// 진행 중이던 스냅샷 응답이 뒤늦게 도착해도 적용되지 않도록 세대를 넘긴다(§6.1-a, R-4 가 소비).
|
|
3536
|
+
this._assistantRoomGeneration.set(roomId, (this._assistantRoomGeneration.get(roomId) || 0) + 1);
|
|
2867
3537
|
}
|
|
2868
3538
|
|
|
2869
3539
|
// ==================== 유틸리티 ====================
|
|
@@ -2914,8 +3584,14 @@ class ChatClient extends EventEmitter {
|
|
|
2914
3584
|
this._assistantTypingTimers.clear();
|
|
2915
3585
|
|
|
2916
3586
|
// AI 진행 표시 상태/타이머도 전체 cleanup (구독 외 경로로 남은 roomId 방어).
|
|
2917
|
-
|
|
3587
|
+
// 슬롯이 방 → stream 2단이라 안쪽까지 순회해야 타이머가 남지 않는다(v5.0.0).
|
|
3588
|
+
this._assistantProgress.forEach((slots) => slots.forEach((slot) => slot.timer && clearTimeout(slot.timer)));
|
|
2918
3589
|
this._assistantProgress.clear();
|
|
3590
|
+
this._assistantStreamTombstones.clear();
|
|
3591
|
+
this._assistantTombstoneWarned.clear();
|
|
3592
|
+
this._assistantRoomGeneration.clear();
|
|
3593
|
+
this._assistantSnapshotPending.clear();
|
|
3594
|
+
this._assistantSnapshotInFlight.clear();
|
|
2919
3595
|
|
|
2920
3596
|
// dedup bucket 전체 clear — unsubscribeRoom 이 방별로 정리하지만 구독 외 경로로 누적된 bucket
|
|
2921
3597
|
// (예: 구독 안 한 방의 push/listener) 이 있을 수 있어 안전하게 명시 정리.
|