@things-factory/board-ai 10.0.0 → 10.0.2

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 (67) hide show
  1. package/client/components/board-ai-chat.ts +107 -2
  2. package/client/components/chat-echo-dedup.ts +3 -0
  3. package/client/components/chat-input-builder.ts +6 -0
  4. package/dist-client/client/components/board-ai-chat.d.ts +17 -0
  5. package/dist-client/client/components/board-ai-chat.js +102 -2
  6. package/dist-client/client/components/board-ai-chat.js.map +1 -1
  7. package/dist-client/client/components/chat-echo-dedup.d.ts +2 -0
  8. package/dist-client/client/components/chat-echo-dedup.js +1 -0
  9. package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
  10. package/dist-client/client/components/chat-input-builder.d.ts +5 -0
  11. package/dist-client/client/components/chat-input-builder.js +1 -0
  12. package/dist-client/client/components/chat-input-builder.js.map +1 -1
  13. package/dist-client/server/service/agentic-loop.d.ts +18 -0
  14. package/dist-client/server/service/agentic-loop.js +10 -1
  15. package/dist-client/server/service/agentic-loop.js.map +1 -1
  16. package/dist-client/server/service/assistant.js +14 -2
  17. package/dist-client/server/service/assistant.js.map +1 -1
  18. package/dist-client/server/service/grounding.d.ts +17 -0
  19. package/dist-client/server/service/grounding.js +42 -0
  20. package/dist-client/server/service/grounding.js.map +1 -0
  21. package/dist-client/server/service/types.d.ts +16 -0
  22. package/dist-client/server/service/types.js.map +1 -1
  23. package/dist-client/tsconfig.tsbuildinfo +1 -1
  24. package/dist-server/service/agentic-loop.d.ts +18 -0
  25. package/dist-server/service/agentic-loop.js +10 -1
  26. package/dist-server/service/agentic-loop.js.map +1 -1
  27. package/dist-server/service/assistant.js +14 -2
  28. package/dist-server/service/assistant.js.map +1 -1
  29. package/dist-server/service/board-ai-resolver.d.ts +2 -0
  30. package/dist-server/service/board-ai-resolver.js +22 -1
  31. package/dist-server/service/board-ai-resolver.js.map +1 -1
  32. package/dist-server/service/chat-message/chat-message.d.ts +12 -0
  33. package/dist-server/service/chat-message/chat-message.js +23 -0
  34. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  35. package/dist-server/service/chat-session/session-inbox.d.ts +26 -0
  36. package/dist-server/service/chat-session/session-inbox.js +41 -0
  37. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  38. package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +11 -0
  39. package/dist-server/service/chat-session-participant/chat-session-participant.js +17 -1
  40. package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -1
  41. package/dist-server/service/chat-session-resolver.d.ts +44 -1
  42. package/dist-server/service/chat-session-resolver.js +306 -6
  43. package/dist-server/service/chat-session-resolver.js.map +1 -1
  44. package/dist-server/service/grounding.d.ts +17 -0
  45. package/dist-server/service/grounding.js +46 -0
  46. package/dist-server/service/grounding.js.map +1 -0
  47. package/dist-server/service/types.d.ts +16 -0
  48. package/dist-server/service/types.js.map +1 -1
  49. package/dist-server/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +5 -5
  51. package/server/service/agentic-loop.test.ts +63 -0
  52. package/server/service/agentic-loop.ts +28 -1
  53. package/server/service/assistant.ts +15 -2
  54. package/server/service/board-ai-resolver.ts +23 -1
  55. package/server/service/chat-message/chat-message.ts +26 -0
  56. package/server/service/chat-session/session-inbox.test.ts +69 -1
  57. package/server/service/chat-session/session-inbox.ts +45 -0
  58. package/server/service/chat-session-participant/chat-session-participant.ts +14 -0
  59. package/server/service/chat-session-resolver.ts +297 -5
  60. package/server/service/grounding.test.ts +55 -0
  61. package/server/service/grounding.ts +53 -0
  62. package/server/service/types.ts +16 -0
  63. package/translations/en.json +3 -1
  64. package/translations/ja.json +3 -1
  65. package/translations/ko.json +2 -0
  66. package/translations/ms.json +3 -1
  67. package/translations/zh.json +3 -1
