@things-factory/ai-assistant 10.1.6

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.
Files changed (164) hide show
  1. package/client/components/board-ai-chat.test.ts +149 -0
  2. package/client/components/board-ai-chat.ts +3228 -0
  3. package/client/components/chat-defaults.ts +27 -0
  4. package/client/components/chat-echo-dedup.test.ts +230 -0
  5. package/client/components/chat-echo-dedup.ts +155 -0
  6. package/client/components/chat-input-builder.ts +69 -0
  7. package/client/components/markdown.test.ts +518 -0
  8. package/client/components/markdown.ts +508 -0
  9. package/client/components/mention-popup-helpers.ts +433 -0
  10. package/client/components/mention-popup.test.ts +588 -0
  11. package/client/components/mention-popup.ts +238 -0
  12. package/client/index.ts +14 -0
  13. package/client/tsconfig.json +13 -0
  14. package/client/utils/board-edit-patch.ts +374 -0
  15. package/dist-client/components/board-ai-chat.d.ts +423 -0
  16. package/dist-client/components/board-ai-chat.js +3034 -0
  17. package/dist-client/components/board-ai-chat.js.map +1 -0
  18. package/dist-client/components/board-ai-chat.test.d.ts +1 -0
  19. package/dist-client/components/board-ai-chat.test.js +138 -0
  20. package/dist-client/components/board-ai-chat.test.js.map +1 -0
  21. package/dist-client/components/chat-defaults.d.ts +8 -0
  22. package/dist-client/components/chat-defaults.js +22 -0
  23. package/dist-client/components/chat-defaults.js.map +1 -0
  24. package/dist-client/components/chat-echo-dedup.d.ts +54 -0
  25. package/dist-client/components/chat-echo-dedup.js +115 -0
  26. package/dist-client/components/chat-echo-dedup.js.map +1 -0
  27. package/dist-client/components/chat-echo-dedup.test.d.ts +1 -0
  28. package/dist-client/components/chat-echo-dedup.test.js +203 -0
  29. package/dist-client/components/chat-echo-dedup.test.js.map +1 -0
  30. package/dist-client/components/chat-input-builder.d.ts +55 -0
  31. package/dist-client/components/chat-input-builder.js +32 -0
  32. package/dist-client/components/chat-input-builder.js.map +1 -0
  33. package/dist-client/components/markdown.d.ts +60 -0
  34. package/dist-client/components/markdown.js +422 -0
  35. package/dist-client/components/markdown.js.map +1 -0
  36. package/dist-client/components/markdown.test.d.ts +1 -0
  37. package/dist-client/components/markdown.test.js +418 -0
  38. package/dist-client/components/markdown.test.js.map +1 -0
  39. package/dist-client/components/mention-popup-helpers.d.ts +165 -0
  40. package/dist-client/components/mention-popup-helpers.js +330 -0
  41. package/dist-client/components/mention-popup-helpers.js.map +1 -0
  42. package/dist-client/components/mention-popup.d.ts +35 -0
  43. package/dist-client/components/mention-popup.js +223 -0
  44. package/dist-client/components/mention-popup.js.map +1 -0
  45. package/dist-client/components/mention-popup.test.d.ts +1 -0
  46. package/dist-client/components/mention-popup.test.js +474 -0
  47. package/dist-client/components/mention-popup.test.js.map +1 -0
  48. package/dist-client/index.d.ts +14 -0
  49. package/dist-client/index.js +15 -0
  50. package/dist-client/index.js.map +1 -0
  51. package/dist-client/tsconfig.tsbuildinfo +1 -0
  52. package/dist-client/utils/board-edit-patch.d.ts +157 -0
  53. package/dist-client/utils/board-edit-patch.js +284 -0
  54. package/dist-client/utils/board-edit-patch.js.map +1 -0
  55. package/dist-server/index.d.ts +28 -0
  56. package/dist-server/index.js +32 -0
  57. package/dist-server/index.js.map +1 -0
  58. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
  59. package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
  60. package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
  61. package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
  62. package/dist-server/service/chat-message/chat-message-publish.js +27 -0
  63. package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
  64. package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
  65. package/dist-server/service/chat-message/chat-message-subscription.js +57 -0
  66. package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
  67. package/dist-server/service/chat-message/chat-message.d.ts +68 -0
  68. package/dist-server/service/chat-message/chat-message.js +172 -0
  69. package/dist-server/service/chat-message/chat-message.js.map +1 -0
  70. package/dist-server/service/chat-message/fold-history.d.ts +43 -0
  71. package/dist-server/service/chat-message/fold-history.js +33 -0
  72. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  73. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  74. package/dist-server/service/chat-message/history-summary.js +77 -0
  75. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  76. package/dist-server/service/chat-message/index.d.ts +5 -0
  77. package/dist-server/service/chat-message/index.js +12 -0
  78. package/dist-server/service/chat-message/index.js.map +1 -0
  79. package/dist-server/service/chat-message/llm-history.d.ts +46 -0
  80. package/dist-server/service/chat-message/llm-history.js +87 -0
  81. package/dist-server/service/chat-message/llm-history.js.map +1 -0
  82. package/dist-server/service/chat-session/chat-session.d.ts +60 -0
  83. package/dist-server/service/chat-session/chat-session.js +159 -0
  84. package/dist-server/service/chat-session/chat-session.js.map +1 -0
  85. package/dist-server/service/chat-session/index.d.ts +3 -0
  86. package/dist-server/service/chat-session/index.js +7 -0
  87. package/dist-server/service/chat-session/index.js.map +1 -0
  88. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +30 -0
  89. package/dist-server/service/chat-session/session-activity-broadcast.js +34 -0
  90. package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -0
  91. package/dist-server/service/chat-session/session-activity-publish.d.ts +12 -0
  92. package/dist-server/service/chat-session/session-activity-publish.js +37 -0
  93. package/dist-server/service/chat-session/session-activity-publish.js.map +1 -0
  94. package/dist-server/service/chat-session/session-activity-subscription.d.ts +14 -0
  95. package/dist-server/service/chat-session/session-activity-subscription.js +87 -0
  96. package/dist-server/service/chat-session/session-activity-subscription.js.map +1 -0
  97. package/dist-server/service/chat-session/session-anchor.d.ts +26 -0
  98. package/dist-server/service/chat-session/session-anchor.js +100 -0
  99. package/dist-server/service/chat-session/session-anchor.js.map +1 -0
  100. package/dist-server/service/chat-session/session-inbox.d.ts +80 -0
  101. package/dist-server/service/chat-session/session-inbox.js +133 -0
  102. package/dist-server/service/chat-session/session-inbox.js.map +1 -0
  103. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +49 -0
  104. package/dist-server/service/chat-session-participant/chat-session-participant.js +141 -0
  105. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -0
  106. package/dist-server/service/chat-session-participant/index.d.ts +4 -0
  107. package/dist-server/service/chat-session-participant/index.js +10 -0
  108. package/dist-server/service/chat-session-participant/index.js.map +1 -0
  109. package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
  110. package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
  112. package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
  113. package/dist-server/service/chat-session-participant/presence.js +22 -0
  114. package/dist-server/service/chat-session-participant/presence.js.map +1 -0
  115. package/dist-server/service/chat-session-resolver.d.ts +93 -0
  116. package/dist-server/service/chat-session-resolver.js +706 -0
  117. package/dist-server/service/chat-session-resolver.js.map +1 -0
  118. package/dist-server/service/index.d.ts +24 -0
  119. package/dist-server/service/index.js +39 -0
  120. package/dist-server/service/index.js.map +1 -0
  121. package/dist-server/service/subscribe-auth.d.ts +14 -0
  122. package/dist-server/service/subscribe-auth.js +33 -0
  123. package/dist-server/service/subscribe-auth.js.map +1 -0
  124. package/dist-server/service/types.d.ts +5 -0
  125. package/dist-server/service/types.js +12 -0
  126. package/dist-server/service/types.js.map +1 -0
  127. package/dist-server/tsconfig.tsbuildinfo +1 -0
  128. package/package.json +48 -0
  129. package/server/index.ts +28 -0
  130. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
  131. package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
  132. package/server/service/chat-message/chat-message-broadcast.ts +27 -0
  133. package/server/service/chat-message/chat-message-publish.ts +22 -0
  134. package/server/service/chat-message/chat-message-subscription.ts +45 -0
  135. package/server/service/chat-message/chat-message.ts +195 -0
  136. package/server/service/chat-message/fold-history.test.ts +98 -0
  137. package/server/service/chat-message/fold-history.ts +77 -0
  138. package/server/service/chat-message/history-summary.test.ts +127 -0
  139. package/server/service/chat-message/history-summary.ts +100 -0
  140. package/server/service/chat-message/index.ts +12 -0
  141. package/server/service/chat-message/llm-history.test.ts +194 -0
  142. package/server/service/chat-message/llm-history.ts +163 -0
  143. package/server/service/chat-session/chat-session.ts +193 -0
  144. package/server/service/chat-session/index.ts +5 -0
  145. package/server/service/chat-session/session-activity-broadcast.ts +57 -0
  146. package/server/service/chat-session/session-activity-publish.ts +44 -0
  147. package/server/service/chat-session/session-activity-subscription.ts +67 -0
  148. package/server/service/chat-session/session-anchor.ts +106 -0
  149. package/server/service/chat-session/session-inbox.test.ts +185 -0
  150. package/server/service/chat-session/session-inbox.ts +161 -0
  151. package/server/service/chat-session-participant/chat-session-participant.ts +135 -0
  152. package/server/service/chat-session-participant/index.ts +6 -0
  153. package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
  154. package/server/service/chat-session-participant/invite-notification.ts +48 -0
  155. package/server/service/chat-session-participant/presence.test.ts +39 -0
  156. package/server/service/chat-session-participant/presence.ts +21 -0
  157. package/server/service/chat-session-resolver.ts +644 -0
  158. package/server/service/index.ts +37 -0
  159. package/server/service/session-anchor.test.ts +133 -0
  160. package/server/service/subscribe-auth.test.ts +57 -0
  161. package/server/service/subscribe-auth.ts +34 -0
  162. package/server/service/types.ts +15 -0
  163. package/things-factory.config.js +1 -0
  164. package/tsconfig.json +9 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-defaults.js","sourceRoot":"","sources":["../../client/components/chat-defaults.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH,MAAM,CAAC,MAAM,YAAY,GAAiB,EAAE,cAAc,EAAE,EAAE,EAAE,cAAc,EAAE,EAAE,EAAE,CAAA;AAEpF,yDAAyD;AACzD,MAAM,UAAU,oBAAoB,CAAC,QAA+B;IAClE,IAAI,QAAQ,CAAC,cAAc;QAAE,YAAY,CAAC,cAAc,GAAG,QAAQ,CAAC,cAAc,CAAA;IAClF,IAAI,QAAQ,CAAC,cAAc;QAAE,YAAY,CAAC,cAAc,GAAG,QAAQ,CAAC,cAAc,CAAA;AACpF,CAAC","sourcesContent":["/*\n * 대화면의 기본 목록 — **넣는 쪽이 주인이다.**\n *\n * `@` 카탈로그와 `/` 슬래시 목록은 도메인마다 다르다. 보드 저작에는 컴포넌트 타입이 뜨고, 공장\n * 대화면에는 그것이 뜨면 안 된다. 그런데 대화면이 보드 목록을 직접 import 하고 있어서, 보드가\n * 없는 제품이 이 컴포넌트를 쓰면 **쓸 수 없는 항목이 목록에 떴다.**\n *\n * 그래서 호스트가 넣는다. `board-ai` 를 import 하면 그 패키지가 부팅 때 자기 것을 넣고(client\n * barrel), 안 하는 제품은 빈 채로 시작한다. 빈 것이 맞다 — 없는 것을 보여 주는 것보다 낫다.\n *\n * ⚠ 대화면이 뜬 뒤에 넣으면 늦는다. 후보 캐시가 인스턴스 만들 때 한 번 만들어진다. 호스트의\n * client barrel(=모듈 로드 시점)에서 부르면 항상 먼저다.\n */\n\n/** 목록의 모양은 이 파일이 정하지 않는다 — 넣는 쪽의 타입을 그대로 받는다. */\nexport interface ChatDefaults {\n catalogEntries: any[]\n slashTemplates: any[]\n}\n\nexport const chatDefaults: ChatDefaults = { catalogEntries: [], slashTemplates: [] }\n\n/** 호스트가 자기 기본 목록을 넣는다. 두 번 부르면 뒤엣것이 이긴다(같은 호스트의 재로드). */\nexport function registerChatDefaults(defaults: Partial<ChatDefaults>): void {\n if (defaults.catalogEntries) chatDefaults.catalogEntries = defaults.catalogEntries\n if (defaults.slashTemplates) chatDefaults.slashTemplates = defaults.slashTemplates\n}\n"]}
