@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
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@things-factory/ai-assistant",
3
+ "version": "10.1.6",
4
+ "main": "dist-server/index.js",
5
+ "things-factory": true,
6
+ "author": "heartyoh",
7
+ "description": "AI 대화의 집 — 세션·메시지·참가자·인박스. 도메인을 모른다. 보드 저작은 @things-factory/board-ai 가 이 위에 선다.",
8
+ "license": "MIT",
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "@things-factory:registry": "https://registry.npmjs.org"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/hatiolab/things-factory.git",
16
+ "directory": "packages/ai-assistant"
17
+ },
18
+ "scripts": {
19
+ "build": "npm run build:server && npm run build:client",
20
+ "build:server": "npm run clean:server && npx tsc --p ./tsconfig.json",
21
+ "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
22
+ "build:client": "npm run clean:client && npm run copy:files && npx tsc --p ./client/tsconfig.json",
23
+ "clean:server": "rm -rf dist-server",
24
+ "clean:client": "rm -rf dist-client",
25
+ "clean": "npm run clean:server && npm run clean:client"
26
+ },
27
+ "dependencies": {
28
+ "@apollo/client": "^3.13.0",
29
+ "@operato/graphql": "^10.0.0",
30
+ "@operato/i18n": "^10.0.0",
31
+ "@operato/styles": "^10.0.0",
32
+ "@things-factory/ai-client-base": "^10.1.6",
33
+ "@things-factory/auth-base": "^10.1.6",
34
+ "@things-factory/env": "^10.1.3",
35
+ "@things-factory/shell": "^10.1.4",
36
+ "dompurify": "^3.0.0",
37
+ "graphql-scalars": "^1.22.0",
38
+ "graphql-tag": "^2.12.6",
39
+ "lit": "^3.1.2",
40
+ "marked": "^15.0.0"
41
+ },
42
+ "browser": "dist-client/index.js",
43
+ "devDependencies": {
44
+ "copyfiles": "^2.4.1",
45
+ "rimraf": "^5.0.0"
46
+ },
47
+ "gitHead": "963aeb9ac470be5aea05bb5842a0a92afcab6036"
48
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@things-factory/ai-assistant` — **AI 대화의 집.**
3
+ *
4
+ * 세션·메시지·참가자·인박스·구독. 도메인을 모른다: 보드도, 공장도, 자산도.
5
+ *
6
+ * ── 왜 board-ai 에서 나왔나 (2026-09-10) ────────────────────────────────────
7
+ * 시작한 물음은 하나였다. **「왜 board-ai 에 종속되어 있는가.」** 공장에 「지금 몇 대 밀렸나」를
8
+ * 묻는 화면이 보드 편집 권한을 요구했고, 그 문 뒤에 스물다섯이 있는데 쓰는 것은 하나였다.
9
+ *
10
+ * 재 보니 문제는 「공통이냐 아니냐」가 아니었다 — `board-ui` 를 지나 **아홉 개 앱**이 이미 이
11
+ * 기계를 싣고 있었다. 이미 공통이었고, 이름과 게이트만 보드 편집기의 것이었다.
12
+ *
13
+ * ── 자르는 선 ───────────────────────────────────────────────────────────────
14
+ * 물음 하나로 줄였다: **「이것이 보드가 없는 제품에서도 뜻이 있나.」**
15
+ *
16
+ * 여기 세션·메시지·참가자·인박스·구독 인증·앵커
17
+ * board-ai 보드 편집기 — assistant · 패치 · 프리팹 · 컴포넌트 트리 · 스타일링
18
+ *
19
+ * `assistant.ts`(113KB)는 **안 옮겼다.** 거의 전부 보드 편집이다. 가장 큰 파일이 제자리에 있다는
20
+ * 뜻이고, 그래서 이 분리는 생각보다 작았다.
21
+ *
22
+ * ── 의존은 한쪽이다 ─────────────────────────────────────────────────────────
23
+ * `board-ai → ai-assistant`. 반대는 없다. 그것을 위해 두 변을 먼저 끊었다 — `ChatSession` 의
24
+ * 보드 패치 관계와, 리졸버 안에 섞여 있던 보드 op 여섯.
25
+ *
26
+ * 설계 정본은 `things-factory/docs/design/ai-modules.md`.
27
+ */
28
+ export * from './service/index.js'
@@ -0,0 +1,73 @@
1
+ /**
2
+ * chat-message 방송 spine — 독립 통합 테스트 (실서버·WS 없이 자족적).
3
+ *
4
+ * 순수 필터(chat-message-broadcast.test.ts)와 달리, 실제 서버가 쓰는 동일한 경로를 구동:
5
+ * publishChatMessage → shell pubsub(in-memory) → 토픽 → chatMessageFilter → async iterator.
6
+ * = resolver 의 subscribe 합성과 동일. WS/Apollo 전송 계층은 patch spine(C2)에서 검증된
7
+ * generic 인프라라 본 테스트 범위 밖 (수동 스모크로 1회 확인).
8
+ */
9
+ import { filter, pipe } from 'graphql-yoga'
10
+ import { pubsub } from '@things-factory/shell'
11
+ import { publishChatMessage } from './chat-message-publish'
12
+ import { CHAT_MESSAGE_TOPIC, chatMessageFilter } from './chat-message-broadcast'
13
+
14
+ const msg = (id: string, role = 'user', content = 'hi') => ({ id, role, content } as any)
15
+
16
+ /** resolver 의 subscribe 와 동일한 합성으로 구독 이터레이터를 만든다. */
17
+ function subscribe(domainId: string, sessionId: string) {
18
+ return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageFilter(domainId, sessionId)))[
19
+ Symbol.asyncIterator
20
+ ]()
21
+ }
22
+
23
+ /** it.next() 를 timeout 과 race — 안 오면 'TIMEOUT'. */
24
+ function nextOrTimeout(it: AsyncIterator<any>, ms = 400): Promise<any> {
25
+ return Promise.race([
26
+ it.next().then(r => r.value),
27
+ new Promise(resolve => setTimeout(() => resolve('TIMEOUT'), ms))
28
+ ])
29
+ }
30
+
31
+ describe('chat-message 방송 통합 (publish → 필터 → 구독)', () => {
32
+ it('같은 domain+session 구독자가 방송 메시지를 수신', async () => {
33
+ const it = subscribe('d1', 's1')
34
+ const p = nextOrTimeout(it)
35
+ publishChatMessage({ domainId: 'd1', sessionId: 's1', message: msg('m1', 'assistant', 'done') })
36
+ const value = await p
37
+ expect(value).not.toBe('TIMEOUT')
38
+ expect(value.message.id).toBe('m1')
39
+ expect(value.message.role).toBe('assistant')
40
+ await it.return?.()
41
+ })
42
+
43
+ it('다른 세션 메시지는 격리 — 내 세션 것만 수신', async () => {
44
+ const it = subscribe('d1', 's1')
45
+ const p = nextOrTimeout(it)
46
+ publishChatMessage({ domainId: 'd1', sessionId: 's2', message: msg('other') }) // 다른 세션 — 필터됨
47
+ publishChatMessage({ domainId: 'd1', sessionId: 's1', message: msg('mine') }) // 내 세션
48
+ const value = await p
49
+ expect(value).not.toBe('TIMEOUT')
50
+ expect(value.message.id).toBe('mine')
51
+ await it.return?.()
52
+ })
53
+
54
+ it('다른 domain 메시지는 격리 (도메인 보안)', async () => {
55
+ const it = subscribe('d1', 's1')
56
+ const p = nextOrTimeout(it)
57
+ publishChatMessage({ domainId: 'd2', sessionId: 's1', message: msg('foreign') }) // 다른 도메인 — 필터됨
58
+ publishChatMessage({ domainId: 'd1', sessionId: 's1', message: msg('local') })
59
+ const value = await p
60
+ expect(value).not.toBe('TIMEOUT')
61
+ expect(value.message.id).toBe('local')
62
+ await it.return?.()
63
+ })
64
+
65
+ it('sessionId 없는 publish 는 방송 안 함 (구독자 미수신)', async () => {
66
+ const it = subscribe('d1', 's1')
67
+ const p = nextOrTimeout(it, 250)
68
+ publishChatMessage({ domainId: 'd1', sessionId: undefined, message: msg('nope') })
69
+ const value = await p
70
+ expect(value).toBe('TIMEOUT') // 아무것도 안 옴
71
+ await it.return?.()
72
+ })
73
+ })
@@ -0,0 +1,40 @@
1
+ /**
2
+ * chat-message 브로드캐스트 — 단위 테스트 (순수 로직).
3
+ * - chatMessageFilter: domain+session 격리(보안 핵심) — 일치만 통과, 불일치/널/session없음 차단.
4
+ * (publishChatMessage / 구독 resolver 는 pubsub·graphql-ws 통합 영역 — 별도 검증.)
5
+ */
6
+ import { chatMessageFilter, CHAT_MESSAGE_TOPIC } from './chat-message-broadcast'
7
+ import type { ChatMessagePayload } from './chat-message-broadcast'
8
+
9
+ const payload = (domainId: string, sessionId?: string): ChatMessagePayload => ({
10
+ domainId,
11
+ sessionId,
12
+ message: { id: 'm1' } as any
13
+ })
14
+
15
+ describe('chatMessageFilter (domain+session 격리)', () => {
16
+ it('domain+session 모두 일치 → 통과', () => {
17
+ expect(chatMessageFilter('d1', 's1')(payload('d1', 's1'))).toBe(true)
18
+ })
19
+
20
+ it('domain 불일치 → 차단 (도메인 격리)', () => {
21
+ expect(chatMessageFilter('d1', 's1')(payload('d2', 's1'))).toBe(false)
22
+ })
23
+
24
+ it('session 불일치 → 차단 (다른 세션 메시지 누수 방지)', () => {
25
+ expect(chatMessageFilter('d1', 's1')(payload('d1', 's2'))).toBe(false)
26
+ })
27
+
28
+ it('sessionId 없는 페이로드 → 차단', () => {
29
+ expect(chatMessageFilter('d1', 's1')(payload('d1', undefined))).toBe(false)
30
+ })
31
+
32
+ it('null/undefined 페이로드 안전 → 차단', () => {
33
+ expect(chatMessageFilter('d1', 's1')(null as any)).toBe(false)
34
+ expect(chatMessageFilter('d1', 's1')(undefined as any)).toBe(false)
35
+ })
36
+
37
+ it('토픽 상수', () => {
38
+ expect(CHAT_MESSAGE_TOPIC).toBe('board-ai-chat-message')
39
+ })
40
+ })
@@ -0,0 +1,27 @@
1
+ /*
2
+ * chat-message 브로드캐스트 — 순수 로직 (토픽/페이로드/필터). 데코레이터·pubsub import 없음
3
+ * → 단위테스트 가능. 데코레이터 resolver(chat-message-subscription.ts)와 pubsub 의존
4
+ * publishChatMessage 는 별도.
5
+ *
6
+ * patch spine(board-patch-broadcast.ts)과 동일 컨벤션. 다만 채팅 메시지는 board 가 아니라
7
+ * **ChatSession 단위**로 묶이므로 필터 키가 sessionId (+ domain 격리).
8
+ */
9
+ import type { ChatMessage } from './chat-message.js' // type-only — 런타임 import 아님
10
+
11
+ /** chat-message 브로드캐스트 토픽. */
12
+ export const CHAT_MESSAGE_TOPIC = 'board-ai-chat-message'
13
+
14
+ /** 방송 페이로드 — 구독 필터(domain+session)용 메타 + ChatMessage 본체. */
15
+ export interface ChatMessagePayload {
16
+ domainId: string
17
+ sessionId?: string
18
+ message: ChatMessage
19
+ }
20
+
21
+ /**
22
+ * 같은 domain + session 구독자에게만 통과시키는 필터 (도메인 격리 = 보안 핵심).
23
+ */
24
+ export function chatMessageFilter(domainId: string, sessionId: string) {
25
+ return (payload: ChatMessagePayload): boolean =>
26
+ !!payload && payload.domainId === domainId && payload.sessionId === sessionId
27
+ }
@@ -0,0 +1,22 @@
1
+ /*
2
+ * chat-message 방송 publisher — pubsub 의존, 데코레이터 없음.
3
+ *
4
+ * 데코레이터 resolver(chat-message-subscription.ts)와 분리한 이유: babel-jest 가 파라미터
5
+ * 데코레이터(@Root/@Arg)를 파싱 못 해 그 파일을 테스트에서 import 할 수 없다. 이 모듈은
6
+ * 데코레이터가 없어 통합 테스트가 실제 publish→방송 경로를 직접 구동할 수 있다.
7
+ */
8
+ import { pubsub } from '@things-factory/shell'
9
+ import { CHAT_MESSAGE_TOPIC, type ChatMessagePayload } from './chat-message-broadcast.js'
10
+
11
+ /**
12
+ * ChatMessage 영속 직후 호출 — 같은 세션 구독자에게 방송.
13
+ * sessionId 없으면 skip. pubsub 미가용/실패해도 본 흐름(영속 완료)을 막지 않는다.
14
+ */
15
+ export function publishChatMessage(payload: ChatMessagePayload): void {
16
+ if (!payload.sessionId) return
17
+ try {
18
+ pubsub.publish(CHAT_MESSAGE_TOPIC, payload)
19
+ } catch {
20
+ /* 방송 생략 — 영속은 이미 끝남 */
21
+ }
22
+ }
@@ -0,0 +1,45 @@
1
+ /*
2
+ * ChatMessageSubscription — 그룹챗 메시지 실시간 방송.
3
+ *
4
+ * 클래스 이름이 `BoardChatMessageSubscription` 이었다. 전선 이름(`chatMessageActivity`)은 처음부터
5
+ * 중립이었고 클래스 이름만 보드였다 — 중립 패키지로 옮기면서 고쳤다(2026-09-10). 전선이 안 바뀌므로
6
+ * 소비처는 그대로다.
7
+ *
8
+ * ChatMessage(사용자 / AI / system) 가 영속될 때마다 같은 세션 구독자에게 방송 →
9
+ * 그룹챗의 척추. 클라이언트(board-ai-chat)가 구독해 원격 메시지를 transcript 에 append.
10
+ * 자기 발신 에코는 클라이언트가 message.id 로 dedup (mutation 응답으로 이미 렌더).
11
+ *
12
+ * patch spine(board-patch-subscription.ts)과 동일 패턴 — PubSub + graphql-ws, domain 격리.
13
+ * 차이: 필터 키가 board 가 아닌 sessionId (채팅은 ChatSession 단위).
14
+ * 순수 로직(토픽/필터)은 chat-message-broadcast.ts (테스트 가능). 여기엔 pubsub 의존만.
15
+ */
16
+ import { Resolver, Subscription, Root, Arg } from 'type-graphql'
17
+ import { filter, pipe } from 'graphql-yoga'
18
+ import { pubsub } from '@things-factory/shell'
19
+ import { ChatMessage } from './chat-message.js'
20
+ import { CHAT_MESSAGE_TOPIC, chatMessageFilter, type ChatMessagePayload } from './chat-message-broadcast.js'
21
+ import { assertDomainSubscribeAllowed } from '../subscribe-auth.js'
22
+
23
+ // publishChatMessage 는 데코레이터 없는 chat-message-publish.ts 에 분리 (테스트 import 가능).
24
+ export { publishChatMessage } from './chat-message-publish.js'
25
+ export { CHAT_MESSAGE_TOPIC, chatMessageFilter } from './chat-message-broadcast.js'
26
+ export type { ChatMessagePayload } from './chat-message-broadcast.js'
27
+
28
+ @Resolver(ChatMessage)
29
+ export class ChatMessageSubscription {
30
+ @Subscription({
31
+ description:
32
+ 'Subscribes to chat messages (user / assistant / system) of a session for real-time group chat.',
33
+ subscribe: async ({ args, context }) => {
34
+ const { domain, user } = context.state
35
+ const { sessionId } = args
36
+
37
+ await assertDomainSubscribeAllowed(domain, user, (d, u) => process.superUserGranted(d, u))
38
+
39
+ return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageFilter(domain.id, sessionId)))
40
+ }
41
+ })
42
+ chatMessageActivity(@Root() payload: ChatMessagePayload, @Arg('sessionId') sessionId: string): ChatMessage {
43
+ return payload.message
44
+ }
45
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * ChatMessage — ChatSession 의 한 메시지.
3
+ *
4
+ * role: 'user' | 'assistant' | 'system'
5
+ * 'system' 은 사용자 직접 편집/import 진행 등 시스템이 자동 생성하는 컨텍스트 메시지.
6
+ *
7
+ * relatedPatchId / relatedImportSessionId 로 메시지가 트리거한 패치·임포트와 연결.
8
+ */
9
+ import {
10
+ Column,
11
+ CreateDateColumn,
12
+ DeleteDateColumn,
13
+ Entity,
14
+ Index,
15
+ ManyToOne,
16
+ PrimaryGeneratedColumn,
17
+ RelationId,
18
+ UpdateDateColumn
19
+ } from 'typeorm'
20
+ import { Field, ID, ObjectType } from 'type-graphql'
21
+ import { GraphQLJSON } from 'graphql-scalars'
22
+
23
+ import { User } from '@things-factory/auth-base'
24
+ import { config } from '@things-factory/env'
25
+
26
+ import { ChatSession } from '../chat-session/chat-session.js'
27
+
28
+ const ORMCONFIG = config.get('ormconfig', {})
29
+ const DATABASE_TYPE = ORMCONFIG.type
30
+
31
+ @Entity()
32
+ @Index('ix_chat_message_1', (message: ChatMessage) => [message.session, message.createdAt])
33
+ @ObjectType({ description: 'A single chat message in a ChatSession.' })
34
+ export class ChatMessage {
35
+ @PrimaryGeneratedColumn('uuid')
36
+ @Field(type => ID, { nullable: true })
37
+ readonly id?: string
38
+
39
+ @ManyToOne(type => ChatSession, session => session.messages, { onDelete: 'CASCADE' })
40
+ session?: ChatSession
41
+
42
+ @RelationId((message: ChatMessage) => message.session)
43
+ sessionId?: string
44
+
45
+ @Column({ type: 'varchar', length: 16 })
46
+ @Field({ description: "'user' | 'assistant' | 'system'" })
47
+ role!: string
48
+
49
+ @Column({
50
+ type:
51
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
52
+ ? 'longtext'
53
+ : DATABASE_TYPE == 'oracle'
54
+ ? 'clob'
55
+ : DATABASE_TYPE == 'mssql'
56
+ ? 'nvarchar'
57
+ : 'text',
58
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined
59
+ })
60
+ @Field({ description: 'Message content' })
61
+ content!: string
62
+
63
+ @Column({ nullable: true })
64
+ @Field({ nullable: true, description: 'PatchEntry.id this message triggered (if any).' })
65
+ relatedPatchId?: string
66
+
67
+ @Column({ nullable: true })
68
+ @Field({ nullable: true, description: 'ImportSession.id this message triggered (if any).' })
69
+ relatedImportSessionId?: string
70
+
71
+ /**
72
+ * AI 가 응답을 만드는 동안 호출한 도구 trace — JSON-stringified.
73
+ *
74
+ * 컬럼은 string (PatchEntry.ops 와 동일 패턴), GraphQL 노출은 toolUsagesJson getter
75
+ * 로 parse 된 객체. UI 의 fold-able 박스 ("AI 가 이런 도구를 사용했습니다") 가
76
+ * 페이지 reload / loadHistory 후에도 살아있도록 영속.
77
+ *
78
+ * 형태: ToolUsage[] (assistant.ts 의 시간순 trace, summarizeToolResult 로 압축).
79
+ * null 이면 도구 호출 없었던 메시지 (text-only 응답).
80
+ */
81
+ @Column({
82
+ type:
83
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
84
+ ? 'longtext'
85
+ : DATABASE_TYPE == 'oracle'
86
+ ? 'clob'
87
+ : DATABASE_TYPE == 'mssql'
88
+ ? 'nvarchar'
89
+ : 'text',
90
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined,
91
+ nullable: true
92
+ })
93
+ toolUsages?: string
94
+
95
+ /** GraphQL 노출용 — JSON 배열로 parse. */
96
+ @Field(() => GraphQLJSON, {
97
+ nullable: true,
98
+ description:
99
+ 'Tool usage trace — array of {name, arguments, result, kind}. Only on assistant messages.'
100
+ })
101
+ get toolUsagesJson(): any {
102
+ if (!this.toolUsages) return null
103
+ try {
104
+ return JSON.parse(this.toolUsages)
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 접지 경고 — 이 답이 언급했으나 근거(프롬프트·보드 문맥·이력·도구 결과)에 없던 식별자 목록.
112
+ *
113
+ * **왜 영속하는가**: 도크의 협의는 여러 사람이 며칠에 걸쳐 읽는다. 경고가 그 자리에서만
114
+ * 보이고 새로 고치면 사라지면, 나중에 읽는 사람은 검증되지 않은 문장을 검증된 것으로 읽는다.
115
+ * 다시 계산할 수도 없다 — 근거였던 도구 결과 원문은 남지 않고 압축본만 남기 때문이다.
116
+ *
117
+ * 형태: string[] 를 JSON-stringify. 비었으면 null(= 접지 정상).
118
+ */
119
+ @Column({ type: 'varchar', length: 1024, nullable: true })
120
+ groundingWarnings?: string
121
+
122
+ /** GraphQL 노출용 — 식별자 배열로 parse. */
123
+ @Field(() => GraphQLJSON, {
124
+ nullable: true,
125
+ description: 'Ungrounded identifiers mentioned by this reply (hallucination candidates). Null when grounded.'
126
+ })
127
+ get groundingWarningsJson(): any {
128
+ if (!this.groundingWarnings) return null
129
+ try {
130
+ return JSON.parse(this.groundingWarnings)
131
+ } catch {
132
+ return null
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 메시지를 일으킨 사용자.
138
+ * - role='user' → 보낸 사용자
139
+ * - role='assistant' → 그 응답을 트리거한 사용자 (직전 user 메시지의 작성자)
140
+ * - role='system' → 시스템 액션을 일으킨 사용자 (예: 직접 편집한 사용자)
141
+ *
142
+ * nullable — legacy 메시지 (이 필드 추가 이전에 작성) 는 null. 또한 ad-hoc 모드 / 시스템
143
+ * 자동 메시지 중 사용자 컨텍스트가 없는 경우도 null 가능.
144
+ */
145
+ @ManyToOne(type => User, { nullable: true })
146
+ @Field(type => User, { nullable: true, description: 'User who triggered this message.' })
147
+ creator?: User
148
+
149
+ @RelationId((message: ChatMessage) => message.creator)
150
+ creatorId?: string
151
+
152
+ @ManyToOne(type => User, { nullable: true })
153
+ @Field(type => User, { nullable: true, description: 'Last user to update this message (for future edit support).' })
154
+ updater?: User
155
+
156
+ @RelationId((message: ChatMessage) => message.updater)
157
+ updaterId?: string
158
+
159
+ /**
160
+ * 부모 메시지 — 이 메시지가 "응답하는 대상" / "이어가는 메시지" 의 id.
161
+ *
162
+ * 의미 (role 별):
163
+ * - 'user' → 직전 in-session 메시지 (보통 assistant 또는 system). 첫 turn 은 null.
164
+ * - 'assistant' → 이 응답이 답하는 user 메시지. (regenerate 로 같은 user 메시지를 부모로
165
+ * 하는 형제 assistant 메시지가 생기면 자연스럽게 branching tree 가 됨)
166
+ * - 'system' → 직전 in-session 메시지 (timeline 연속성 유지).
167
+ *
168
+ * 이 한 컬럼으로 향후 어떤 흐름이든 표현 가능 — linear chain / branching (regenerate) /
169
+ * tree (replies-to-specific-message) 등. UI/쿼리는 점진 도입.
170
+ *
171
+ * onDelete: 'SET NULL' — 부모 메시지가 삭제돼도 child 는 남되 dangling 마킹.
172
+ */
173
+ @ManyToOne(type => ChatMessage, { nullable: true, onDelete: 'SET NULL' })
174
+ parentMessage?: ChatMessage
175
+
176
+ @RelationId((message: ChatMessage) => message.parentMessage)
177
+ @Field({
178
+ nullable: true,
179
+ description:
180
+ 'Id of the message this one is in-response-to / continues. Enables linear / branching / tree structures.'
181
+ })
182
+ parentMessageId?: string
183
+
184
+ @CreateDateColumn()
185
+ @Field({ nullable: true, description: 'Creation timestamp.' })
186
+ createdAt?: Date
187
+
188
+ @UpdateDateColumn()
189
+ @Field({ nullable: true, description: 'Last update timestamp (relevant once message editing is supported).' })
190
+ updatedAt?: Date
191
+
192
+ @DeleteDateColumn()
193
+ @Field({ nullable: true, description: 'Soft delete timestamp.' })
194
+ deletedAt?: Date
195
+ }
@@ -0,0 +1,98 @@
1
+ /*
2
+ * 요약 접기 오케스트레이션 검증 — **"배선했지만 한 번도 안 돌아본" 코드를 만들지 않기 위해.**
3
+ *
4
+ * 이 경로는 51번째 메시지부터 처음 돌기 때문에 실제 대화로 확인하려면 오래 걸린다. 그래서 요약
5
+ * 생성기(LLM)만 대역으로 끼우고 **판정·조립·저장 값**을 여기서 고정한다.
6
+ *
7
+ * 무엇을 지키는가:
8
+ * ① 밀려난 것이 없으면 부르지 않는다(비용).
9
+ * ② 임계치 미만이면 부르지 않는다(매 턴 호출 방지).
10
+ * ③ 이전 요약을 이어 넘긴다(누적 — 전체 재요약 금지).
11
+ * ④ 저장 값은 본문 + 어디까지 접었는지, 항상 함께.
12
+ * ⑤ 생성 실패·빈 응답은 삼키되 **이유를 남긴다**(조용한 무동작 금지).
13
+ */
14
+ import { foldHistorySummary } from './fold-history'
15
+
16
+ const rows = (n: number) =>
17
+ Array.from({ length: n }, (_, i) => ({
18
+ id: `m${i}`,
19
+ role: i % 2 ? 'assistant' : 'user',
20
+ content: `말 ${i}`
21
+ }))
22
+
23
+ /** 호출 여부·입력을 기록하는 요약 생성기 대역. */
24
+ function recorder(reply: string | undefined | null = '요약 결과') {
25
+ const calls: string[] = []
26
+ return {
27
+ calls,
28
+ summarize: async (content: string) => {
29
+ calls.push(content)
30
+ return reply
31
+ }
32
+ }
33
+ }
34
+
35
+ describe('foldHistorySummary', () => {
36
+ it('상한 안이면 요약기를 부르지 않는다 — 비용을 쓰지 않는다', async () => {
37
+ const rec = recorder()
38
+ const r = await foldHistorySummary({ rows: rows(10), maxTurns: 40, threshold: 10, summarize: rec.summarize })
39
+ expect(r.patch).toBeNull()
40
+ expect(r.skipped).toBe('nothing-dropped')
41
+ expect(rec.calls).toHaveLength(0)
42
+ })
43
+
44
+ it('밀려났지만 임계치 미만이면 부르지 않는다 — 상한 넘긴 뒤 매 턴 호출 방지', async () => {
45
+ const rec = recorder()
46
+ /* 45개 중 40개 유지 → 5개 밀려남(임계치 10 미만) */
47
+ const r = await foldHistorySummary({ rows: rows(45), maxTurns: 40, threshold: 10, summarize: rec.summarize })
48
+ expect(r.skipped).toBe('below-threshold')
49
+ expect(rec.calls).toHaveLength(0)
50
+ })
51
+
52
+ it('임계치를 넘으면 요약을 만들고 저장 값을 돌려준다', async () => {
53
+ const rec = recorder('· dock-1 포화 확인\n· 지게차 1대 추가로 합의')
54
+ const r = await foldHistorySummary({ rows: rows(52), maxTurns: 40, threshold: 10, summarize: rec.summarize })
55
+ expect(rec.calls).toHaveLength(1)
56
+ expect(r.patch).toEqual({
57
+ lastSummary: '· dock-1 포화 확인\n· 지게차 1대 추가로 합의',
58
+ /* 밀려난 것은 m0..m11 → 마지막으로 접은 것이 m11 */
59
+ summaryUpToMessageId: 'm11'
60
+ })
61
+ })
62
+
63
+ it('이전 요약을 이어 넘긴다 — 원본 전체를 다시 읽지 않는다(누적)', async () => {
64
+ const rec = recorder()
65
+ await foldHistorySummary({
66
+ rows: rows(60),
67
+ stored: { text: '앞에서 정한 것: 지게차 1대 추가', upToMessageId: 'm5' },
68
+ maxTurns: 40,
69
+ threshold: 10,
70
+ summarize: rec.summarize
71
+ })
72
+ const sent = rec.calls[0]
73
+ expect(sent).toContain('앞에서 정한 것: 지게차 1대 추가')
74
+ /* 이미 접은 m0..m5 는 다시 넘기지 않는다 */
75
+ expect(sent).not.toContain('말 0')
76
+ expect(sent).toContain('말 6')
77
+ })
78
+
79
+ it('빈 요약은 저장하지 않는다 — 이유를 남긴다', async () => {
80
+ const rec = recorder(' ')
81
+ const r = await foldHistorySummary({ rows: rows(52), maxTurns: 40, threshold: 10, summarize: rec.summarize })
82
+ expect(r.patch).toBeNull()
83
+ expect(r.skipped).toBe('empty-summary')
84
+ })
85
+
86
+ it('요약기가 실패해도 대화를 막지 않는다 — 이유를 남기고 넘어간다', async () => {
87
+ const r = await foldHistorySummary({
88
+ rows: rows(52),
89
+ maxTurns: 40,
90
+ threshold: 10,
91
+ summarize: async () => {
92
+ throw new Error('provider down')
93
+ }
94
+ })
95
+ expect(r.patch).toBeNull()
96
+ expect(r.skipped).toBe('failed')
97
+ })
98
+ })
@@ -0,0 +1,77 @@
1
+ /*
2
+ * 요약 접기 오케스트레이션 — **판정과 조립은 여기, DB 는 호출부**.
3
+ *
4
+ * 왜 분리하는가: 요약을 만드는 결정(무엇을·언제·무엇에 이어)은 규칙이고, 규칙은 테스트로 고정할 수
5
+ * 있어야 한다. 리졸버 안에 두면 DB 없이 검증할 수 없어 "배선했지만 한 번도 안 돌아본" 코드가 된다.
6
+ * 요약 생성기(LLM)와 이력·저장은 호출부가 주입한다.
7
+ */
8
+ import type { HistoryRow } from './llm-history.js'
9
+ import {
10
+ splitByCap,
11
+ pendingForSummary,
12
+ shouldResummarize,
13
+ summaryUserContent,
14
+ summaryPatch,
15
+ type StoredSummary
16
+ } from './history-summary.js'
17
+
18
+ export interface FoldHistoryInput {
19
+ /** 시간 오름차순 이력. 기본은 **전체**이고, `rowsStartAfterSummary` 면 요약 뒤부터의 구간이다. */
20
+ rows: HistoryRow[]
21
+ /** 세션에 저장된 요약 상태. */
22
+ stored?: StoredSummary
23
+ /**
24
+ * `rows` 가 **이미 요약 뒤부터** 잘려 온 구간인가.
25
+ *
26
+ * ── 왜 이 플래그가 필요한가 ─────────────────────────────────────────────────
27
+ * 호출부가 DB 에서 워터마크(`stored.upToMessageId`) 뒤부터만 읽으면(대화가 길어져도 상수 시간),
28
+ * 여기서 `pendingForSummary` 로 **또 자를 것이 없다.** 그런데 그 함수는 「id 를 못 찾으면 전부
29
+ * 미반영으로 본다」는 안전 규칙을 갖고 있어서, 창으로 읽은 경우에도 우연히 맞는 답을 낸다.
30
+ *
31
+ * 우연에 기대지 않는다. 그 규칙은 **행이 지워졌을 때의 안전망**이고, 여기서 필요한 것은 「이미
32
+ * 잘랐다」는 사실이다. 둘을 같은 분기로 두면, 나중에 그 안전망을 고치는 사람이 이 경로를 조용히
33
+ * 망가뜨린다(요약이 앞부분을 두 번 담거나 빠뜨린다).
34
+ */
35
+ rowsStartAfterSummary?: boolean
36
+ /** 프롬프트 상한(이 밖으로 밀려난 것이 요약 대상). */
37
+ maxTurns: number
38
+ /** 새로 밀려난 것이 이 수 이상일 때만 요약을 만든다(매 턴 호출 방지). */
39
+ threshold: number
40
+ /** 요약 생성 — 실패하면 throw 하거나 빈 문자열을 돌려주면 된다(둘 다 "접지 않음" 으로 처리). */
41
+ summarize: (content: string) => Promise<string | undefined | null>
42
+ }
43
+
44
+ export interface FoldHistoryResult {
45
+ /** 세션에 저장할 값. null 이면 이번엔 접지 않는다(대상 없음·임계치 미만·생성 실패). */
46
+ patch: { lastSummary: string; summaryUpToMessageId?: string } | null
47
+ /** 접지 않은 이유 — 관측·테스트용(조용한 무동작을 남기지 않는다). */
48
+ skipped?: 'nothing-dropped' | 'below-threshold' | 'empty-summary' | 'failed'
49
+ }
50
+
51
+ /**
52
+ * 접을지 판정하고, 접어야 하면 요약을 만들어 저장 값을 돌려준다.
53
+ *
54
+ * 실패를 삼키되 **왜 안 접었는지**는 남긴다 — 조용한 무동작은 나중에 "요약이 왜 안 생기지" 를
55
+ * 추적 불가능하게 만든다.
56
+ */
57
+ export async function foldHistorySummary(input: FoldHistoryInput): Promise<FoldHistoryResult> {
58
+ const { dropped } = splitByCap(input.rows, input.maxTurns)
59
+ if (dropped.length === 0) return { patch: null, skipped: 'nothing-dropped' }
60
+
61
+ /*
62
+ * 창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 요약이 덮은 앞부분은 애초에 실려 오지 않았다.
63
+ * 전체를 읽어 왔을 때만 워터마크로 자른다.
64
+ */
65
+ const pending = input.rowsStartAfterSummary ? dropped : pendingForSummary(dropped, input.stored)
66
+ if (!shouldResummarize(pending, input.threshold)) return { patch: null, skipped: 'below-threshold' }
67
+
68
+ let text: string | undefined | null
69
+ try {
70
+ text = await input.summarize(summaryUserContent(input.stored?.text, pending))
71
+ } catch {
72
+ return { patch: null, skipped: 'failed' }
73
+ }
74
+ if (!text || !text.trim()) return { patch: null, skipped: 'empty-summary' }
75
+
76
+ return { patch: summaryPatch(text, pending) }
77
+ }