@@ -14,6 +14,8 @@ declare class ChatSessionInboxEntry {
14
14
  createdAt?: Date;
15
15
  unread: boolean;
16
16
  participant: boolean;
17
+ mine: boolean;
18
+ hidden?: boolean;
17
19
  }
18
20
  export declare class ChatSessionResolver {
19
21
  chatSession(id: string, context: ResolverContext): Promise<ChatSession | null>;
@@ -26,7 +28,43 @@ export declare class ChatSessionResolver {
26
28
  * 집계 쿼리를 쓰지 않는다: 세션의 비정규화된 lastMessageAt 과 내 참여자 lastReadAt 을 **값 비교**한다
27
29
  * (5개 DB 드라이버 지원 때문에 group by/MAX 를 목록 경로에 두지 않는다).
28
30
  */
29
- chatSessionInbox(anchorType: string, anchorId: string, context: ResolverContext): Promise<ChatSessionInboxEntry[]>;
31
+ chatSessionInbox(anchorType: string, anchorId: string, context: ResolverContext, showHidden?: boolean, search?: string, mineOnly?: boolean, limit?: number, offset?: number): Promise<ChatSessionInboxEntry[]>;
32
+ /**
33
+ * 필터를 통과한 **전체 개수** — "더 보기" 가 남았는지, 검색 결과가 몇 건인지.
34
+ *
35
+ * 목록과 같은 요청에 담아 가져가므로 왕복은 늘지 않는다. 개수를 모르면 사용자는 목록의 끝이
36
+ * 페이지의 끝인지 자료의 끝인지 구별할 수 없다.
37
+ */
38
+ chatSessionInboxTotal(anchorType: string, anchorId: string, context: ResolverContext, showHidden?: boolean, search?: string, mineOnly?: boolean): Promise<number>;
39
+ /** 필터 적용(페이지 전) — 목록·개수가 **같은 규칙**을 쓰도록 한 곳에 둔다(어긋나면 "더 보기" 가 거짓말이 된다). */
40
+ private _inboxFiltered;
41
+ /**
42
+ * 내가 숨긴 대화 수 — 필터에 "몇 개가 접혀 있는지" 를 보여주기 위한 값.
43
+ *
44
+ * 숨긴 것이 몇 개인지 모르면 필터를 눌러 볼 이유가 생기지 않는다(숨긴 사실 자체를 잊는다).
45
+ * 목록 조회와 **같은 요청**에 담아 가져갈 수 있으므로 왕복은 늘지 않는다.
46
+ */
47
+ hiddenSessionCount(anchorType: string, anchorId: string, context: ResolverContext): Promise<number>;
48
+ /** 목록 조립 — 숨김 표시까지 붙인 **전체**. 목록·개수 두 진입점이 같은 규칙을 쓰도록 한 곳에 둔다. */
49
+ private _inboxEntries;
50
+ /**
51
+ * 세션 삭제 — **만든 사람만.**
52
+ *
53
+ * 협의는 여럿의 기록이고 삭제는 화면에서 되돌릴 수 없다. 그래서 권한을 좁힌다(이름 변경은 되돌릴 수
54
+ * 있어서 누구나 할 수 있는 것과 대비). 권한이 없는 사람에게는 `hideAISession`(내 목록에서만 숨김)이
55
+ * 있다 — 남의 기록을 지우지 않고 자기 목록을 정리하는 길.
56
+ *
57
+ * 소프트 삭제(deletedAt)다 — 메시지·패치는 세션을 통해서만 도달하므로 함께 사라지고, 잘못 지운 경우
58
+ * 데이터로는 남아 있다. 다른 참여자 목록에서도 사라져야 하므로 meta 활동으로 방송한다.
59
+ */
60
+ deleteAISession(sessionId: string, context: ResolverContext): Promise<boolean>;
61
+ /**
62
+ * 내 목록에서 숨기기·되돌리기 — **나에게만** 적용된다(대화·다른 참여자 목록은 그대로).
63
+ *
64
+ * 참여자가 아니어도 숨길 수 있어야 한다 — 목록에는 같은 공간의 남의 협의도 보이고, 그것을 정리하는
65
+ * 것은 남에게 아무 영향이 없다. 그 경우 'viewer' 역할로 기록만 남긴다(참여자 명부·배지에는 들지 않는다).
66
+ */
67
+ hideAISession(sessionId: string, context: ResolverContext, hidden?: boolean): Promise<boolean>;
30
68
  /**
31
69
  * 세션 이름 변경 — **참여자 누구나** 할 수 있다.
32
70
  *
@@ -53,6 +91,11 @@ export declare class ChatSessionResolver {
53
91
  startBoardAISession(boardId: string, context: ResolverContext): Promise<ChatSession>;
54
92
  createAISession(anchorType: string, anchorId: string, name: string | undefined, context: ResolverContext): Promise<ChatSession>;
55
93
  renameChatSession(sessionId: string, name: string, context: ResolverContext): Promise<boolean>;
94
+ appendSessionNote(sessionId: string, content: string, context: ResolverContext): Promise<boolean>;
95
+ appendSessionTurns(sessionId: string, turns: {
96
+ role?: string;
97
+ content?: string;
98
+ }[], context: ResolverContext): Promise<boolean>;
56
99
  recordDirectPatch(sessionId: string, ops: any[], summary: string | undefined, context: ResolverContext): Promise<PatchEntry>;
57
100
  revertPatch(patchId: string, context: ResolverContext): Promise<boolean>;
58
101
  }
@@ -65,6 +65,14 @@ tslib_1.__decorate([
65
65
  (0, type_graphql_1.Field)({ description: 'Whether this viewer is a participant of the session (non-participants get no unread state).' }),
66
66
  tslib_1.__metadata("design:type", Boolean)
67
67
  ], ChatSessionInboxEntry.prototype, "participant", void 0);
68
+ tslib_1.__decorate([
69
+ (0, type_graphql_1.Field)({ description: 'Whether this viewer created the session — only the creator may delete it.' }),
70
+ tslib_1.__metadata("design:type", Boolean)
71
+ ], ChatSessionInboxEntry.prototype, "mine", void 0);
72
+ tslib_1.__decorate([
73
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Whether this viewer hid the session from their own list (only returned when showHidden is on).' }),
74
+ tslib_1.__metadata("design:type", Boolean)
75
+ ], ChatSessionInboxEntry.prototype, "hidden", void 0);
68
76
  ChatSessionInboxEntry = tslib_1.__decorate([
69
77
  (0, type_graphql_1.ObjectType)({
70
78
  description: '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.'
@@ -95,17 +103,133 @@ let ChatSessionResolver = class ChatSessionResolver {
95
103
  * 집계 쿼리를 쓰지 않는다: 세션의 비정규화된 lastMessageAt 과 내 참여자 lastReadAt 을 **값 비교**한다
96
104
  * (5개 DB 드라이버 지원 때문에 group by/MAX 를 목록 경로에 두지 않는다).
97
105
  */
98
- async chatSessionInbox(anchorType, anchorId, context) {
106
+ async chatSessionInbox(anchorType, anchorId, context, showHidden, search, mineOnly, limit, offset) {
107
+ const filtered = await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly });
108
+ const from = Math.max(0, offset ?? 0);
109
+ const size = Math.min(Math.max(1, limit ?? 50), 200);
110
+ return filtered.slice(from, from + size);
111
+ }
112
+ /**
113
+ * 필터를 통과한 **전체 개수** — "더 보기" 가 남았는지, 검색 결과가 몇 건인지.
114
+ *
115
+ * 목록과 같은 요청에 담아 가져가므로 왕복은 늘지 않는다. 개수를 모르면 사용자는 목록의 끝이
116
+ * 페이지의 끝인지 자료의 끝인지 구별할 수 없다.
117
+ */
118
+ async chatSessionInboxTotal(anchorType, anchorId, context, showHidden, search, mineOnly) {
119
+ return (await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly })).length;
120
+ }
121
+ /** 필터 적용(페이지 전) — 목록·개수가 **같은 규칙**을 쓰도록 한 곳에 둔다(어긋나면 "더 보기" 가 거짓말이 된다). */
122
+ async _inboxFiltered(anchorType, anchorId, context, opts) {
123
+ const all = await this._inboxEntries(anchorType, anchorId, context);
124
+ let entries = opts.showHidden ? all : all.filter(e => !e.hidden);
125
+ if (opts.mineOnly)
126
+ entries = entries.filter(e => e.mine);
127
+ return (0, session_inbox_js_1.searchInbox)(entries, opts.search);
128
+ }
129
+ /**
130
+ * 내가 숨긴 대화 수 — 필터에 "몇 개가 접혀 있는지" 를 보여주기 위한 값.
131
+ *
132
+ * 숨긴 것이 몇 개인지 모르면 필터를 눌러 볼 이유가 생기지 않는다(숨긴 사실 자체를 잊는다).
133
+ * 목록 조회와 **같은 요청**에 담아 가져갈 수 있으므로 왕복은 늘지 않는다.
134
+ */
135
+ async hiddenSessionCount(anchorType, anchorId, context) {
136
+ const all = await this._inboxEntries(anchorType, anchorId, context);
137
+ return all.filter(e => e.hidden).length;
138
+ }
139
+ /** 목록 조립 — 숨김 표시까지 붙인 **전체**. 목록·개수 두 진입점이 같은 규칙을 쓰도록 한 곳에 둔다. */
140
+ async _inboxEntries(anchorType, anchorId, context) {
99
141
  const { domain, user } = context.state;
142
+ /* 조회 상한 — 한 앵커의 대화가 아무리 늘어도 한 번에 이만큼만 훑는다. 최근 활동순으로 자른다
143
+ * (오래된 대화가 잘리는 것이 최근 대화가 잘리는 것보다 낫다). 정렬·검색·페이지는 순수 로직이 한다. */
100
144
  const sessions = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).find({
101
- where: (0, session_anchor_js_1.anchorWhere)(domain.id, anchorType, anchorId)
145
+ where: (0, session_anchor_js_1.anchorWhere)(domain.id, anchorType, anchorId),
146
+ order: { lastMessageAt: 'DESC' },
147
+ take: session_inbox_js_1.INBOX_SCAN_CAP
102
148
  });
