@things-factory/board-ai 10.0.0-beta.95 → 10.0.0-y.0
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 +326 -25
- package/client/components/chat-echo-dedup.test.ts +116 -0
- package/client/components/chat-echo-dedup.ts +89 -0
- package/dist-client/client/components/board-ai-chat.d.ts +36 -0
- package/dist-client/client/components/board-ai-chat.js +334 -44
- package/dist-client/client/components/board-ai-chat.js.map +1 -1
- package/dist-client/client/components/chat-echo-dedup.d.ts +45 -0
- package/dist-client/client/components/chat-echo-dedup.js +58 -0
- package/dist-client/client/components/chat-echo-dedup.js.map +1 -0
- package/dist-client/client/components/chat-echo-dedup.test.d.ts +1 -0
- package/dist-client/client/components/chat-echo-dedup.test.js +101 -0
- package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/board-ai-resolver.d.ts +8 -0
- package/dist-server/service/board-ai-resolver.js +70 -0
- package/dist-server/service/board-ai-resolver.js.map +1 -1
- package/dist-server/service/chat-message/chat-message-broadcast.d.ts +13 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js +13 -0
- package/dist-server/service/chat-message/chat-message-broadcast.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-publish.d.ts +6 -0
- package/dist-server/service/chat-message/chat-message-publish.js +27 -0
- package/dist-server/service/chat-message/chat-message-publish.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-subscription.d.ts +8 -0
- package/dist-server/service/chat-message/chat-message-subscription.js +53 -0
- package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -0
- package/dist-server/service/chat-message/index.d.ts +2 -0
- package/dist-server/service/chat-message/index.js +6 -1
- package/dist-server/service/chat-message/index.js.map +1 -1
- package/dist-server/service/chat-session-participant/index.d.ts +1 -0
- package/dist-server/service/chat-session-participant/index.js +4 -1
- package/dist-server/service/chat-session-participant/index.js.map +1 -1
- package/dist-server/service/chat-session-participant/invite-notification.d.ts +27 -0
- package/dist-server/service/chat-session-participant/invite-notification.js +30 -0
- package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -0
- package/dist-server/service/chat-session-participant/presence.d.ts +4 -0
- package/dist-server/service/chat-session-participant/presence.js +22 -0
- package/dist-server/service/chat-session-participant/presence.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +1 -0
- package/dist-server/service/chat-session-resolver.js +31 -1
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/index.d.ts +3 -1
- package/dist-server/service/index.js +3 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/mention-marker.d.ts +11 -0
- package/dist-server/service/mention-marker.js +41 -0
- package/dist-server/service/mention-marker.js.map +1 -1
- package/dist-server/service/patch-entry/board-patch-broadcast.d.ts +13 -0
- package/dist-server/service/patch-entry/board-patch-broadcast.js +13 -0
- package/dist-server/service/patch-entry/board-patch-broadcast.js.map +1 -0
- package/dist-server/service/patch-entry/board-patch-subscription.d.ts +12 -0
- package/dist-server/service/patch-entry/board-patch-subscription.js +63 -0
- package/dist-server/service/patch-entry/board-patch-subscription.js.map +1 -0
- package/dist-server/service/patch-entry/index.d.ts +1 -0
- package/dist-server/service/patch-entry/index.js +6 -1
- package/dist-server/service/patch-entry/index.js.map +1 -1
- package/dist-server/service/subscribe-auth.d.ts +14 -0
- package/dist-server/service/subscribe-auth.js +33 -0
- package/dist-server/service/subscribe-auth.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/assistant.test.ts +5 -1
- package/server/service/board-ai-resolver.ts +87 -1
- package/server/service/chat-message/chat-message-broadcast.integration.test.ts +73 -0
- package/server/service/chat-message/chat-message-broadcast.test.ts +40 -0
- package/server/service/chat-message/chat-message-broadcast.ts +27 -0
- package/server/service/chat-message/chat-message-publish.ts +22 -0
- package/server/service/chat-message/chat-message-subscription.ts +41 -0
- package/server/service/chat-message/index.ts +7 -0
- package/server/service/chat-session-participant/index.ts +1 -0
- package/server/service/chat-session-participant/invite-notification.test.ts +30 -0
- package/server/service/chat-session-participant/invite-notification.ts +48 -0
- package/server/service/chat-session-participant/presence.test.ts +39 -0
- package/server/service/chat-session-participant/presence.ts +21 -0
- package/server/service/chat-session-resolver.ts +25 -1
- package/server/service/index.ts +3 -1
- package/server/service/mention-marker.extract.test.ts +61 -0
- package/server/service/mention-marker.ts +42 -0
- package/server/service/patch-entry/board-patch-broadcast.ts +24 -0
- package/server/service/patch-entry/board-patch-subscription.test.ts +40 -0
- package/server/service/patch-entry/board-patch-subscription.ts +50 -0
- package/server/service/patch-entry/index.ts +7 -0
- package/server/service/styling/tier1-tools.test.ts +2 -1
- package/server/service/subscribe-auth.test.ts +57 -0
- package/server/service/subscribe-auth.ts +34 -0
- package/translations/en.json +1 -0
- package/translations/ko.json +1 -0
|
@@ -19,6 +19,7 @@ import { LitElement, css, html, nothing } from 'lit'
|
|
|
19
19
|
import { customElement, property, state } from 'lit/decorators.js'
|
|
20
20
|
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
|
|
21
21
|
import { client } from '@operato/graphql'
|
|
22
|
+
import { auth } from '@things-factory/auth-base/dist-client/index.js'
|
|
22
23
|
import { i18next, localize } from '@operato/i18n'
|
|
23
24
|
import { ScrollbarStyles } from '@operato/styles'
|
|
24
25
|
import gql from 'graphql-tag'
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
stripMentionRefids
|
|
31
32
|
} from './markdown.js'
|
|
32
33
|
import { buildChatMutationInput } from './chat-input-builder.js'
|
|
34
|
+
import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js'
|
|
33
35
|
import './mention-popup.js'
|
|
34
36
|
import {
|
|
35
37
|
buildMentionCandidates,
|
|
@@ -48,6 +50,13 @@ import { DEFAULT_SLASH_TEMPLATES } from './default-slash-templates.js'
|
|
|
48
50
|
|
|
49
51
|
interface ChatLine {
|
|
50
52
|
role: 'user' | 'assistant' | 'system'
|
|
53
|
+
/** 서버 ChatMessage.id — 실시간 방송 에코 dedup 의 키 (있을 때만). */
|
|
54
|
+
id?: string
|
|
55
|
+
/** 클라 임시 핸들 — 영속 id 부여 전 optimistic 라인을 index 비의존으로 식별/reconcile. */
|
|
56
|
+
_localId?: string
|
|
57
|
+
/** 보낸 사용자 id/name — 그룹챗 발신자 표시용 (다른 참여자 메시지 구분). */
|
|
58
|
+
senderId?: string
|
|
59
|
+
senderName?: string
|
|
51
60
|
/** 메시지 본문. `#token{refid:N}` inline marker 가 포함될 수 있음 (영속된 메시지 / 사용자
|
|
52
61
|
* popup pick / AI resolved mention). 렌더링 시 추출해서 `<span data-refid="N">` 으로 변환. */
|
|
53
62
|
content: string
|
|
@@ -87,10 +96,65 @@ const CHAT_MESSAGES_QUERY = gql`
|
|
|
87
96
|
relatedPatchId
|
|
88
97
|
toolUsagesJson
|
|
89
98
|
createdAt
|
|
99
|
+
creator {
|
|
100
|
+
id
|
|
101
|
+
name
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
`
|
|
106
|
+
|
|
107
|
+
// C4 그룹챗 — 같은 세션의 메시지(사용자/AI/system) 실시간 방송 구독.
|
|
108
|
+
// 자기 발신 에코는 message.id 로 dedup (mutation 응답으로 이미 렌더).
|
|
109
|
+
const CHAT_MESSAGE_SUBSCRIPTION = gql`
|
|
110
|
+
subscription ChatMessageActivity($sessionId: String!) {
|
|
111
|
+
chatMessageActivity(sessionId: $sessionId) {
|
|
112
|
+
id
|
|
113
|
+
role
|
|
114
|
+
content
|
|
115
|
+
relatedPatchId
|
|
116
|
+
toolUsagesJson
|
|
117
|
+
createdAt
|
|
118
|
+
creator {
|
|
119
|
+
id
|
|
120
|
+
name
|
|
121
|
+
}
|
|
90
122
|
}
|
|
91
123
|
}
|
|
92
124
|
`
|
|
93
125
|
|
|
126
|
+
// C4 presence/roster — 세션 참여자 목록 + 마지막 접속(online 판정용).
|
|
127
|
+
const CHAT_PARTICIPANTS_QUERY = gql`
|
|
128
|
+
query ChatSessionParticipants($sessionId: String!) {
|
|
129
|
+
chatSessionParticipants(sessionId: $sessionId) {
|
|
130
|
+
id
|
|
131
|
+
role
|
|
132
|
+
lastSeenAt
|
|
133
|
+
user {
|
|
134
|
+
id
|
|
135
|
+
name
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
`
|
|
140
|
+
const TOUCH_PRESENCE_MUTATION = gql`
|
|
141
|
+
mutation TouchPresence($sessionId: String!) {
|
|
142
|
+
touchPresence(sessionId: $sessionId)
|
|
143
|
+
}
|
|
144
|
+
`
|
|
145
|
+
// heartbeat 주기(ms) — 서버 PRESENCE_TTL_MS(75s)의 ~1/2.5. 로컬 online 판정도 동일 TTL.
|
|
146
|
+
const PRESENCE_HEARTBEAT_MS = 30_000
|
|
147
|
+
const PRESENCE_TTL_MS = 75_000
|
|
148
|
+
|
|
149
|
+
/** 이름 → roster chip 이니셜 (한글 1자 / 영문 최대 2자). */
|
|
150
|
+
function initials(name: string): string {
|
|
151
|
+
const n = (name || '?').trim()
|
|
152
|
+
if (!n) return '?'
|
|
153
|
+
const parts = n.split(/\s+/)
|
|
154
|
+
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
|
|
155
|
+
return /[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(0, 1)
|
|
156
|
+
}
|
|
157
|
+
|
|
94
158
|
@customElement('ox-board-ai-chat')
|
|
95
159
|
export class OxBoardAIChat extends LitElement {
|
|
96
160
|
/** 영속 세션 id. 없으면 ad-hoc (메시지 영속 안 됨). */
|
|
@@ -221,6 +285,28 @@ export class OxBoardAIChat extends LitElement {
|
|
|
221
285
|
@state()
|
|
222
286
|
private errorMessage?: string
|
|
223
287
|
|
|
288
|
+
/** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
|
|
289
|
+
@state()
|
|
290
|
+
private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string } }> = []
|
|
291
|
+
|
|
292
|
+
/** roster online 재평가 트리거 — heartbeat tick 마다 갱신(시간 경과 반영). */
|
|
293
|
+
@state()
|
|
294
|
+
private _presenceTick = 0
|
|
295
|
+
|
|
296
|
+
private _heartbeatTimer?: ReturnType<typeof setInterval>
|
|
297
|
+
|
|
298
|
+
/** C4 그룹챗 — 메시지 실시간 방송 구독 핸들 + 에코 dedup. */
|
|
299
|
+
private _messageSub?: { unsubscribe: () => void }
|
|
300
|
+
/** 이미 transcript 에 반영된 서버 메시지 id 집합 — 자기 에코·중복 방송 차단. */
|
|
301
|
+
private _seenMessageIds = new Set<string>()
|
|
302
|
+
/** optimistic 라인 임시 핸들 시퀀스 (index 비의존 reconcile 용). */
|
|
303
|
+
private _localSeq = 0
|
|
304
|
+
|
|
305
|
+
/** 현재 로그인 사용자 id — 그룹챗에서 내 메시지 vs 타인 메시지 구분 (발신자 표시). */
|
|
306
|
+
private get _myUserId(): string | undefined {
|
|
307
|
+
return (auth.credential as any)?.id
|
|
308
|
+
}
|
|
309
|
+
|
|
224
310
|
/** Mention popup — `#` (컴포넌트) / `@` (카탈로그) / `/` (slash) 트리거 시 활성.
|
|
225
311
|
* 단일 popup 재사용 — mentionTrigger 로 활성 trigger 추적. */
|
|
226
312
|
@state()
|
|
@@ -307,6 +393,50 @@ export class OxBoardAIChat extends LitElement {
|
|
|
307
393
|
background: var(--md-sys-color-surface, #ffffff);
|
|
308
394
|
}
|
|
309
395
|
|
|
396
|
+
/* ── C4 Roster (participants + presence) ───────────── */
|
|
397
|
+
.roster {
|
|
398
|
+
display: flex;
|
|
399
|
+
align-items: center;
|
|
400
|
+
flex-wrap: wrap;
|
|
401
|
+
gap: 4px;
|
|
402
|
+
padding: 4px 8px;
|
|
403
|
+
border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
|
|
404
|
+
}
|
|
405
|
+
.roster-count {
|
|
406
|
+
display: inline-flex;
|
|
407
|
+
align-items: center;
|
|
408
|
+
gap: 2px;
|
|
409
|
+
font-size: 10px;
|
|
410
|
+
opacity: 0.7;
|
|
411
|
+
margin-right: 2px;
|
|
412
|
+
}
|
|
413
|
+
.roster-count md-icon {
|
|
414
|
+
font-size: 13px;
|
|
415
|
+
width: 13px;
|
|
416
|
+
height: 13px;
|
|
417
|
+
}
|
|
418
|
+
.roster-chip {
|
|
419
|
+
display: inline-flex;
|
|
420
|
+
align-items: center;
|
|
421
|
+
gap: 3px;
|
|
422
|
+
padding: 1px 6px 1px 4px;
|
|
423
|
+
border-radius: 9px;
|
|
424
|
+
font-size: 10px;
|
|
425
|
+
background: var(--md-sys-color-surface-variant, #f1f5f9);
|
|
426
|
+
}
|
|
427
|
+
.roster-chip.offline {
|
|
428
|
+
opacity: 0.45;
|
|
429
|
+
}
|
|
430
|
+
.roster-dot {
|
|
431
|
+
width: 6px;
|
|
432
|
+
height: 6px;
|
|
433
|
+
border-radius: 50%;
|
|
434
|
+
background: #cbd5e1;
|
|
435
|
+
}
|
|
436
|
+
.roster-chip.online .roster-dot {
|
|
437
|
+
background: #22c55e;
|
|
438
|
+
}
|
|
439
|
+
|
|
310
440
|
/* ── Session tabs ─────────────────────────────────── */
|
|
311
441
|
.session-tabs {
|
|
312
442
|
display: flex;
|
|
@@ -616,6 +746,24 @@ export class OxBoardAIChat extends LitElement {
|
|
|
616
746
|
/* margin-top 0 — .messages gap 으로만 spacing */
|
|
617
747
|
}
|
|
618
748
|
|
|
749
|
+
/* 그룹챗 — 다른 참여자의 메시지. 내 메시지(우측 primary)와 구분: 좌측 정렬 + 보조 색. */
|
|
750
|
+
.msg-wrap.user.other {
|
|
751
|
+
align-self: flex-start;
|
|
752
|
+
align-items: flex-start;
|
|
753
|
+
}
|
|
754
|
+
.msg.user.other {
|
|
755
|
+
align-self: flex-start;
|
|
756
|
+
background: var(--md-sys-color-secondary-container, #e2e8f0);
|
|
757
|
+
color: var(--md-sys-color-on-secondary-container, #0f172a);
|
|
758
|
+
border-radius: 12px 12px 12px 3px;
|
|
759
|
+
}
|
|
760
|
+
/* 발신자 이름 라벨 — AI 라벨(대문자)과 달리 사람 이름은 그대로. */
|
|
761
|
+
.msg-label.sender {
|
|
762
|
+
text-transform: none;
|
|
763
|
+
letter-spacing: 0;
|
|
764
|
+
color: var(--md-sys-color-primary, #475569);
|
|
765
|
+
}
|
|
766
|
+
|
|
619
767
|
.msg.assistant {
|
|
620
768
|
align-self: flex-start;
|
|
621
769
|
background: var(--md-sys-color-surface-container, #f8fafc);
|
|
@@ -1293,8 +1441,15 @@ export class OxBoardAIChat extends LitElement {
|
|
|
1293
1441
|
this.cachedUserCandidates = []
|
|
1294
1442
|
this.errorMessage = undefined
|
|
1295
1443
|
this.input = ''
|
|
1444
|
+
this.participants = []
|
|
1445
|
+
this._seenMessageIds.clear() // 세션 전환 — 에코 dedup 컨텍스트 리셋
|
|
1296
1446
|
if (this.sessionId) {
|
|
1297
1447
|
this.loadHistory()
|
|
1448
|
+
this._startPresence() // C4: roster 로드 + heartbeat 시작
|
|
1449
|
+
this._startMessageSubscription() // C4: 메시지 실시간 방송 구독
|
|
1450
|
+
} else {
|
|
1451
|
+
this._stopPresence()
|
|
1452
|
+
this._stopMessageSubscription()
|
|
1298
1453
|
}
|
|
1299
1454
|
}
|
|
1300
1455
|
// 자동 스크롤
|
|
@@ -1302,6 +1457,119 @@ export class OxBoardAIChat extends LitElement {
|
|
|
1302
1457
|
if (ms) ms.scrollTop = ms.scrollHeight
|
|
1303
1458
|
}
|
|
1304
1459
|
|
|
1460
|
+
disconnectedCallback() {
|
|
1461
|
+
this._stopPresence()
|
|
1462
|
+
this._stopMessageSubscription()
|
|
1463
|
+
super.disconnectedCallback()
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// ── C4 presence/roster ─────────────────────────────────────────────────────
|
|
1467
|
+
private _startPresence() {
|
|
1468
|
+
this._stopPresence()
|
|
1469
|
+
this._loadParticipants()
|
|
1470
|
+
this._touchPresence() // 즉시 1회 — 내 online 즉시 반영
|
|
1471
|
+
this._heartbeatTimer = setInterval(() => {
|
|
1472
|
+
this._touchPresence()
|
|
1473
|
+
this._loadParticipants()
|
|
1474
|
+
this._presenceTick++ // online 판정 재평가(시간 경과)
|
|
1475
|
+
}, PRESENCE_HEARTBEAT_MS)
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
private _stopPresence() {
|
|
1479
|
+
if (this._heartbeatTimer) {
|
|
1480
|
+
clearInterval(this._heartbeatTimer)
|
|
1481
|
+
this._heartbeatTimer = undefined
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
// ── C4 그룹챗 — 메시지 실시간 방송 구독 ─────────────────────────────────────
|
|
1486
|
+
private _startMessageSubscription() {
|
|
1487
|
+
this._stopMessageSubscription()
|
|
1488
|
+
if (!this.sessionId) return
|
|
1489
|
+
try {
|
|
1490
|
+
this._messageSub = client
|
|
1491
|
+
.subscribe({ query: CHAT_MESSAGE_SUBSCRIPTION, variables: { sessionId: this.sessionId } })
|
|
1492
|
+
.subscribe({
|
|
1493
|
+
next: ({ data }: any) => this._onRemoteMessage(data?.chatMessageActivity),
|
|
1494
|
+
error: () => {
|
|
1495
|
+
/* 구독 에러는 채팅 본 기능 무영향 — 히스토리 쿼리로 폴백 */
|
|
1496
|
+
}
|
|
1497
|
+
})
|
|
1498
|
+
} catch {
|
|
1499
|
+
/* 구독 미지원 환경 — 조용히 스킵 */
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
private _stopMessageSubscription() {
|
|
1504
|
+
if (this._messageSub) {
|
|
1505
|
+
try {
|
|
1506
|
+
this._messageSub.unsubscribe()
|
|
1507
|
+
} catch {
|
|
1508
|
+
/* 이미 닫힘 */
|
|
1509
|
+
}
|
|
1510
|
+
this._messageSub = undefined
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/** 서버 ChatMessage → ChatLine. */
|
|
1515
|
+
private _msgToLine(m: any): ChatLine {
|
|
1516
|
+
return remoteMessageToLine(m) as ChatLine
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* 원격(방송) 메시지 수신 — 에코·중복은 id 로 차단, 신규는 transcript 에 append.
|
|
1521
|
+
* 자기 발신 메시지의 id 는 전송 reconcile 에서 이미 _seenMessageIds 에 등록됨.
|
|
1522
|
+
*/
|
|
1523
|
+
private _onRemoteMessage(m: any) {
|
|
1524
|
+
const next = receiveRemoteMessage(this.lines as any, this._seenMessageIds, m)
|
|
1525
|
+
if (!next) return
|
|
1526
|
+
this._seenMessageIds = next.seen
|
|
1527
|
+
this.lines = next.lines as ChatLine[]
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* 전송한 optimistic 라인을 서버 id 로 reconcile (index 비의존 — _localId 로 탐색).
|
|
1532
|
+
* 에코가 응답보다 먼저 도착해 이미 append 됐으면 optimistic 중복 라인을 제거한다.
|
|
1533
|
+
*/
|
|
1534
|
+
private _reconcileSentLine(localId: string, serverId: string | undefined, patch: Partial<ChatLine>) {
|
|
1535
|
+
const next = reconcileSentLine(this.lines as any, this._seenMessageIds, localId, serverId, patch as any)
|
|
1536
|
+
this._seenMessageIds = next.seen
|
|
1537
|
+
this.lines = next.lines as ChatLine[]
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
private async _loadParticipants() {
|
|
1541
|
+
if (!this.sessionId) return
|
|
1542
|
+
try {
|
|
1543
|
+
const result = await client.query({
|
|
1544
|
+
query: CHAT_PARTICIPANTS_QUERY,
|
|
1545
|
+
variables: { sessionId: this.sessionId },
|
|
1546
|
+
fetchPolicy: 'network-only'
|
|
1547
|
+
})
|
|
1548
|
+
this.participants = result.data?.chatSessionParticipants ?? []
|
|
1549
|
+
} catch {
|
|
1550
|
+
/* roster 로드 실패는 채팅 본 기능 무영향 — 조용히 무시 */
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
private async _touchPresence() {
|
|
1555
|
+
if (!this.sessionId) return
|
|
1556
|
+
try {
|
|
1557
|
+
await client.mutate({ mutation: TOUCH_PRESENCE_MUTATION, variables: { sessionId: this.sessionId } })
|
|
1558
|
+
} catch {
|
|
1559
|
+
/* heartbeat 실패 무시 */
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** lastSeenAt 이 TTL 이내면 online. (_presenceTick 의존으로 주기 재평가.) */
|
|
1564
|
+
private _isOnline(lastSeenAt?: string): boolean {
|
|
1565
|
+
void this._presenceTick
|
|
1566
|
+
if (!lastSeenAt) return false
|
|
1567
|
+
const t = new Date(lastSeenAt).getTime()
|
|
1568
|
+
if (!Number.isFinite(t)) return false
|
|
1569
|
+
const age = Date.now() - t
|
|
1570
|
+
return age >= 0 ? age <= PRESENCE_TTL_MS : true
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1305
1573
|
private async loadHistory() {
|
|
1306
1574
|
if (!this.sessionId) return
|
|
1307
1575
|
try {
|
|
@@ -1311,12 +1579,9 @@ export class OxBoardAIChat extends LitElement {
|
|
|
1311
1579
|
fetchPolicy: 'network-only'
|
|
1312
1580
|
})
|
|
1313
1581
|
const msgs = result.data?.chatMessages ?? []
|
|
1314
|
-
this.lines = msgs.map((m: any) => (
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
patchId: m.relatedPatchId ?? undefined,
|
|
1318
|
-
toolUsages: Array.isArray(m.toolUsagesJson) ? m.toolUsagesJson : undefined
|
|
1319
|
-
}))
|
|
1582
|
+
this.lines = msgs.map((m: any) => this._msgToLine(m))
|
|
1583
|
+
// 로드된 메시지 id 를 seen 에 등록 — 이후 방송 에코 dedup 기준.
|
|
1584
|
+
this._seenMessageIds = new Set<string>(msgs.map((m: any) => m.id).filter(Boolean))
|
|
1320
1585
|
} catch (e: any) {
|
|
1321
1586
|
this.errorMessage = `${i18next.t('board-ai.text.failed-to-load-history')}: ${e.message ?? e}`
|
|
1322
1587
|
}
|
|
@@ -1329,20 +1594,29 @@ export class OxBoardAIChat extends LitElement {
|
|
|
1329
1594
|
? html`<div class="toast show">${this.toastMessage}</div>`
|
|
1330
1595
|
: nothing}
|
|
1331
1596
|
${this.renderSessionTabs()}
|
|
1597
|
+
${this.renderRoster()}
|
|
1332
1598
|
<div class="messages" @click=${this.onBubbleClick}>
|
|
1333
1599
|
${!hasMessages ? this.renderEmpty() : nothing}
|
|
1334
|
-
${
|
|
1600
|
+
${(() => {
|
|
1601
|
+
const myId = this._myUserId
|
|
1602
|
+
return this.lines.map((line, idx) => {
|
|
1335
1603
|
const prev = this.lines[idx - 1]
|
|
1336
1604
|
const showLabel = line.role === 'assistant' && (!prev || prev.role !== 'assistant')
|
|
1605
|
+
// 그룹챗: 다른 참여자의 user 메시지는 좌측 + 보낸이 이름 표시(연속 동일 발신자는 1회만).
|
|
1606
|
+
const isOther = line.role === 'user' && !!line.senderId && line.senderId !== myId
|
|
1607
|
+
const prevSameSender = !!prev && prev.role === 'user' && prev.senderId === line.senderId
|
|
1608
|
+
const showSenderLabel = isOther && !prevSameSender
|
|
1337
1609
|
const content = (line.content ?? '').trim()
|
|
1338
1610
|
const isMarkdown = line.role !== 'system' && content.length > 0
|
|
1339
1611
|
const showActions = line.role !== 'system' && !line.pending && content.length > 0
|
|
1340
1612
|
return html`
|
|
1341
1613
|
${showLabel
|
|
1342
1614
|
? html`<div class="msg-label">${i18next.t('board-ai.label.ai')}</div>`
|
|
1343
|
-
:
|
|
1344
|
-
|
|
1345
|
-
|
|
1615
|
+
: showSenderLabel
|
|
1616
|
+
? html`<div class="msg-label sender">${line.senderName || i18next.t('board-ai.label.participant')}</div>`
|
|
1617
|
+
: nothing}
|
|
1618
|
+
<div class="msg-wrap ${line.role} ${isOther ? 'other' : ''}">
|
|
1619
|
+
<div class="msg ${line.role} ${isOther ? 'other' : ''} ${line.pending ? 'pending' : ''}">
|
|
1346
1620
|
${line.pending
|
|
1347
1621
|
? html`<span class="typing-dots"><span></span><span></span><span></span></span>`
|
|
1348
1622
|
: isMarkdown
|
|
@@ -1376,7 +1650,8 @@ export class OxBoardAIChat extends LitElement {
|
|
|
1376
1650
|
${showActions ? this.renderMessageActions(idx, line, content) : nothing}
|
|
1377
1651
|
</div>
|
|
1378
1652
|
`
|
|
1379
|
-
|
|
1653
|
+
})
|
|
1654
|
+
})()}
|
|
1380
1655
|
</div>
|
|
1381
1656
|
${this.errorMessage
|
|
1382
1657
|
? html`
|
|
@@ -1828,6 +2103,28 @@ export class OxBoardAIChat extends LitElement {
|
|
|
1828
2103
|
`
|
|
1829
2104
|
}
|
|
1830
2105
|
|
|
2106
|
+
/** C4 roster — 세션 참여자 + online 표시. 참여자 2명 미만이면 숨김(혼자면 의미 없음). */
|
|
2107
|
+
private renderRoster() {
|
|
2108
|
+
if (!this.sessionId || this.participants.length < 2) return nothing
|
|
2109
|
+
const online = this.participants.filter(p => this._isOnline(p.lastSeenAt)).length
|
|
2110
|
+
return html`
|
|
2111
|
+
<div class="roster" role="group" aria-label="participants">
|
|
2112
|
+
<span class="roster-count" title=${`${online} online / ${this.participants.length} total`}>
|
|
2113
|
+
<md-icon>group</md-icon>${online}/${this.participants.length}
|
|
2114
|
+
</span>
|
|
2115
|
+
${this.participants.map(p => {
|
|
2116
|
+
const name = p.user?.name || p.user?.id || '?'
|
|
2117
|
+
const on = this._isOnline(p.lastSeenAt)
|
|
2118
|
+
return html`<span
|
|
2119
|
+
class="roster-chip ${on ? 'online' : 'offline'}"
|
|
2120
|
+
title=${`${name} · ${p.role}${on ? ' · online' : ''}`}>
|
|
2121
|
+
<span class="roster-dot"></span>${initials(name)}
|
|
2122
|
+
</span>`
|
|
2123
|
+
})}
|
|
2124
|
+
</div>
|
|
2125
|
+
`
|
|
2126
|
+
}
|
|
2127
|
+
|
|
1831
2128
|
private _onSessionTabClick(sessionId: string) {
|
|
1832
2129
|
if (sessionId === this.sessionId) return
|
|
1833
2130
|
this.dispatchEvent(
|
|
@@ -2160,23 +2457,26 @@ export class OxBoardAIChat extends LitElement {
|
|
|
2160
2457
|
let enrichedContent = injectMentionRefids(text, mentions)
|
|
2161
2458
|
enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
|
|
2162
2459
|
|
|
2460
|
+
// optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
|
|
2461
|
+
const userLocalId = `local-${++this._localSeq}-u`
|
|
2462
|
+
const asstLocalId = `local-${++this._localSeq}-a`
|
|
2463
|
+
|
|
2163
2464
|
this.lines = [
|
|
2164
2465
|
...this.lines,
|
|
2165
|
-
{ role: 'user', content: enrichedContent }
|
|
2466
|
+
{ role: 'user', content: enrichedContent, _localId: userLocalId }
|
|
2166
2467
|
]
|
|
2167
2468
|
this.input = ''
|
|
2168
2469
|
this.busy = true
|
|
2169
2470
|
this.errorMessage = undefined
|
|
2170
2471
|
|
|
2171
2472
|
// 가짜 pending 응답 표시 (UX 부드럽게)
|
|
2172
|
-
|
|
2173
|
-
this.lines = [...this.lines, { role: 'assistant', content: '', pending: true }]
|
|
2473
|
+
this.lines = [...this.lines, { role: 'assistant', content: '', pending: true, _localId: asstLocalId }]
|
|
2174
2474
|
|
|
2175
2475
|
try {
|
|
2176
|
-
// LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류)
|
|
2476
|
+
// LLM 으로 보낼 history — user/assistant 만 (system 은 백엔드가 자동 합류).
|
|
2477
|
+
// pending placeholder 와 system 은 제외 (index 비의존 — 방송 append 와 무관).
|
|
2177
2478
|
const history = this.lines
|
|
2178
|
-
.
|
|
2179
|
-
.filter(l => l.role !== 'system')
|
|
2479
|
+
.filter(l => !l.pending && l._localId !== asstLocalId && l.role !== 'system')
|
|
2180
2480
|
.map(l => ({ role: l.role, content: l.content }))
|
|
2181
2481
|
|
|
2182
2482
|
// 라이브 보드 우선 — 호스트의 캔버스가 사용자 수작업 편집을 들고 있을 수 있음.
|
|
@@ -2208,17 +2508,18 @@ export class OxBoardAIChat extends LitElement {
|
|
|
2208
2508
|
this.pickedMentions.clear()
|
|
2209
2509
|
this.pickedUserMentions.clear()
|
|
2210
2510
|
|
|
2211
|
-
//
|
|
2212
|
-
|
|
2213
|
-
|
|
2511
|
+
// optimistic 라인을 서버 id 로 reconcile (방송 에코와 중복 방지, index 비의존).
|
|
2512
|
+
// 사용자 라인: enrich 된 본문 유지, id 만 부여. AI 라인: pending → 실제 응답.
|
|
2513
|
+
this._reconcileSentLine(userLocalId, out.userMessageId, {})
|
|
2514
|
+
this._reconcileSentLine(asstLocalId, out.assistantMessageId, {
|
|
2214
2515
|
role: 'assistant',
|
|
2215
2516
|
content: out.reply,
|
|
2216
2517
|
patchSummary: out.patch?.summary,
|
|
2217
2518
|
followUp: out.followUp || undefined,
|
|
2218
2519
|
patchId: out.patchId || undefined,
|
|
2219
|
-
toolUsages: Array.isArray(out.toolUsages) ? out.toolUsages : undefined
|
|
2220
|
-
|
|
2221
|
-
|
|
2520
|
+
toolUsages: Array.isArray(out.toolUsages) ? out.toolUsages : undefined,
|
|
2521
|
+
pending: false
|
|
2522
|
+
})
|
|
2222
2523
|
|
|
2223
2524
|
// 호스트로 patch 이벤트 전파
|
|
2224
2525
|
if (out.patch) {
|
|
@@ -2256,8 +2557,8 @@ export class OxBoardAIChat extends LitElement {
|
|
|
2256
2557
|
)
|
|
2257
2558
|
}
|
|
2258
2559
|
} catch (e: any) {
|
|
2259
|
-
// pending
|
|
2260
|
-
this.lines = this.lines.
|
|
2560
|
+
// pending(AI) placeholder 만 제거 — 사용자 라인은 보존(재시도). index 비의존.
|
|
2561
|
+
this.lines = this.lines.filter(l => l._localId !== asstLocalId)
|
|
2261
2562
|
this.errorMessage = e?.message ?? String(e)
|
|
2262
2563
|
this.lastFailedInput = text
|
|
2263
2564
|
} finally {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine, type DedupLine } from './chat-echo-dedup'
|
|
2
|
+
|
|
3
|
+
const msg = (id: string, role = 'user', content = 'hi') => ({ id, role, content })
|
|
4
|
+
|
|
5
|
+
describe('remoteMessageToLine', () => {
|
|
6
|
+
it('필드 매핑 + relatedPatchId/toolUsagesJson 변환', () => {
|
|
7
|
+
const line = remoteMessageToLine({
|
|
8
|
+
id: 'm1',
|
|
9
|
+
role: 'assistant',
|
|
10
|
+
content: 'done',
|
|
11
|
+
relatedPatchId: 'p1',
|
|
12
|
+
toolUsagesJson: [{ name: 'x' }]
|
|
13
|
+
})
|
|
14
|
+
expect(line).toMatchObject({ id: 'm1', role: 'assistant', content: 'done', patchId: 'p1' })
|
|
15
|
+
expect(line.toolUsages).toEqual([{ name: 'x' }])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('toolUsagesJson 이 배열 아니면 undefined', () => {
|
|
19
|
+
expect(remoteMessageToLine({ id: 'm', role: 'user', content: 'x', toolUsagesJson: null }).toolUsages).toBeUndefined()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('creator → senderId/senderName (그룹챗 발신자 표시)', () => {
|
|
23
|
+
const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: { id: 'u9', name: 'Kim' } })
|
|
24
|
+
expect(line.senderId).toBe('u9')
|
|
25
|
+
expect(line.senderName).toBe('Kim')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('creator 없으면 sender 필드 undefined', () => {
|
|
29
|
+
const line = remoteMessageToLine({ id: 'm', role: 'user', content: 'x', creator: null })
|
|
30
|
+
expect(line.senderId).toBeUndefined()
|
|
31
|
+
expect(line.senderName).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('receiveRemoteMessage — 방송 수신/에코 차단', () => {
|
|
36
|
+
it('신규 id → append + seen 등록', () => {
|
|
37
|
+
const res = receiveRemoteMessage([], new Set(), msg('m1'))!
|
|
38
|
+
expect(res).not.toBeNull()
|
|
39
|
+
expect(res.lines).toHaveLength(1)
|
|
40
|
+
expect(res.lines[0].id).toBe('m1')
|
|
41
|
+
expect(res.seen.has('m1')).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('이미 seen 인 id → null (에코·중복 차단)', () => {
|
|
45
|
+
expect(receiveRemoteMessage([], new Set(['m1']), msg('m1'))).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('id 없는 메시지 → null', () => {
|
|
49
|
+
expect(receiveRemoteMessage([], new Set(), { role: 'user', content: 'x' } as any)).toBeNull()
|
|
50
|
+
expect(receiveRemoteMessage([], new Set(), null)).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('불변 — 원본 배열/셋 미변경', () => {
|
|
54
|
+
const lines: DedupLine[] = []
|
|
55
|
+
const seen = new Set<string>()
|
|
56
|
+
receiveRemoteMessage(lines, seen, msg('m1'))
|
|
57
|
+
expect(lines).toHaveLength(0)
|
|
58
|
+
expect(seen.size).toBe(0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('다른 참여자 메시지 두 개 순차 append', () => {
|
|
62
|
+
let lines: DedupLine[] = []
|
|
63
|
+
let seen = new Set<string>()
|
|
64
|
+
;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('a'))!)
|
|
65
|
+
;({ lines, seen } = receiveRemoteMessage(lines, seen, msg('b'))!)
|
|
66
|
+
expect(lines.map(l => l.id)).toEqual(['a', 'b'])
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('reconcileSentLine — optimistic 라인 조정', () => {
|
|
71
|
+
const optimistic = (): DedupLine[] => [{ _localId: 'L1', role: 'user', content: 'typed' }]
|
|
72
|
+
|
|
73
|
+
it('정상(에코 전): serverId 부여 + seen 등록 + _localId 해제', () => {
|
|
74
|
+
const res = reconcileSentLine(optimistic(), new Set(), 'L1', 'srv1', {})
|
|
75
|
+
expect(res.lines).toHaveLength(1)
|
|
76
|
+
expect(res.lines[0].id).toBe('srv1')
|
|
77
|
+
expect(res.lines[0]._localId).toBeUndefined()
|
|
78
|
+
expect(res.seen.has('srv1')).toBe(true)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('레이스(에코 먼저): serverId 가 이미 seen → optimistic 중복 제거', () => {
|
|
82
|
+
// 에코가 먼저 도착해 같은 메시지가 이미 append + seen 됨
|
|
83
|
+
const lines: DedupLine[] = [
|
|
84
|
+
{ id: 'srv1', role: 'user', content: 'typed#enriched' }, // 방송 에코로 들어온 것
|
|
85
|
+
{ _localId: 'L1', role: 'user', content: 'typed' } // optimistic 중복
|
|
86
|
+
]
|
|
87
|
+
const res = reconcileSentLine(lines, new Set(['srv1']), 'L1', 'srv1', {})
|
|
88
|
+
expect(res.lines).toHaveLength(1)
|
|
89
|
+
expect(res.lines[0].id).toBe('srv1')
|
|
90
|
+
expect(res.lines.some(l => l._localId === 'L1')).toBe(false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('patch 병합 (AI 라인: pending → 실제 응답)', () => {
|
|
94
|
+
const lines: DedupLine[] = [{ _localId: 'A1', role: 'assistant', content: '', pending: true }]
|
|
95
|
+
const res = reconcileSentLine(lines, new Set(), 'A1', 'srv2', {
|
|
96
|
+
content: 'reply',
|
|
97
|
+
pending: false,
|
|
98
|
+
patchId: 'p9'
|
|
99
|
+
})
|
|
100
|
+
expect(res.lines[0]).toMatchObject({ id: 'srv2', content: 'reply', pending: false, patchId: 'p9' })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('serverId 없음(ad-hoc 세션): 라인 유지 + _localId 만 해제, seen 불변', () => {
|
|
104
|
+
const seen = new Set<string>()
|
|
105
|
+
const res = reconcileSentLine(optimistic(), seen, 'L1', undefined, {})
|
|
106
|
+
expect(res.lines[0]._localId).toBeUndefined()
|
|
107
|
+
expect(res.lines[0].id).toBeUndefined()
|
|
108
|
+
expect(res.seen.size).toBe(0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('해당 _localId 없으면 변화 없음', () => {
|
|
112
|
+
const lines = optimistic()
|
|
113
|
+
const res = reconcileSentLine(lines, new Set(), 'NOPE', 'srv1', {})
|
|
114
|
+
expect(res.lines).toBe(lines)
|
|
115
|
+
})
|
|
116
|
+
})
|