@things-factory/board-ai 10.1.4 → 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 (199) hide show
  1. package/client/components/default-catalog-entries.ts +1 -1
  2. package/client/components/default-slash-templates.ts +1 -1
  3. package/client/index.ts +27 -6
  4. package/dist-client/client/components/default-catalog-entries.d.ts +1 -1
  5. package/dist-client/client/components/default-catalog-entries.js.map +1 -1
  6. package/dist-client/client/components/default-slash-templates.d.ts +1 -1
  7. package/dist-client/client/components/default-slash-templates.js.map +1 -1
  8. package/dist-client/client/index.d.ts +3 -11
  9. package/dist-client/client/index.js +25 -6
  10. package/dist-client/client/index.js.map +1 -1
  11. package/dist-client/server/service/assistant.d.ts +22 -22
  12. package/dist-client/server/service/assistant.js +32 -70
  13. package/dist-client/server/service/assistant.js.map +1 -1
  14. package/dist-client/server/service/grounding.d.ts +2 -12
  15. package/dist-client/server/service/grounding.js +15 -23
  16. package/dist-client/server/service/grounding.js.map +1 -1
  17. package/dist-client/server/service/types.d.ts +2 -4
  18. package/dist-client/server/service/types.js.map +1 -1
  19. package/dist-client/server/service/validation/board-model-schema.d.ts +270 -270
  20. package/dist-client/tsconfig.tsbuildinfo +1 -1
  21. package/dist-server/service/assistant.d.ts +22 -22
  22. package/dist-server/service/assistant.js +25 -76
  23. package/dist-server/service/assistant.js.map +1 -1
  24. package/dist-server/service/board-ai-resolver.js +41 -30
  25. package/dist-server/service/board-ai-resolver.js.map +1 -1
  26. package/dist-server/service/board-session-resolver.d.ts +12 -0
  27. package/dist-server/service/board-session-resolver.js +203 -0
  28. package/dist-server/service/board-session-resolver.js.map +1 -0
  29. package/dist-server/service/board-tool-rights.d.ts +7 -0
  30. package/dist-server/service/board-tool-rights.js +62 -0
  31. package/dist-server/service/board-tool-rights.js.map +1 -0
  32. package/dist-server/service/grounding.d.ts +2 -12
  33. package/dist-server/service/grounding.js +16 -24
  34. package/dist-server/service/grounding.js.map +1 -1
  35. package/dist-server/service/index.d.ts +4 -10
  36. package/dist-server/service/index.js +16 -23
  37. package/dist-server/service/index.js.map +1 -1
  38. package/dist-server/service/patch-entry/board-patch-subscription.js +2 -2
  39. package/dist-server/service/patch-entry/board-patch-subscription.js.map +1 -1
  40. package/dist-server/service/patch-entry/patch-entry.d.ts +1 -1
  41. package/dist-server/service/patch-entry/patch-entry.js +3 -3
  42. package/dist-server/service/patch-entry/patch-entry.js.map +1 -1
  43. package/dist-server/service/types.d.ts +2 -4
  44. package/dist-server/service/types.js.map +1 -1
  45. package/dist-server/tsconfig.tsbuildinfo +1 -1
  46. package/package.json +6 -5
  47. package/server/service/apply-patch-drift.test.ts +3 -1
  48. package/server/service/assistant.ts +47 -73
  49. package/server/service/board-ai-resolver.ts +22 -12
  50. package/server/service/board-session-resolver.ts +183 -0
  51. package/server/service/board-tool-rights.test.ts +96 -0
  52. package/server/service/board-tool-rights.ts +73 -0
  53. package/server/service/dock-contract.test.ts +3 -0
  54. package/server/service/grounding.test.ts +3 -3
  55. package/server/service/grounding.ts +15 -34
  56. package/server/service/index.ts +16 -22
  57. package/server/service/patch-entry/board-patch-subscription.ts +1 -1
  58. package/server/service/patch-entry/patch-entry.ts +3 -2
  59. package/server/service/registry-tool-loop.test.ts +1 -0
  60. package/server/service/tool-exposure.test.ts +2 -0
  61. package/server/service/types.ts +3 -4
  62. package/client/components/board-ai-chat.test.ts +0 -149
  63. package/client/components/board-ai-chat.ts +0 -3184
  64. package/client/components/chat-echo-dedup.test.ts +0 -230
  65. package/client/components/chat-echo-dedup.ts +0 -155
  66. package/client/components/chat-input-builder.ts +0 -69
  67. package/client/components/markdown.test.ts +0 -518
  68. package/client/components/markdown.ts +0 -508
  69. package/client/components/mention-popup-helpers.ts +0 -433
  70. package/client/components/mention-popup.test.ts +0 -588
  71. package/client/components/mention-popup.ts +0 -238
  72. package/client/utils/board-edit-patch.ts +0 -374
  73. package/dist-client/client/components/board-ai-chat.d.ts +0 -384
  74. package/dist-client/client/components/board-ai-chat.js +0 -3008
  75. package/dist-client/client/components/board-ai-chat.js.map +0 -1
  76. package/dist-client/client/components/board-ai-chat.test.d.ts +0 -1
  77. package/dist-client/client/components/board-ai-chat.test.js +0 -138
  78. package/dist-client/client/components/board-ai-chat.test.js.map +0 -1
  79. package/dist-client/client/components/chat-echo-dedup.d.ts +0 -54
  80. package/dist-client/client/components/chat-echo-dedup.js +0 -115
  81. package/dist-client/client/components/chat-echo-dedup.js.map +0 -1
  82. package/dist-client/client/components/chat-echo-dedup.test.d.ts +0 -1
  83. package/dist-client/client/components/chat-echo-dedup.test.js +0 -203
  84. package/dist-client/client/components/chat-echo-dedup.test.js.map +0 -1
  85. package/dist-client/client/components/chat-input-builder.d.ts +0 -55
  86. package/dist-client/client/components/chat-input-builder.js +0 -32
  87. package/dist-client/client/components/chat-input-builder.js.map +0 -1
  88. package/dist-client/client/components/markdown.d.ts +0 -60
  89. package/dist-client/client/components/markdown.js +0 -422
  90. package/dist-client/client/components/markdown.js.map +0 -1
  91. package/dist-client/client/components/markdown.test.d.ts +0 -1
  92. package/dist-client/client/components/markdown.test.js +0 -418
  93. package/dist-client/client/components/markdown.test.js.map +0 -1
  94. package/dist-client/client/components/mention-popup-helpers.d.ts +0 -165
  95. package/dist-client/client/components/mention-popup-helpers.js +0 -330
  96. package/dist-client/client/components/mention-popup-helpers.js.map +0 -1
  97. package/dist-client/client/components/mention-popup.d.ts +0 -35
  98. package/dist-client/client/components/mention-popup.js +0 -223
  99. package/dist-client/client/components/mention-popup.js.map +0 -1
  100. package/dist-client/client/components/mention-popup.test.d.ts +0 -1
  101. package/dist-client/client/components/mention-popup.test.js +0 -474
  102. package/dist-client/client/components/mention-popup.test.js.map +0 -1
  103. package/dist-client/client/utils/board-edit-patch.d.ts +0 -157
  104. package/dist-client/client/utils/board-edit-patch.js +0 -284
  105. package/dist-client/client/utils/board-edit-patch.js.map +0 -1
  106. package/dist-server/service/chat-message/chat-message-broadcast.d.ts +0 -13
  107. package/dist-server/service/chat-message/chat-message-broadcast.js +0 -13
  108. package/dist-server/service/chat-message/chat-message-broadcast.js.map +0 -1
  109. package/dist-server/service/chat-message/chat-message-publish.d.ts +0 -6
  110. package/dist-server/service/chat-message/chat-message-publish.js +0 -27
  111. package/dist-server/service/chat-message/chat-message-publish.js.map +0 -1
  112. package/dist-server/service/chat-message/chat-message-subscription.d.ts +0 -8
  113. package/dist-server/service/chat-message/chat-message-subscription.js +0 -53
  114. package/dist-server/service/chat-message/chat-message-subscription.js.map +0 -1
  115. package/dist-server/service/chat-message/chat-message.d.ts +0 -68
  116. package/dist-server/service/chat-message/chat-message.js +0 -172
  117. package/dist-server/service/chat-message/chat-message.js.map +0 -1
  118. package/dist-server/service/chat-message/fold-history.d.ts +0 -43
  119. package/dist-server/service/chat-message/fold-history.js +0 -33
  120. package/dist-server/service/chat-message/fold-history.js.map +0 -1
  121. package/dist-server/service/chat-message/history-summary.d.ts +0 -43
  122. package/dist-server/service/chat-message/history-summary.js +0 -77
  123. package/dist-server/service/chat-message/history-summary.js.map +0 -1
  124. package/dist-server/service/chat-message/index.d.ts +0 -5
  125. package/dist-server/service/chat-message/index.js +0 -12
  126. package/dist-server/service/chat-message/index.js.map +0 -1
  127. package/dist-server/service/chat-message/llm-history.d.ts +0 -46
  128. package/dist-server/service/chat-message/llm-history.js +0 -87
  129. package/dist-server/service/chat-message/llm-history.js.map +0 -1
  130. package/dist-server/service/chat-session/chat-session.d.ts +0 -62
  131. package/dist-server/service/chat-session/chat-session.js +0 -164
  132. package/dist-server/service/chat-session/chat-session.js.map +0 -1
  133. package/dist-server/service/chat-session/index.d.ts +0 -3
  134. package/dist-server/service/chat-session/index.js +0 -7
  135. package/dist-server/service/chat-session/index.js.map +0 -1
  136. package/dist-server/service/chat-session/session-activity-broadcast.d.ts +0 -30
  137. package/dist-server/service/chat-session/session-activity-broadcast.js +0 -34
  138. package/dist-server/service/chat-session/session-activity-broadcast.js.map +0 -1
  139. package/dist-server/service/chat-session/session-activity-publish.d.ts +0 -12
  140. package/dist-server/service/chat-session/session-activity-publish.js +0 -37
  141. package/dist-server/service/chat-session/session-activity-publish.js.map +0 -1
  142. package/dist-server/service/chat-session/session-activity-subscription.d.ts +0 -14
  143. package/dist-server/service/chat-session/session-activity-subscription.js +0 -87
  144. package/dist-server/service/chat-session/session-activity-subscription.js.map +0 -1
  145. package/dist-server/service/chat-session/session-anchor.d.ts +0 -12
  146. package/dist-server/service/chat-session/session-anchor.js +0 -20
  147. package/dist-server/service/chat-session/session-anchor.js.map +0 -1
  148. package/dist-server/service/chat-session/session-inbox.d.ts +0 -80
  149. package/dist-server/service/chat-session/session-inbox.js +0 -133
  150. package/dist-server/service/chat-session/session-inbox.js.map +0 -1
  151. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +0 -49
  152. package/dist-server/service/chat-session-participant/chat-session-participant.js +0 -141
  153. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +0 -1
  154. package/dist-server/service/chat-session-participant/index.d.ts +0 -4
  155. package/dist-server/service/chat-session-participant/index.js +0 -10
  156. package/dist-server/service/chat-session-participant/index.js.map +0 -1
  157. package/dist-server/service/chat-session-participant/invite-notification.d.ts +0 -27
  158. package/dist-server/service/chat-session-participant/invite-notification.js +0 -30
  159. package/dist-server/service/chat-session-participant/invite-notification.js.map +0 -1
  160. package/dist-server/service/chat-session-participant/presence.d.ts +0 -4
  161. package/dist-server/service/chat-session-participant/presence.js +0 -22
  162. package/dist-server/service/chat-session-participant/presence.js.map +0 -1
  163. package/dist-server/service/chat-session-resolver.d.ts +0 -102
  164. package/dist-server/service/chat-session-resolver.js +0 -856
  165. package/dist-server/service/chat-session-resolver.js.map +0 -1
  166. package/dist-server/service/subscribe-auth.d.ts +0 -14
  167. package/dist-server/service/subscribe-auth.js +0 -33
  168. package/dist-server/service/subscribe-auth.js.map +0 -1
  169. package/server/service/chat-message/chat-message-broadcast.integration.test.ts +0 -73
  170. package/server/service/chat-message/chat-message-broadcast.test.ts +0 -40
  171. package/server/service/chat-message/chat-message-broadcast.ts +0 -27
  172. package/server/service/chat-message/chat-message-publish.ts +0 -22
  173. package/server/service/chat-message/chat-message-subscription.ts +0 -41
  174. package/server/service/chat-message/chat-message.ts +0 -195
  175. package/server/service/chat-message/fold-history.test.ts +0 -98
  176. package/server/service/chat-message/fold-history.ts +0 -77
  177. package/server/service/chat-message/history-summary.test.ts +0 -127
  178. package/server/service/chat-message/history-summary.ts +0 -100
  179. package/server/service/chat-message/index.ts +0 -12
  180. package/server/service/chat-message/llm-history.test.ts +0 -194
  181. package/server/service/chat-message/llm-history.ts +0 -163
  182. package/server/service/chat-session/chat-session.ts +0 -182
  183. package/server/service/chat-session/index.ts +0 -5
  184. package/server/service/chat-session/session-activity-broadcast.ts +0 -57
  185. package/server/service/chat-session/session-activity-publish.ts +0 -44
  186. package/server/service/chat-session/session-activity-subscription.ts +0 -67
  187. package/server/service/chat-session/session-anchor.ts +0 -35
  188. package/server/service/chat-session/session-inbox.test.ts +0 -185
  189. package/server/service/chat-session/session-inbox.ts +0 -161
  190. package/server/service/chat-session-participant/chat-session-participant.ts +0 -135
  191. package/server/service/chat-session-participant/index.ts +0 -6
  192. package/server/service/chat-session-participant/invite-notification.test.ts +0 -30
  193. package/server/service/chat-session-participant/invite-notification.ts +0 -48
  194. package/server/service/chat-session-participant/presence.test.ts +0 -39
  195. package/server/service/chat-session-participant/presence.ts +0 -21
  196. package/server/service/chat-session-resolver.ts +0 -779
  197. package/server/service/session-anchor.test.ts +0 -65
  198. package/server/service/subscribe-auth.test.ts +0 -57
  199. package/server/service/subscribe-auth.ts +0 -34