103
149
  if (sessions.length === 0)
104
150
  return [];
105
- const mine = await (0, shell_1.getRepository)(chat_session_participant_js_1.ChatSessionParticipant).find({
151
+ const rows = await (0, shell_1.getRepository)(chat_session_participant_js_1.ChatSessionParticipant).find({
106
152
  where: { domain: { id: domain.id }, user: { id: user.id }, session: { id: (0, typeorm_1.In)(sessions.map(s => s.id)) } }
107
153
  });
108
- return (0, session_inbox_js_1.buildInbox)(sessions, mine);
154
+ /* 'viewer' 대화에 낀 사람이 아니다 — 숨김을 기록하려고 만든 행이므로 읽음·안 읽음 판정에서 뺀다.
155
+ * (참여자가 아닌 사람에게 남의 협의 배지를 띄우지 않는다.) */
156
+ const participations = rows.filter(r => r.role !== 'viewer');
157
+ const hiddenAt = new Map(rows.map(r => [r.sessionId, r.hiddenAt]));
158
+ /* 내가 만든 대화인지 표시 — 삭제 버튼을 보일지 결정하는 UI 힌트(권한은 서버가 다시 판정한다). */
159
+ const createdByMe = new Set(sessions.filter(s => s.creatorId === user?.id).map(s => s.id));
160
+ const entries = (0, session_inbox_js_1.buildInbox)(sessions, participations).map(entry => ({
161
+ ...entry,
162
+ mine: createdByMe.has(entry.id)
163
+ }));
164
+ /* showHidden=true 로 불러 **전체**를 얻는다 — 숨김 여부 판정을 두 곳에 복제하지 않기 위함. */
165
+ return (0, session_inbox_js_1.applyHidden)(entries, hiddenAt, true);
166
+ }
167
+ /**
168
+ * 세션 삭제 — **만든 사람만.**
169
+ *
170
+ * 협의는 여럿의 기록이고 삭제는 화면에서 되돌릴 수 없다. 그래서 권한을 좁힌다(이름 변경은 되돌릴 수
171
+ * 있어서 누구나 할 수 있는 것과 대비). 권한이 없는 사람에게는 `hideAISession`(내 목록에서만 숨김)이
172
+ * 있다 — 남의 기록을 지우지 않고 자기 목록을 정리하는 길.
173
+ *
174
+ * 소프트 삭제(deletedAt)다 — 메시지·패치는 세션을 통해서만 도달하므로 함께 사라지고, 잘못 지운 경우
175
+ * 데이터로는 남아 있다. 다른 참여자 목록에서도 사라져야 하므로 meta 활동으로 방송한다.
176
+ */
177
+ async deleteAISession(sessionId, context) {
178
+ const { domain, user } = context.state;
179
+ const repo = (0, shell_1.getRepository)(chat_session_js_1.ChatSession);
180
+ const session = await repo.findOneBy({ id: sessionId, domain: { id: domain.id } });
181
+ if (!session)
182
+ return false;
183
+ if (!user?.id || session.creatorId !== user.id) {
184
+ /* 조용한 무동작이 아니라 명시 실패 — 지워진 줄 알고 넘어가면 안 된다. */
185
+ throw new Error('Only the user who created this session may delete it. Hide it from your own list instead.');
186
+ }
187
+ await repo.softDelete(sessionId);
188
+ (0, session_activity_publish_js_1.publishSessionActivity)({
189
+ domainId: domain.id,
190
+ sessionId,
191
+ kind: 'meta',
192
+ anchorType: session.anchorType,
193
+ anchorId: session.anchorId,
194
+ actorId: user?.id
195
+ });
196
+ return true;
197
+ }
198
+ /**
199
+ * 내 목록에서 숨기기·되돌리기 — **나에게만** 적용된다(대화·다른 참여자 목록은 그대로).
200
+ *
201
+ * 참여자가 아니어도 숨길 수 있어야 한다 — 목록에는 같은 공간의 남의 협의도 보이고, 그것을 정리하는
202
+ * 것은 남에게 아무 영향이 없다. 그 경우 'viewer' 역할로 기록만 남긴다(참여자 명부·배지에는 들지 않는다).
203
+ */
204
+ async hideAISession(sessionId, context, hidden) {
205
+ const { domain, user } = context.state;
206
+ if (!user?.id)
207
+ return false;
208
+ const session = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } });
209
+ if (!session)
210
+ return false;
211
+ const repo = (0, shell_1.getRepository)(chat_session_participant_js_1.ChatSessionParticipant);
212
+ const existing = await repo.findOne({
213
+ where: { domain: { id: domain.id }, session: { id: sessionId }, user: { id: user.id } }
214
+ });
215
+ const hiddenAt = hidden === false ? null : new Date();
216
+ if (existing) {
217
+ await repo.update(existing.id, { hiddenAt, updater: user });
218
+ }
219
+ else {
220
+ if (hidden === false)
221
+ return true; // 숨긴 적이 없으면 되돌릴 것도 없다
222
+ await repo.save({
223
+ domain: { id: domain.id },
224
+ session: { id: sessionId },
225
+ user: { id: user.id },
226
+ role: 'viewer', // 대화에 낀 것이 아니라 숨김만 기록한다
227
+ hiddenAt,
228
+ creator: user,
229
+ updater: user
230
+ });
231
+ }
232
+ return true;
109
233
  }
