@vibexnpm/talkx 8.1.0 → 8.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibexnpm/talkx",
3
- "version": "8.1.0",
3
+ "version": "8.4.1",
4
4
  "type": "module",
5
5
  "description": "TalkFlow SDK - Chat & WebRTC unified client",
6
6
  "main": "dist/talkflow-sdk.umd.js",
@@ -143,6 +143,7 @@ class ChatClient extends EventEmitter {
143
143
  SEARCHING: '검색 중…',
144
144
  PLANNING: '기획 중…',
145
145
  DOCUMENT_ANALYSIS: '문서 분석 중…',
146
+ VIDEO_ANALYSIS: '영상 분석 중…',
146
147
  WRITING: '작성 중…'
147
148
  };
148
149
  // PHASE 의 expiresAt 누락/이상 시 fallback TTL (ms) — 서버 ttl(최소 30s) 보다 약간 길게.
@@ -595,6 +596,50 @@ class ChatClient extends EventEmitter {
595
596
  return this._unwrapSuccessResponse(response);
596
597
  }
597
598
 
599
+ /** 영상 분석용 비공개 업로드. 반환한 assetId를 analyzeVideo에 전달한다. */
600
+ async uploadVideoAsset(file, options = {}) {
601
+ if (!file) throw new Error('file is required');
602
+ return this._unwrapSuccessResponse(await this.apiClient.upload('/api/v1/video-assets', file, {
603
+ onProgress: options.onProgress, signal: options.signal
604
+ }));
605
+ }
606
+
607
+ /** 비공개 분석 접수. 네트워크 재시도에는 같은 idempotencyKey를 사용한다. */
608
+ async analyzeVideo(analysis, options = {}) {
609
+ if (typeof options.idempotencyKey !== 'string' || !options.idempotencyKey.trim()) {
610
+ throw new Error('idempotencyKey is required for video analysis');
611
+ }
612
+ return this._unwrapSuccessResponse(await this.apiClient.post('/api/v1/video-analyses', analysis,
613
+ undefined, { 'Idempotency-Key': options.idempotencyKey }));
614
+ }
615
+
616
+ /** 같은 분석 코어를 호출하고 결과를 지정한 방에 게시한다. */
617
+ async analyzeVideoWithAssistant(roomId, request, options = {}) {
618
+ if (!roomId || !request?.personaId || !request?.triggerMessageId) {
619
+ throw new Error('roomId, personaId and triggerMessageId are required');
620
+ }
621
+ if (typeof options.idempotencyKey !== 'string' || !options.idempotencyKey.trim()) {
622
+ throw new Error('idempotencyKey is required for video analysis');
623
+ }
624
+ return this._unwrapSuccessResponse(await this.apiClient.post(
625
+ `/api/v1/rooms/${encodeURIComponent(roomId)}/assistant/tools/analyze-video`, request,
626
+ undefined, { 'Idempotency-Key': options.idempotencyKey }));
627
+ }
628
+
629
+ async getVideoAnalysis(jobId) {
630
+ if (!jobId) throw new Error('jobId is required');
631
+ return this._unwrapSuccessResponse(await this.apiClient.get(`/api/v1/video-analyses/${encodeURIComponent(jobId)}`));
632
+ }
633
+
634
+ async cancelVideoAnalysis(jobId) {
635
+ if (!jobId) throw new Error('jobId is required');
636
+ return this._unwrapSuccessResponse(await this.apiClient.post(`/api/v1/video-analyses/${encodeURIComponent(jobId)}/cancel`));
637
+ }
638
+
639
+ async getVideoAnalysisCapabilities() {
640
+ return this._unwrapSuccessResponse(await this.apiClient.get('/api/v1/video-analyses/capabilities'));
641
+ }
642
+
598
643
  /**
599
644
  * 특정 메시지 온디맨드 번역 (버튼 진입점, 동기).
600
645
  *
@@ -868,12 +913,84 @@ class ChatClient extends EventEmitter {
868
913
  }
869
914
 
870
915
  /**
871
- * 채팅방 나가기 (구독도 자동 해제됨)
916
+ * 채팅방 나가기 (구독도 자동 해제됨).
917
+ *
918
+ * <p>일반 참가자는 {@code leaveRoom(roomId)} 그대로. <b>방장</b>은 그냥 나갈 수 없다 — 나가면 방에 관리자가
919
+ * 0명이 되기 때문이다. 방장은 둘 중 하나를 고른다:</p>
920
+ * <ul>
921
+ * <li>위임 후 나가기 — {@code leaveRoom(roomId, { newOwnerId })}</li>
922
+ * <li>방 삭제 — {@link #deleteRoom}</li>
923
+ * </ul>
924
+ *
925
+ * <p>서버 응답:</p>
926
+ * <ul>
927
+ * <li>409 {@code ROOM_OWNER_CANNOT_LEAVE} — 방장이 newOwnerId 없이 나가려 함 (선택 화면을 띄울 신호)</li>
928
+ * <li>403 {@code NOT_ROOM_ADMIN} — 방장이 아닌데 newOwnerId 를 보냄</li>
929
+ * <li>400 {@code CANNOT_TRANSFER_OWNERSHIP_TO_SELF} / {@code INVALID_OWNERSHIP_TARGET}, 404 {@code USER_NOT_IN_ROOM}
930
+ * — 위임 대상 오류 ({@link #transferOwnership} 와 같다). 이때는 나가지도 않는다.</li>
931
+ * </ul>
932
+ *
872
933
  * @param {string} roomId - 채팅방 ID
934
+ * @param {Object} [options]
935
+ * @param {string} [options.newOwnerId] - 방장이 나갈 때 방장을 넘겨받을 사람 (방의 활성 사람 참가자 · 활성 계정)
936
+ * @returns {Promise<Object>}
937
+ *
938
+ * @example
939
+ * try {
940
+ * await chat.leaveRoom(roomId);
941
+ * } catch (e) {
942
+ * // 서버 코드는 e.response.code — 방장이면 «위임 후 나가기 / 방 삭제» 선택 화면
943
+ * if (e.response?.code === 'ROOM_OWNER_CANNOT_LEAVE') openOwnerExitDialog(roomId);
944
+ * }
945
+ * // 위임 후 나가기
946
+ * await chat.leaveRoom(roomId, { newOwnerId: 'user-2' });
947
+ */
948
+ async leaveRoom(roomId, options = {}) {
949
+ const body = options.newOwnerId ? { newOwnerId: options.newOwnerId } : undefined;
950
+ const result = await this.apiClient.post(`/api/v1/rooms/${roomId}/leave`, body);
951
+ this.unsubscribeRoom(roomId);
952
+ return result;
953
+ }
954
+
955
+ /**
956
+ * 방장 위임 — 방장은 방에 남고 MEMBER 가 된다. 방장(OWNER) 전용.
957
+ *
958
+ * <p>나가면서 넘기려면 {@code leaveRoom(roomId, { newOwnerId })}. 방 참가자 전원에게
959
+ * {@code roomListOwnerChanged} 가 간다(actorId = 이전 방장, members[0] = 새 방장).</p>
960
+ *
961
+ * <p>서버 응답:</p>
962
+ * <ul>
963
+ * <li>403 {@code NOT_ROOM_ADMIN} — 방장 아님 (DIRECT 방은 방장이 없다)</li>
964
+ * <li>400 {@code CANNOT_TRANSFER_OWNERSHIP_TO_SELF} — 자기 자신에게 위임</li>
965
+ * <li>404 {@code USER_NOT_IN_ROOM} — 대상이 현재 참가자가 아님</li>
966
+ * <li>400 {@code INVALID_OWNERSHIP_TARGET} — 대상이 어시스턴트이거나 퇴사·차단 계정</li>
967
+ * </ul>
968
+ *
969
+ * @param {string} roomId
970
+ * @param {string} newOwnerId - 새 방장 userId
971
+ * @returns {Promise<Object>} 갱신된 방 상세 (participants[].role 반영 — 봉투를 벗긴 data)
972
+ */
973
+ async transferOwnership(roomId, newOwnerId) {
974
+ const response = await this.apiClient.put(`/api/v1/rooms/${roomId}/owner`, { newOwnerId });
975
+ return response && typeof response === 'object' && 'data' in response
976
+ ? response.data
977
+ : response;
978
+ }
979
+
980
+ /**
981
+ * 방 삭제 — 방장(OWNER) 전용. 참가자 전원이 나가게 되고 방은 목록에서 사라진다.
982
+ * 이후 enterRoom·getRoomInfo 는 403(ACCESS_FORBIDDEN — 더는 참가자가 아님), joinGroupRoom 은 404(CHAT_ROOM_NOT_FOUND).
983
+ *
984
+ * <p>참가자 전원(방장 포함)에게 {@code roomListDeleted} 가 간다. 본인 구독은 자동 해제된다.
985
+ * 메시지·첨부는 서버에 보존된다(소프트 삭제) — 되살리는 API 는 없다.</p>
986
+ *
987
+ * <p>서버 응답: 403 {@code NOT_ROOM_ADMIN} — 방장 아님.</p>
988
+ *
989
+ * @param {string} roomId
873
990
  * @returns {Promise<Object>}
874
991
  */
