@things-factory/board-ai 10.0.1 → 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 +4 -4
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/board-ai",
3
- "version": "10.0.1",
3
+ "version": "10.0.2",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/client/index.js",
6
6
  "things-factory": true,
@@ -30,9 +30,9 @@
30
30
  "@operato/graphql": "^10.0.0",
31
31
  "@operato/i18n": "^10.0.0",
32
32
  "@operato/styles": "^10.0.0",
33
- "@things-factory/ai-client-base": "^10.0.0",
33
+ "@things-factory/ai-client-base": "^10.0.2",
34
34
  "@things-factory/auth-base": "^10.0.0",
35
- "@things-factory/board-import": "^10.0.1",
35
+ "@things-factory/board-import": "^10.0.2",
36
36
  "@things-factory/env": "^10.0.0",
37
37
  "@things-factory/shell": "^10.0.0",
38
38
  "dompurify": "^3.0.0",
@@ -44,5 +44,5 @@
44
44
  "copyfiles": "^2.4.1",
45
45
  "rimraf": "^5.0.0"
46
46
  },
47
- "gitHead": "6dd5b25ed1fe863415793bd156c3411ffe1ce47d"
47
+ "gitHead": "a69c3592a44e27bfa8212bc06268bff6140afc9a"
48
48
  }
@@ -703,3 +703,66 @@ describe('runAgenticLoop — currentBoard / selectedRefids 전달', () => {
703
703
  expect(receivedComponents).toEqual([{ refid: 7 }])
704
704
  })
705
705
  })
706
+
707
+ describe('runAgenticLoop — 접지 근거 수집', () => {
708
+ test('도구 결과 원문을 groundedTexts 에 담는다 — 압축본(toolUsages.result)이 아니라 원문이어야 한다', async () => {
709
+ let callCount = 0
710
+ const input = mkInput(
711
+ {
712
+ chat: async () => {
713
+ callCount++
714
+ return callCount === 1 ? okResult(undefined, [TC('getTwinStructure', {})]) : okResult('done')
715
+ }
716
+ },
717
+ {
718
+ isReadTool: () => true,
719
+ executeReadTool: () => ({ nodes: [{ id: 'dock-1' }, { id: 'dock-2' }] }),
720
+ /* 표시용 압축이 식별자를 잘라내도 근거는 온전해야 한다. */
721
+ summarizeToolResult: () => ({ nodes: '…(생략)' })
722
+ }
723
+ )
724
+ const r = await runAgenticLoop(input)
725
+ expect(r.groundedTexts).toEqual([JSON.stringify({ nodes: [{ id: 'dock-1' }, { id: 'dock-2' }] })])
726
+ expect(r.toolUsages[0].result).toEqual({ nodes: '…(생략)' })
727
+ })
728
+
729
+ test('도구를 부르지 않으면 근거가 비어 있다 — 그 답은 접지될 수 없다', async () => {
730
+ const r = await runAgenticLoop(mkInput({ chat: async () => okResult('바쁘게 움직이고 있습니다') }))
731
+ expect(r.groundedTexts).toEqual([])
732
+ })
733
+ })
734
+
735
+ describe('runAgenticLoop — 첫 턴 도구 강제(근거 없는 단언 차단)', () => {
736
+ /** 각 iteration 의 toolChoice 를 기록하는 mock. */
737
+ const recordChoices = (turns: number) => {
738
+ const choices: any[] = []
739
+ let i = 0
740
+ return {
741
+ choices,
742
+ chat: async (_m: any, _t: any, opts: any) => {
743
+ choices.push(opts.toolChoice)
744
+ i++
745
+ return i < turns ? okResult(undefined, [TC('getSelection', {})]) : okResult('done')
746
+ }
747
+ }
748
+ }
749
+
750
+ test('켜면 첫 턴만 required, 두 번째부터 auto — 조회 결과를 받고도 또 부르지 않게', async () => {
751
+ const rec = recordChoices(2)
752
+ await runAgenticLoop(
753
+ mkInput(
754
+ { chat: rec.chat, options: { systemPrompt: 'x', requireToolOnFirstTurn: true } },
755
+ { isReadTool: () => true, executeReadTool: () => ({ ok: 1 }) }
756
+ )
757
+ )
758
+ expect(rec.choices).toEqual(['required', 'auto'])
759
+ })
760
+
761
+ test('끄면 기존 그대로 auto — 라이브 상태를 다루지 않는 대화면은 강제하지 않는다', async () => {
762
+ const rec = recordChoices(2)
763
+ await runAgenticLoop(
764
+ mkInput({ chat: rec.chat }, { isReadTool: () => true, executeReadTool: () => ({ ok: 1 }) })
765
+ )
766
+ expect(rec.choices).toEqual(['auto', 'auto'])
767
+ })
768
+ })
@@ -64,6 +64,17 @@ export interface AgenticLoopOptions {
64
64
  * LLM 이 같은 잘못을 무한 반복하는 경우 방어.
65
65
  */
66
66
  repeatedFailureLimit?: number
67
+ /**
68
+ * **첫 턴에 도구 호출을 강제**한다(toolChoice: 'required'). 두 번째 턴부터는 자유(auto).
69
+ *
70
+ * 왜: 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다
71
+ * ("지게차들이 바쁘게 움직이고 있다" — 실제로 이 답이 나왔다). 그건 도구를 아예 부르지 않고 답한
72
+ * 경우이고, 사후 검사로는 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할
73
+ * 길이 없어진다 — 개념·사용법 질문도 문서 조회 도구로 답하므로 막히지 않는다.
74
+ *
75
+ * 대화면이 정한다(라이브 상태를 다루는 면에서만 켠다) — 여기서 질문 내용을 눈치로 분류하지 않는다.
76
+ */
77
+ requireToolOnFirstTurn?: boolean
67
78
  }
