@things-factory/board-ai 10.0.1 → 10.0.3
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.
- package/client/components/board-ai-chat.ts +565 -21
- package/client/components/chat-echo-dedup.test.ts +59 -3
- package/client/components/chat-echo-dedup.ts +32 -0
- package/client/components/chat-input-builder.ts +6 -0
- package/dist-client/client/components/board-ai-chat.d.ts +80 -0
- package/dist-client/client/components/board-ai-chat.js +540 -18
- package/dist-client/client/components/board-ai-chat.js.map +1 -1
- package/dist-client/client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/client/components/chat-echo-dedup.js +29 -0
- package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
- package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
- package/dist-client/client/components/chat-input-builder.d.ts +5 -0
- package/dist-client/client/components/chat-input-builder.js +1 -0
- package/dist-client/client/components/chat-input-builder.js.map +1 -1
- package/dist-client/server/service/agentic-loop.d.ts +33 -0
- package/dist-client/server/service/agentic-loop.js +80 -10
- package/dist-client/server/service/agentic-loop.js.map +1 -1
- package/dist-client/server/service/assistant.js +32 -5
- package/dist-client/server/service/assistant.js.map +1 -1
- package/dist-client/server/service/grounding.d.ts +17 -0
- package/dist-client/server/service/grounding.js +42 -0
- package/dist-client/server/service/grounding.js.map +1 -0
- package/dist-client/server/service/types.d.ts +39 -0
- package/dist-client/server/service/types.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/agentic-loop.d.ts +33 -0
- package/dist-server/service/agentic-loop.js +81 -10
- package/dist-server/service/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant.js +31 -4
- package/dist-server/service/assistant.js.map +1 -1
- package/dist-server/service/board-ai-resolver.d.ts +15 -0
- package/dist-server/service/board-ai-resolver.js +121 -2
- package/dist-server/service/board-ai-resolver.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +12 -0
- package/dist-server/service/chat-message/chat-message.js +23 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-message/fold-history.d.ts +30 -0
- package/dist-server/service/chat-message/fold-history.js +29 -0
- package/dist-server/service/chat-message/fold-history.js.map +1 -0
- package/dist-server/service/chat-message/history-summary.d.ts +43 -0
- package/dist-server/service/chat-message/history-summary.js +77 -0
- package/dist-server/service/chat-message/history-summary.js.map +1 -0
- package/dist-server/service/chat-message/llm-history.d.ts +19 -0
- package/dist-server/service/chat-message/llm-history.js +31 -1
- package/dist-server/service/chat-message/llm-history.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +8 -0
- package/dist-server/service/chat-session/chat-session.js +5 -0
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +26 -0
- package/dist-server/service/chat-session/session-inbox.js +41 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +11 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js +17 -1
- package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +44 -1
- package/dist-server/service/chat-session-resolver.js +306 -6
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/grounding.d.ts +17 -0
- package/dist-server/service/grounding.js +46 -0
- package/dist-server/service/grounding.js.map +1 -0
- package/dist-server/service/types.d.ts +39 -0
- package/dist-server/service/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/agentic-loop.test.ts +154 -0
- package/server/service/agentic-loop.ts +108 -10
- package/server/service/assistant.ts +36 -5
- package/server/service/board-ai-resolver.ts +131 -2
- package/server/service/chat-message/chat-message.ts +26 -0
- package/server/service/chat-message/fold-history.test.ts +98 -0
- package/server/service/chat-message/fold-history.ts +60 -0
- package/server/service/chat-message/history-summary.test.ts +127 -0
- package/server/service/chat-message/history-summary.ts +100 -0
- package/server/service/chat-message/llm-history.test.ts +65 -0
- package/server/service/chat-message/llm-history.ts +48 -1
- package/server/service/chat-session/chat-session.ts +11 -0
- package/server/service/chat-session/session-inbox.test.ts +69 -1
- package/server/service/chat-session/session-inbox.ts +45 -0
- package/server/service/chat-session-participant/chat-session-participant.ts +14 -0
- package/server/service/chat-session-resolver.ts +297 -5
- package/server/service/dock-contract.test.ts +305 -0
- package/server/service/grounding.test.ts +55 -0
- package/server/service/grounding.ts +53 -0
- package/server/service/types.ts +39 -0
- package/translations/en.json +16 -1
- package/translations/ja.json +16 -1
- package/translations/ko.json +15 -0
- package/translations/ms.json +16 -1
- package/translations/zh.json +16 -1
|
@@ -127,3 +127,68 @@ describe('기타', () => {
|
|
|
127
127
|
expect(out.map(m => m.content)).toEqual(['A', 'B', 'C'])
|
|
128
128
|
})
|
|
129
129
|
})
|
|
130
|
+
|
|
131
|
+
describe('이력 상한 — 협의가 길어질 때', () => {
|
|
132
|
+
const rows = (n: number) =>
|
|
133
|
+
Array.from({ length: n }, (_, i) => ({ id: `m${i}`, role: i % 2 ? 'assistant' : 'user', content: `말 ${i}` }))
|
|
134
|
+
|
|
135
|
+
it('상한 미지정이면 전부 보낸다 — 기존 동작 유지', () => {
|
|
136
|
+
expect(buildLlmHistory(rows(10))).toHaveLength(10)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('상한을 넘으면 최근 것만 남긴다 — 오래된 말보다 최근 상황이 답을 좌우한다', () => {
|
|
140
|
+
const out = buildLlmHistory(rows(10), { maxTurns: 4 })
|
|
141
|
+
/* 생략 알림 1줄 + 최근 4줄 */
|
|
142
|
+
expect(out).toHaveLength(5)
|
|
143
|
+
expect(out[out.length - 1].content).toContain('말 9')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('생략은 **반드시 알린다** — 조용히 버리면 모델이 남은 첫 줄을 대화의 시작으로 오해한다', () => {
|
|
147
|
+
const out = buildLlmHistory(rows(10), { maxTurns: 4 })
|
|
148
|
+
expect(out[0].content).toContain('6개')
|
|
149
|
+
expect(out[0].content).toContain('생략')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('상한에 걸리지 않으면 알림을 붙이지 않는다 — 없는 생략을 말하지 않는다', () => {
|
|
153
|
+
const out = buildLlmHistory(rows(3), { maxTurns: 10 })
|
|
154
|
+
expect(out).toHaveLength(3)
|
|
155
|
+
expect(out[0].content).toBe('말 0')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('접은 구간 제거가 먼저, 상한이 나중 — 두 규칙이 겹쳐도 최근 것이 남는다', () => {
|
|
159
|
+
const out = buildLlmHistory(rows(10), { truncateAfterMessageId: 'm5', maxTurns: 3 })
|
|
160
|
+
expect(out).toHaveLength(4) // 알림 + 3
|
|
161
|
+
expect(out[out.length - 1].content).toContain('말 5')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('이력 요약 접기 — 프롬프트 조립', () => {
|
|
166
|
+
const rows = (n: number) =>
|
|
167
|
+
Array.from({ length: n }, (_, i) => ({ id: `m${i}`, role: i % 2 ? 'assistant' : 'user', content: `말 ${i}` }))
|
|
168
|
+
|
|
169
|
+
it('요약이 있으면 "생략" 대신 요약을 싣는다 — 앞에서 정한 것이 없던 일이 되지 않게', () => {
|
|
170
|
+
const out = buildLlmHistory(rows(10), {
|
|
171
|
+
maxTurns: 4,
|
|
172
|
+
summary: { text: '앞에서 정한 것: 지게차 1대 추가', upToMessageId: 'm5' }
|
|
173
|
+
})
|
|
174
|
+
expect(out[0].content).toContain('지게차 1대 추가')
|
|
175
|
+
expect(out[0].content).not.toContain('생략되었습니다.')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('요약임을 밝힌다 — 모델이 원문처럼 인용하지 않게', () => {
|
|
179
|
+
const out = buildLlmHistory(rows(10), { maxTurns: 4, summary: { text: '요약본', upToMessageId: 'm5' } })
|
|
180
|
+
expect(out[0].content).toContain('원문 아님')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('요약이 밀려난 구간을 다 덮지 못하면 남은 개수를 밝힌다 — 최신이 아님을 숨기지 않는다', () => {
|
|
184
|
+
/* 밀려난 것은 m0..m5(6개), 요약은 m3 까지 → m4·m5 두 개가 미반영. */
|
|
185
|
+
const out = buildLlmHistory(rows(10), { maxTurns: 4, summary: { text: '요약본', upToMessageId: 'm3' } })
|
|
186
|
+
expect(out[0].content).toContain('2개')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('상한에 걸리지 않으면 요약을 싣지 않는다 — 필요 없는 문맥을 넣지 않는다', () => {
|
|
190
|
+
const out = buildLlmHistory(rows(3), { maxTurns: 10, summary: { text: '요약본', upToMessageId: 'm0' } })
|
|
191
|
+
expect(out).toHaveLength(3)
|
|
192
|
+
expect(out[0].content).toBe('말 0')
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
* "내 발언만 접기"는 별도 개선으로 다룬다.
|
|
36
36
|
*/
|
|
37
37
|
import type { LLMMessage } from '../types.js'
|
|
38
|
+
import { pendingForSummary, type StoredSummary } from './history-summary.js'
|
|
38
39
|
|
|
39
40
|
/** 이력 한 줄 — 저장된 ChatMessage 에서 필요한 것만(엔티티·typeorm 비의존). */
|
|
40
41
|
export interface HistoryRow {
|
|
@@ -55,12 +56,32 @@ export interface BuildLlmHistoryOptions {
|
|
|
55
56
|
keepMessageIds?: string[]
|
|
56
57
|
/** 발신자 표기 강제 — 미지정이면 사람 발신자가 둘 이상일 때만 표기. 테스트·특수 호출용. */
|
|
57
58
|
labelSenders?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* 모델에 넘길 **최근 메시지 수 상한**. 초과분(오래된 앞부분)은 버리고 생략 표시를 남긴다.
|
|
61
|
+
*
|
|
62
|
+
* 왜 필요한가: 협의는 며칠에 걸친다. 상한이 없으면 매 턴 전체 이력을 보내 프롬프트가 무한히
|
|
63
|
+
* 자라고(비용·지연·문맥 한계) 오래된 말이 최근 상황을 덮는다. 미지정이면 전부 보낸다(기존 동작).
|
|
64
|
+
*
|
|
65
|
+
* **생략은 반드시 알린다** — 조용히 버리면 모델은 남은 첫 줄을 대화의 시작으로 오해한다.
|
|
66
|
+
* 이건 상한일 뿐 요약이 아니다. 버린 내용을 요약해 실어 보내는 것은 다음 단계다(ChatSession.lastSummary).
|
|
67
|
+
*/
|
|
68
|
+
maxTurns?: number
|
|
69
|
+
/**
|
|
70
|
+
* 상한으로 밀려난 앞부분을 대신할 **요약**(세션에 저장된 것).
|
|
71
|
+
*
|
|
72
|
+
* 있으면 "N개 생략" 대신 요약을 첫 줄로 싣는다 — 며칠 이어지는 협의에서 앞에서 합의한 것이
|
|
73
|
+
* 없던 일이 되지 않게. 요약이 밀려난 구간을 **전부 덮지 못하면** 덮지 못한 개수도 함께 밝힌다
|
|
74
|
+
* (요약이 최신이 아닐 수 있다는 사실을 숨기지 않는다).
|
|
75
|
+
*/
|
|
76
|
+
summary?: StoredSummary
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
/** 사람이 아닌 기록(캔버스 직접 편집 등)의 표기. */
|
|
61
80
|
const SYSTEM_LABEL = '시스템'
|
|
62
81
|
/** 이름을 모르는 참여자의 표기 — 빈 대괄호를 남기지 않는다. */
|
|
63
82
|
const UNKNOWN_LABEL = '참여자'
|
|
83
|
+
/** 상한으로 잘라낸 앞부분을 알리는 표기 — 모델이 남은 첫 줄을 대화의 시작으로 오해하지 않게. */
|
|
84
|
+
const OMITTED_LABEL = '시스템'
|
|
64
85
|
|
|
65
86
|
/**
|
|
66
87
|
* 저장된 이력 → LLM 대화.
|
|
@@ -70,12 +91,38 @@ const UNKNOWN_LABEL = '참여자'
|
|
|
70
91
|
*/
|
|
71
92
|
export function buildLlmHistory(rows: HistoryRow[], options: BuildLlmHistoryOptions = {}): LLMMessage[] {
|
|
72
93
|
const keep = new Set(options.keepMessageIds ?? [])
|
|
73
|
-
const
|
|
94
|
+
const truncated = truncate(rows, options.truncateAfterMessageId, keep)
|
|
95
|
+
/* 상한 — 최근 것부터 남긴다. 오래된 말보다 최근 상황이 답을 좌우한다. */
|
|
96
|
+
const limit = options.maxTurns && options.maxTurns > 0 ? options.maxTurns : 0
|
|
97
|
+
const omitted = limit && truncated.length > limit ? truncated.length - limit : 0
|
|
98
|
+
const kept = omitted ? truncated.slice(-limit) : truncated
|
|
74
99
|
|
|
75
100
|
const label =
|
|
76
101
|
options.labelSenders ?? new Set(kept.filter(r => isHuman(r)).map(r => r.senderId ?? '')).size > 1
|
|
77
102
|
|
|
78
103
|
const out: LLMMessage[] = []
|
|
104
|
+
if (omitted) {
|
|
105
|
+
const dropped = truncated.slice(0, truncated.length - kept.length)
|
|
106
|
+
const summaryText = (options.summary?.text ?? '').trim()
|
|
107
|
+
if (summaryText) {
|
|
108
|
+
/* 요약이 있으면 그것을 싣는다. **요약임을 밝힌다** — 모델이 원문처럼 인용하지 않게.
|
|
109
|
+
* 요약이 밀려난 구간을 다 덮지 못하면 남은 개수도 함께 알린다(최신이 아님을 숨기지 않는다). */
|
|
110
|
+
const uncovered = pendingForSummary(dropped, options.summary).length
|
|
111
|
+
const tail = uncovered
|
|
112
|
+
? ` 그 뒤 ${uncovered}개 메시지는 요약에 아직 반영되지 않았습니다.`
|
|
113
|
+
: ''
|
|
114
|
+
out.push({
|
|
115
|
+
role: 'user',
|
|
116
|
+
content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${dropped.length}개 메시지는 길이 제한으로 아래 요약으로 대체되었습니다(원문 아님 — 요약에 없는 사실은 사용자에게 확인하세요).${tail}\n${summaryText}`
|
|
117
|
+
})
|
|
118
|
+
} else {
|
|
119
|
+
/* 요약이 아직 없으면 생략 사실만 알린다 — 개수만 밝히고 내용은 지어내지 않는다. */
|
|
120
|
+
out.push({
|
|
121
|
+
role: 'user',
|
|
122
|
+
content: `[${OMITTED_LABEL}] 이 대화의 앞부분 ${omitted}개 메시지는 길이 제한으로 생략되었습니다. 필요하면 사용자에게 다시 확인하세요.`
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
79
126
|
for (const row of kept) {
|
|
80
127
|
const content = (row.content ?? '').trim()
|
|
81
128
|
if (!content) continue // 빈 본문(pending 자리 등)은 모델에 넘길 것이 없다
|
|
@@ -146,6 +146,17 @@ export class ChatSession {
|
|
|
146
146
|
@Field({ nullable: true, description: 'Compressed summary of older messages (for token saving).' })
|
|
147
147
|
lastSummary?: string
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* `lastSummary` 가 **어디까지 덮는지** — 이 메시지까지 요약에 반영됐다.
|
|
151
|
+
*
|
|
152
|
+
* 이게 없으면 누적 요약을 할 수 없다: 매 턴 전체를 다시 요약하거나(비용) 이미 요약한 구간을
|
|
153
|
+
* 또 이어 붙인다(중복). 이력에서 이 id 를 못 찾으면(삭제·편집) 덮은 범위를 알 수 없으므로
|
|
154
|
+
* 전부 미반영으로 보고 다시 요약한다 — 빠뜨리는 쪽보다 안전하다.
|
|
155
|
+
*/
|
|
156
|
+
@Column({ nullable: true })
|
|
157
|
+
@Field({ nullable: true, description: 'Id of the last message folded into lastSummary — enables incremental summarization instead of re-reading the whole history.' })
|
|
158
|
+
summaryUpToMessageId?: string
|
|
159
|
+
|
|
149
160
|
/** 사용 모델 식별 — 'anthropic:claude-sonnet-4-6' 등 */
|
|
150
161
|
@Column({ nullable: true })
|
|
151
162
|
@Field({ nullable: true })
|
|
@@ -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
|