@@ -0,0 +1,54 @@
1
+ /** dedup 에 필요한 최소 라인 형태 (board-ai-chat 의 ChatLine 부분집합). */
2
+ export interface DedupLine {
3
+ id?: string;
4
+ _localId?: string;
5
+ role: string;
6
+ content: string;
7
+ senderEmail?: string;
8
+ senderName?: string;
9
+ [k: string]: any;
10
+ }
11
+ /** 서버 방송 메시지(필요 필드만). */
12
+ export interface RemoteMessage {
13
+ id?: string;
14
+ role: string;
15
+ content: string;
16
+ relatedPatchId?: string;
17
+ toolUsagesJson?: any;
18
+ /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */
19
+ groundingWarningsJson?: any;
20
+ /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */
21
+ creator?: {
22
+ name?: string;
23
+ email?: string;
24
+ } | null;
25
+ }
26
+ /** 방송 메시지 → 라인. */
27
+ export declare function remoteMessageToLine(m: RemoteMessage): DedupLine;
28
+ /**
29
+ * 원격(방송) 메시지 수신 결과를 계산.
30
+ * - id 없음 / 이미 seen → 변화 없음(null).
31
+ * - **내 발신 에코**이고 아직 서버 id 를 못 받은 optimistic 라인이 있으면 → 그 라인에 id 를 입힌다.
32
+ * - 그 밖 → seen 에 추가 + 라인 append.
33
+ *
34
+ * 왜 '입히기'인가: 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 에코를 새 줄로 붙이고
35
+ * 중복 제거를 mutation 응답 시점(reconcileSentLine)에 했는데, 그 응답은 **LLM 이 끝난 뒤** 오므로
36
+ * 사용자는 대기 시간 내내 자기 질문을 두 줄로 본다. 그래서 붙이지 않고 대기 중인 줄에 흡수시킨다.
37
+ *
38
+ * `_localId` 는 유지한다 — 이후 mutation 응답이 patch(요약·patchId 등)를 그 줄에 병합할 수 있어야 한다.
39
+ */
40
+ export declare function receiveRemoteMessage(lines: DedupLine[], seen: Set<string>, m: RemoteMessage | undefined | null): {
41
+ lines: DedupLine[];
42
+ seen: Set<string>;
43
+ } | null;
44
+ /**
45
+ * 전송한 optimistic 라인(_localId)을 서버 id 로 reconcile.
46
+ * - **다른 줄**이 이미 그 serverId 를 갖고 있으면(옛 append 경로) optimistic 중복 라인 제거.
47
+ * - 아니면 serverId 부여(+ seen 등록) + patch 병합 + _localId 해제.
48
+ * - 해당 _localId 라인이 없으면 변화 없음.
49
+ * 반환: 새 { lines, seen } (불변).
50
+ */
51
+ export declare function reconcileSentLine(lines: DedupLine[], seen: Set<string>, localId: string, serverId: string | undefined, patch: Partial<DedupLine>): {
52
+ lines: DedupLine[];
53
+ seen: Set<string>;
54
+ };
@@ -0,0 +1,115 @@
1
+ /*
2
+ * 그룹챗 메시지 에코 dedup — 순수 로직.
3
+ *
4
+ * 실시간 방송은 자기 발신 메시지도 되돌려준다(에코). 또 pubsub publish 가 mutation 응답보다
5
+ * 먼저 도착할 수 있어(레이스) optimistic 라인과 방송 라인이 겹칠 수 있다. 이 모듈은 그 조정을
6
+ * **id 기반**으로 결정한다 (서버가 멘션 마커로 content 를 enrich 하므로 content 매칭은 불가).
7
+ *
8
+ * 컴포넌트 상태(this.lines / this._seenMessageIds)에서 분리 → 단위테스트 가능.
9
+ * (C2 의 patch-apply-gate.ts 와 동일한 "순수 게이트" 컨벤션.)
10
+ */
11
+ /** 방송 메시지 → 라인. */
12
+ export function remoteMessageToLine(m) {
13
+ return {
14
+ id: m.id,
15
+ role: m.role,
16
+ content: m.content,
17
+ patchId: m.relatedPatchId ?? undefined,
18
+ toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,
19
+ groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,
20
+ senderEmail: m.creator?.email ?? undefined,
21
+ senderName: m.creator?.name ?? undefined
22
+ };
23
+ }
24
+ /**
25
+ * 원격(방송) 메시지 수신 결과를 계산.
26
+ * - id 없음 / 이미 seen → 변화 없음(null).
27
+ * - **내 발신 에코**이고 아직 서버 id 를 못 받은 optimistic 라인이 있으면 → 그 라인에 id 를 입힌다.
28
+ * - 그 밖 → seen 에 추가 + 라인 append.
29
+ *
30
+ * 왜 '입히기'인가: 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 에코를 새 줄로 붙이고
31
+ * 중복 제거를 mutation 응답 시점(reconcileSentLine)에 했는데, 그 응답은 **LLM 이 끝난 뒤** 오므로
32
+ * 사용자는 대기 시간 내내 자기 질문을 두 줄로 본다. 그래서 붙이지 않고 대기 중인 줄에 흡수시킨다.
33
+ *
34
+ * `_localId` 는 유지한다 — 이후 mutation 응답이 patch(요약·patchId 등)를 그 줄에 병합할 수 있어야 한다.
35
+ */
36
+ export function receiveRemoteMessage(lines, seen, m) {
37
+ if (!m || !m.id)
38
+ return null;
39
+ if (seen.has(m.id))
40
+ return null;
41
+ const nextSeen = new Set(seen);
42
+ nextSeen.add(m.id);
43
+ /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.
44
+ * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)
45
+ * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */
46
+ /* AI 답변 에코 흡수 — 사용자 발화와 **같은 이유**로 붙이지 않고 대기 줄에 흡수한다.
47
+ *
48
+ * 왜 필요한가(실제 사고): 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 assistant 방송을
49
+ * 새 줄로 붙였고, 뒤이어 mutation 응답이 "같은 서버 id 를 가진 다른 줄이 있다" 며 **대기 줄을 지웠다**.
50
+ * 그런데 대기 줄에만 있는 것이 있다 — **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고).
51
+ * 그래서 화면에서 실행 버튼이 사라졌다(제안 도구는 분명히 호출됐는데도).
52
+ *
53
+ * 흡수하면 서버가 보낸 본문(멘션 마커 포함)을 그 줄에 입히면서 대기 표시를 풀 수 있어, 답이 더
54
+ * 빨리 보이는 효과도 있다. */
55
+ if (m.role === 'assistant') {
56
+ const idx = lines.findIndex(l => l.role === 'assistant' && !l.id && !!l._localId);
57
+ if (idx >= 0) {
58
+ const nextLines = [...lines];
59
+ nextLines[idx] = {
60
+ ...nextLines[idx],
61
+ id: m.id,
62
+ /* 본문·영속 데이터는 서버 것으로 갱신하고, 이 줄이 들고 있던 값(제안 등)은 유지한다. */
63
+ content: m.content,
64
+ patchId: m.relatedPatchId ?? nextLines[idx].patchId,
65
+ toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,
66
+ groundingWarnings: Array.isArray(m.groundingWarningsJson)
67
+ ? m.groundingWarningsJson
68
+ : nextLines[idx].groundingWarnings,
69
+ pending: false
70
+ };
71
+ return { lines: nextLines, seen: nextSeen };
72
+ }
73
+ }
74
+ if (m.role === 'user') {
75
+ const echoEmail = m.creator?.email;
76
+ const idx = lines.findIndex(l => l.role === 'user' &&
77
+ !l.id &&
78
+ !!l._localId &&
79
+ (!echoEmail || l.senderEmail == null || l.senderEmail === echoEmail));
80
+ if (idx >= 0) {
81
+ const nextLines = [...lines];
82
+ nextLines[idx] = {
83
+ ...nextLines[idx],
84
+ id: m.id,
85
+ senderEmail: echoEmail ?? nextLines[idx].senderEmail,
86
+ senderName: m.creator?.name ?? nextLines[idx].senderName
87
+ };
88
+ return { lines: nextLines, seen: nextSeen };
89
+ }
90
+ }
91
+ return { lines: [...lines, remoteMessageToLine(m)], seen: nextSeen };
92
+ }
93
+ /**
94
+ * 전송한 optimistic 라인(_localId)을 서버 id 로 reconcile.
95
+ * - **다른 줄**이 이미 그 serverId 를 갖고 있으면(옛 append 경로) optimistic 중복 라인 제거.
96
+ * - 아니면 serverId 부여(+ seen 등록) + patch 병합 + _localId 해제.
97
+ * - 해당 _localId 라인이 없으면 변화 없음.
98
+ * 반환: 새 { lines, seen } (불변).
99
+ */
100
+ export function reconcileSentLine(lines, seen, localId, serverId, patch) {
101
+ const idx = lines.findIndex(l => l._localId === localId);
102
+ if (idx < 0)
103
+ return { lines, seen };
104
+ const nextLines = [...lines];
105
+ /* seen 만 보고 지우면, 에코를 이 줄에 **흡수**한 경우(id 가 이미 이 줄에 있다) 자기 줄을 지운다.
106
+ * 실제 중복은 "다른 줄이 같은 id 를 가진" 상태일 때뿐이다. */
107
+ if (serverId && nextLines.some((l, i) => i !== idx && l.id === serverId)) {
108
+ nextLines.splice(idx, 1);
109
+ return { lines: nextLines, seen };
110
+ }
111
+ const nextSeen = serverId ? new Set(seen).add(serverId) : seen;
112
+ nextLines[idx] = { ...nextLines[idx], ...patch, id: serverId ?? nextLines[idx].id, _localId: undefined };
113
+ return { lines: nextLines, seen: nextSeen };
114
+ }
115
+ //# sourceMappingURL=chat-echo-dedup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-echo-dedup.js","sourceRoot":"","sources":["../../client/components/chat-echo-dedup.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 그룹챗 메시지 에코 dedup — 순수 로직.\n *\n * 실시간 방송은 자기 발신 메시지도 되돌려준다(에코). 또 pubsub publish 가 mutation 응답보다\n * 먼저 도착할 수 있어(레이스) optimistic 라인과 방송 라인이 겹칠 수 있다. 이 모듈은 그 조정을\n * **id 기반**으로 결정한다 (서버가 멘션 마커로 content 를 enrich 하므로 content 매칭은 불가).\n *\n * 컴포넌트 상태(this.lines / this._seenMessageIds)에서 분리 → 단위테스트 가능.\n * (C2 의 patch-apply-gate.ts 와 동일한 \"순수 게이트\" 컨벤션.)\n */\n\n/** dedup 에 필요한 최소 라인 형태 (board-ai-chat 의 ChatLine 부분집합). */\nexport interface DedupLine {\n id?: string\n _localId?: string\n role: string\n content: string\n senderEmail?: string\n senderName?: string\n [k: string]: any\n}\n\n/** 서버 방송 메시지(필요 필드만). */\nexport interface RemoteMessage {\n id?: string\n role: string\n content: string\n relatedPatchId?: string\n toolUsagesJson?: any\n /** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */\n groundingWarningsJson?: any\n /** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */\n creator?: { name?: string; email?: string } | null\n}\n\n/** 방송 메시지 → 라인. */\nexport function remoteMessageToLine(m: RemoteMessage): DedupLine {\n return {\n id: m.id,\n role: m.role,\n content: m.content,\n patchId: m.relatedPatchId ?? undefined,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined,\n groundingWarnings: Array.isArray(m.groundingWarningsJson) ? m.groundingWarningsJson : undefined,\n senderEmail: m.creator?.email ?? undefined,\n senderName: m.creator?.name ?? undefined\n }\n}\n\n/**\n * 원격(방송) 메시지 수신 결과를 계산.\n * - id 없음 / 이미 seen → 변화 없음(null).\n * - **내 발신 에코**이고 아직 서버 id 를 못 받은 optimistic 라인이 있으면 → 그 라인에 id 를 입힌다.\n * - 그 밖 → seen 에 추가 + 라인 append.\n *\n * 왜 '입히기'인가: 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 에코를 새 줄로 붙이고\n * 중복 제거를 mutation 응답 시점(reconcileSentLine)에 했는데, 그 응답은 **LLM 이 끝난 뒤** 오므로\n * 사용자는 대기 시간 내내 자기 질문을 두 줄로 본다. 그래서 붙이지 않고 대기 중인 줄에 흡수시킨다.\n *\n * `_localId` 는 유지한다 — 이후 mutation 응답이 patch(요약·patchId 등)를 그 줄에 병합할 수 있어야 한다.\n */\nexport function receiveRemoteMessage(\n lines: DedupLine[],\n seen: Set<string>,\n m: RemoteMessage | undefined | null\n): { lines: DedupLine[]; seen: Set<string> } | null {\n if (!m || !m.id) return null\n if (seen.has(m.id)) return null\n const nextSeen = new Set(seen)\n nextSeen.add(m.id)\n\n /* 내 발신 에코 흡수 — `_localId` 가 있는 줄은 이 클라이언트가 만든 것뿐이다.\n * 발신자 판정: 양쪽 email 이 있으면 같아야 하고, 방송에 발신자 email 이 없으면(단일 사용자·구형)\n * 내 것으로 본다. 다른 사용자의 메시지가 내 대기 줄을 삼키지 않도록 email 이 다르면 흡수하지 않는다. */\n /* AI 답변 에코 흡수 — 사용자 발화와 **같은 이유**로 붙이지 않고 대기 줄에 흡수한다.\n *\n * 왜 필요한가(실제 사고): 방송은 mutation 응답보다 먼저 도착할 수 있다. 예전에는 assistant 방송을\n * 새 줄로 붙였고, 뒤이어 mutation 응답이 \"같은 서버 id 를 가진 다른 줄이 있다\" 며 **대기 줄을 지웠다**.\n * 그런데 대기 줄에만 있는 것이 있다 — **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고).\n * 그래서 화면에서 실행 버튼이 사라졌다(제안 도구는 분명히 호출됐는데도).\n *\n * 흡수하면 서버가 보낸 본문(멘션 마커 포함)을 그 줄에 입히면서 대기 표시를 풀 수 있어, 답이 더\n * 빨리 보이는 효과도 있다. */\n if (m.role === 'assistant') {\n const idx = lines.findIndex(l => l.role === 'assistant' && !l.id && !!l._localId)\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n /* 본문·영속 데이터는 서버 것으로 갱신하고, 이 줄이 들고 있던 값(제안 등)은 유지한다. */\n content: m.content,\n patchId: m.relatedPatchId ?? nextLines[idx].patchId,\n toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : nextLines[idx].toolUsages,\n groundingWarnings: Array.isArray(m.groundingWarningsJson)\n ? m.groundingWarningsJson\n : nextLines[idx].groundingWarnings,\n pending: false\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n if (m.role === 'user') {\n const echoEmail = m.creator?.email\n const idx = lines.findIndex(\n l =>\n l.role === 'user' &&\n !l.id &&\n !!l._localId &&\n (!echoEmail || l.senderEmail == null || l.senderEmail === echoEmail)\n )\n if (idx >= 0) {\n const nextLines = [...lines]\n nextLines[idx] = {\n ...nextLines[idx],\n id: m.id,\n senderEmail: echoEmail ?? nextLines[idx].senderEmail,\n senderName: m.creator?.name ?? nextLines[idx].senderName\n }\n return { lines: nextLines, seen: nextSeen }\n }\n }\n\n return { lines: [...lines, remoteMessageToLine(m)], seen: nextSeen }\n}\n\n/**\n * 전송한 optimistic 라인(_localId)을 서버 id 로 reconcile.\n * - **다른 줄**이 이미 그 serverId 를 갖고 있으면(옛 append 경로) optimistic 중복 라인 제거.\n * - 아니면 serverId 부여(+ seen 등록) + patch 병합 + _localId 해제.\n * - 해당 _localId 라인이 없으면 변화 없음.\n * 반환: 새 { lines, seen } (불변).\n */\nexport function reconcileSentLine(\n lines: DedupLine[],\n seen: Set<string>,\n localId: string,\n serverId: string | undefined,\n patch: Partial<DedupLine>\n): { lines: DedupLine[]; seen: Set<string> } {\n const idx = lines.findIndex(l => l._localId === localId)\n if (idx < 0) return { lines, seen }\n\n const nextLines = [...lines]\n /* seen 만 보고 지우면, 에코를 이 줄에 **흡수**한 경우(id 가 이미 이 줄에 있다) 자기 줄을 지운다.\n * 실제 중복은 \"다른 줄이 같은 id 를 가진\" 상태일 때뿐이다. */\n if (serverId && nextLines.some((l, i) => i !== idx && l.id === serverId)) {\n nextLines.splice(idx, 1)\n return { lines: nextLines, seen }\n }\n const nextSeen = serverId ? new Set(seen).add(serverId) : seen\n nextLines[idx] = { ...nextLines[idx], ...patch, id: serverId ?? nextLines[idx].id, _localId: undefined }\n return { lines: nextLines, seen: nextSeen }\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,203 @@
1
+ import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup';
2
+ const msg = (id, role = 'user', content = 'hi') => ({ id, role, content });
3
+ describe('remoteMessageToLine', () => {
4
+ it('필드 매핑 + relatedPatchId/toolUsagesJson 변환', () => {
5
+ const line = remoteMessageToLine({
6
+ id: 'm1',
7
+ role: 'assistant',
8
+ content: 'done',
9
+ relatedPatchId: 'p1',
10
+ toolUsagesJson: [{ name: 'x' }]
11
+ });
12
+ expect(line).toMatchObject({ id: 'm1', role: 'assistant', content: 'done', patchId: 'p1' });
13
+ expect(line.toolUsages).toEqual([{ name: 'x' }]);
14
+ });
15
+ it('toolUsagesJson 이 배열 아니면 undefined', () => {
16
+ expect(remoteMessageToLine({ id: 'm', role: 'user', content: 'x', toolUsagesJson: null }).toolUsages).toBeUndefined();
17
+ });
18
+ it('creator → senderEmail/senderName (그룹챗 발신자 표시·자기 판정)', () => {
19
+ const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: { name: 'Kim', email: 'u9@example.com' } });
20
+ expect(line.senderEmail).toBe('u9@example.com');
21
+ expect(line.senderName).toBe('Kim');
22
+ });
23
+ it('creator 없으면 sender 필드 undefined', () => {
24
+ const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: null });
25
+ expect(line.senderEmail).toBeUndefined();
26
+ expect(line.senderName).toBeUndefined();
27
+ });
28
+ });
29
+ describe('receiveRemoteMessage — 방송 수신/에코 차단', () => {
30
+ it('신규 id → append + seen 등록', () => {
31
+ const res = receiveRemoteMessage([], new Set(), msg('m1'));
32
+ expect(res).not.toBeNull();
33
+ expect(res.lines).toHaveLength(1);
34
+ expect(res.lines[0].id).toBe('m1');
35
+ expect(res.seen.has('m1')).toBe(true);
36
+ });
37
+ it('이미 seen 인 id → null (에코·중복 차단)', () => {
38
+ expect(receiveRemoteMessage([], new Set(['m1']), msg('m1'))).toBeNull();
39
+ });
40
+ it('id 없는 메시지 → null', () => {
41
+ expect(receiveRemoteMessage([], new Set(), { role: 'user', content: 'x' })).toBeNull();
42
+ expect(receiveRemoteMessage([], new Set(), null)).toBeNull();
43
+ });
44
+ it('불변 — 원본 배열/셋 미변경', () => {
45
+ const lines = [];
46
+ const seen = new Set();
47
+ receiveRemoteMessage(lines, seen, msg('m1'));
48
+ expect(lines).toHaveLength(0);
49
+ expect(seen.size).toBe(0);
50
+ });
51
+ it('다른 참여자 메시지 두 개 순차 append', () => {
52
+ let lines = [];
53
+ let seen = new Set();
54
+ ({ lines, seen } = receiveRemoteMessage(lines, seen, msg('a')));
55
+ ({ lines, seen } = receiveRemoteMessage(lines, seen, msg('b')));
56
+ expect(lines.map(l => l.id)).toEqual(['a', 'b']);
57
+ });
58
+ });
59
+ describe('reconcileSentLine — optimistic 라인 조정', () => {
60
+ const optimistic = () => [{ _localId: 'L1', role: 'user', content: 'typed' }];
61
+ it('정상(에코 전): serverId 부여 + seen 등록 + _localId 해제', () => {
62
+ const res = reconcileSentLine(optimistic(), new Set(), 'L1', 'srv1', {});
63
+ expect(res.lines).toHaveLength(1);
64
+ expect(res.lines[0].id).toBe('srv1');
65
+ expect(res.lines[0]._localId).toBeUndefined();
66
+ expect(res.seen.has('srv1')).toBe(true);
67
+ });
68
+ it('레이스(에코 먼저): serverId 가 이미 seen → optimistic 중복 제거', () => {
69
+ // 에코가 먼저 도착해 같은 메시지가 이미 append + seen 됨
70
+ const lines = [
71
+ { id: 'srv1', role: 'user', content: 'typed#enriched' }, // 방송 에코로 들어온 것
72
+ { _localId: 'L1', role: 'user', content: 'typed' } // optimistic 중복
73
+ ];
74
+ const res = reconcileSentLine(lines, new Set(['srv1']), 'L1', 'srv1', {});
75
+ expect(res.lines).toHaveLength(1);
76
+ expect(res.lines[0].id).toBe('srv1');
77
+ expect(res.lines.some(l => l._localId === 'L1')).toBe(false);
78
+ });
79
+ it('patch 병합 (AI 라인: pending → 실제 응답)', () => {
80
+ const lines = [{ _localId: 'A1', role: 'assistant', content: '', pending: true }];
81
+ const res = reconcileSentLine(lines, new Set(), 'A1', 'srv2', {
82
+ content: 'reply',
83
+ pending: false,
84
+ patchId: 'p9'
85
+ });
86
+ expect(res.lines[0]).toMatchObject({ id: 'srv2', content: 'reply', pending: false, patchId: 'p9' });
87
+ });
88
+ it('serverId 없음(ad-hoc 세션): 라인 유지 + _localId 만 해제, seen 불변', () => {
89
+ const seen = new Set();
90
+ const res = reconcileSentLine(optimistic(), seen, 'L1', undefined, {});
91
+ expect(res.lines[0]._localId).toBeUndefined();
92
+ expect(res.lines[0].id).toBeUndefined();
93
+ expect(res.seen.size).toBe(0);
94
+ });
95
+ it('해당 _localId 없으면 변화 없음', () => {
96
+ const lines = optimistic();
97
+ const res = reconcileSentLine(lines, new Set(), 'NOPE', 'srv1', {});
98
+ expect(res.lines).toBe(lines);
99
+ });
100
+ });
101
+ describe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {
102
+ /* 방송은 mutation 응답보다 먼저 올 수 있고, 그 응답은 LLM 이 끝난 뒤에 온다.
103
+ * 붙였다 나중에 지우면 사용자는 **대기 시간 내내** 자기 질문을 두 줄로 본다. */
104
+ const pending = () => [
105
+ { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },
106
+ { _localId: 'L2', role: 'assistant', content: '', pending: true }
107
+ ];
108
+ it('내 에코는 대기 중인 내 줄에 id 를 입힌다 — 줄 수가 늘지 않는다', () => {
109
+ const r = receiveRemoteMessage(pending(), new Set(), {
110
+ id: 'S1',
111
+ role: 'user',
112
+ content: '질문',
113
+ creator: { name: 'Me', email: 'me@x.com' }
114
+ });
115
+ expect(r.lines).toHaveLength(2); // append 되지 않음
116
+ expect(r.lines[0].id).toBe('S1');
117
+ expect(r.lines[0]._localId).toBe('L1'); // 이후 patch 병합을 위해 유지
118
+ expect(r.seen.has('S1')).toBe(true);
119
+ });
120
+ it('발신자 email 이 없는 방송(단일 사용자·구형)도 내 줄에 흡수된다', () => {
121
+ const r = receiveRemoteMessage(pending(), new Set(), { id: 'S1', role: 'user', content: '질문' });
122
+ expect(r.lines).toHaveLength(2);
123
+ expect(r.lines[0].id).toBe('S1');
124
+ });
125
+ it('다른 사용자 메시지는 흡수하지 않고 append 한다 — 내 대기 줄을 삼키면 안 된다', () => {
126
+ const r = receiveRemoteMessage(pending(), new Set(), {
127
+ id: 'S9',
128
+ role: 'user',
129
+ content: '남의 말',
130
+ creator: { name: 'Other', email: 'other@x.com' }
131
+ });
132
+ expect(r.lines).toHaveLength(3);
133
+ expect(r.lines[0].id).toBeUndefined(); // 내 대기 줄은 그대로
134
+ expect(r.lines[2]).toMatchObject({ id: 'S9', senderEmail: 'other@x.com' });
135
+ });
136
+ /* 이 테스트는 예전에 "assistant 방송은 흡수하지 않고 붙인다" 를 규칙으로 굳혀 두고 있었다.
137
+ * 그것이 실제 결함이었다 — 붙이면 뒤이은 mutation 응답이 대기 줄을 지우고, 그 줄에만 있던
138
+ * **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고)가 함께 사라진다. 규칙을 바로잡는다. */
139
+ it('assistant 방송도 대기 중인 AI 줄에 흡수한다 — 붙이면 제안 카드가 사라진다', () => {
140
+ const lines = [
141
+ { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },
142
+ { _localId: 'L2', role: 'assistant', content: '', pending: true }
143
+ ];
144
+ const r = receiveRemoteMessage(lines, new Set(), { id: 'S2', role: 'assistant', content: '답' });
145
+ expect(r.lines).toHaveLength(2);
146
+ expect(r.lines[1]).toMatchObject({ id: 'S2', content: '답', pending: false });
147
+ });
148
+ it('대기 중인 AI 줄이 없으면 붙인다 — 다른 참여자가 부른 답변', () => {
149
+ const settled = [{ id: 'S0', role: 'user', content: '질문' }];
150
+ const r = receiveRemoteMessage(settled, new Set(), { id: 'S2', role: 'assistant', content: '답' });
151
+ expect(r.lines).toHaveLength(2);
152
+ expect(r.lines[1].id).toBe('S2');
153
+ });
154
+ it('흡수한 뒤 mutation 응답 reconcile 은 그 줄을 지우지 않고 patch 를 병합한다', () => {
155
+ const echo = receiveRemoteMessage(pending(), new Set(), {
156
+ id: 'S1',
157
+ role: 'user',
158
+ content: '질문',
159
+ creator: { email: 'me@x.com' }
160
+ });
161
+ const after = reconcileSentLine(echo.lines, echo.seen, 'L1', 'S1', { content: '질문(보강)' });
162
+ expect(after.lines).toHaveLength(2); // 자기 줄을 지우지 않음
163
+ expect(after.lines[0]).toMatchObject({ id: 'S1', content: '질문(보강)' });
164
+ expect(after.lines[0]._localId).toBeUndefined();
165
+ });
166
+ });
167
+ describe('AI 답변 에코 흡수 — 영속되지 않는 응답 데이터를 잃지 않기', () => {
168
+ /* 실제 사고: 방송이 mutation 응답보다 먼저 와서 assistant 줄이 새로 붙고, 뒤이어 응답이 대기 줄을
169
+ * 지웠다. 대기 줄에만 있던 **조치 제안 카드**가 그때 사라졌다(도구는 분명히 호출됐는데도). */
170
+ const pendingAi = () => [
171
+ { id: 'u1', role: 'user', content: '지게차 한 대 추가해줘' },
172
+ { _localId: 'L2', role: 'assistant', content: '', pending: true, proposals: [{ command: 'resource.add' }] }
173
+ ];
174
+ it('대기 중인 AI 줄에 흡수한다 — 새 줄로 붙이지 않는다', () => {
175
+ const r = receiveRemoteMessage(pendingAi(), new Set(), {
176
+ id: 'a1',
177
+ role: 'assistant',
178
+ content: '제안해 두었습니다.'
179
+ });
180
+ expect(r.lines).toHaveLength(2);
181
+ expect(r.lines[1].id).toBe('a1');
182
+ expect(r.lines[1].content).toBe('제안해 두었습니다.');
183
+ });
184
+ it('제안 카드를 유지한다 — 영속되지 않는 값이라 잃으면 화면에서 버튼이 사라진다', () => {
185
+ const r = receiveRemoteMessage(pendingAi(), new Set(), {
186
+ id: 'a1',
187
+ role: 'assistant',
188
+ content: '제안해 두었습니다.'
189
+ });
190
+ expect(r.lines[1].proposals).toEqual([{ command: 'resource.add' }]);
191
+ });
192
+ it('대기 표시를 푼다 — 방송이 먼저 오면 답이 더 빨리 보인다', () => {
193
+ const r = receiveRemoteMessage(pendingAi(), new Set(), { id: 'a1', role: 'assistant', content: '답' });
194
+ expect(r.lines[1].pending).toBe(false);
195
+ });
196
+ it('대기 줄이 없으면(다른 참여자가 부른 AI 답변) 새 줄로 붙인다', () => {
197
+ const lines = [{ id: 'u1', role: 'user', content: '질문' }];
198
+ const r = receiveRemoteMessage(lines, new Set(), { id: 'a9', role: 'assistant', content: '답' });
199
+ expect(r.lines).toHaveLength(2);
200
+ expect(r.lines[1].id).toBe('a9');
201
+ });
202
+ });
203
+ //# sourceMappingURL=chat-echo-dedup.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-echo-dedup.test.js","sourceRoot":"","sources":["../../client/components/chat-echo-dedup.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine, type DedupLine } from './chat-echo-dedup'\n\nconst msg = (id: string, role = 'user', content = 'hi') => ({ id, role, content })\n\ndescribe('remoteMessageToLine', () => {\n it('필드 매핑 + relatedPatchId/toolUsagesJson 변환', () => {\n const line = remoteMessageToLine({\n id: 'm1',\n role: 'assistant',\n content: 'done',\n relatedPatchId: 'p1',\n toolUsagesJson: [{ name: 'x' }]\n })\n expect(line).toMatchObject({ id: 'm1', role: 'assistant', content: 'done', patchId: 'p1' })\n expect(line.toolUsages).toEqual([{ name: 'x' }])\n })\n\n it('toolUsagesJson 이 배열 아니면 undefined', () => {\n expect(remoteMessageToLine({ id: 'm', role: 'user', content: 'x', toolUsagesJson: null }).toolUsages).toBeUndefined()\n })\n\n it('creator → senderEmail/senderName (그룹챗 발신자 표시·자기 판정)', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: { name: 'Kim', email: 'u9@example.com' } })\n expect(line.senderEmail).toBe('u9@example.com')\n expect(line.senderName).toBe('Kim')\n })\n\n it('creator 없으면 sender 필드 undefined', () => {\n const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: null })\n expect(line.senderEmail).toBeUndefined()\n expect(line.senderName).toBeUndefined()\n })\n})\n\ndescribe('receiveRemoteMessage — 방송 수신/에코 차단', () => {\n it('신규 id → append + seen 등록', () => {\n const res = receiveRemoteMessage([], new Set(), msg('m1'))!\n expect(res).not.toBeNull()\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('m1')\n expect(res.seen.has('m1')).toBe(true)\n })\n\n it('이미 seen 인 id → null (에코·중복 차단)', () => {\n expect(receiveRemoteMessage([], new Set(['m1']), msg('m1'))).toBeNull()\n })\n\n it('id 없는 메시지 → null', () => {\n expect(receiveRemoteMessage([], new Set(), { role: 'user', content: 'x' } as any)).toBeNull()\n expect(receiveRemoteMessage([], new Set(), null)).toBeNull()\n })\n\n it('불변 — 원본 배열/셋 미변경', () => {\n const lines: DedupLine[] = []\n const seen = new Set<string>()\n receiveRemoteMessage(lines, seen, msg('m1'))\n expect(lines).toHaveLength(0)\n expect(seen.size).toBe(0)\n })\n\n it('다른 참여자 메시지 두 개 순차 append', () => {\n let lines: DedupLine[] = []\n let seen = new Set<string>()\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('a'))!)\n ;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('b'))!)\n expect(lines.map(l => l.id)).toEqual(['a', 'b'])\n })\n})\n\ndescribe('reconcileSentLine — optimistic 라인 조정', () => {\n const optimistic = (): DedupLine[] => [{ _localId: 'L1', role: 'user', content: 'typed' }]\n\n it('정상(에코 전): serverId 부여 + seen 등록 + _localId 해제', () => {\n const res = reconcileSentLine(optimistic(), new Set(), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.seen.has('srv1')).toBe(true)\n })\n\n it('레이스(에코 먼저): serverId 가 이미 seen → optimistic 중복 제거', () => {\n // 에코가 먼저 도착해 같은 메시지가 이미 append + seen 됨\n const lines: DedupLine[] = [\n { id: 'srv1', role: 'user', content: 'typed#enriched' }, // 방송 에코로 들어온 것\n { _localId: 'L1', role: 'user', content: 'typed' } // optimistic 중복\n ]\n const res = reconcileSentLine(lines, new Set(['srv1']), 'L1', 'srv1', {})\n expect(res.lines).toHaveLength(1)\n expect(res.lines[0].id).toBe('srv1')\n expect(res.lines.some(l => l._localId === 'L1')).toBe(false)\n })\n\n it('patch 병합 (AI 라인: pending → 실제 응답)', () => {\n const lines: DedupLine[] = [{ _localId: 'A1', role: 'assistant', content: '', pending: true }]\n const res = reconcileSentLine(lines, new Set(), 'A1', 'srv2', {\n content: 'reply',\n pending: false,\n patchId: 'p9'\n })\n expect(res.lines[0]).toMatchObject({ id: 'srv2', content: 'reply', pending: false, patchId: 'p9' })\n })\n\n it('serverId 없음(ad-hoc 세션): 라인 유지 + _localId 만 해제, seen 불변', () => {\n const seen = new Set<string>()\n const res = reconcileSentLine(optimistic(), seen, 'L1', undefined, {})\n expect(res.lines[0]._localId).toBeUndefined()\n expect(res.lines[0].id).toBeUndefined()\n expect(res.seen.size).toBe(0)\n })\n\n it('해당 _localId 없으면 변화 없음', () => {\n const lines = optimistic()\n const res = reconcileSentLine(lines, new Set(), 'NOPE', 'srv1', {})\n expect(res.lines).toBe(lines)\n })\n})\n\ndescribe('내 발신 에코 흡수 (대기 중 중복 렌더 방지)', () => {\n /* 방송은 mutation 응답보다 먼저 올 수 있고, 그 응답은 LLM 이 끝난 뒤에 온다.\n * 붙였다 나중에 지우면 사용자는 **대기 시간 내내** 자기 질문을 두 줄로 본다. */\n const pending = (): DedupLine[] => [\n { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true }\n ]\n\n it('내 에코는 대기 중인 내 줄에 id 를 입힌다 — 줄 수가 늘지 않는다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { name: 'Me', email: 'me@x.com' }\n })!\n expect(r.lines).toHaveLength(2) // append 되지 않음\n expect(r.lines[0].id).toBe('S1')\n expect(r.lines[0]._localId).toBe('L1') // 이후 patch 병합을 위해 유지\n expect(r.seen.has('S1')).toBe(true)\n })\n\n it('발신자 email 이 없는 방송(단일 사용자·구형)도 내 줄에 흡수된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), { id: 'S1', role: 'user', content: '질문' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[0].id).toBe('S1')\n })\n\n it('다른 사용자 메시지는 흡수하지 않고 append 한다 — 내 대기 줄을 삼키면 안 된다', () => {\n const r = receiveRemoteMessage(pending(), new Set(), {\n id: 'S9',\n role: 'user',\n content: '남의 말',\n creator: { name: 'Other', email: 'other@x.com' }\n })!\n expect(r.lines).toHaveLength(3)\n expect(r.lines[0].id).toBeUndefined() // 내 대기 줄은 그대로\n expect(r.lines[2]).toMatchObject({ id: 'S9', senderEmail: 'other@x.com' })\n })\n\n /* 이 테스트는 예전에 \"assistant 방송은 흡수하지 않고 붙인다\" 를 규칙으로 굳혀 두고 있었다.\n * 그것이 실제 결함이었다 — 붙이면 뒤이은 mutation 응답이 대기 줄을 지우고, 그 줄에만 있던\n * **영속되지 않는 응답 데이터**(조치 제안 카드·접지 경고)가 함께 사라진다. 규칙을 바로잡는다. */\n it('assistant 방송도 대기 중인 AI 줄에 흡수한다 — 붙이면 제안 카드가 사라진다', () => {\n const lines = [\n { _localId: 'L1', role: 'user', content: '질문', senderEmail: 'me@x.com' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true }\n ]\n const r = receiveRemoteMessage(lines as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1]).toMatchObject({ id: 'S2', content: '답', pending: false })\n })\n\n it('대기 중인 AI 줄이 없으면 붙인다 — 다른 참여자가 부른 답변', () => {\n const settled = [{ id: 'S0', role: 'user', content: '질문' }]\n const r = receiveRemoteMessage(settled as any, new Set(), { id: 'S2', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('S2')\n })\n\n it('흡수한 뒤 mutation 응답 reconcile 은 그 줄을 지우지 않고 patch 를 병합한다', () => {\n const echo = receiveRemoteMessage(pending(), new Set(), {\n id: 'S1',\n role: 'user',\n content: '질문',\n creator: { email: 'me@x.com' }\n })!\n const after = reconcileSentLine(echo.lines, echo.seen, 'L1', 'S1', { content: '질문(보강)' })\n expect(after.lines).toHaveLength(2) // 자기 줄을 지우지 않음\n expect(after.lines[0]).toMatchObject({ id: 'S1', content: '질문(보강)' })\n expect(after.lines[0]._localId).toBeUndefined()\n })\n})\n\ndescribe('AI 답변 에코 흡수 — 영속되지 않는 응답 데이터를 잃지 않기', () => {\n /* 실제 사고: 방송이 mutation 응답보다 먼저 와서 assistant 줄이 새로 붙고, 뒤이어 응답이 대기 줄을\n * 지웠다. 대기 줄에만 있던 **조치 제안 카드**가 그때 사라졌다(도구는 분명히 호출됐는데도). */\n const pendingAi = () => [\n { id: 'u1', role: 'user', content: '지게차 한 대 추가해줘' },\n { _localId: 'L2', role: 'assistant', content: '', pending: true, proposals: [{ command: 'resource.add' }] }\n ]\n\n it('대기 중인 AI 줄에 흡수한다 — 새 줄로 붙이지 않는다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), {\n id: 'a1',\n role: 'assistant',\n content: '제안해 두었습니다.'\n })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('a1')\n expect(r.lines[1].content).toBe('제안해 두었습니다.')\n })\n\n it('제안 카드를 유지한다 — 영속되지 않는 값이라 잃으면 화면에서 버튼이 사라진다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), {\n id: 'a1',\n role: 'assistant',\n content: '제안해 두었습니다.'\n })!\n expect(r.lines[1].proposals).toEqual([{ command: 'resource.add' }])\n })\n\n it('대기 표시를 푼다 — 방송이 먼저 오면 답이 더 빨리 보인다', () => {\n const r = receiveRemoteMessage(pendingAi() as any, new Set(), { id: 'a1', role: 'assistant', content: '답' })!\n expect(r.lines[1].pending).toBe(false)\n })\n\n it('대기 줄이 없으면(다른 참여자가 부른 AI 답변) 새 줄로 붙인다', () => {\n const lines = [{ id: 'u1', role: 'user', content: '질문' }]\n const r = receiveRemoteMessage(lines as any, new Set(), { id: 'a9', role: 'assistant', content: '답' })!\n expect(r.lines).toHaveLength(2)\n expect(r.lines[1].id).toBe('a9')\n })\n})\n"]}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * boardAIChat GraphQL mutation 의 input 객체 빌더.
3
+ *
4
+ * board-ai-chat.ts (Lit / Material Web 의존) 와 분리 — pure 함수만 두면 jest 의 node
5
+ * 환경에서도 import 가능. 회귀 테스트 (selectedRefids 누락 등) 가 단위 테스트로 잠긴다.
6
+ */
7
+ export interface ChatMutationInputArgs {
8
+ sessionId?: string;
9
+ history: Array<{
10
+ role: string;
11
+ content: string;
12
+ }>;
13
+ liveBoard: any;
14
+ scopes?: string[];
15
+ /** 편집으로 접은 지점(마지막으로 남긴 서버 메시지 id) — 서버가 그 뒤 이력을 에이전트 문맥에서 뺀다. */
16
+ truncateAfterMessageId?: string;
17
+ /** 이 대화면이 무엇을 보고 있는지(예: { spaceId }) — 도구가 대상을 알 수 있게. 없으면 모델이 식별자를 되묻는다. */
18
+ hostContext?: any;
19
+ /**
20
+ * 이 대화면이 쓸 수 있는 외부 도구 카테고리(registerToolCategory 이름).
21
+ * 미지정 = 등록된 전부(현행 동작). 지정하면 정의와 **실행** 모두 그 범위로 제한된다.
22
+ */
23
+ toolCategories?: string[];
24
+ /** 코어 보드 편집 도구(addComponent·스타일링·씬 조작) 사용 여부. 기본 true. */
25
+ boardTools?: boolean;
26
+ /**
27
+ * 첫 턴에 도구 호출을 강제할지 — 라이브 상태를 다루는 대화면에서 켠다.
28
+ * 근거 없이 상태를 단언하는 답(도구를 아예 부르지 않은 답)을 원천에서 막는다.
29
+ */
30
+ requireGroundingTools?: boolean;
31
+ knownTypes?: string[];
32
+ categories?: string[];
33
+ componentSchemas?: any;
34
+ /**
35
+ * 선택된 컴포넌트의 `refid` 배열 — things-scene universal numeric handle.
36
+ * id (데이터 바인딩 이름) 와는 다른 개념.
37
+ */
38
+ selectedRefids: number[];
39
+ /**
40
+ * popup 에서 명시 선택한 #mention 매핑 — {token, refid}.
41
+ * 사용자 textarea 에는 #token 만 보이고, 정확한 refid 는 이 별도 채널로 server 에 전달.
42
+ * server 가 mention map 으로 LLM 에 정확한 refid 정보 첨부 (사용자 노출 0).
43
+ */
44
+ mentions?: Array<{
45
+ token: string;
46
+ refid: number;
47
+ }>;
48
+ }
49
+ /**
50
+ * GraphQL mutation 의 input 객체를 빌드.
51
+ *
52
+ * 핵심 보호 — `selectedRefids` 가 항상 포함되어야 한다. 누락이 사용자에게 보고된
53
+ * "AI 가 선택된 컴포넌트를 모른다" 의 root cause 였다.
54
+ */
55
+ export declare function buildChatMutationInput(args: ChatMutationInputArgs): Record<string, any>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * boardAIChat GraphQL mutation 의 input 객체 빌더.
3
+ *
4
+ * board-ai-chat.ts (Lit / Material Web 의존) 와 분리 — pure 함수만 두면 jest 의 node
5
+ * 환경에서도 import 가능. 회귀 테스트 (selectedRefids 누락 등) 가 단위 테스트로 잠긴다.
6
+ */
7
+ /**
8
+ * GraphQL mutation 의 input 객체를 빌드.
9
+ *
10
+ * 핵심 보호 — `selectedRefids` 가 항상 포함되어야 한다. 누락이 사용자에게 보고된
11
+ * "AI 가 선택된 컴포넌트를 모른다" 의 root cause 였다.
12
+ */
13
+ export function buildChatMutationInput(args) {
14
+ return {
15
+ sessionId: args.sessionId ?? null,
16
+ messages: args.history,
17
+ currentBoard: args.liveBoard ?? null,
18
+ scopes: args.scopes,
19
+ ...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),
20
+ ...(args.hostContext ? { hostContext: args.hostContext } : {}),
21
+ /* 미지정은 서버 기본(전부 허용)에 맡긴다 — null 을 보내 굳이 덮어쓰지 않는다. */
22
+ ...(args.toolCategories ? { toolCategories: args.toolCategories } : {}),
23
+ ...(args.boardTools === false ? { boardTools: false } : {}),
24
+ ...(args.requireGroundingTools ? { requireGroundingTools: true } : {}),
25
+ knownTypes: args.knownTypes,
26
+ categories: args.categories,
27
+ componentSchemas: args.componentSchemas,
28
+ selectedRefids: args.selectedRefids,
29
+ ...(args.mentions && args.mentions.length > 0 && { mentions: args.mentions })
30
+ };
31
+ }
32
+ //# sourceMappingURL=chat-input-builder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-input-builder.js","sourceRoot":"","sources":["../../client/components/chat-input-builder.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAuCH;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAA2B;IAChE,OAAO;QACL,SAAS,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI;QACjC,QAAQ,EAAE,IAAI,CAAC,OAAO;QACtB,YAAY,EAAE,IAAI,CAAC,SAAS,IAAI,IAAI;QACpC,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,GAAG,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,EAAE,sBAAsB,EAAE,IAAI,CAAC,sBAAsB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC/F,GAAG,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,qDAAqD;QACrD,GAAG,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,cAAc,EAAE,IAAI,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvE,GAAG,CAAC,IAAI,CAAC,UAAU,KAAK,KAAK,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,GAAG,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC,CAAC,EAAE,qBAAqB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACtE,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,gBAAgB,EAAE,IAAI,CAAC,gBAAgB;QACvC,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC;KAC9E,CAAA;AACH,CAAC","sourcesContent":["/**\n * boardAIChat GraphQL mutation 의 input 객체 빌더.\n *\n * board-ai-chat.ts (Lit / Material Web 의존) 와 분리 — pure 함수만 두면 jest 의 node\n * 환경에서도 import 가능. 회귀 테스트 (selectedRefids 누락 등) 가 단위 테스트로 잠긴다.\n */\n\nexport interface ChatMutationInputArgs {\n sessionId?: string\n history: Array<{ role: string; content: string }>\n liveBoard: any\n scopes?: string[]\n /** 편집으로 접은 지점(마지막으로 남긴 서버 메시지 id) — 서버가 그 뒤 이력을 에이전트 문맥에서 뺀다. */\n truncateAfterMessageId?: string\n /** 이 대화면이 무엇을 보고 있는지(예: { spaceId }) — 도구가 대상을 알 수 있게. 없으면 모델이 식별자를 되묻는다. */\n hostContext?: any\n /**\n * 이 대화면이 쓸 수 있는 외부 도구 카테고리(registerToolCategory 이름).\n * 미지정 = 등록된 전부(현행 동작). 지정하면 정의와 **실행** 모두 그 범위로 제한된다.\n */\n toolCategories?: string[]\n /** 코어 보드 편집 도구(addComponent·스타일링·씬 조작) 사용 여부. 기본 true. */\n boardTools?: boolean\n /**\n * 첫 턴에 도구 호출을 강제할지 — 라이브 상태를 다루는 대화면에서 켠다.\n * 근거 없이 상태를 단언하는 답(도구를 아예 부르지 않은 답)을 원천에서 막는다.\n */\n requireGroundingTools?: boolean\n knownTypes?: string[]\n categories?: string[]\n componentSchemas?: any\n /**\n * 선택된 컴포넌트의 `refid` 배열 — things-scene universal numeric handle.\n * id (데이터 바인딩 이름) 와는 다른 개념.\n */\n selectedRefids: number[]\n /**\n * popup 에서 명시 선택한 #mention 매핑 — {token, refid}.\n * 사용자 textarea 에는 #token 만 보이고, 정확한 refid 는 이 별도 채널로 server 에 전달.\n * server 가 mention map 으로 LLM 에 정확한 refid 정보 첨부 (사용자 노출 0).\n */\n mentions?: Array<{ token: string; refid: number }>\n}\n\n/**\n * GraphQL mutation 의 input 객체를 빌드.\n *\n * 핵심 보호 — `selectedRefids` 가 항상 포함되어야 한다. 누락이 사용자에게 보고된\n * \"AI 가 선택된 컴포넌트를 모른다\" 의 root cause 였다.\n */\nexport function buildChatMutationInput(args: ChatMutationInputArgs): Record<string, any> {\n return {\n sessionId: args.sessionId ?? null,\n messages: args.history,\n currentBoard: args.liveBoard ?? null,\n scopes: args.scopes,\n ...(args.truncateAfterMessageId ? { truncateAfterMessageId: args.truncateAfterMessageId } : {}),\n ...(args.hostContext ? { hostContext: args.hostContext } : {}),\n /* 미지정은 서버 기본(전부 허용)에 맡긴다 — null 을 보내 굳이 덮어쓰지 않는다. */\n ...(args.toolCategories ? { toolCategories: args.toolCategories } : {}),\n ...(args.boardTools === false ? { boardTools: false } : {}),\n ...(args.requireGroundingTools ? { requireGroundingTools: true } : {}),\n knownTypes: args.knownTypes,\n categories: args.categories,\n componentSchemas: args.componentSchemas,\n selectedRefids: args.selectedRefids,\n ...(args.mentions && args.mentions.length > 0 && { mentions: args.mentions })\n }\n}\n"]}
@@ -0,0 +1,60 @@
1
+ export declare function escapeHtml(text: string): string;
2
+ interface ExtractResult {
3
+ processed: string;
4
+ stored: string[];
5
+ }
6
+ interface MentionExtractResult {
7
+ processed: string;
8
+ stored: StoredMention[];
9
+ }
10
+ /**
11
+ * `**X**` 패턴 중 CJK + 구두점 인접 때문에 marked 의 flanking 규칙에서 누락되는
12
+ * 케이스를 추출. 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않는다.
13
+ *
14
+ * 검출 기준 (CommonMark right-flanking 실패 ↔ left-flanking 실패):
15
+ * - 닫는 ** 직전 문자가 구두점 AND 직후 문자가 CJK letter
16
+ * - 여는 ** 직전 문자가 CJK letter AND 직후 문자가 구두점
17
+ */
18
+ export declare function extractCjkStrongs(text: string): ExtractResult;
19
+ /**
20
+ * `#token` mention 패턴을 추출 → placeholder 로 치환.
21
+ *
22
+ * 활성 조건 (chat textarea 의 extractMentionContext 와 일관):
23
+ * - `#` 직전이 문자열 시작 또는 word char 가 아님 (공백/구두점/줄 시작)
24
+ * - `#` 직후가 word char (영숫자/한글/일본/중국어)
25
+ * - 코드 펜스(```)와 인라인 코드(`...`) 안쪽은 건드리지 않음
26
+ *
27
+ * Markdown ATX 헤딩 (`# Title`) 과 충돌하지 않음 — 헤딩은 `#` 다음에 공백이 오므로
28
+ * 본 정규식 (직후가 word char) 매칭 안 됨.
29
+ */
30
+ export declare function extractMentions(text: string): MentionExtractResult;
31
+ /** 추출된 mention — trigger 와 함께 보존해 restore 시 trigger 별 다른 span 생성. */
32
+ interface StoredMention {
33
+ trigger: '#' | '@';
34
+ token: string;
35
+ }
36
+ /**
37
+ * 본문에서 모든 `{key:value}` 마커 제거 — textarea 편집 / LLM 입력 / 평문 표출 등.
38
+ * 토큰 (#component / @user) 자체는 보존.
39
+ */
40
+ export declare function stripMentionRefids(text: string | null | undefined): string;
41
+ /** `#component` 멘션 picks → `{refid:N}` 마커 주입 (호환 유지). */
42
+ export declare function injectMentionRefids(text: string, picks: ReadonlyArray<{
43
+ token: string;
44
+ refid: number;
45
+ }> | null | undefined): string;
46
+ /** `@user` 멘션 picks → `{userId:UUID}` 마커 주입. */
47
+ export declare function injectMentionUserIds(text: string, picks: ReadonlyArray<{
48
+ token: string;
49
+ userId: string;
50
+ }> | null | undefined): string;
51
+ export declare function renderMarkdown(text: string): string;
52
+ /**
53
+ * DOMPurify wrapper. Node 환경 (테스트) 에서도 안전하게 동작하도록 fallback.
54
+ *
55
+ * 브라우저: window.DOMParser 가 있으므로 DOMPurify 가 직접 정화.
56
+ * Node (테스트): jsdom 없이 DOMPurify 호출은 실패 — 실패 시 escapeHtml fallback
57
+ * (테스트에서 sanitize 동작은 별도 검증, 본 fallback 은 회귀 안전).
58
+ */
59
+ export declare function sanitizeHtml(html: string): string;
60
+ export {};