68
79
 
69
80
  export interface AgenticLoopInput {
@@ -86,6 +97,13 @@ export interface AgenticLoopResult {
86
97
  accumulatedWriteCalls: AIToolCall[]
87
98
  accumulatedActions: BoardActionOp[]
88
99
  toolUsages: ToolUsage[]
100
+ /**
101
+ * LLM 에게 실제로 회신된 도구 결과 문자열(시간순) — **접지 판정의 근거집합**.
102
+ *
103
+ * `toolUsages[].result` 를 쓰면 안 된다: 그건 UI 표시용으로 압축된(잘린) 값이라 근거로 삼으면
104
+ * 잘려 나간 식별자를 "근거 없음" 으로 오판한다. 여기 담기는 것은 tool_result 로 보낸 원문 그대로다.
105
+ */
106
+ groundedTexts: string[]
89
107
  /** LLM 마지막 응답의 stopReason. provider-specific 문자열. */
90
108
  stopReason: string
91
109
  /**
@@ -132,6 +150,8 @@ export async function runAgenticLoop(
132
150
  const accumulatedWriteCalls: AIToolCall[] = []
133
151
  const accumulatedActions: BoardActionOp[] = []
134
152
  const toolUsages: ToolUsage[] = []
153
+ /* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */
154
+ const groundedTexts: string[] = []
135
155
  let lastText: string | undefined
136
156
  let stopReason: string = 'end_turn'
137
157
  let abortReason: AgenticLoopResult['abortReason']
@@ -158,7 +178,8 @@ export async function runAgenticLoop(
158
178
  model: options.model,
159
179
  temperature: options.temperature,
160
180
  maxTokens: options.maxTokens,
161
- toolChoice: 'auto',
181
+ /* 첫 턴만 강제 — 이후는 자유. 계속 강제하면 조회 결과를 받아 놓고도 답을 못 쓰고 도구를 또 부른다. */
182
+ toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',
162
183
  allowParallelToolCalls: true,
163
184
  signal
164
185
  })
@@ -206,6 +227,11 @@ export async function runAgenticLoop(
206
227
  toolUsages
207
228
  )
208
229
 
230
+ /* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */
231
+ for (const part of toolResultParts) {
232
+ if (typeof part?.content === 'string') groundedTexts.push(part.content)
233
+ }
234
+
209
235
  // (4) 같은 tool 의 validation 실패 연속 감지 — LLM 이 같은 잘못 반복 시 abort
210
236
  const newUsages = toolUsages.slice(beforeUsages)
211
237
  const repeatedFailureAbort = detectRepeatedValidationFailure(
@@ -264,6 +290,7 @@ export async function runAgenticLoop(
264
290
  accumulatedWriteCalls,
265
291
  accumulatedActions,
266
292
  toolUsages,
293
+ groundedTexts,
267
294
  stopReason,
268
295
  abortReason
269
296
  }
@@ -34,6 +34,7 @@ import { resolveCatalogMentions } from './catalog-resolver'
34
34
  // 직접 import 안 함 — 신규 styling tool 추가 시 registry 에만 spec 추가하면 됨.
35
35
  import { buildComponentStyleSummary } from './styling/read-tools'
36
36
  import { runAgenticLoop, type AgenticLoopResult } from './agentic-loop'
37
+ import { checkReplyGrounding } from './grounding'
37
38
  import { collectAllComponents, findComponentByRefid } from './component-tree'
38
39
  import {
39
40
  isStylingTool,
@@ -222,7 +223,9 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
222
223
  systemPrompt,
223
224
  model: options.model,
224
225
  temperature: options.temperature,
225
- maxTokens: options.maxTokens
226
+ maxTokens: options.maxTokens,
227
+ /* 라이브 상태를 다루는 대화면은 첫 턴에 조회를 강제한다 — 근거 없이 상태를 단언할 길을 막는다. */
228
+ requireToolOnFirstTurn: options.requireGroundingTools
226
229
  },
227
230
  chat: (msgs, tools, opts) => this.base.chatWithTools!(msgs, tools, opts),
228
231
  dispatch: {
@@ -267,12 +270,22 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
267
270
  }
268
271
  }
269
272
 
273
+ /* 접지 검사 — 답이 **모델이 받은 것** 밖의 식별자를 지목했는지. 답은 고치지 않고 경고만 낸다.
274
+ * 근거집합의 정의와 사정거리는 ./grounding 머리말 참조(식별자 없는 창작 서술은 잡지 못한다). */
275
+ const warnings = checkReplyGrounding(baseReply, {
276
+ systemPrompt,
277
+ boardContext,
278
+ messages: messages.map(m => (typeof m.content === 'string' ? m.content : '')),
279
+ toolResults: loopResult.groundedTexts
280
+ })
281
+
270
282
  return {
271
283
  reply,
272
284
  patch,
273
285
  followUp: undefined,
274
286
  toolUsages: toolUsages.length > 0 ? toolUsages : undefined,
275
- actions: accumulatedActions.length > 0 ? accumulatedActions : undefined
287
+ actions: accumulatedActions.length > 0 ? accumulatedActions : undefined,
288
+ groundingWarnings: warnings.length > 0 ? warnings : undefined
276
289
  }
277
290
  }