110
234
  /**
111
235
  * 세션 이름 변경 — **참여자 누구나** 할 수 있다.
@@ -285,6 +409,101 @@ let ChatSessionResolver = class ChatSessionResolver {
285
409
  await repo.update(sessionId, { name: name.trim() || null, updater: user });
286
410
  return true;
287
411
  }
412
+ async appendSessionNote(sessionId, content, context) {
413
+ const { domain, user, tx } = context.state;
414
+ const session = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } });
415
+ if (!session)
416
+ throw new Error(`ChatSession ${sessionId} not found`);
417
+ /* 프롬프트가 한 기록으로 폭증하지 않도록 자른다 — 맥락 전달이 목적이고 전문 보존이 아니다. */
418
+ const body = (content ?? '').trim().slice(0, 4000);
419
+ if (!body)
420
+ return false;
421
+ const previousLast = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).findOne({
422
+ where: { session: { id: session.id } },
423
+ order: { createdAt: 'DESC' }
424
+ });
425
+ const sysMsg = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).save({
426
+ session: { id: session.id },
427
+ role: 'system',
428
+ content: body,
429
+ creator: user,
430
+ updater: user,
431
+ parentMessage: previousLast ? { id: previousLast.id } : undefined
432
+ });
433
+ /* 활동 갱신 + 방송 — 다른 참여자 목록·안 읽음과 열려 있는 화면의 transcript 일관성. best-effort. */
434
+ try {
435
+ await (0, shell_1.getRepository)(chat_session_js_1.ChatSession, tx).update(session.id, (0, session_inbox_js_1.activityPatch)(body));
436
+ }
437
+ catch {
438
+ /* 기록 자체를 막지 않는다 */
439
+ }
440
+ (0, session_activity_publish_js_1.publishSessionActivity)({
441
+ domainId: domain.id,
442
+ sessionId: session.id,
443
+ kind: 'message',
444
+ anchorType: session.anchorType,
445
+ anchorId: session.anchorId,
446
+ preview: body,
447
+ actorId: user?.id
448
+ });
449
+ (0, chat_message_publish_js_1.publishChatMessage)({ domainId: domain.id, sessionId: session.id, message: sysMsg });
450
+ return true;
451
+ }
452
+ async appendSessionTurns(sessionId, turns, context) {
453
+ const { domain, user, tx } = context.state;
454
+ const session = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } });
455
+ if (!session)
456
+ throw new Error(`ChatSession ${sessionId} not found`);
457
+ /* 옮겨온 대화는 **그 대화의 발언 그대로** 들어간다 — 한 덩어리 기록으로 뭉치면 사람이 읽을 수 없고
458
+ * (화면 하나를 잡아먹는 이탤릭 덩어리), 마크다운도 죽는다. 대신 개수·길이는 제한한다: 맥락 전달이
459
+ * 목적이고 전문 보존이 아니다. */
460
+ const rows = (Array.isArray(turns) ? turns : [])
461
+ .map(t => ({
462
+ role: t?.role === 'ai' || t?.role === 'assistant' ? 'assistant' : t?.role === 'user' ? 'user' : 'system',
463
+ content: (t?.content ?? '').trim().slice(0, 4000)
464
+ }))
465
+ .filter(t => t.content)
466
+ .slice(-40);
467
+ if (!rows.length)
468
+ return false;
469
+ let previous = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).findOne({
470
+ where: { session: { id: session.id } },
471
+ order: { createdAt: 'DESC' }
472
+ });
473
+ const saved = [];
474
+ for (const row of rows) {
475
+ previous = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage, tx).save({
476
+ session: { id: session.id },
477
+ role: row.role,
478
+ content: row.content,
479
+ creator: user,
480
+ updater: user,
481
+ parentMessage: previous ? { id: previous.id } : undefined
482
+ });
483
+ saved.push(previous);
484
+ }
485
+ /* 활동은 마지막 발언 기준 한 번만 — 옮긴 줄마다 목록을 흔들 이유가 없다. */
486
+ const last = rows[rows.length - 1];
487
+ try {
488
+ await (0, shell_1.getRepository)(chat_session_js_1.ChatSession, tx).update(session.id, (0, session_inbox_js_1.activityPatch)(last.content));
489
+ }
490
+ catch {
491
+ /* best-effort — 옮기기 자체를 막지 않는다 */
492
+ }
493
+ (0, session_activity_publish_js_1.publishSessionActivity)({
494
+ domainId: domain.id,
495
+ sessionId: session.id,
496
+ kind: 'message',
497
+ anchorType: session.anchorType,
498
+ anchorId: session.anchorId,
499
+ preview: last.content,
500
+ actorId: user?.id
501
+ });
502
+ for (const message of saved) {
503
+ (0, chat_message_publish_js_1.publishChatMessage)({ domainId: domain.id, sessionId: session.id, message });
504
+ }
505
+ return true;
506
+ }
288
507
  async recordDirectPatch(sessionId, ops, summary, context) {
289
508
  const { domain, tx, user } = context.state;
290
509
  const session = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession, tx).findOneBy({
@@ -387,16 +606,71 @@ tslib_1.__decorate([
387
606
  ], ChatSessionResolver.prototype, "chatSessionsByBoard", null);
388
607
  tslib_1.__decorate([
389
608
  (0, type_graphql_1.Query)(() => [ChatSessionInboxEntry], {
390
- description: "List sessions anchored to a target with this viewer's unread state, ordered by recent activity. Uses the denormalized last-activity timestamp compared against the viewer's read point — no per-session aggregation."
609
+ description: "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."
391
610
  }),
392
611
  (0, type_graphql_1.Directive)('@privilege(category: "board-ai", privilege: "query")'),
393
612
  tslib_1.__param(0, (0, type_graphql_1.Arg)('anchorType')),
394
613
  tslib_1.__param(1, (0, type_graphql_1.Arg)('anchorId')),
395
614
  tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
615
+ tslib_1.__param(3, (0, type_graphql_1.Arg)('showHidden', { nullable: true })),
616
+ tslib_1.__param(4, (0, type_graphql_1.Arg)('search', { nullable: true })),
617
+ tslib_1.__param(5, (0, type_graphql_1.Arg)('mineOnly', { nullable: true })),
618
+ tslib_1.__param(6, (0, type_graphql_1.Arg)('limit', () => type_graphql_1.Int, { nullable: true })),
619
+ tslib_1.__param(7, (0, type_graphql_1.Arg)('offset', () => type_graphql_1.Int, { nullable: true })),
396
620
  tslib_1.__metadata("design:type", Function),
397
- tslib_1.__metadata("design:paramtypes", [String, String, Object]),
621
+ tslib_1.__metadata("design:paramtypes", [String, String, Object, Boolean, String, Boolean, Number, Number]),
398
622
  tslib_1.__metadata("design:returntype", Promise)
399
623
  ], ChatSessionResolver.prototype, "chatSessionInbox", null);
624
+ tslib_1.__decorate([
625
+ (0, type_graphql_1.Query)(() => type_graphql_1.Int, {
626
+ description: '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".'
627
+ }),
628
+ (0, type_graphql_1.Directive)('@privilege(category: "board-ai", privilege: "query")'),
629
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('anchorType')),
630
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('anchorId')),
631
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
632
+ tslib_1.__param(3, (0, type_graphql_1.Arg)('showHidden', { nullable: true })),
633
+ tslib_1.__param(4, (0, type_graphql_1.Arg)('search', { nullable: true })),
634
+ tslib_1.__param(5, (0, type_graphql_1.Arg)('mineOnly', { nullable: true })),
635
+ tslib_1.__metadata("design:type", Function),
636
+ tslib_1.__metadata("design:paramtypes", [String, String, Object, Boolean, String, Boolean]),
637
+ tslib_1.__metadata("design:returntype", Promise)
638
+ ], ChatSessionResolver.prototype, "chatSessionInboxTotal", null);
639
+ tslib_1.__decorate([
640
+ (0, type_graphql_1.Query)(() => type_graphql_1.Int, {
641
+ description: "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."
642
+ }),
643
+ (0, type_graphql_1.Directive)('@privilege(category: "board-ai", privilege: "query")'),
644
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('anchorType')),
645
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('anchorId')),
646
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
647
+ tslib_1.__metadata("design:type", Function),
648
+ tslib_1.__metadata("design:paramtypes", [String, String, Object]),
649
+ tslib_1.__metadata("design:returntype", Promise)
650
+ ], ChatSessionResolver.prototype, "hiddenSessionCount", null);
651
+ tslib_1.__decorate([
652
+ (0, type_graphql_1.Mutation)(() => Boolean, {
653
+ description: '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.'
654
+ }),
655
+ (0, type_graphql_1.Directive)('@privilege(category: "board-ai", privilege: "mutation")'),
656
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('sessionId')),
657
+ tslib_1.__param(1, (0, type_graphql_1.Ctx)()),
658
+ tslib_1.__metadata("design:type", Function),
659
+ tslib_1.__metadata("design:paramtypes", [String, Object]),
660
+ tslib_1.__metadata("design:returntype", Promise)
661
+ ], ChatSessionResolver.prototype, "deleteAISession", null);
662
+ tslib_1.__decorate([
663
+ (0, type_graphql_1.Mutation)(() => Boolean, {
664
+ description: '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.'
665
+ }),
666
+ (0, type_graphql_1.Directive)('@privilege(category: "board-ai", privilege: "mutation")'),
667
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('sessionId')),
668
+ tslib_1.__param(1, (0, type_graphql_1.Ctx)()),
669
+ tslib_1.__param(2, (0, type_graphql_1.Arg)('hidden', { nullable: true })),
670
+ tslib_1.__metadata("design:type", Function),
671
+ tslib_1.__metadata("design:paramtypes", [String, Object, Boolean]),
672
+ tslib_1.__metadata("design:returntype", Promise)
673
+ ], ChatSessionResolver.prototype, "hideAISession", null);
400
674
  tslib_1.__decorate([
401
675
  (0, type_graphql_1.Mutation)(() => Boolean, {
402
676
  description: '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.'
@@ -526,6 +800,32 @@ tslib_1.__decorate([
526
800
  tslib_1.__metadata("design:paramtypes", [String, String, Object]),
527
801
  tslib_1.__metadata("design:returntype", Promise)
528
802
  ], ChatSessionResolver.prototype, "renameChatSession", null);
803
+ tslib_1.__decorate([
804
+ (0, type_graphql_1.Directive)('@transaction'),
805
+ (0, type_graphql_1.Mutation)(() => Boolean, {
806
+ description: '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.'
807
+ }),
808
+ (0, type_graphql_1.Directive)('@privilege(category: "board-ai", privilege: "mutation")'),
809
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('sessionId')),
810
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('content')),
811
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
812
+ tslib_1.__metadata("design:type", Function),
813
+ tslib_1.__metadata("design:paramtypes", [String, String, Object]),
814
+ tslib_1.__metadata("design:returntype", Promise)
815
+ ], ChatSessionResolver.prototype, "appendSessionNote", null);
816
+ tslib_1.__decorate([
817
+ (0, type_graphql_1.Directive)('@transaction'),
818
+ (0, type_graphql_1.Mutation)(() => Boolean, {
819
+ description: '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.'
820
+ }),
821
+ (0, type_graphql_1.Directive)('@privilege(category: "board-ai", privilege: "mutation")'),
822
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('sessionId')),
823
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('turns', () => graphql_scalars_1.GraphQLJSON)),
824
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
825
+ tslib_1.__metadata("design:type", Function),
826
+ tslib_1.__metadata("design:paramtypes", [String, Array, Object]),
827
+ tslib_1.__metadata("design:returntype", Promise)
828
+ ], ChatSessionResolver.prototype, "appendSessionTurns", null);
529
829
  tslib_1.__decorate([
530
830
  (0, type_graphql_1.Directive)('@transaction'),
531
831
  (0, type_graphql_1.Mutation)(() => patch_entry_js_1.PatchEntry, {