875
- async leaveRoom(roomId) {
876
- const result = await this.apiClient.post(`/api/v1/rooms/${roomId}/leave`);
992
+ async deleteRoom(roomId) {
993
+ const result = await this.apiClient.delete(`/api/v1/rooms/${roomId}`);
877
994
  this.unsubscribeRoom(roomId);
878
995
  return result;
879
996
  }
@@ -2551,6 +2668,8 @@ class ChatClient extends EventEmitter {
2551
2668
  * - roomListJoined — 방 입장 (ROOM_JOINED)
2552
2669
  * - roomListLeft — 방 퇴장 (ROOM_LEFT)
2553
2670
  * - roomListSelfLeft — 본인이 나간 경우 (리스트에서 제거 신호)
2671
+ * - roomListOwnerChanged — 방장 위임 (ROOM_OWNER_CHANGED, members[0] = 새 방장)
2672
+ * - roomListDeleted — 방장이 방을 삭제 (ROOM_DELETED, 리스트 제거 + 방 화면 닫기)
2554
2673
  *
2555
2674
  * @returns {Promise<void>}
2556
2675
  *
@@ -2819,6 +2938,19 @@ class ChatClient extends EventEmitter {
2819
2938
  this.emit('roomListSelfKicked', event);
2820
2939
  }
2821
2940
  break;
2941
+ case RoomListEventType.ROOM_OWNER_CHANGED:
2942
+ // 방장 위임 — actorId=이전 방장, members[0]=새 방장. 방장 표시·관리 메뉴를 갱신한다.
2943
+ this.emit('roomListOwnerChanged', event);
2944
+ break;
2945
+ case RoomListEventType.ROOM_DELETED:
2946
+ // 방장이 방을 삭제 — 모든 참가자가 리스트에서 제거하고 열린 방 화면을 닫는다.
2947
+ // 🔴 그 방 구독은 SDK 가 «먼저» 푼다. 남겨 두면 아무것도 못 받으면서 재연결 때마다 되살아나 서버에
2948
+ // 거부된다 — 그 거부로 연결을 끊던 서버 버전에서는 «재연결 → 거부 → 끊김» 이 반복돼 다른 방 실시간까지 멈췄다.
2949
+ if (this.subscribedRooms.has(event.roomId)) {
2950
+ this.unsubscribeRoom(event.roomId);
2951
+ }
2952
+ this.emit('roomListDeleted', event);
2953
+ break;
2822
2954
  case RoomListEventType.ROOM_BANNED:
2823
2955
  this.emit('roomListBanned', event);
2824
2956
  // 본인이 영구 차단당한 경우 편의 이벤트 — "영구 차단되었습니다" 표시 + 리스트 제거
package/src/constants.js CHANGED
@@ -333,6 +333,18 @@ export const RoomListEventType = {
333
333
  ROOM_KICKED: 'ROOM_KICKED',
334
334
  /** 방장에 의한 추방 + 영구 차단 → payload 는 ROOM_KICKED 와 동일, 재입장 거부됨. */
335
335
  ROOM_BANNED: 'ROOM_BANNED',
336
+ /**
337
+ * 방장 위임 → actorId 는 이전 방장, members[0] 이 새 방장. 방장 표시·관리 권한을 갱신한다.
338
+ * «위임 후 나가기» 면 이어서 같은 이전 방장의 ROOM_LEFT 가 온다.
339
+ * SDK 는 이를 roomListOwnerChanged 이벤트로 emit 한다.
340
+ */
341
+ ROOM_OWNER_CHANGED: 'ROOM_OWNER_CHANGED',
342
+ /**
343
+ * 방장이 방을 삭제 → 리스트에서 제거 + 열린 방 화면 닫기. actorId 는 삭제한 방장.
344
+ * 이후 enterRoom·getRoomInfo 는 403(ACCESS_FORBIDDEN — 더는 참가자가 아님), joinGroupRoom 은 404.
345
+ * SDK 는 이를 roomListDeleted 이벤트로 emit 한다.
346
+ */
347
+ ROOM_DELETED: 'ROOM_DELETED',
336
348
  /** 메시지 보관 기간 정리 → cutoffTime 이전 메시지를 UI에서 제거. */
337
349
  MESSAGE_RETENTION_CLEANUP: 'MESSAGE_RETENTION_CLEANUP',
338
350
  /**
@@ -7,6 +7,9 @@ const CHAT_DELEGATE_METHODS = [
7
7
  'createGroupRoom',
8
8
  'joinGroupRoom',
9
9
  'leaveRoom',
10
+ // 방장 위임(방에 남음) · 방 삭제 — 방장의 «위임 후 나가기» 는 leaveRoom(roomId, { newOwnerId }).
11
+ 'transferOwnership',
12
+ 'deleteRoom',
10
13
  'updateGroupRoom',
11
14
  'inviteToGroupRoom',
12
15
  'kickMember',
@@ -61,6 +64,12 @@ const CHAT_DELEGATE_METHODS = [
61
64
  'rateAssistantMessage',
62
65
  'summarizeWithAssistant',
63
66
  'translateWithAssistant',
67
+ 'uploadVideoAsset',
68
+ 'analyzeVideo',
69
+ 'analyzeVideoWithAssistant',
70
+ 'getVideoAnalysis',
71
+ 'cancelVideoAnalysis',
72
+ 'getVideoAnalysisCapabilities',
64
73
  'refreshSummary',
65
74
  'getSummary',
66
75
  'getRoomPmPrompt',
@@ -34,6 +34,8 @@ const CHAT_EVENT_MAP = [
34
34
  ['roomListUnsubscribed', 'roomListUnsubscribed'],
35
35
  ['roomListUpdate', 'roomListUpdate'],
36
36
  ['roomListMessage', 'roomListMessage'],
37
+ ['roomListMessageDeleted', 'roomListMessageDeleted'],
38
+ ['roomListMessageUpdated', 'roomListMessageUpdated'],
37
39
  ['roomListCreated', 'roomListCreated'],
38
40
  ['roomListJoined', 'roomListJoined'],
39
41
  ['roomListLeft', 'roomListLeft'],
@@ -42,6 +44,9 @@ const CHAT_EVENT_MAP = [
42
44
  ['roomListSelfKicked', 'roomListSelfKicked'],
43
45
  ['roomListBanned', 'roomListBanned'],
44
46
  ['roomListSelfBanned', 'roomListSelfBanned'],
47
+ // 방장 위임 · 방 삭제 — 타입에 선언한 이벤트는 여기 없으면 최상위 client.on 에서 안 울린다.
48
+ ['roomListOwnerChanged', 'roomListOwnerChanged'],
49
+ ['roomListDeleted', 'roomListDeleted'],
45
50
  ['roomListRoomUpdated', 'roomListRoomUpdated'],
46
51
  ['roomListUnreadUpdated', 'roomListUnreadUpdated'],
47
52
  ['retentionCleanup', 'retentionCleanup']
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 방장 위임 · 위임 후 나가기 · 방 삭제 타입 — docs/chat.md 의 예제가 <b>실제로 컴파일되는지</b>.
3
+ *
4
+ * <p>jest 는 `.d.ts` 를 보지 않는다. 최상위 client 와 chat 양쪽에 같은 모양이 선언됐는지,
5
+ * leaveRoom 의 두 번째 인자가 선택인지(일반 참가자 기존 호출 유지), 새 이벤트가 구독 가능한지를 tsc 로 고정한다.</p>
6
+ *
7
+ * <p>실행: {@code npm run typecheck}</p>
8
+ */
9
+ import TalkFlowClient, { RoomListEventType } from '../index';
10
+ import type { ChatClient, ChatRoomResponse, LeaveRoomOptions, RoomListUpdateEvent } from '../index';
11
+
12
+ declare const talkflow: TalkFlowClient;
13
+ /** {@code talkflow.chat} 은 연결 전 null 이라 선언이 nullable 이다 — 연결된 chat 을 직접 받는 모양으로 본다. */
14
+ declare const chat: ChatClient;
15
+ declare const roomId: string;
16
+
17
+ async function ownerExit(): Promise<void> {
18
+ // 일반 참가자 — 기존 호출 그대로
19
+ await talkflow.leaveRoom(roomId);
20
+ // 방장 — 위임 후 나가기
21
+ const options: LeaveRoomOptions = { newOwnerId: 'u2' };
22
+ await talkflow.leaveRoom(roomId, options);
23
+ await chat.leaveRoom(roomId, { newOwnerId: 'u2' });
24
+ // 방장 — 방 삭제
25
+ await talkflow.deleteRoom(roomId);
26
+ await chat.deleteRoom(roomId);
27
+ }
28
+
29
+ async function transfer(): Promise<ChatRoomResponse> {
30
+ const fromRoot: ChatRoomResponse = await talkflow.transferOwnership(roomId, 'u2');
31
+ void fromRoot;
32
+ return chat.transferOwnership(roomId, 'u2');
33
+ }
34
+
35
+ talkflow.on('roomListOwnerChanged', (event: RoomListUpdateEvent) => {
36
+ const previousOwner: string | undefined = event.actorId;
37
+ // docs/chat.md 예제 — members[0] 이 새 방장
38
+ const newOwnerId: string | undefined = event.members?.[0]?.userId;
39
+ const nickname: string | null | undefined = event.members?.[0]?.nickname;
40
+ const count: number | undefined = event.activeParticipantCount;
41
+ void [previousOwner, newOwnerId, nickname, count];
42
+ });
43
+ talkflow.on('roomListDeleted', (event: RoomListUpdateEvent) => {
44
+ const deletedRoom: string = event.roomId;
45
+ void deletedRoom;
46
+ });
47
+
48
+ const ownerChanged: 'ROOM_OWNER_CHANGED' = RoomListEventType.ROOM_OWNER_CHANGED;
49
+ const deleted: 'ROOM_DELETED' = RoomListEventType.ROOM_DELETED;
50
+
51
+ void [ownerExit, transfer, ownerChanged, deleted];
@@ -45,7 +45,23 @@ function names(card: ScheduleCard): string {
45
45
  return (card.participantNames ?? []).join(', ') + (card.title ?? '') + (card.whenText ?? '') + (card.location ?? '');
46
46
  }
47
47
 
48
- // 6) 알 수 없는 kind 문자열도 받아들인다(구버전 호환).
48
+ // 6) 개설자는 선택 필드다 — 옛 카드엔 키가 없다. 있을 때만 초대 명단과 따로 그린다.
49
+ // 승인 대기 카드의 확인 안내(organizerNotice)도 선택 필드다 — 있으면 카드 안에 그대로 그린다.
50
+ function roles(card: ScheduleCard): string[] {
51
+ const lines: string[] = [];
52
+ if (card.organizerName) lines.push('개설: ' + card.organizerName);
53
+ const invitees = card.participantNames ?? [];
54
+ lines.push(invitees.length > 0 ? '초대: ' + invitees.join(', ') : '서비스 초대 없음 · 방 구성원 공개');
55
+ if (card.kind === 'PROPOSED' && card.organizerNotice) lines.push(card.organizerNotice);
56
+ return lines;
57
+ }
58
+ const legacy: ScheduleCard = { proposalId: 'p-0', kind: 'PROPOSED', visibilityNotice: '이 방 구성원에게 공개되는 일정입니다' };
59
+ const withOrganizer: ScheduleCard = {
60
+ ...legacy, organizerName: '이승주', participantNames: ['김홍섭'],
61
+ organizerNotice: '같은 이름의 다른 직원이 있습니다. 개설자 외 초대 명단을 확인해 주세요.',
62
+ };
63
+
64
+ // 7) 알 수 없는 kind 문자열도 받아들인다(구버전 호환).
49
65
  const future: ScheduleCard = { proposalId: 'p-1', kind: 'SOMETHING_NEW', visibilityNotice: '이 방 구성원에게 공개되는 일정입니다' };
50
66
 
51
- export { maybeCard, notice, headline, names, future };
67
+ export { maybeCard, notice, headline, names, roles, legacy, withOrganizer, future };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 영상 분석 타입 — docs/video-analysis.md 의 예제가 <b>실제로 컴파일되는지</b>.
3
+ *
4
+ * <p>jest 는 `.d.ts` 를 보지 않는다. 문서가 약속한 모양(멱등키 필수 · 대상 두 종류 · 종료 판정은 status ·
5
+ * 시각은 문자열 · 취소 응답에는 결과가 없음 · 진행 단계 VIDEO_ANALYSIS)이 선언과 맞는지 여기서 tsc 로 고정한다.</p>
6
+ *
7
+ * <p>실행: {@code npm run typecheck}</p>
8
+ */
9
+ import TalkFlowClient from '../index';
10
+ import type {
11
+ AssistantProgressEvent,
12
+ ChatMessage,
13
+ VideoAnalysisCancellation,
14
+ VideoAnalysisCapabilities,
15
+ VideoAnalysisJob,
16
+ VideoAnalysisRequest,
17
+ VideoAnalysisSource,
18
+ VideoAsset,
19
+ } from '../index';
20
+
21
+ declare const talkflow: TalkFlowClient;
22
+ declare const file: File;
23
+ declare const roomId: string;
24
+ declare const personaId: string;
25
+ declare const message: ChatMessage;
26
+ declare const idempotencyKey: string;
27
+
28
+ // 1) 사용 가능 여부와 한도 — 화면은 이 응답의 숫자를 쓴다.
29
+ async function gate(): Promise<boolean> {
30
+ const caps: VideoAnalysisCapabilities = await talkflow.getVideoAnalysisCapabilities();
31
+ const limits: number[] = [caps.maxSources, caps.maxBytesPerSource, caps.maxTotalDurationMs, caps.framesPerSecond];
32
+ const formats: string[] = caps.mimeTypes;
33
+ void [limits, formats, caps.model];
34
+ return caps.available && caps.standaloneUploadConfigured;
35
+ }
36
+
37
+ // 2) 업로드 — 진행률은 객체 하나, 만료 시각은 문자열(ISO-8601)이다.
38
+ async function upload(controller: AbortController): Promise<VideoAsset> {
39
+ const asset = await talkflow.uploadVideoAsset(file, {
40
+ onProgress: ({ loaded, total, percent }) => { void [loaded, total, percent]; },
41
+ signal: controller.signal,
42
+ });
43
+ const expiresAt: string = asset.expiresAt;
44
+ const lengthMs: number = asset.durationMs;
45
+ void [expiresAt, lengthMs, asset.fileName, asset.mimeType, asset.sizeBytes];
46
+ return asset;
47
+ }
48
+
49
+ // 3) 대상은 두 종류다 — 업로드 자산과 채팅 첨부. 구간은 선택이다.
50
+ const uploaded: VideoAnalysisSource = { type: 'UPLOADED_ASSET', assetId: 'asset-1' };
51
+ const attachment: VideoAnalysisSource = {
52
+ type: 'CHAT_ATTACHMENT',
53
+ roomId,
54
+ messageId: message.messageId,
55
+ attachmentId: message.fileMetaDataList?.[0]?.fileId ?? '',
56
+ startMs: 10_000,
57
+ endMs: 30_000,
58
+ };
59
+ // @ts-expect-error 채팅 첨부는 방·메시지·첨부 ID 가 모두 있어야 한다
60
+ const incompleteAttachment: VideoAnalysisSource = { type: 'CHAT_ATTACHMENT', roomId };
61
+ // @ts-expect-error 외부 URL 은 대상이 될 수 없다
62
+ const external: VideoAnalysisSource = { type: 'URL', url: 'https://example.com/video.mp4' };
63
+ void [incompleteAttachment, external];
64
+
65
+ // 4) 접수는 멱등키가 필수다 — 비공개 분석과 방 게시 분석 모두.
66
+ const analysis: VideoAnalysisRequest = { instruction: '요약해 주세요.', locale: 'ko-KR', sources: [uploaded] };
67
+ const privateJob: Promise<VideoAnalysisJob> = talkflow.analyzeVideo(analysis, { idempotencyKey });
68
+ const roomJob: Promise<VideoAnalysisJob> = talkflow.analyzeVideoWithAssistant(roomId, {
69
+ personaId,
70
+ triggerMessageId: message.messageId,
71
+ analysis: { instruction: '이 구간의 발언을 요약해 주세요.', sources: [attachment] },
72
+ }, { idempotencyKey });
73
+ // @ts-expect-error 키 없이 접수할 수 없다
74
+ talkflow.analyzeVideo(analysis);
75
+ // @ts-expect-error 방 게시 분석도 키가 필수다
76
+ talkflow.analyzeVideoWithAssistant(roomId, { personaId, triggerMessageId: message.messageId, analysis });
77
+ void [privateJob, roomJob];
78
+
79
+ // 5) 종료는 status 로 판정한다 — 값이 늘면 이 switch 가 컴파일되지 않는다.
80
+ function isTerminal(job: VideoAnalysisJob): boolean {
81
+ switch (job.status) {
82
+ case 'QUEUED':
83
+ case 'RUNNING':
84
+ return false;
85
+ case 'SUCCEEDED':
86
+ case 'FAILED':
87
+ case 'CANCELLED':
88
+ return true;
89
+ default: {
90
+ const unreachable: never = job.status;
91
+ return unreachable;
92
+ }
93
+ }
94
+ }
95
+
96
+ // 6) 결과는 성공일 때만 있고, 근거 시각은 없을 수 있다(둘 다 null). 시각 필드는 숫자가 아니다.
97
+ function render(job: VideoAnalysisJob): string[] {
98
+ const createdAt: string = job.createdAt;
99
+ const deadline: number = Date.parse(job.deadlineAt);
100
+ // @ts-expect-error createdAt 은 epoch millis 가 아니라 ISO-8601 문자열이다
101
+ const wrong: number = job.createdAt;
102
+ void [createdAt, deadline, wrong, job.detail, job.errorCode, job.resultMessageId, job.deliveryPending];
103
+ if (job.result === null) return [];
104
+ const known = new Set(job.sources.map((source) => source.sourceId));
105
+ return job.result.evidence
106
+ .filter((evidence) => known.has(evidence.sourceId))
107
+ .map((evidence) => {
108
+ const range = evidence.startMs === null || evidence.endMs === null
109
+ ? '(시각 미상)'
110
+ : `${evidence.startMs}–${evidence.endMs}`;
111
+ const modality: 'VISUAL' | 'AUDIO' | 'BOTH' = evidence.modality;
112
+ return `${evidence.sourceId} ${range} ${modality} ${evidence.observation}`;
113
+ });
114
+ }
115
+
116
+ // 7) 취소 응답에는 상태만 있다 — 결과는 getVideoAnalysis 로 읽는다.
117
+ async function cancel(jobId: string): Promise<boolean> {
118
+ const cancelled: VideoAnalysisCancellation = await talkflow.cancelVideoAnalysis(jobId);
119
+ // @ts-expect-error 취소 응답은 결과를 담지 않는다
120
+ void cancelled.result;
121
+ const latest: VideoAnalysisJob = await talkflow.getVideoAnalysis(jobId);
122
+ void [cancelled.deliveryPending, latest];
123
+ return cancelled.status === 'CANCELLED';
124
+ }
125
+
126
+ // 8) 진행 단계에 VIDEO_ANALYSIS 가 있다 — phase 를 직접 분기하는 화면이 쓸 수 있어야 한다.
127
+ function isVideoAnalysis(event: AssistantProgressEvent): boolean {
128
+ return event.active && event.phase === 'VIDEO_ANALYSIS';
129
+ }
130
+
131
+ void [gate, upload, isTerminal, render, cancel, isVideoAnalysis];