@things-factory/ai-assistant 10.1.125 → 10.1.129
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/assistant-chat.ts +122 -68
- package/client/components/chat-echo-dedup.ts +2 -1
- package/client/components/reply-message.test.ts +15 -0
- package/client/components/reply-message.ts +11 -0
- package/client/components/roster-identity.test.ts +18 -0
- package/client/components/roster-identity.ts +10 -0
- package/dist-client/components/assistant-chat.d.ts +7 -0
- package/dist-client/components/assistant-chat.js +127 -68
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.d.ts +1 -0
- package/dist-client/components/chat-echo-dedup.js +1 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/reply-message.d.ts +5 -0
- package/dist-client/components/reply-message.js +14 -0
- package/dist-client/components/reply-message.js.map +1 -0
- package/dist-client/components/reply-message.test.d.ts +1 -0
- package/dist-client/components/reply-message.test.js +16 -0
- package/dist-client/components/reply-message.test.js.map +1 -0
- package/dist-client/components/roster-identity.d.ts +11 -0
- package/dist-client/components/roster-identity.js +11 -0
- package/dist-client/components/roster-identity.js.map +1 -0
- package/dist-client/components/roster-identity.test.d.ts +1 -0
- package/dist-client/components/roster-identity.test.js +20 -0
- package/dist-client/components/roster-identity.test.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/chat-session-participant/read-roster.d.ts +9 -0
- package/dist-server/service/chat-session-participant/read-roster.js +15 -0
- package/dist-server/service/chat-session-participant/read-roster.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +1 -1
- package/dist-server/service/chat-session-resolver.js +15 -11
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/server/service/chat-session-participant/read-roster.test.ts +18 -0
- package/server/service/chat-session-participant/read-roster.ts +14 -0
- package/server/service/chat-session-resolver.ts +11 -8
- package/translations/en.json +10 -2
- package/translations/ja.json +1 -1
- package/translations/ko.json +10 -2
- package/translations/ms.json +1 -1
- package/translations/zh.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { replyDestination, quotedReply } from './reply-message.js'
|
|
2
|
+
import { rosterInviteState, rosterName } from './roster-identity'
|
|
1
3
|
/**
|
|
2
4
|
* <ox-assistant-chat> — AI 도우미 채팅 컴포넌트 (Lit).
|
|
3
5
|
*
|
|
@@ -81,6 +83,7 @@ interface ChatLine {
|
|
|
81
83
|
/** 보낸 사용자 email/name — 그룹챗 발신자 표시 + 자기 여부 판정.
|
|
82
84
|
* **email 로 판정한다**: 로그인 식별자라 비어 있지 않고, 시스템 내부 관리키(User.id)를
|
|
83
85
|
* 클라이언트 판정에 끌어들이지 않는다(관리키 노출이 계약처럼 굳는 것을 피한다). */
|
|
86
|
+
senderId?: string
|
|
84
87
|
senderEmail?: string
|
|
85
88
|
senderName?: string
|
|
86
89
|
/** 메시지 본문. `#token{refid:N}` inline marker 가 포함될 수 있음 (영속된 메시지 / 사용자
|
|
@@ -277,8 +280,8 @@ const DOMAIN_USERS_FOR_MENTION_QUERY = gql`
|
|
|
277
280
|
`
|
|
278
281
|
|
|
279
282
|
const SAY_TO_ROOM_MUTATION = gql`
|
|
280
|
-
mutation SayToRoom($sessionId: String!, $content: String
|
|
281
|
-
sayToRoom(sessionId: $sessionId, content: $content)
|
|
283
|
+
mutation SayToRoom($sessionId: String!, $content: String!, $replyToId: String) {
|
|
284
|
+
sayToRoom(sessionId: $sessionId, content: $content, replyToId: $replyToId)
|
|
282
285
|
}
|
|
283
286
|
`
|
|
284
287
|
|
|
@@ -296,6 +299,7 @@ const CHAT_MESSAGES_QUERY = gql`
|
|
|
296
299
|
createdAt
|
|
297
300
|
redacted
|
|
298
301
|
creator {
|
|
302
|
+
id
|
|
299
303
|
name
|
|
300
304
|
email
|
|
301
305
|
}
|
|
@@ -319,6 +323,7 @@ const CHAT_MESSAGE_SUBSCRIPTION = gql`
|
|
|
319
323
|
createdAt
|
|
320
324
|
redacted
|
|
321
325
|
creator {
|
|
326
|
+
id
|
|
322
327
|
name
|
|
323
328
|
email
|
|
324
329
|
}
|
|
@@ -336,6 +341,7 @@ const CHAT_PARTICIPANTS_QUERY = gql`
|
|
|
336
341
|
user {
|
|
337
342
|
id
|
|
338
343
|
name
|
|
344
|
+
username
|
|
339
345
|
}
|
|
340
346
|
}
|
|
341
347
|
}
|
|
@@ -349,14 +355,7 @@ const TOUCH_PRESENCE_MUTATION = gql`
|
|
|
349
355
|
const PRESENCE_HEARTBEAT_MS = 30_000
|
|
350
356
|
const PRESENCE_TTL_MS = 75_000
|
|
351
357
|
|
|
352
|
-
|
|
353
|
-
function initials(name: string): string {
|
|
354
|
-
const n = (name || '?').trim()
|
|
355
|
-
if (!n) return '?'
|
|
356
|
-
const parts = n.split(/\s+/)
|
|
357
|
-
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
|
|
358
|
-
return /[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(0, 1)
|
|
359
|
-
}
|
|
358
|
+
|
|
360
359
|
|
|
361
360
|
@customElement('ox-assistant-chat')
|
|
362
361
|
export class OxAssistantChat extends LitElement {
|
|
@@ -922,7 +921,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
922
921
|
|
|
923
922
|
/** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
|
|
924
923
|
@state()
|
|
925
|
-
private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string } }> = []
|
|
924
|
+
private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string; username?: string } }> = []
|
|
925
|
+
@state() private rosterLoaded = false
|
|
926
926
|
|
|
927
927
|
/** roster online 재평가 트리거 — heartbeat tick 마다 갱신(시간 경과 반영). */
|
|
928
928
|
@state()
|
|
@@ -983,6 +983,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
983
983
|
* textarea 에는 @username 만 보이지만 server 한테는 정확한 userId 전달.
|
|
984
984
|
* send 시 mutation input.userMentions 로 함께 전송. */
|
|
985
985
|
private pickedUserMentions: Map<string, string> = new Map()
|
|
986
|
+
@state() private replyingTo?: { id?: string; name: string; excerpt: string; role: string; token?: string }
|
|
986
987
|
|
|
987
988
|
/** input 디바운스 timer — 150ms 후 refreshMentionPopup 실행 (F-Finder 와 동일) */
|
|
988
989
|
private inputDebounceTimer?: ReturnType<typeof setTimeout>
|
|
@@ -1251,6 +1252,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
1251
1252
|
font-size: 10px;
|
|
1252
1253
|
background: var(--md-sys-color-surface-variant, #f1f5f9);
|
|
1253
1254
|
}
|
|
1255
|
+
.roster-name { max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1254
1256
|
.roster-chip.offline {
|
|
1255
1257
|
opacity: 0.45;
|
|
1256
1258
|
}
|
|
@@ -2760,6 +2762,21 @@ export class OxAssistantChat extends LitElement {
|
|
|
2760
2762
|
|
|
2761
2763
|
/* ── Input area ───────────────────────────────────── */
|
|
2762
2764
|
/* border-top 은 .composer 가 담당 (actions-row 가 같은 wrapper 안) */
|
|
2765
|
+
.reply-preview {
|
|
2766
|
+
display: flex; gap: 8px; margin: 4px 14px; padding: 8px 10px;
|
|
2767
|
+
border-left: 3px solid var(--md-sys-color-primary);
|
|
2768
|
+
background: var(--md-sys-color-surface-container, #f1f5f9); border-radius: 6px;
|
|
2769
|
+
}
|
|
2770
|
+
.reply-preview > div { flex: 1; min-width: 0; }
|
|
2771
|
+
.reply-preview strong { display: block; font-size: 12px; }
|
|
2772
|
+
.reply-preview span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
|
2773
|
+
.reply-preview button { border: 0; background: transparent; color: inherit; cursor: pointer; }
|
|
2774
|
+
.send-destination {
|
|
2775
|
+
font-size: 11px;
|
|
2776
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
2777
|
+
align-self: center;
|
|
2778
|
+
max-width: 64px;
|
|
2779
|
+
}
|
|
2763
2780
|
.input-row {
|
|
2764
2781
|
display: flex;
|
|
2765
2782
|
gap: 8px;
|
|
@@ -2835,15 +2852,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
2835
2852
|
--md-icon-size: 15px;
|
|
2836
2853
|
}
|
|
2837
2854
|
|
|
2838
|
-
/*
|
|
2855
|
+
/* One send control, aligned with the resting input height. */
|
|
2839
2856
|
.input-row > button {
|
|
2840
|
-
width:
|
|
2841
|
-
height:
|
|
2857
|
+
width: 54px;
|
|
2858
|
+
height: 54px;
|
|
2842
2859
|
padding: 0;
|
|
2843
2860
|
background: var(--md-sys-color-primary, #1e293b);
|
|
2844
2861
|
color: var(--md-sys-color-on-primary, #ffffff);
|
|
2845
2862
|
border: 0;
|
|
2846
|
-
border-radius:
|
|
2863
|
+
border-radius: 12px;
|
|
2847
2864
|
cursor: pointer;
|
|
2848
2865
|
display: flex;
|
|
2849
2866
|
align-items: center;
|
|
@@ -2854,17 +2871,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
2854
2871
|
opacity 0.15s;
|
|
2855
2872
|
flex-shrink: 0;
|
|
2856
2873
|
align-self: flex-end;
|
|
2857
|
-
margin-bottom:
|
|
2858
|
-
}
|
|
2859
|
-
/*
|
|
2860
|
-
방에 말하는 자리는 **두 번째 행동**이다 — 이 화면의 첫 행동은 어시스턴트에게 묻는 것이다.
|
|
2861
|
-
같은 무게로 두면 무엇이 기본인지 화면이 말하지 않게 된다(ADR-0070 A15 의 자리 둘).
|
|
2862
|
-
*/
|
|
2863
|
-
.input-row > button.say-to-room {
|
|
2864
|
-
background: var(--md-sys-color-surface-container-highest, #e2e8f0);
|
|
2865
|
-
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
2874
|
+
margin-bottom: 0;
|
|
2866
2875
|
}
|
|
2867
|
-
|
|
2868
2876
|
.input-row > button:disabled {
|
|
2869
2877
|
background: var(--md-sys-color-surface-container-highest, #e2e8f0);
|
|
2870
2878
|
color: var(--md-sys-color-outline, #94a3b8);
|
|
@@ -2982,6 +2990,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
2982
2990
|
this.errorMessage = undefined
|
|
2983
2991
|
this.input = ''
|
|
2984
2992
|
this.participants = []
|
|
2993
|
+
this.rosterLoaded = false
|
|
2994
|
+
this.replyingTo = undefined
|
|
2985
2995
|
this._seenMessageIds.clear() // 세션 전환 — 에코 dedup 컨텍스트 리셋
|
|
2986
2996
|
if (this.sessionId) {
|
|
2987
2997
|
this.loadHistory()
|
|
@@ -3105,16 +3115,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
3105
3115
|
}
|
|
3106
3116
|
|
|
3107
3117
|
private async _loadParticipants() {
|
|
3108
|
-
|
|
3118
|
+
const sessionId=this.sessionId
|
|
3119
|
+
if (!sessionId) return
|
|
3109
3120
|
try {
|
|
3110
|
-
const result = await client.query({
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
})
|
|
3115
|
-
this.participants = result.data?.chatSessionParticipants ?? []
|
|
3121
|
+
const result = await client.query({query:CHAT_PARTICIPANTS_QUERY,variables:{sessionId},fetchPolicy:'network-only'})
|
|
3122
|
+
if (this.sessionId !== sessionId) return
|
|
3123
|
+
this.participants=result.data?.chatSessionParticipants??[]
|
|
3124
|
+
this.rosterLoaded=true
|
|
3116
3125
|
} catch {
|
|
3117
|
-
|
|
3126
|
+
if (this.sessionId === sessionId) this.rosterLoaded=false
|
|
3118
3127
|
}
|
|
3119
3128
|
}
|
|
3120
3129
|
|
|
@@ -3392,6 +3401,12 @@ export class OxAssistantChat extends LitElement {
|
|
|
3392
3401
|
</div>
|
|
3393
3402
|
`
|
|
3394
3403
|
: nothing}
|
|
3404
|
+
${this.replyingTo ? html`<div class="reply-preview">
|
|
3405
|
+
<div><strong>${i18next.t('ai-assistant.text.reply-to', { name: this.replyingTo.name })}</strong>
|
|
3406
|
+
<span>${this.replyingTo.excerpt}</span></div>
|
|
3407
|
+
<button type="button" aria-label=${i18next.t('ai-assistant.button.cancel-reply')}
|
|
3408
|
+
@click=${() => this.cancelReply()}><md-icon>close</md-icon></button>
|
|
3409
|
+
</div>` : nothing}
|
|
3395
3410
|
${this.renderInviteNotice()}
|
|
3396
3411
|
<div class="input-row">
|
|
3397
3412
|
<div class="textarea-wrap">
|
|
@@ -3429,33 +3444,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
3429
3444
|
`
|
|
3430
3445
|
: nothing}
|
|
3431
3446
|
</div>
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
정하고, 서버는 어느 문이 불렸는지로 그 사실을 저장한다(ADR-0070 A15 결정 ①).
|
|
3435
|
-
|
|
3436
|
-
**방에 사람이 있을 때만 보인다.** 혼자 쓰는 대화에서 「방에 말하기」는 아무에게도 하지 않는
|
|
3437
|
-
말이고, 누를 일이 없는 단추는 무엇이 기본인지 흐린다. 조건은 참여자 목록과 같다(둘 이상) —
|
|
3438
|
-
둘째 사람은 멘션으로 들어오고, 그 순간 이 자리도 함께 선다.
|
|
3439
|
-
*/ ''}
|
|
3440
|
-
${this.sessionId && this.participants.length >= 2
|
|
3441
|
-
? html`
|
|
3442
|
-
<button
|
|
3443
|
-
class="say-to-room"
|
|
3444
|
-
?disabled=${this.busy || !this.input.trim()}
|
|
3445
|
-
@click=${() => this.sayToRoom()}
|
|
3446
|
-
title=${i18next.t('ai-assistant.text.say-to-room-tooltip', {
|
|
3447
|
-
defaultValue: 'say this to the people here — the assistant does not answer'
|
|
3448
|
-
})}>
|
|
3449
|
-
<md-icon>forum</md-icon>
|
|
3450
|
-
</button>
|
|
3451
|
-
`
|
|
3452
|
-
: nothing}
|
|
3447
|
+
<span class="send-destination" aria-live="polite">${i18next.t(this.addressesPeople
|
|
3448
|
+
? 'ai-assistant.text.send-to-people' : 'ai-assistant.text.send-to-ai')}</span>
|
|
3453
3449
|
${this.composerWide
|
|
3454
3450
|
? html`<span class="send-shortcut">${i18next.t('ai-assistant.text.send-shortcut', { key: sendShortcut(navigator.platform), defaultValue: '{key} to send' })}</span>`
|
|
3455
3451
|
: nothing}
|
|
3456
3452
|
<button
|
|
3457
3453
|
?disabled=${this.busy || !!this.sendBlocked || (!this.input.trim() && this.attachments.length === 0)}
|
|
3458
|
-
@click=${this.
|
|
3454
|
+
@click=${() => this.submitMessage()}
|
|
3455
|
+
aria-label=${i18next.t(this.addressesPeople ? 'ai-assistant.text.send-to-people' : 'ai-assistant.text.send-to-ai')}
|
|
3459
3456
|
title=${this.sendBlocked ||
|
|
3460
3457
|
(this.composerWide
|
|
3461
3458
|
? i18next.t('ai-assistant.text.send-tooltip-wide', { key: sendShortcut(navigator.platform), defaultValue: 'Send ({key})' })
|
|
@@ -3475,6 +3472,53 @@ export class OxAssistantChat extends LitElement {
|
|
|
3475
3472
|
* 저장되는 대화가 있을 때만 할 수 있다. 세션이 없는 즉석 질문에는 「방」이 없다 — 그 자리에서
|
|
3476
3473
|
* 한 말은 누구에게도 남지 않는다.
|
|
3477
3474
|
*/
|
|
3475
|
+
private startReply(line: ChatLine) {
|
|
3476
|
+
this.cancelReply()
|
|
3477
|
+
const name = line.role === 'assistant' ? 'AI' : line.senderName || i18next.t('ai-assistant.label.participant')
|
|
3478
|
+
const token = line.role === 'user' ? name : undefined
|
|
3479
|
+
this.replyingTo = { id: line.id, name, role: line.role, token,
|
|
3480
|
+
excerpt: stripMentionRefids(line.content).replace(/\{userId:[^}]+\}/g, '').replace(/\s+/g, ' ').slice(0, 180) }
|
|
3481
|
+
if (token && line.senderId) {
|
|
3482
|
+
this.pickedUserMentions.set(token, line.senderId)
|
|
3483
|
+
if (!this.input.includes(`@${token}`)) this.input = `@${token} ${this.input}`
|
|
3484
|
+
}
|
|
3485
|
+
this.updateComplete.then(() => (this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null)?.focus())
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
private cancelReply() {
|
|
3489
|
+
const token = this.replyingTo?.token
|
|
3490
|
+
if (token && this.input.startsWith(`@${token} `)) this.input = this.input.slice(token.length + 2)
|
|
3491
|
+
this.replyingTo = undefined
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
private replyContent(body: string): string {
|
|
3495
|
+
return quotedReply(body, this.replyingTo)
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
private get addressesPeople(): boolean {
|
|
3499
|
+
const mentioned = [...this.pickedUserMentions.keys()].some(token => this.input.includes(`@${token}`))
|
|
3500
|
+
return replyDestination(this.replyingTo?.role, mentioned) === 'room'
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3503
|
+
private async submitMessage() {
|
|
3504
|
+
if (this.busy || !this.input.trim() && !this.attachments.length) return
|
|
3505
|
+
if (!this.addressesPeople) return this.send()
|
|
3506
|
+
if (this.attachments.length) {
|
|
3507
|
+
this.errorMessage = i18next.t('ai-assistant.text.room-attachments-unavailable')
|
|
3508
|
+
return
|
|
3509
|
+
}
|
|
3510
|
+
if (!this.sessionId && this.prepareSession) {
|
|
3511
|
+
try {
|
|
3512
|
+
await this.prepareSession(id => { this.adoptingSession = true; this.sessionId = id })
|
|
3513
|
+
await this.updateComplete
|
|
3514
|
+
} catch (error) {
|
|
3515
|
+
this.errorMessage = error instanceof Error ? error.message : String(error)
|
|
3516
|
+
return
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
return this.sayToRoom()
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3478
3522
|
private async sayToRoom() {
|
|
3479
3523
|
const body = this.input.trim()
|
|
3480
3524
|
if (!body || this.busy) return
|
|
@@ -3486,15 +3530,20 @@ export class OxAssistantChat extends LitElement {
|
|
|
3486
3530
|
return
|
|
3487
3531
|
}
|
|
3488
3532
|
|
|
3533
|
+
const reply = this.replyingTo
|
|
3534
|
+
const content = this.replyContent(injectMentionUserIds(body, [...this.pickedUserMentions.entries()]
|
|
3535
|
+
.filter(([token]) => body.includes(`@${token}`))
|
|
3536
|
+
.map(([token, userId]) => ({ token, userId }))))
|
|
3489
3537
|
const localId = `local-${++this._localSeq}-r`
|
|
3490
|
-
this.lines = [...this.lines, { role: 'user', content
|
|
3538
|
+
this.lines = [...this.lines, { role: 'user', content, _localId: localId, senderId: this._myUserId, senderEmail: this._myEmail }]
|
|
3491
3539
|
this.input = ''
|
|
3492
3540
|
this.busy = true
|
|
3493
3541
|
try {
|
|
3494
3542
|
const result = await client.mutate({
|
|
3495
3543
|
mutation: SAY_TO_ROOM_MUTATION,
|
|
3496
|
-
variables: { sessionId: this.sessionId, content:
|
|
3544
|
+
variables: { sessionId: this.sessionId, content, replyToId: reply?.id }
|
|
3497
3545
|
})
|
|
3546
|
+
this.replyingTo = undefined
|
|
3498
3547
|
/* 방송 에코와 겹치지 않게 서버 id 로 맞춘다 — 보낸 말이 두 줄로 보이지 않게. */
|
|
3499
3548
|
this._reconcileSentLine(localId, result.data?.sayToRoom ?? undefined, {})
|
|
3500
3549
|
} catch (error) {
|
|
@@ -3524,15 +3573,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
3524
3573
|
.map(([token]) => token)
|
|
3525
3574
|
if (incoming.length === 0) return nothing
|
|
3526
3575
|
|
|
3527
|
-
const
|
|
3528
|
-
const mayInvite =
|
|
3576
|
+
const inviteState = rosterInviteState(this.participants,this._myUserId,this.rosterLoaded)
|
|
3577
|
+
const mayInvite = inviteState === 'allowed'
|
|
3529
3578
|
const names = incoming.map(token => `@${token}`).join(', ')
|
|
3530
3579
|
|
|
3531
3580
|
return html`
|
|
3532
3581
|
<div class="invite-notice">
|
|
3533
3582
|
<md-icon>person_add</md-icon>
|
|
3534
3583
|
<span>
|
|
3535
|
-
${mayInvite
|
|
3584
|
+
${inviteState === 'checking' ? i18next.t('ai-assistant.text.invite-checking') : mayInvite
|
|
3536
3585
|
? i18next.t('ai-assistant.text.will-be-invited', {
|
|
3537
3586
|
names,
|
|
3538
3587
|
defaultValue: '{names} will join this conversation when you send'
|
|
@@ -3650,7 +3699,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
3650
3699
|
this.setComposerWide(false)
|
|
3651
3700
|
} else if (action === 'send') {
|
|
3652
3701
|
e.preventDefault()
|
|
3653
|
-
if (!this.busy) this.
|
|
3702
|
+
if (!this.busy) void this.submitMessage()
|
|
3654
3703
|
}
|
|
3655
3704
|
}
|
|
3656
3705
|
|
|
@@ -4070,12 +4119,12 @@ export class OxAssistantChat extends LitElement {
|
|
|
4070
4119
|
<md-icon>group</md-icon>${online}/${this.participants.length}
|
|
4071
4120
|
</span>
|
|
4072
4121
|
${this.participants.map(p => {
|
|
4073
|
-
const name = p.
|
|
4122
|
+
const name = rosterName(p,String(i18next.t('ai-assistant.text.participant-unavailable')))
|
|
4074
4123
|
const on = this._isOnline(p.lastSeenAt)
|
|
4075
4124
|
return html`<span
|
|
4076
4125
|
class="roster-chip ${on ? 'online' : 'offline'}"
|
|
4077
4126
|
title=${`${name} · ${p.role}${on ? ' · online' : ''}`}>
|
|
4078
|
-
<span class="roster-dot"></span>${
|
|
4127
|
+
<span class="roster-dot"></span><span class="roster-name">${name}</span>
|
|
4079
4128
|
</span>`
|
|
4080
4129
|
})}
|
|
4081
4130
|
</div>
|
|
@@ -4227,7 +4276,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
4227
4276
|
* system : 없음
|
|
4228
4277
|
*/
|
|
4229
4278
|
private renderMessageActions(idx: number, line: ChatLine, content: string) {
|
|
4230
|
-
if (line.role
|
|
4279
|
+
if (line.role === 'system') return nothing
|
|
4231
4280
|
|
|
4232
4281
|
const isCopied = this.copiedIdx === idx
|
|
4233
4282
|
// revert 는 인라인 patch chip (메시지 안) 한 곳에만 — hover 액션에서 제외 (중복 방지).
|
|
@@ -4241,13 +4290,17 @@ export class OxAssistantChat extends LitElement {
|
|
|
4241
4290
|
@click=${() => this.copyMessage(idx, content)}>
|
|
4242
4291
|
<md-icon>${isCopied ? 'check' : 'content_copy'}</md-icon>
|
|
4243
4292
|
</button>
|
|
4244
|
-
<button
|
|
4293
|
+
<button class="msg-action" ?disabled=${this.busy || (line.role === 'user' && !line.senderId)}
|
|
4294
|
+
title=${i18next.t('ai-assistant.button.reply')}
|
|
4295
|
+
aria-label=${i18next.t('ai-assistant.button.reply')}
|
|
4296
|
+
@click=${() => this.startReply(line)}><md-icon>reply</md-icon></button>
|
|
4297
|
+
${line.role === 'assistant' ? html`<button
|
|
4245
4298
|
class="msg-action"
|
|
4246
4299
|
?disabled=${this.busy}
|
|
4247
4300
|
title=${i18next.t('ai-assistant.button.regenerate')}
|
|
4248
4301
|
@click=${() => this.regenerateAssistant(idx)}>
|
|
4249
4302
|
<md-icon>refresh</md-icon>
|
|
4250
|
-
</button
|
|
4303
|
+
</button>` : nothing}
|
|
4251
4304
|
</div>
|
|
4252
4305
|
`
|
|
4253
4306
|
}
|
|
@@ -5046,7 +5099,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
5046
5099
|
if (promptText.includes(`@${token}`)) userMentions.push({ token, userId })
|
|
5047
5100
|
}
|
|
5048
5101
|
let enrichedContent = injectMentionRefids(promptText, mentions)
|
|
5049
|
-
enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
|
|
5102
|
+
enrichedContent = this.replyContent(injectMentionUserIds(enrichedContent, userMentions))
|
|
5103
|
+
this.replyingTo = undefined
|
|
5050
5104
|
|
|
5051
5105
|
// optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
|
|
5052
5106
|
const userLocalId = `local-${++this._localSeq}-u`
|
|
@@ -5065,7 +5119,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
5065
5119
|
base64: a.base64
|
|
5066
5120
|
})),
|
|
5067
5121
|
_localId: userLocalId,
|
|
5068
|
-
senderEmail: this._myEmail
|
|
5122
|
+
senderId: this._myUserId, senderEmail: this._myEmail
|
|
5069
5123
|
}
|
|
5070
5124
|
]
|
|
5071
5125
|
this.input = ''
|
|
@@ -32,7 +32,7 @@ export interface RemoteMessage {
|
|
|
32
32
|
/** 접지 경고(영속) — 답이 언급했으나 근거에 없던 식별자. 나중에 읽는 사람도 같은 주의를 봐야 한다. */
|
|
33
33
|
groundingWarningsJson?: any
|
|
34
34
|
/** 발신자 — 표시용 name + **자기 여부 판정용 email**(내부 관리키 id 는 쓰지 않는다). */
|
|
35
|
-
creator?: { name?: string; email?: string } | null
|
|
35
|
+
creator?: { id?: string; name?: string; email?: string } | null
|
|
36
36
|
/** 이 사람이 이 답의 문을 못 지나 본문이 한 줄로 바뀌어 왔나(ADR-0073 판정 1). */
|
|
37
37
|
redacted?: boolean
|
|
38
38
|
}
|
|
@@ -51,6 +51,7 @@ export function remoteMessageToLine(m: RemoteMessage): DedupLine {
|
|
|
51
51
|
attachments: Array.isArray((m as any).attachmentsJson)
|
|
52
52
|
? (m as any).attachmentsJson.map((a: any) => ({ id: a.id, name: a.name, type: a.mimetype, size: a.size }))
|
|
53
53
|
: undefined,
|
|
54
|
+
senderId: m.creator?.id ?? undefined,
|
|
54
55
|
senderEmail: m.creator?.email ?? undefined,
|
|
55
56
|
senderName: m.creator?.name ?? undefined,
|
|
56
57
|
/*
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { replyDestination, quotedReply } from './reply-message'
|
|
2
|
+
describe('message replies', () => {
|
|
3
|
+
it('routes a human reply to the room and an AI reply to AI even with an earlier mention', () => {
|
|
4
|
+
expect(replyDestination('user', false)).toBe('room')
|
|
5
|
+
expect(replyDestination('assistant', true)).toBe('assistant')
|
|
6
|
+
expect(replyDestination(undefined, true)).toBe('room')
|
|
7
|
+
expect(replyDestination(undefined, false)).toBe('assistant')
|
|
8
|
+
})
|
|
9
|
+
it('keeps a short quoted original above the answer and escapes markup', () => {
|
|
10
|
+
expect(quotedReply('answer', {name: 'Kim', excerpt: 'original'})).toBe('> Kim: original\n\nanswer')
|
|
11
|
+
expect(quotedReply('answer')).toBe('answer')
|
|
12
|
+
expect(quotedReply('answer', {name: '<Kim>', excerpt: '**text**'})).toContain('\\<Kim\\>')
|
|
13
|
+
expect(quotedReply('answer', {name: 'Kim', excerpt: '**text**'})).toContain('\\*\\*text\\*\\*')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function replyDestination(role: string | undefined, hasUserMention: boolean): 'room' | 'assistant' {
|
|
2
|
+
if (role === 'assistant') return 'assistant'
|
|
3
|
+
if (role === 'user') return 'room'
|
|
4
|
+
return hasUserMention ? 'room' : 'assistant'
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function quotedReply(body: string, reply?: { name: string; excerpt: string }): string {
|
|
8
|
+
if (!reply) return body
|
|
9
|
+
const escape = (text: string) => text.replace(/[\\`*_{}\[\]<>#]/g, char => `\\${char}`)
|
|
10
|
+
return `> ${escape(reply.name)}: ${escape(reply.excerpt)}\n\n${body}`
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { rosterInviteState, rosterName } from './roster-identity'
|
|
2
|
+
describe('roster identity and invite notice',()=>{
|
|
3
|
+
it('does not claim denial when participant identities are missing or still loading',()=>{
|
|
4
|
+
expect(rosterInviteState([{role:'owner'}],'me',true)).toBe('checking')
|
|
5
|
+
expect(rosterInviteState([], 'me',false)).toBe('checking')
|
|
6
|
+
expect(rosterInviteState([], undefined,true)).toBe('checking')
|
|
7
|
+
})
|
|
8
|
+
it('uses the current person role rather than another member or a stale loading state',()=>{
|
|
9
|
+
for(const role of ['owner','member'])expect(rosterInviteState([{role,user:{id:'me'}}],'me',true)).toBe('allowed')
|
|
10
|
+
expect(rosterInviteState([{role:'viewer',user:{id:'me'}},{role:'owner',user:{id:'other'}}],'me',true)).toBe('denied')
|
|
11
|
+
expect(rosterInviteState([{role:'member',user:{id:'other'}}],'me',true)).toBe('denied')
|
|
12
|
+
})
|
|
13
|
+
it('shows a recognizable full name, then username, then an explicit unavailable label',()=>{
|
|
14
|
+
expect(rosterName({role:'member',user:{id:'u',name:' 정연희 '}},'Unavailable')).toBe('정연희')
|
|
15
|
+
expect(rosterName({role:'member',user:{id:'u',username:'yeonhee'}},'Unavailable')).toBe('yeonhee')
|
|
16
|
+
expect(rosterName({role:'member'},'Unavailable')).toBe('Unavailable')
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface RosterParticipant { role: string; user?: { id: string; name?: string; username?: string } }
|
|
2
|
+
/** Missing identity/loading is not evidence that a person lacks membership. Server gates remain authoritative. */
|
|
3
|
+
export function rosterInviteState(rows: readonly RosterParticipant[], userId: string | undefined, loaded: boolean): 'checking' | 'allowed' | 'denied' {
|
|
4
|
+
if (!loaded || !userId || rows.some(row => !row.user?.id)) return 'checking'
|
|
5
|
+
const role = rows.find(row => row.user?.id === userId)?.role
|
|
6
|
+
return role === 'owner' || role === 'member' ? 'allowed' : 'denied'
|
|
7
|
+
}
|
|
8
|
+
export function rosterName(person: RosterParticipant, fallback: string): string {
|
|
9
|
+
return person.user?.name?.trim() || person.user?.username?.trim() || fallback
|
|
10
|
+
}
|
|
@@ -301,6 +301,7 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
301
301
|
private errorMessage?;
|
|
302
302
|
/** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
|
|
303
303
|
private participants;
|
|
304
|
+
private rosterLoaded;
|
|
304
305
|
/** roster online 재평가 트리거 — heartbeat tick 마다 갱신(시간 경과 반영). */
|
|
305
306
|
private _presenceTick;
|
|
306
307
|
private _heartbeatTimer?;
|
|
@@ -338,6 +339,7 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
338
339
|
* textarea 에는 @username 만 보이지만 server 한테는 정확한 userId 전달.
|
|
339
340
|
* send 시 mutation input.userMentions 로 함께 전송. */
|
|
340
341
|
private pickedUserMentions;
|
|
342
|
+
private replyingTo?;
|
|
341
343
|
/** input 디바운스 timer — 150ms 후 refreshMentionPopup 실행 (F-Finder 와 동일) */
|
|
342
344
|
private inputDebounceTimer?;
|
|
343
345
|
/** centerTo 디바운스 timer — arrow nav / hover 빠를 때 카메라 애니메이션 race 방지 */
|
|
@@ -431,6 +433,11 @@ export declare class OxAssistantChat extends LitElement {
|
|
|
431
433
|
* 저장되는 대화가 있을 때만 할 수 있다. 세션이 없는 즉석 질문에는 「방」이 없다 — 그 자리에서
|
|
432
434
|
* 한 말은 누구에게도 남지 않는다.
|
|
433
435
|
*/
|
|
436
|
+
private startReply;
|
|
437
|
+
private cancelReply;
|
|
438
|
+
private replyContent;
|
|
439
|
+
private get addressesPeople();
|
|
440
|
+
private submitMessage;
|
|
434
441
|
private sayToRoom;
|
|
435
442
|
/**
|
|
436
443
|
* 보내기 전에 말한다 — 「이 사람은 이 대화에 들어옵니다」(ADR-0071 결정 1 ④).
|