278
291
 
@@ -105,6 +105,13 @@ class BoardAIChatInput {
105
105
  })
106
106
  boardTools?: boolean
107
107
 
108
+ @Field({
109
+ nullable: true,
110
+ description:
111
+ 'Force a tool call on the first turn (toolChoice: required). Turn this on for surfaces that discuss live state: the grounding guard catches invented identifiers but not an invented situation stated without any, which only happens when the model answers without querying at all. Concept / how-to questions are unaffected — they are answered through a documentation tool.'
112
+ })
113
+ requireGroundingTools?: boolean
114
+
108
115
  @Field(() => [String], {
109
116
  nullable: true,
110
117
  description: 'Component types the LLM is allowed to emit (e.g. ["rect", "label"]). Constrains generation to the current solution.'
@@ -196,6 +203,13 @@ class BoardAIChatOutput {
196
203
  'Ephemeral scene actions (selection/view/mode) — sequence of BoardActionOp. Applied via board-action-execute event on the host. Distinct from `patch` which carries persistent model changes.'
197
204
  })
198
205
  actions?: any
206
+
207
+ @Field(() => GraphQLJSON, {
208
+ nullable: true,
209
+ description:
210
+ 'Ungrounded identifiers mentioned by the reply — present in the answer but absent from everything the model received (prompt, board context, history, tool results). Hallucination candidates surfaced to the user; the reply itself is not altered. Null when the answer is grounded.'
211
+ })
212
+ groundingWarnings?: any
199
213
  }