@@ -1,779 +0,0 @@
1
- /**
2
- * ChatSession lifecycle resolver — start/get/recordDirectPatch/revertPatch + 메시지·패치 페이징 query.
3
- *
4
- * 핵심: `recordDirectPatch` — 사용자가 모델러에서 직접 편집 시 클라이언트가 호출.
5
- * 이게 PatchEntry(source:'user-direct') + system 메시지 자동 추가 → AI 가 모든 변경을 인지.
6
- */
7
- import { Resolver, Query, Mutation, Arg, Ctx, Directive, Int, ObjectType, Field, ID } from 'type-graphql'
8
- import { GraphQLJSON } from 'graphql-scalars'
9
- import { User } from '@things-factory/auth-base'
10
- import { buildDomainUsersQueryBuilder } from '@things-factory/auth-base/dist-server/utils/get-domain-users.js'
11
-
12
- import { getRepository } from '@things-factory/shell'
13
- import { In } from 'typeorm'
14
-
15
- import { ChatSession } from './chat-session/chat-session.js'
16
- import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
17
- import { buildInbox, applyHidden, activityPatch, searchInbox, INBOX_SCAN_CAP } from './chat-session/session-inbox.js'
18
- import { publishSessionActivity } from './chat-session/session-activity-publish.js'
19
- import { ChatMessage } from './chat-message/chat-message.js'
20
- import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
21
- import { PatchEntry } from './patch-entry/patch-entry.js'
22
- import { publishBoardPatch } from './patch-entry/board-patch-subscription.js'
23
- import { publishChatMessage } from './chat-message/chat-message-publish.js'
24
-
25
- @ObjectType({
26
- description:
27
- 'Session list entry for the inbox — a session plus this viewer\'s unread state. Ordered by recent activity so a user can find where to go back to.'
28
- })
29
- class ChatSessionInboxEntry {
30
- @Field(type => ID, { nullable: true, description: 'Session id.' })
31
- id?: string
32
-
33
- @Field({ nullable: true, description: 'User-given session name (tab label).' })
34
- name?: string
35
-
36
- @Field({ nullable: true, description: "What the session is anchored to: 'board' | 'space' | 'instance' | 'none'." })
37
- anchorType?: string
38
-
39
- @Field({ nullable: true, description: 'Id of the anchored target.' })
40
- anchorId?: string
41
-
42
- @Field({ nullable: true, description: 'Connected board id (board-anchored sessions).' })
43
- boardId?: string
44
-
45
- @Field({ nullable: true, description: 'Timestamp of the last message (list ordering / unread comparison).' })
46
- lastMessageAt?: Date
47
-
48
- @Field({ nullable: true, description: 'Short preview of the last message.' })
49
- lastMessagePreview?: string
50
-
51
- @Field({ nullable: true, description: 'Session creation timestamp.' })
52
- createdAt?: Date
53
-
54
- @Field({ description: 'Whether this viewer has unread activity in the session.' })
55
- unread!: boolean
56
-
57
- @Field({ description: 'Whether this viewer is a participant of the session (non-participants get no unread state).' })
58
- participant!: boolean
59
-
60
- /* 삭제는 되돌릴 수 없으므로 **만든 사람**만 한다 — 클라이언트가 버튼을 보일지 판단하는 근거.
61
- * (권한 판정은 서버가 다시 한다. 이 값은 UI 힌트일 뿐이다.) */
62
- @Field({ description: 'Whether this viewer created the session — only the creator may delete it.' })
63
- mine!: boolean
64
-
65
- /* 숨김은 나에게만 적용되고 되돌릴 수 있다 — 필터를 켠 사용자가 무엇이 숨겨진 것인지 알아야 한다. */
66
- @Field({ nullable: true, description: 'Whether this viewer hid the session from their own list (only returned when showHidden is on).' })
67
- hidden?: boolean
68
- }
69
-
70
- @Resolver()
71
- export class ChatSessionResolver {
72
- @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by id.' })
73
- @Directive('@privilege(category: "board-ai", privilege: "query")')
74
- async chatSession(
75
- @Arg('id') id: string,
76
- @Ctx() context: ResolverContext
77
- ): Promise<ChatSession | null> {
78
- const { domain } = context.state
79
- return (await getRepository(ChatSession).findOneBy({ id, domain: { id: domain.id } })) ?? null
80
- }
81
-
82
- @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by board id (returns first match — backward compat single-session lookup).' })
83
- @Directive('@privilege(category: "board-ai", privilege: "query")')
84
- async chatSessionByBoard(
85
- @Arg('boardId') boardId: string,
86
- @Ctx() context: ResolverContext
87
- ): Promise<ChatSession | null> {
88
- const { domain } = context.state
89
- return (
90
- (await getRepository(ChatSession).findOneBy({ boardId, domain: { id: domain.id } })) ?? null
91
- )
92
- }
93
-
94
- @Query(() => [ChatSession], {
95
- description:
96
- 'List AI chat sessions anchored to a target (oldest first). Anchor decides the nature and the history boundary of the conversation: board = authoring, space = the site and the twins running there. Board anchor also matches legacy rows that only have boardId.'
97
- })
98
- @Directive('@privilege(category: "board-ai", privilege: "query")')
99
- async chatSessionsByAnchor(
100
- @Arg('anchorType') anchorType: string,
101
- @Arg('anchorId') anchorId: string,
102
- @Ctx() context: ResolverContext
103
- ): Promise<ChatSession[]> {
104
- const { domain } = context.state
105
- return await getRepository(ChatSession).find({
106
- where: anchorWhere(domain.id, anchorType, anchorId),
107
- order: { createdAt: 'ASC' }
108
- })
109
- }
110
-
111
- /** 보드 전용 진입점 — 앵커 조회의 얇은 래퍼(기존 소비자 무변경). */
112
- @Query(() => [ChatSession], {
113
- description: 'List all AI chat sessions for a board (oldest first). Thin wrapper over chatSessionsByAnchor(board).'
114
- })
115
- @Directive('@privilege(category: "board-ai", privilege: "query")')
116
- async chatSessionsByBoard(
117
- @Arg('boardId') boardId: string,
118
- @Ctx() context: ResolverContext
119
- ): Promise<ChatSession[]> {
120
- return await this.chatSessionsByAnchor('board', boardId, context)
121
- }
122
-
123
- /**
124
- * 받은편지함 — 앵커의 세션 목록 + 내 안 읽음 상태. 최근 활동순.
125
- * 집계 쿼리를 쓰지 않는다: 세션의 비정규화된 lastMessageAt 과 내 참여자 lastReadAt 을 **값 비교**한다
126
- * (5개 DB 드라이버 지원 때문에 group by/MAX 를 목록 경로에 두지 않는다).
127
- */
128
- @Query(() => [ChatSessionInboxEntry], {
129
- description:
130
- "List sessions anchored to a target with this viewer's unread state, most recent activity first. Supports free-text search over name and last preview, a mine-only filter, hidden inclusion, and paging. Uses the denormalized last-activity timestamp compared against the viewer's read point — no per-session aggregation."
131
- })
132
- @Directive('@privilege(category: "board-ai", privilege: "query")')
133
- async chatSessionInbox(
134
- @Arg('anchorType') anchorType: string,
135
- @Arg('anchorId') anchorId: string,
136
- @Ctx() context: ResolverContext,
137
- /* 숨긴 대화 보기 — 숨김이 한 번 넣으면 못 꺼내는 함정이 되지 않도록 필터를 준다. */
138
- @Arg('showHidden', { nullable: true }) showHidden?: boolean,
139
- /* 대량 목록 대응 — 검색·소유 필터·페이지. 목록 UI 는 처음부터 이걸 갖고 있어야 한다. */
140
- @Arg('search', { nullable: true }) search?: string,
141
- @Arg('mineOnly', { nullable: true }) mineOnly?: boolean,
142
- @Arg('limit', () => Int, { nullable: true }) limit?: number,
143
- @Arg('offset', () => Int, { nullable: true }) offset?: number
144
- ): Promise<ChatSessionInboxEntry[]> {
145
- const filtered = await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly })
146
- const from = Math.max(0, offset ?? 0)
147
- const size = Math.min(Math.max(1, limit ?? 50), 200)
148
- return filtered.slice(from, from + size) as any
149
- }
150
-
151
- /**
152
- * 필터를 통과한 **전체 개수** — "더 보기" 가 남았는지, 검색 결과가 몇 건인지.
153
- *
154
- * 목록과 같은 요청에 담아 가져가므로 왕복은 늘지 않는다. 개수를 모르면 사용자는 목록의 끝이
155
- * 페이지의 끝인지 자료의 끝인지 구별할 수 없다.
156
- */
157
- @Query(() => Int, {
158
- description:
159
- 'Total number of sessions matching the same filters as chatSessionInbox (before paging). Request it alongside the list so the UI can tell "end of page" from "end of data".'
160
- })
161
- @Directive('@privilege(category: "board-ai", privilege: "query")')
162
- async chatSessionInboxTotal(
163
- @Arg('anchorType') anchorType: string,
164
- @Arg('anchorId') anchorId: string,
165
- @Ctx() context: ResolverContext,
166
- @Arg('showHidden', { nullable: true }) showHidden?: boolean,
167
- @Arg('search', { nullable: true }) search?: string,
168
- @Arg('mineOnly', { nullable: true }) mineOnly?: boolean
169
- ): Promise<number> {
170
- return (await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly })).length
171
- }
172
-
173
- /** 필터 적용(페이지 전) — 목록·개수가 **같은 규칙**을 쓰도록 한 곳에 둔다(어긋나면 "더 보기" 가 거짓말이 된다). */
174
- private async _inboxFiltered(
175
- anchorType: string,
176
- anchorId: string,
177
- context: ResolverContext,
178
- opts: { showHidden?: boolean; search?: string; mineOnly?: boolean }
179
- ): Promise<ChatSessionInboxEntry[]> {
180
- const all = await this._inboxEntries(anchorType, anchorId, context)
181
- let entries = opts.showHidden ? all : all.filter(e => !e.hidden)
182
- if (opts.mineOnly) entries = entries.filter(e => (e as any).mine)
183
- return searchInbox(entries as any, opts.search) as any
184
- }
185
-
186
- /**
187
- * 내가 숨긴 대화 수 — 필터에 "몇 개가 접혀 있는지" 를 보여주기 위한 값.
188
- *
189
- * 숨긴 것이 몇 개인지 모르면 필터를 눌러 볼 이유가 생기지 않는다(숨긴 사실 자체를 잊는다).
190
- * 목록 조회와 **같은 요청**에 담아 가져갈 수 있으므로 왕복은 늘지 않는다.
191
- */
192
- @Query(() => Int, {
193
- description:
194
- "Number of sessions on this anchor that the caller has hidden and that have had no activity since. Meant to be requested alongside chatSessionInbox in the same operation so the filter can say how many are folded away."
195
- })
196
- @Directive('@privilege(category: "board-ai", privilege: "query")')
197
- async hiddenSessionCount(
198
- @Arg('anchorType') anchorType: string,
199
- @Arg('anchorId') anchorId: string,
200
- @Ctx() context: ResolverContext
201
- ): Promise<number> {
202
- const all = await this._inboxEntries(anchorType, anchorId, context)
203
- return all.filter(e => e.hidden).length
204
- }
205
-
206
- /** 목록 조립 — 숨김 표시까지 붙인 **전체**. 목록·개수 두 진입점이 같은 규칙을 쓰도록 한 곳에 둔다. */
207
- private async _inboxEntries(
208
- anchorType: string,
209
- anchorId: string,
210
- context: ResolverContext
211
- ): Promise<ChatSessionInboxEntry[]> {
212
- const { domain, user } = context.state
213
- /* 조회 상한 — 한 앵커의 대화가 아무리 늘어도 한 번에 이만큼만 훑는다. 최근 활동순으로 자른다
214
- * (오래된 대화가 잘리는 것이 최근 대화가 잘리는 것보다 낫다). 정렬·검색·페이지는 순수 로직이 한다. */
215
- const sessions = await getRepository(ChatSession).find({
216
- where: anchorWhere(domain.id, anchorType, anchorId),
217
- order: { lastMessageAt: 'DESC' },
218
- take: INBOX_SCAN_CAP
219
- })
220
- if (sessions.length === 0) return []
221
- const rows = await getRepository(ChatSessionParticipant).find({
222
- where: { domain: { id: domain.id } as any, user: { id: user.id } as any, session: { id: In(sessions.map(s => s.id!)) } as any }
223
- })
224
- /* 'viewer' 는 대화에 낀 사람이 아니다 — 숨김을 기록하려고 만든 행이므로 읽음·안 읽음 판정에서 뺀다.
225
- * (참여자가 아닌 사람에게 남의 협의 배지를 띄우지 않는다.) */
226
- const participations = rows.filter(r => r.role !== 'viewer')
227
- const hiddenAt = new Map(rows.map(r => [(r as any).sessionId as string, r.hiddenAt]))
228
- /* 내가 만든 대화인지 표시 — 삭제 버튼을 보일지 결정하는 UI 힌트(권한은 서버가 다시 판정한다). */
229
- const createdByMe = new Set(sessions.filter(s => (s as any).creatorId === user?.id).map(s => s.id))
230
- const entries = buildInbox(sessions as any, participations as any).map(entry => ({
231
- ...entry,
232
- mine: createdByMe.has(entry.id)
233
- }))
234
- /* showHidden=true 로 불러 **전체**를 얻는다 — 숨김 여부 판정을 두 곳에 복제하지 않기 위함. */
235
- return applyHidden(entries as any, hiddenAt, true) as any
236
- }
237
-
238
- /**
239
- * 세션 삭제 — **만든 사람만.**
240
- *
241
- * 협의는 여럿의 기록이고 삭제는 화면에서 되돌릴 수 없다. 그래서 권한을 좁힌다(이름 변경은 되돌릴 수
242
- * 있어서 누구나 할 수 있는 것과 대비). 권한이 없는 사람에게는 `hideAISession`(내 목록에서만 숨김)이
243
- * 있다 — 남의 기록을 지우지 않고 자기 목록을 정리하는 길.
244
- *
245
- * 소프트 삭제(deletedAt)다 — 메시지·패치는 세션을 통해서만 도달하므로 함께 사라지고, 잘못 지운 경우
246
- * 데이터로는 남아 있다. 다른 참여자 목록에서도 사라져야 하므로 meta 활동으로 방송한다.
247
- */
248
- @Mutation(() => Boolean, {
249
- description:
250
- 'Delete a chat session — creator only, since a shared deliberation cannot be un-deleted from the UI. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete; broadcast as meta activity so every list drops it.'
251
- })
252
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
253
- async deleteAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
254
- const { domain, user } = context.state
255
- const repo = getRepository(ChatSession)
256
- const session = await repo.findOneBy({ id: sessionId, domain: { id: domain.id } as any })
257
- if (!session) return false
258
- if (!user?.id || (session as any).creatorId !== user.id) {
259
- /* 조용한 무동작이 아니라 명시 실패 — 지워진 줄 알고 넘어가면 안 된다. */
260
- throw new Error('Only the user who created this session may delete it. Hide it from your own list instead.')
261
- }
262
- await repo.softDelete(sessionId)
263
- publishSessionActivity({
264
- domainId: domain.id,
265
- sessionId,
266
- kind: 'meta',
267
- anchorType: (session as any).anchorType,
268
- anchorId: (session as any).anchorId,
269
- actorId: user?.id
270
- })
271
- return true
272
- }
273
-
274
- /**
275
- * 내 목록에서 숨기기·되돌리기 — **나에게만** 적용된다(대화·다른 참여자 목록은 그대로).
276
- *
277
- * 참여자가 아니어도 숨길 수 있어야 한다 — 목록에는 같은 공간의 남의 협의도 보이고, 그것을 정리하는
278
- * 것은 남에게 아무 영향이 없다. 그 경우 'viewer' 역할로 기록만 남긴다(참여자 명부·배지에는 들지 않는다).
279
- */
280
- @Mutation(() => Boolean, {
281
- description:
282
- 'Hide (or unhide) a session from the calling user\'s own list. Per-user and reversible — the session and other participants are unaffected. Activity newer than the hide makes it reappear; the inbox filter (showHidden) reveals hidden ones.'
283
- })
284
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
285
- async hideAISession(
286
- @Arg('sessionId') sessionId: string,
287
- @Ctx() context: ResolverContext,
288
- @Arg('hidden', { nullable: true }) hidden?: boolean
289
- ): Promise<boolean> {
290
- const { domain, user } = context.state
291
- if (!user?.id) return false
292
- const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
293
- if (!session) return false
294
- const repo = getRepository(ChatSessionParticipant)
295
- const existing = await repo.findOne({
296
- where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
297
- })
298
- const hiddenAt = hidden === false ? null : new Date()
299
- if (existing) {
300
- await repo.update(existing.id!, { hiddenAt, updater: user } as any)
301
- } else {
302
- if (hidden === false) return true // 숨긴 적이 없으면 되돌릴 것도 없다
303
- await repo.save({
304
- domain: { id: domain.id } as any,
305
- session: { id: sessionId } as any,
306
- user: { id: user.id } as any,
307
- role: 'viewer', // 대화에 낀 것이 아니라 숨김만 기록한다
308
- hiddenAt,
309
- creator: user,
310
- updater: user
311
- } as any)
312
- }
313
- return true
314
- }
315
-
316
- /**
317
- * 세션 이름 변경 — **참여자 누구나** 할 수 있다.
318
- *
319
- * 왜 소유자 전용이 아닌가: 이름은 대화의 주제표이고 협의는 여럿이 한다. 논의가 흘러 주제가 달라졌는데
320
- * 만든 사람만 고칠 수 있으면 그 사람이 없을 때 목록이 계속 어긋난다. 되돌릴 수 있는 변경이고 대화
321
- * 내용에 영향이 없으므로 권한을 좁힐 이유가 없다(되돌릴 수 없는 것만 좁힌다).
322
- *
323
- * 변경은 **meta 활동**으로 방송한다 — 모든 참여자 목록에 즉시 반영되지만 안 읽음 배지는 올리지 않는다
324
- * (이름 변경을 "새 소식" 으로 알리면 소음이다).
325
- */
326
- @Mutation(() => Boolean, {
327
- description:
328
- 'Rename a chat session. Any participant may rename it — the name is the topic label of a shared deliberation and the change is reversible. Broadcast as meta activity so every participant sees it without raising unread badges.'
329
- })
330
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
331
- async renameAISession(
332
- @Arg('sessionId') sessionId: string,
333
- @Arg('name') name: string,
334
- @Ctx() context: ResolverContext
335
- ): Promise<boolean> {
336
- const { domain, user } = context.state
337
- const trimmed = (name ?? '').trim().slice(0, 200)
338
- if (!trimmed) return false // 빈 이름으로 지우지 않는다 — 지우려면 별도 의도가 필요하다
339
- const participant = await getRepository(ChatSessionParticipant).findOne({
340
- where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
341
- })
342
- if (!participant) return false // 참여자가 아니면 남의 대화 제목을 바꿀 수 없다
343
- const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
344
- if (!session) return false
345
- await getRepository(ChatSession).update(sessionId, { name: trimmed, updater: user } as any)
346
- publishSessionActivity({
347
- domainId: domain.id,
348
- sessionId,
349
- kind: 'meta',
350
- anchorType: (session as any).anchorType,
351
- anchorId: (session as any).anchorId,
352
- actorId: user?.id
353
- })
354
- return true
355
- }
356
-
357
- /**
358
- * 읽은 지점 갱신 — 이 세션을 여기까지 읽었다. 멱등(여러 번 호출해도 최신 시각으로만 이동).
359
- * 접속 표시(lastSeenAt)와 분리한다 — 창을 열어 두고 화면을 안 볼 수 있다.
360
- */
361
- @Mutation(() => Boolean, {
362
- description:
363
- "Mark a session as read up to now for the current user. Idempotent. Distinct from presence (lastSeenAt) — a window can be open without being read."
364
- })
365
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
366
- async markSessionRead(
367
- @Arg('sessionId') sessionId: string,
368
- @Ctx() context: ResolverContext
369
- ): Promise<boolean> {
370
- const { domain, user } = context.state
371
- const repo = getRepository(ChatSessionParticipant)
372
- const mine = await repo.findOne({
373
- where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
374
- })
375
- /* 참여자가 아니면 읽음 상태를 만들지 않는다 — 참여하지 않은 대화에 내 읽음 지점을 남기지 않는다. */
376
- if (!mine) return false
377
- await repo.update(mine.id!, { lastReadAt: new Date(), updater: user } as any)
378
- return true
379
- }
380
-
381
- @Query(() => [User], {
382
- description:
383
- 'List users in the current domain for `@` mention popup. board-ai 권한이면 누구나 멘션용 검색 가능 (관리자 전용 users() 와 별도).'
384
- })
385
- @Directive('@privilege(category: "board-ai", privilege: "query")')
386
- async domainUsersForMention(
387
- @Arg('query', { nullable: true, description: 'Substring to match against name/email (case-insensitive). Empty → all.' }) query: string | undefined,
388
- @Arg('limit', () => Int, { nullable: true, defaultValue: 50 }) limit: number,
389
- @Ctx() context: ResolverContext
390
- ): Promise<User[]> {
391
- const { domain } = context.state
392
- const qb = buildDomainUsersQueryBuilder(domain.id, 'USER')
393
- if (query?.trim()) {
394
- const q = `%${query.trim().toLowerCase()}%`
395
- qb.andWhere('(LOWER(USER.name) LIKE :q OR LOWER(USER.email) LIKE :q)', { q })
396
- }
397
- qb.orderBy('USER.name', 'ASC').limit(limit)
398
- return await qb.getMany()
399
- }
400
-
401
- @Query(() => [ChatSessionParticipant], {
402
- description: 'List participants of a ChatSession (members / owner).'
403
- })
404
- @Directive('@privilege(category: "board-ai", privilege: "query")')
405
- async chatSessionParticipants(
406
- @Arg('sessionId') sessionId: string,
407
- @Ctx() context: ResolverContext
408
- ): Promise<ChatSessionParticipant[]> {
409
- const { domain } = context.state
410
- const session = await getRepository(ChatSession).findOneBy({
411
- id: sessionId,
412
- domain: { id: domain.id }
413
- })
414
- if (!session) return []
415
- return await getRepository(ChatSessionParticipant).find({
416
- where: { session: { id: sessionId } as any, domain: { id: domain.id } },
417
- order: { createdAt: 'ASC' }
418
- })
419
- }
420
-
421
- @Mutation(() => Boolean, {
422
- description: 'Presence heartbeat — bump current user\'s lastSeenAt in the session (online indicator).'
423
- })
424
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
425
- async touchPresence(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
426
- const { domain, user } = context.state
427
- const repo = getRepository(ChatSessionParticipant)
428
- const part = await repo.findOne({
429
- where: { session: { id: sessionId } as any, user: { id: user.id } as any, domain: { id: domain.id } as any }
430
- })
431
- if (!part) return false // 참여자가 아니면 heartbeat 무시 (초대 전)
432
- await repo.update(part.id!, { lastSeenAt: new Date(), updater: user } as any)
433
- return true
434
- }
435
-
436
- @Query(() => [ChatMessage], { description: 'List chat messages of a session, oldest first.' })
437
- @Directive('@privilege(category: "board-ai", privilege: "query")')
438
- async chatMessages(
439
- @Arg('sessionId') sessionId: string,
440
- @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
441
- @Arg('offset', () => Int, { nullable: true, defaultValue: 0 }) offset: number,
442
- @Ctx() context: ResolverContext
443
- ): Promise<ChatMessage[]> {
444
- const { domain } = context.state
445
- const session = await getRepository(ChatSession).findOneBy({
446
- id: sessionId,
447
- domain: { id: domain.id }
448
- })
449
- if (!session) return []
450
- return await getRepository(ChatMessage).find({
451
- where: { session: { id: sessionId } as any },
452
- // creator: 그룹챗 발신자 표시(name) + 자기 여부 판정(email). 내부 관리키(id)에 의존하지 않는다.
453
- relations: { creator: true },
454
- order: { createdAt: 'ASC' },
455
- take: limit,
456
- skip: offset
457
- })
458
- }
459
-
460
- @Query(() => [PatchEntry], { description: 'List patch entries of a session, newest first.' })
461
- @Directive('@privilege(category: "board-ai", privilege: "query")')
462
- async chatPatches(
463
- @Arg('sessionId') sessionId: string,
464
- @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
465
- @Ctx() context: ResolverContext
466
- ): Promise<PatchEntry[]> {
467
- const { domain } = context.state
468
- const session = await getRepository(ChatSession).findOneBy({
469
- id: sessionId,
470
- domain: { id: domain.id }
471
- })
472
- if (!session) return []
473
- return await getRepository(PatchEntry).find({
474
- where: { session: { id: sessionId } as any },
475
- order: { createdAt: 'DESC' },
476
- take: limit
477
- })
478
- }
479
-
480
- @Directive('@transaction')
481
- @Mutation(() => ChatSession, {
482
- description:
483
- 'Start (or get existing) AI chat session anchored to a target. Idempotent — returns first existing or creates one. Use anchorType board for board authoring, space for a site (its co-located twins).'
484
- })
485
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
486
- async startAISession(
487
- @Arg('anchorType') anchorType: string,
488
- @Arg('anchorId') anchorId: string,
489
- @Ctx() context: ResolverContext
490
- ): Promise<ChatSession> {
491
- const { domain, user, tx } = context.state
492
- const repo = getRepository(ChatSession, tx)
493
-
494
- const existing = await repo.findOneBy(anchorWhere(domain.id, anchorType, anchorId))
495
- if (existing) return existing
496
-
497
- const session = await repo.save({
498
- domain,
499
- ...anchorColumns(anchorType, anchorId),
500
- creator: user,
501
- updater: user
502
- } as any)
503
- // creator 를 owner participant 로 자동 등록 — 다자간 채팅 기반.
504
- await getRepository(ChatSessionParticipant, tx).save({
505
- domain,
506
- session: { id: session.id } as any,
507
- user,
508
- role: 'owner',
509
- creator: user,
510
- updater: user
511
- } as any)
512
- return session
513
- }
514
-
515
- /** 보드 전용 진입점 — 앵커 시작의 얇은 래퍼(기존 소비자 무변경). */
516
- @Mutation(() => ChatSession, {
517
- description: 'Start (or get existing) AI chat session for a board. Thin wrapper over startAISession(board).'
518
- })
519
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
520
- async startBoardAISession(
521
- @Arg('boardId') boardId: string,
522
- @Ctx() context: ResolverContext
523
- ): Promise<ChatSession> {
524
- return await this.startAISession('board', boardId, context)
525
- }
526
-
527
- @Directive('@transaction')
528
- @Mutation(() => ChatSession, {
529
- description:
530
- 'Always create a new AI chat session anchored to a target (no idempotent reuse). For multi-session UX — 새 탭 열기.'
531
- })
532
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
533
- async createAISession(
534
- @Arg('anchorType') anchorType: string,
535
- @Arg('anchorId') anchorId: string,
536
- @Arg('name', { nullable: true, description: 'Optional name (defaults to auto-generated `세션 N`).' }) name: string | undefined,
537
- @Ctx() context: ResolverContext
538
- ): Promise<ChatSession> {
539
- const { domain, user, tx } = context.state
540
- const repo = getRepository(ChatSession, tx)
541
-
542
- // 이름 미지정 시 자동 생성 — **같은 앵커**의 기존 세션 수 + 1.
543
- let finalName = name?.trim()
544
- if (!finalName) {
545
- const count = await repo.count({ where: anchorWhere(domain.id, anchorType, anchorId) })
546
- finalName = `세션 ${count + 1}`
547
- }
548
-
549
- const session = await repo.save({
550
- domain,
551
- ...anchorColumns(anchorType, anchorId),
552
- creator: user,
553
- updater: user,
554
- name: finalName
555
- } as any)
556
- // creator 를 owner participant 로 자동 등록.
557
- await getRepository(ChatSessionParticipant, tx).save({
558
- domain,
559
- session: { id: session.id } as any,
560
- user,
561
- role: 'owner',
562
- creator: user,
563
- updater: user
564
- } as any)
565
- return session
566
- }
567
-
568
- @Directive('@transaction')
569
- @Mutation(() => Boolean, {
570
- description: 'Rename a ChatSession (tab label).'
571
- })
572
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
573
- async renameChatSession(
574
- @Arg('sessionId') sessionId: string,
575
- @Arg('name') name: string,
576
- @Ctx() context: ResolverContext
577
- ): Promise<boolean> {
578
- const { domain, user, tx } = context.state
579
- const repo = getRepository(ChatSession, tx)
580
- const session = await repo.findOneBy({ id: sessionId, domain: { id: domain.id } })
581
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
582
- await repo.update(sessionId, { name: name.trim() || null, updater: user } as any)
583
- return true
584
- }
585
-
586
- @Directive('@transaction')
587
- @Mutation(() => Boolean, {
588
- description:
589
- 'Append a system note to a session — context that did not come from a participant typing (e.g. a conversation carried over from another surface, an external state change). The AI sees it as part of the shared history.'
590
- })
591
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
592
- async appendSessionNote(
593
- @Arg('sessionId') sessionId: string,
594
- @Arg('content') content: string,
595
- @Ctx() context: ResolverContext
596
- ): Promise<boolean> {
597
- const { domain, user, tx } = context.state
598
- const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
599
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
600
- /* 프롬프트가 한 기록으로 폭증하지 않도록 자른다 — 맥락 전달이 목적이고 전문 보존이 아니다. */
601
- const body = (content ?? '').trim().slice(0, 4000)
602
- if (!body) return false
603
-
604
- const previousLast = await getRepository(ChatMessage, tx).findOne({
605
- where: { session: { id: session.id } as any },
606
- order: { createdAt: 'DESC' }
607
- })
608
- const sysMsg = await getRepository(ChatMessage, tx).save({
609
- session: { id: session.id } as any,
610
- role: 'system',
611
- content: body,
612
- creator: user,
613
- updater: user,
614
- parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
615
- } as any)
616
-
617
- /* 활동 갱신 + 방송 — 다른 참여자 목록·안 읽음과 열려 있는 화면의 transcript 일관성. best-effort. */
618
- try {
619
- await getRepository(ChatSession, tx).update(session.id!, activityPatch(body) as any)
620
- } catch {
621
- /* 기록 자체를 막지 않는다 */
622
- }
623
- publishSessionActivity({
624
- domainId: domain.id,
625
- sessionId: session.id!,
626
- kind: 'message',
627
- anchorType: (session as any).anchorType,
628
- anchorId: (session as any).anchorId,
629
- preview: body,
630
- actorId: user?.id
631
- })
632
- publishChatMessage({ domainId: domain.id, sessionId: session.id, message: sysMsg })
633
- return true
634
- }
635
-
636
- @Directive('@transaction')
637
- @Mutation(() => Boolean, {
638
- description:
639
- 'Carry a conversation from another surface into this session as its own turns (user / assistant), preserving order. Use when a lightweight exchange is promoted into a persistent discussion — the participants should read it as the conversation continuing, not as a machine dump.'
640
- })
641
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
642
- async appendSessionTurns(
643
- @Arg('sessionId') sessionId: string,
644
- @Arg('turns', () => GraphQLJSON) turns: { role?: string; content?: string }[],
645
- @Ctx() context: ResolverContext
646
- ): Promise<boolean> {
647
- const { domain, user, tx } = context.state
648
- const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
649
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
650
-
651
- /* 옮겨온 대화는 **그 대화의 발언 그대로** 들어간다 — 한 덩어리 기록으로 뭉치면 사람이 읽을 수 없고
652
- * (화면 하나를 잡아먹는 이탤릭 덩어리), 마크다운도 죽는다. 대신 개수·길이는 제한한다: 맥락 전달이
653
- * 목적이고 전문 보존이 아니다. */
654
- const rows = (Array.isArray(turns) ? turns : [])
655
- .map(t => ({
656
- role: t?.role === 'ai' || t?.role === 'assistant' ? 'assistant' : t?.role === 'user' ? 'user' : 'system',
657
- content: (t?.content ?? '').trim().slice(0, 4000)
658
- }))
659
- .filter(t => t.content)
660
- .slice(-40)
661
- if (!rows.length) return false
662
-
663
- let previous = await getRepository(ChatMessage, tx).findOne({
664
- where: { session: { id: session.id } as any },
665
- order: { createdAt: 'DESC' }
666
- })
667
- const saved: ChatMessage[] = []
668
- for (const row of rows) {
669
- previous = await getRepository(ChatMessage, tx).save({
670
- session: { id: session.id } as any,
671
- role: row.role,
672
- content: row.content,
673
- creator: user,
674
- updater: user,
675
- parentMessage: previous ? ({ id: previous.id } as any) : undefined
676
- } as any)
677
- saved.push(previous!)
678
- }
679
-
680
- /* 활동은 마지막 발언 기준 한 번만 — 옮긴 줄마다 목록을 흔들 이유가 없다. */
681
- const last = rows[rows.length - 1]
682
- try {
683
- await getRepository(ChatSession, tx).update(session.id!, activityPatch(last.content) as any)
684
- } catch {
685
- /* best-effort — 옮기기 자체를 막지 않는다 */
686
- }
687
- publishSessionActivity({
688
- domainId: domain.id,
689
- sessionId: session.id!,
690
- kind: 'message',
691
- anchorType: (session as any).anchorType,
692
- anchorId: (session as any).anchorId,
693
- preview: last.content,
694
- actorId: user?.id
695
- })
696
- for (const message of saved) {
697
- publishChatMessage({ domainId: domain.id, sessionId: session.id, message })
698
- }
699
- return true
700
- }
701
-
702
- @Directive('@transaction')
703
- @Mutation(() => PatchEntry, {
704
- description: 'Record a patch from user direct edit. Adds a system message so AI sees the change next turn.'
705
- })
706
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
707
- async recordDirectPatch(
708
- @Arg('sessionId') sessionId: string,
709
- @Arg('ops', () => GraphQLJSON) ops: any[],
710
- @Arg('summary', { nullable: true }) summary: string | undefined,
711
- @Ctx() context: ResolverContext
712
- ): Promise<PatchEntry> {
713
- const { domain, tx, user } = context.state
714
- const session = await getRepository(ChatSession, tx).findOneBy({
715
- id: sessionId,
716
- domain: { id: domain.id }
717
- })
718
- if (!session) throw new Error(`ChatSession ${sessionId} not found`)
719
-
720
- const entry = await getRepository(PatchEntry, tx).save({
721
- session: { id: session.id } as any,
722
- source: 'user-direct',
723
- ops: JSON.stringify(ops || []),
724
- summary,
725
- reverted: false
726
- } as any)
727
-
728
- // AI 컨텍스트 흐름 — system 메시지 자동 추가. creator = 직접 편집한 사용자.
729
- // parentMessage = 직전 in-session 메시지 (timeline 연속성 유지 — system 도 chain 에 합류).
730
- const previousLast = await getRepository(ChatMessage, tx).findOne({
731
- where: { session: { id: session.id } as any },
732
- order: { createdAt: 'DESC' }
733
- })
734
- const sysMsg = await getRepository(ChatMessage, tx).save({
735
- session: { id: session.id } as any,
736
- role: 'system',
737
- content: `User directly edited the board: ${summary ?? `${(ops || []).length} op(s)`}`,
738
- relatedPatchId: entry.id,
739
- creator: user,
740
- updater: user,
741
- parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
742
- } as any)
743
-
744
- /* 세션 활동 갱신 — 직접 편집도 다른 참여자에게는 새 소식이다(목록 정렬·안 읽음 판정). */
745
- try {
746
- await getRepository(ChatSession, tx).update(session.id!, activityPatch(sysMsg.content) as any)
747
- } catch {
748
- /* best-effort — 편집 기록을 막지 않는다 */
749
- }
750
- publishSessionActivity({
751
- domainId: domain.id,
752
- sessionId: session.id!,
753
- kind: 'message',
754
- anchorType: (session as any).anchorType,
755
- anchorId: (session as any).anchorId,
756
- preview: sysMsg.content,
757
- actorId: user?.id
758
- })
759
-
760
- // C1: user-direct 편집도 같은 board 구독자에게 방송 (AI·타참여자 실시간 반영). best-effort.
761
- publishBoardPatch({ domainId: domain.id, boardId: session.boardId, patch: entry })
762
- // C4: 직접편집 system 메시지를 같은 세션 구독자에게 방송 (transcript 실시간 일관성). best-effort.
763
- publishChatMessage({ domainId: domain.id, sessionId: session.id, message: sysMsg })
764
-
765
- return entry
766
- }
767
-
768
- @Directive('@transaction')
769
- @Mutation(() => Boolean, { description: 'Mark a patch as reverted (does not undo, only flags).' })
770
- @Directive('@privilege(category: "board-ai", privilege: "mutation")')
771
- async revertPatch(
772
- @Arg('patchId') patchId: string,
773
- @Ctx() context: ResolverContext
774
- ): Promise<boolean> {
775
- const { tx } = context.state
776
- await getRepository(PatchEntry, tx).update(patchId, { reverted: true })
777
- return true
778
- }
779
- }