@things-factory/ai-assistant 10.1.123 → 10.1.127
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 +145 -95
- 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/client/utils/assistant-navigation.test.ts +22 -0
- package/dist-client/components/assistant-chat.d.ts +7 -0
- package/dist-client/components/assistant-chat.js +148 -94
- 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-client/utils/assistant-navigation.test.js +20 -0
- package/dist-client/utils/assistant-navigation.test.js.map +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 +2 -2
- 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/test/assistant-request-context.test.ts +3 -1
- package/translations/en.json +9 -1
- package/translations/ko.json +9 -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);
|
|
@@ -2931,33 +2939,6 @@ export class OxAssistantChat extends LitElement {
|
|
|
2931
2939
|
updated(changed: Map<string, any>) {
|
|
2932
2940
|
if (changed.has('input') || changed.has('composerWide') || changed.has('previewAttachmentId') || changed.has('attachments')) this.fitComposer()
|
|
2933
2941
|
|
|
2934
|
-
/*
|
|
2935
|
-
* 밖에서 넣어 준 질문을 자동 전송한다(오류 토스트의 "AI에게 묻기" 등).
|
|
2936
|
-
*
|
|
2937
|
-
* sessionId 가 붙기 전에 보내면 대화에 남지 않으므로 세션이 준비될 때까지 기다린다.
|
|
2938
|
-
* 같은 문장을 두 번 보내지 않도록 보낸 값을 기억한다 — 재렌더마다 updated 가 다시 돌기 때문이다.
|
|
2939
|
-
*/
|
|
2940
|
-
if (this.autoAsk && assistantAutoAskKey(this.autoAsk, this.autoAskRequestId) !== this._autoAsked && (this.sessionId || this.prepareSession) && !this.busy) {
|
|
2941
|
-
this._autoAsked = assistantAutoAskKey(this.autoAsk, this.autoAskRequestId)
|
|
2942
|
-
this.input = this.autoAsk
|
|
2943
|
-
this.send()
|
|
2944
|
-
}
|
|
2945
|
-
|
|
2946
|
-
/* A draft fills the input and waits for the person — it is never sent from here. */
|
|
2947
|
-
const draft = assistantDraftDecision({
|
|
2948
|
-
draft: this.draft,
|
|
2949
|
-
draftRequestId: this.draftRequestId,
|
|
2950
|
-
drafted: this._drafted,
|
|
2951
|
-
typed: this.input,
|
|
2952
|
-
autoAsk: this.autoAsk,
|
|
2953
|
-
autoAskRequestId: this.autoAskRequestId
|
|
2954
|
-
})
|
|
2955
|
-
if (draft.consumed) this._drafted = draft.consumed
|
|
2956
|
-
if (draft.fill) {
|
|
2957
|
-
this.input = this.draft!
|
|
2958
|
-
this.updateComplete.then(() => (this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null)?.focus())
|
|
2959
|
-
}
|
|
2960
|
-
|
|
2961
2942
|
/*
|
|
2962
2943
|
* The subject changed, so the conversation is over.
|
|
2963
2944
|
*
|
|
@@ -3009,6 +2990,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
3009
2990
|
this.errorMessage = undefined
|
|
3010
2991
|
this.input = ''
|
|
3011
2992
|
this.participants = []
|
|
2993
|
+
this.rosterLoaded = false
|
|
2994
|
+
this.replyingTo = undefined
|
|
3012
2995
|
this._seenMessageIds.clear() // 세션 전환 — 에코 dedup 컨텍스트 리셋
|
|
3013
2996
|
if (this.sessionId) {
|
|
3014
2997
|
this.loadHistory()
|
|
@@ -3019,6 +3002,29 @@ export class OxAssistantChat extends LitElement {
|
|
|
3019
3002
|
this._stopMessageSubscription()
|
|
3020
3003
|
}
|
|
3021
3004
|
}
|
|
3005
|
+
/* Send carried questions only after subject, mode and session cleanup has completed. */
|
|
3006
|
+
if (this.autoAsk && assistantAutoAskKey(this.autoAsk, this.autoAskRequestId) !== this._autoAsked && (this.sessionId || this.prepareSession) && !this.busy) {
|
|
3007
|
+
this._autoAsked = assistantAutoAskKey(this.autoAsk, this.autoAskRequestId)
|
|
3008
|
+
this.input = this.autoAsk
|
|
3009
|
+
void this.send()
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
/* A draft fills the input and waits for the person — it is never sent from here. */
|
|
3013
|
+
const draft = assistantDraftDecision({
|
|
3014
|
+
draft: this.draft,
|
|
3015
|
+
draftRequestId: this.draftRequestId,
|
|
3016
|
+
drafted: this._drafted,
|
|
3017
|
+
typed: this.input,
|
|
3018
|
+
autoAsk: this.autoAsk,
|
|
3019
|
+
autoAskRequestId: this.autoAskRequestId
|
|
3020
|
+
})
|
|
3021
|
+
if (draft.consumed) this._drafted = draft.consumed
|
|
3022
|
+
if (draft.fill) {
|
|
3023
|
+
this.input = this.draft!
|
|
3024
|
+
this.updateComplete.then(() => (this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null)?.focus())
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
|
|
3022
3028
|
// 자동 스크롤
|
|
3023
3029
|
const ms = this.renderRoot.querySelector('.messages') as HTMLElement | null
|
|
3024
3030
|
if (ms) ms.scrollTop = ms.scrollHeight
|
|
@@ -3109,16 +3115,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
3109
3115
|
}
|
|
3110
3116
|
|
|
3111
3117
|
private async _loadParticipants() {
|
|
3112
|
-
|
|
3118
|
+
const sessionId=this.sessionId
|
|
3119
|
+
if (!sessionId) return
|
|
3113
3120
|
try {
|
|
3114
|
-
const result = await client.query({
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
})
|
|
3119
|
-
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
|
|
3120
3125
|
} catch {
|
|
3121
|
-
|
|
3126
|
+
if (this.sessionId === sessionId) this.rosterLoaded=false
|
|
3122
3127
|
}
|
|
3123
3128
|
}
|
|
3124
3129
|
|
|
@@ -3396,6 +3401,12 @@ export class OxAssistantChat extends LitElement {
|
|
|
3396
3401
|
</div>
|
|
3397
3402
|
`
|
|
3398
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}
|
|
3399
3410
|
${this.renderInviteNotice()}
|
|
3400
3411
|
<div class="input-row">
|
|
3401
3412
|
<div class="textarea-wrap">
|
|
@@ -3433,33 +3444,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
3433
3444
|
`
|
|
3434
3445
|
: nothing}
|
|
3435
3446
|
</div>
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
정하고, 서버는 어느 문이 불렸는지로 그 사실을 저장한다(ADR-0070 A15 결정 ①).
|
|
3439
|
-
|
|
3440
|
-
**방에 사람이 있을 때만 보인다.** 혼자 쓰는 대화에서 「방에 말하기」는 아무에게도 하지 않는
|
|
3441
|
-
말이고, 누를 일이 없는 단추는 무엇이 기본인지 흐린다. 조건은 참여자 목록과 같다(둘 이상) —
|
|
3442
|
-
둘째 사람은 멘션으로 들어오고, 그 순간 이 자리도 함께 선다.
|
|
3443
|
-
*/ ''}
|
|
3444
|
-
${this.sessionId && this.participants.length >= 2
|
|
3445
|
-
? html`
|
|
3446
|
-
<button
|
|
3447
|
-
class="say-to-room"
|
|
3448
|
-
?disabled=${this.busy || !this.input.trim()}
|
|
3449
|
-
@click=${() => this.sayToRoom()}
|
|
3450
|
-
title=${i18next.t('ai-assistant.text.say-to-room-tooltip', {
|
|
3451
|
-
defaultValue: 'say this to the people here — the assistant does not answer'
|
|
3452
|
-
})}>
|
|
3453
|
-
<md-icon>forum</md-icon>
|
|
3454
|
-
</button>
|
|
3455
|
-
`
|
|
3456
|
-
: 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>
|
|
3457
3449
|
${this.composerWide
|
|
3458
3450
|
? html`<span class="send-shortcut">${i18next.t('ai-assistant.text.send-shortcut', { key: sendShortcut(navigator.platform), defaultValue: '{key} to send' })}</span>`
|
|
3459
3451
|
: nothing}
|
|
3460
3452
|
<button
|
|
3461
3453
|
?disabled=${this.busy || !!this.sendBlocked || (!this.input.trim() && this.attachments.length === 0)}
|
|
3462
|
-
@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')}
|
|
3463
3456
|
title=${this.sendBlocked ||
|
|
3464
3457
|
(this.composerWide
|
|
3465
3458
|
? i18next.t('ai-assistant.text.send-tooltip-wide', { key: sendShortcut(navigator.platform), defaultValue: 'Send ({key})' })
|
|
@@ -3479,6 +3472,53 @@ export class OxAssistantChat extends LitElement {
|
|
|
3479
3472
|
* 저장되는 대화가 있을 때만 할 수 있다. 세션이 없는 즉석 질문에는 「방」이 없다 — 그 자리에서
|
|
3480
3473
|
* 한 말은 누구에게도 남지 않는다.
|
|
3481
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
|
+
|
|
3482
3522
|
private async sayToRoom() {
|
|
3483
3523
|
const body = this.input.trim()
|
|
3484
3524
|
if (!body || this.busy) return
|
|
@@ -3490,15 +3530,20 @@ export class OxAssistantChat extends LitElement {
|
|
|
3490
3530
|
return
|
|
3491
3531
|
}
|
|
3492
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 }))))
|
|
3493
3537
|
const localId = `local-${++this._localSeq}-r`
|
|
3494
|
-
this.lines = [...this.lines, { role: 'user', content
|
|
3538
|
+
this.lines = [...this.lines, { role: 'user', content, _localId: localId, senderId: this._myUserId, senderEmail: this._myEmail }]
|
|
3495
3539
|
this.input = ''
|
|
3496
3540
|
this.busy = true
|
|
3497
3541
|
try {
|
|
3498
3542
|
const result = await client.mutate({
|
|
3499
3543
|
mutation: SAY_TO_ROOM_MUTATION,
|
|
3500
|
-
variables: { sessionId: this.sessionId, content:
|
|
3544
|
+
variables: { sessionId: this.sessionId, content, replyToId: reply?.id }
|
|
3501
3545
|
})
|
|
3546
|
+
this.replyingTo = undefined
|
|
3502
3547
|
/* 방송 에코와 겹치지 않게 서버 id 로 맞춘다 — 보낸 말이 두 줄로 보이지 않게. */
|
|
3503
3548
|
this._reconcileSentLine(localId, result.data?.sayToRoom ?? undefined, {})
|
|
3504
3549
|
} catch (error) {
|
|
@@ -3528,15 +3573,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
3528
3573
|
.map(([token]) => token)
|
|
3529
3574
|
if (incoming.length === 0) return nothing
|
|
3530
3575
|
|
|
3531
|
-
const
|
|
3532
|
-
const mayInvite =
|
|
3576
|
+
const inviteState = rosterInviteState(this.participants,this._myUserId,this.rosterLoaded)
|
|
3577
|
+
const mayInvite = inviteState === 'allowed'
|
|
3533
3578
|
const names = incoming.map(token => `@${token}`).join(', ')
|
|
3534
3579
|
|
|
3535
3580
|
return html`
|
|
3536
3581
|
<div class="invite-notice">
|
|
3537
3582
|
<md-icon>person_add</md-icon>
|
|
3538
3583
|
<span>
|
|
3539
|
-
${mayInvite
|
|
3584
|
+
${inviteState === 'checking' ? i18next.t('ai-assistant.text.invite-checking') : mayInvite
|
|
3540
3585
|
? i18next.t('ai-assistant.text.will-be-invited', {
|
|
3541
3586
|
names,
|
|
3542
3587
|
defaultValue: '{names} will join this conversation when you send'
|
|
@@ -3654,7 +3699,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
3654
3699
|
this.setComposerWide(false)
|
|
3655
3700
|
} else if (action === 'send') {
|
|
3656
3701
|
e.preventDefault()
|
|
3657
|
-
if (!this.busy) this.
|
|
3702
|
+
if (!this.busy) void this.submitMessage()
|
|
3658
3703
|
}
|
|
3659
3704
|
}
|
|
3660
3705
|
|
|
@@ -4074,12 +4119,12 @@ export class OxAssistantChat extends LitElement {
|
|
|
4074
4119
|
<md-icon>group</md-icon>${online}/${this.participants.length}
|
|
4075
4120
|
</span>
|
|
4076
4121
|
${this.participants.map(p => {
|
|
4077
|
-
const name = p.
|
|
4122
|
+
const name = rosterName(p,String(i18next.t('ai-assistant.text.participant-unavailable')))
|
|
4078
4123
|
const on = this._isOnline(p.lastSeenAt)
|
|
4079
4124
|
return html`<span
|
|
4080
4125
|
class="roster-chip ${on ? 'online' : 'offline'}"
|
|
4081
4126
|
title=${`${name} · ${p.role}${on ? ' · online' : ''}`}>
|
|
4082
|
-
<span class="roster-dot"></span>${
|
|
4127
|
+
<span class="roster-dot"></span><span class="roster-name">${name}</span>
|
|
4083
4128
|
</span>`
|
|
4084
4129
|
})}
|
|
4085
4130
|
</div>
|
|
@@ -4231,7 +4276,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
4231
4276
|
* system : 없음
|
|
4232
4277
|
*/
|
|
4233
4278
|
private renderMessageActions(idx: number, line: ChatLine, content: string) {
|
|
4234
|
-
if (line.role
|
|
4279
|
+
if (line.role === 'system') return nothing
|
|
4235
4280
|
|
|
4236
4281
|
const isCopied = this.copiedIdx === idx
|
|
4237
4282
|
// revert 는 인라인 patch chip (메시지 안) 한 곳에만 — hover 액션에서 제외 (중복 방지).
|
|
@@ -4245,13 +4290,17 @@ export class OxAssistantChat extends LitElement {
|
|
|
4245
4290
|
@click=${() => this.copyMessage(idx, content)}>
|
|
4246
4291
|
<md-icon>${isCopied ? 'check' : 'content_copy'}</md-icon>
|
|
4247
4292
|
</button>
|
|
4248
|
-
<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
|
|
4249
4298
|
class="msg-action"
|
|
4250
4299
|
?disabled=${this.busy}
|
|
4251
4300
|
title=${i18next.t('ai-assistant.button.regenerate')}
|
|
4252
4301
|
@click=${() => this.regenerateAssistant(idx)}>
|
|
4253
4302
|
<md-icon>refresh</md-icon>
|
|
4254
|
-
</button
|
|
4303
|
+
</button>` : nothing}
|
|
4255
4304
|
</div>
|
|
4256
4305
|
`
|
|
4257
4306
|
}
|
|
@@ -5050,7 +5099,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
5050
5099
|
if (promptText.includes(`@${token}`)) userMentions.push({ token, userId })
|
|
5051
5100
|
}
|
|
5052
5101
|
let enrichedContent = injectMentionRefids(promptText, mentions)
|
|
5053
|
-
enrichedContent = injectMentionUserIds(enrichedContent, userMentions)
|
|
5102
|
+
enrichedContent = this.replyContent(injectMentionUserIds(enrichedContent, userMentions))
|
|
5103
|
+
this.replyingTo = undefined
|
|
5054
5104
|
|
|
5055
5105
|
// optimistic 라인에 임시 핸들 — 방송 에코와의 reconcile 을 index 비의존으로.
|
|
5056
5106
|
const userLocalId = `local-${++this._localSeq}-u`
|
|
@@ -5069,7 +5119,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
5069
5119
|
base64: a.base64
|
|
5070
5120
|
})),
|
|
5071
5121
|
_localId: userLocalId,
|
|
5072
|
-
senderEmail: this._myEmail
|
|
5122
|
+
senderId: this._myUserId, senderEmail: this._myEmail
|
|
5073
5123
|
}
|
|
5074
5124
|
]
|
|
5075
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
|
+
}
|
|
@@ -36,3 +36,25 @@ describe('navigation wiring', () => {
|
|
|
36
36
|
expect(dock).toContain("errorCode: 'conversation-screen-changed'")
|
|
37
37
|
})
|
|
38
38
|
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
describe('a carried draft survives opening a new conversation', () => {
|
|
42
|
+
test('automatic review starts after cleanup and is sent once per request', () => {
|
|
43
|
+
const source = readFileSync(join(__dirname, '../components/assistant-chat.ts'), 'utf8')
|
|
44
|
+
const update = source.slice(source.indexOf(' updated(changed:'), source.indexOf(' disconnectedCallback()', source.indexOf(' updated(changed:')))
|
|
45
|
+
const placement = update.indexOf('if (this.autoAsk &&')
|
|
46
|
+
expect(placement).toBeGreaterThan(update.indexOf("if (changed.has('sessionId')"))
|
|
47
|
+
expect(update.slice(placement)).not.toContain("this.input = ''")
|
|
48
|
+
expect(update.slice(placement)).toContain('!== this._autoAsked')
|
|
49
|
+
expect(update.slice(placement)).toContain('void this.send()')
|
|
50
|
+
})
|
|
51
|
+
test('draft placement follows subject, mode and session cleanup', () => {
|
|
52
|
+
const source = readFileSync(join(__dirname, '../components/assistant-chat.ts'), 'utf8')
|
|
53
|
+
const update = source.slice(source.indexOf(' updated(changed:'), source.indexOf(' disconnectedCallback()', source.indexOf(' updated(changed:')))
|
|
54
|
+
const placement = update.indexOf('const draft = assistantDraftDecision(')
|
|
55
|
+
expect(placement).toBeGreaterThan(update.indexOf("if (changed.has('subject'))"))
|
|
56
|
+
expect(placement).toBeGreaterThan(update.indexOf("if (changed.has('mode')"))
|
|
57
|
+
expect(placement).toBeGreaterThan(update.indexOf("if (changed.has('sessionId')"))
|
|
58
|
+
expect(update.slice(placement)).not.toContain("this.input = ''")
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -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 ④).
|