200
214
 
201
215
  @Resolver()
@@ -341,6 +355,7 @@ export class BoardAIChatResolver {
341
355
  /* 대화면별 도구 노출 — 미지정이면 현행(전부 허용) 그대로. */
342
356
  toolCategories: input.toolCategories ?? undefined,
343
357
  boardTools: input.boardTools ?? undefined,
358
+ requireGroundingTools: input.requireGroundingTools ?? undefined,
344
359
  knownTypes: input.knownTypes,
345
360
  categories: input.categories as ComponentCategory[] | undefined,
346
361
  selectedRefids: input.selectedRefids,
@@ -385,6 +400,12 @@ export class BoardAIChatResolver {
385
400
  content: enrichedReply,
386
401
  relatedPatchId: patchEntry?.id,
387
402
  toolUsages: r.toolUsages && r.toolUsages.length > 0 ? JSON.stringify(r.toolUsages) : undefined,
403
+ /* 접지 경고를 함께 영속 — 나중에 읽는 참여자도 같은 주의를 봐야 한다(재계산 불가: 근거 원문이 남지 않는다).
404
+ * 컬럼 길이(1024) 안에 들도록 앞 20개까지만 — 그 이상이면 목록이 아니라 답 자체가 문제다. */
405
+ groundingWarnings:
406
+ r.groundingWarnings && r.groundingWarnings.length > 0
407
+ ? JSON.stringify(r.groundingWarnings.slice(0, 20))
408
+ : undefined,
388
409
  // assistant 메시지의 creator = 이 응답을 트리거한 user (= 직전 user 메시지의 작성자).
389
410
  // 같은 turn 으로 묶임 → 향후 사용자별 필터에서 user/assistant 가 함께 따라옴.
390
411
  creator: user,
@@ -426,7 +447,8 @@ export class BoardAIChatResolver {
426
447
  assistantMessageId,
427
448
  patchId,
428
449
  toolUsages: r.toolUsages ?? null,
429
- actions: r.actions && r.actions.length > 0 ? r.actions : null
450
+ actions: r.actions && r.actions.length > 0 ? r.actions : null,
451
+ groundingWarnings: r.groundingWarnings && r.groundingWarnings.length > 0 ? r.groundingWarnings : null
430
452
  }
431
453
  }
432
454
 
@@ -107,6 +107,32 @@ export class ChatMessage {
107
107
  }
108
108
  }
109
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
+
110
136
  /**
111
137
  * 메시지를 일으킨 사용자.
112
138
  * - role='user' → 보낸 사용자
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * 집계 쿼리를 쓰지 않는 설계라(5개 DB 드라이버) 판정이 전부 값 비교로 끝난다 — 그래서 순수 테스트가 된다.
13
13
  */
14
- import { buildInbox, countUnreadSessions, activityPatch } from './session-inbox'
14
+ import { buildInbox, countUnreadSessions, activityPatch, applyHidden, searchInbox } from './session-inbox'
15
15
 
16
16
  const t = (iso: string) => new Date(iso)
17
17
 
@@ -115,3 +115,71 @@ describe('활동 갱신 값', () => {
115
115
  expect(activityPatch('').lastMessagePreview).toBe('')
116
116
  })
117
117
  })
118
+
119
+ describe('applyHidden — 내 목록에서만 숨기기', () => {
120
+ const entry = (id: string, lastMessageAt?: string): any => ({
121
+ id,
122
+ lastMessageAt,
123
+ unread: false,
124
+ participant: true
125
+ })
126
+
127
+ it('숨긴 대화는 기본 목록에서 빠진다', () => {
128
+ const out = applyHidden([entry('a', '2026-07-30T01:00:00Z'), entry('b')], new Map([['a', '2026-07-30T02:00:00Z']]))
129
+ expect(out.map(e => e.id)).toEqual(['b'])
130
+ })
131
+
132
+ it('숨긴 뒤 새 활동이 생기면 다시 보인다 — 참여 중인 대화에서 조용히 잘려 나가지 않게', () => {
133
+ const out = applyHidden([entry('a', '2026-07-30T03:00:00Z')], new Map([['a', '2026-07-30T02:00:00Z']]))
134
+ expect(out.map(e => e.id)).toEqual(['a'])
135
+ expect(out[0].hidden).toBeUndefined()
136
+ })
137
+
138
+ it('필터를 켜면 숨긴 것도 표시와 함께 돌려준다 — 되돌릴 수 있어야 한다', () => {
139
+ const out = applyHidden(
140
+ [entry('a', '2026-07-30T01:00:00Z'), entry('b')],
141
+ new Map([['a', '2026-07-30T02:00:00Z']]),
142
+ true
143
+ )
144
+ expect(out.map(e => e.id)).toEqual(['a', 'b'])
145
+ expect(out[0].hidden).toBe(true)
146
+ expect(out[1].hidden).toBeUndefined()
147
+ })
148
+
149
+ it('숨김 기록이 없으면 아무것도 바꾸지 않는다', () => {
150
+ const entries = [entry('a'), entry('b')]
151
+ expect(applyHidden(entries, new Map())).toEqual(entries)
152
+ })
153
+ })
154
+
155
+ describe('searchInbox — 대량 목록의 풀텍스트 필터', () => {
156
+ const rows: any[] = [
157
+ { id: 'a', name: 'A-DC 포화 원인', lastMessagePreview: '점유율이 92% 입니다', unread: false, participant: true },
158
+ { id: 'b', name: 'Dock 배치 검토', lastMessagePreview: '입고 도크를 늘리면', unread: false, participant: true },
159
+ { id: 'c', lastMessagePreview: '이름 없는 대화', unread: false, participant: true }
160
+ ]
161
+
162
+ it('이름으로 찾는다', () => {
163
+ expect(searchInbox(rows, '포화').map(e => e.id)).toEqual(['a'])
164
+ })
165
+
166
+ it('미리보기 본문으로도 찾는다 — 제목만으로는 돌아갈 대화를 못 찾는다', () => {
167
+ expect(searchInbox(rows, '입고').map(e => e.id)).toEqual(['b'])
168
+ })
169
+
170
+ it('대소문자를 무시한다', () => {
171
+ expect(searchInbox(rows, 'dock').map(e => e.id)).toEqual(['b'])
172
+ })
173
+
174
+ it('이름이 없는 대화도 미리보기로 걸린다', () => {
175
+ expect(searchInbox(rows, '이름 없는').map(e => e.id)).toEqual(['c'])
176
+ })
177
+
178
+ it('빈 검색어는 전체를 그대로 돌려준다(필터 없음)', () => {
179
+ expect(searchInbox(rows, ' ')).toBe(rows)
180
+ })
181
+
182
+ it('맞는 것이 없으면 빈 목록 — 조용히 전체를 보여주지 않는다', () => {
183
+ expect(searchInbox(rows, 'zzz')).toEqual([])
184
+ })
185
+ })
@@ -31,6 +31,8 @@ export interface InboxSessionRow {
31
31
  export interface MyParticipation {
32
32
  sessionId?: string
33
33
  lastReadAt?: Date | string | null
34
+ /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */
35
+ hiddenAt?: Date | string | null
34
36
  }
35
37
 
36
38
  export interface InboxEntry extends InboxSessionRow {
@@ -38,6 +40,8 @@ export interface InboxEntry extends InboxSessionRow {
38
40
  unread: boolean
39
41
  /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */
40
42
  participant: boolean
43
+ /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */
44
+ hidden?: boolean
41
45
  }
42
46
 
43
47
  function time(v: Date | string | null | undefined): number {
@@ -83,6 +87,47 @@ export function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[])
83
87
  )
84
88
  }
85
89
 
90
+ /**
91
+ * 숨김 표시·필터.
92
+ *
93
+ * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —
94
+ * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.
95
+ *
96
+ * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이
97
+ * 숨겨진 것인지 알아야 되돌릴 수 있다.
98
+ */
99
+ export function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date | string | null | undefined>, showHidden = false): InboxEntry[] {
100
+ const out: InboxEntry[] = []
101
+ for (const entry of entries) {
102
+ const at = hiddenAt.get(entry.id ?? '')
103
+ /* 숨긴 시각보다 **뒤에** 활동이 있으면 숨김은 풀린 것으로 본다. */
104
+ const hidden = !!at && time(entry.lastMessageAt) <= time(at)
105
+ if (hidden && !showHidden) continue
106
+ out.push(hidden ? { ...entry, hidden: true } : entry)
107
+ }
108
+ return out
109
+ }
110
+
111
+ /**
112
+ * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.
113
+ *
114
+ * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은
115
+ * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),
116
+ * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로
117
+ * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.
118
+ * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).
119
+ */
120
+ export function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {
121
+ const q = (search ?? '').trim().toLowerCase()
122
+ if (!q) return entries
123
+ return entries.filter(e =>
124
+ `${e.name ?? ''}\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q)
125
+ )
126
+ }
127
+
128
+ /** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
129
+ export const INBOX_SCAN_CAP = 500
130
+
86
131
  /** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */
87
132
  export function countUnreadSessions(entries: InboxEntry[]): number {
88
133
  return entries.filter(e => e.unread).length
@@ -93,6 +93,20 @@ export class ChatSessionParticipant {
93
93
  @Field({ nullable: true, description: 'Point up to which this participant has read the session. Distinct from lastSeenAt (presence): a window can be open without being read, and the read point survives disconnection. Null means nothing read yet.' })
94
94
  lastReadAt?: Date
95
95
 
96
+ /**
97
+ * 이 사용자가 **자기 목록에서 숨긴 시각**. 숨김은 **나에게만** 적용된다 — 남의 목록·대화 내용은 그대로다.
98
+ *
99
+ * 왜 삭제와 나누는가: 협의는 여럿의 기록이므로 지우는 것은 **만든 사람**만 할 수 있다(되돌릴 수 없다).
100
+ * 그 권한이 없는 사람에게도 "내 목록을 정리할" 방법은 있어야 하고, 그것이 숨김이다.
101
+ *
102
+ * 숨긴 **뒤에 새 활동이 생기면 다시 나타난다** — 아직 참여자인 대화에서 조용히 잘려 나가는 것이
103
+ * 목록이 한 줄 늘어나는 것보다 나쁘다. 완전히 빠지려면 대화에서 나가야 한다(별개 기능).
104
+ * nullable — 값이 없으면 숨기지 않은 상태.
105
+ */
106
+ @Column({ nullable: true })
107
+ @Field({ nullable: true, description: 'When this user hid the session from their own list. Per-user only; the session and other participants are unaffected. Activity newer than this timestamp makes it reappear.' })
108
+ hiddenAt?: Date
109
+
96
110
  @ManyToOne(type => User)
97
111
  @Field(type => User, { nullable: true, description: 'User who created this participant record (typically session owner who invited).' })
98
112
  creator?: User