@things-factory/ai-assistant 10.1.208 → 10.1.210
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 +238 -9
- package/client/components/assistant-dock.ts +9 -4
- package/client/components/assistant-invite.ts +138 -0
- package/client/components/assistant-session-manager.ts +53 -12
- package/client/components/proposal-request.test.ts +10 -0
- package/client/components/roster-identity.test.ts +2 -12
- package/client/components/roster-identity.ts +0 -6
- package/client/utils/assistant-session-actions.ts +3 -1
- package/client/utils/assistant-session-admin.ts +9 -2
- package/client/utils/assistant-session-controller.ts +1 -1
- package/client/utils/assistant-typing.test.ts +33 -0
- package/client/utils/assistant-typing.ts +53 -0
- package/dist-client/components/assistant-chat.d.ts +28 -0
- package/dist-client/components/assistant-chat.js +259 -8
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.js +12 -4
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-invite.d.ts +39 -0
- package/dist-client/components/assistant-invite.js +150 -0
- package/dist-client/components/assistant-invite.js.map +1 -0
- package/dist-client/components/assistant-session-manager.d.ts +5 -1
- package/dist-client/components/assistant-session-manager.js +40 -10
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/components/proposal-request.test.js +8 -0
- package/dist-client/components/proposal-request.test.js.map +1 -1
- package/dist-client/components/roster-identity.d.ts +0 -2
- package/dist-client/components/roster-identity.js +0 -7
- package/dist-client/components/roster-identity.js.map +1 -1
- package/dist-client/components/roster-identity.test.js +2 -13
- package/dist-client/components/roster-identity.test.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-session-actions.d.ts +1 -0
- package/dist-client/utils/assistant-session-actions.js +3 -1
- package/dist-client/utils/assistant-session-actions.js.map +1 -1
- package/dist-client/utils/assistant-session-admin.d.ts +5 -2
- package/dist-client/utils/assistant-session-admin.js +3 -0
- package/dist-client/utils/assistant-session-admin.js.map +1 -1
- package/dist-client/utils/assistant-session-controller.d.ts +1 -1
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-typing.d.ts +20 -0
- package/dist-client/utils/assistant-typing.js +48 -0
- package/dist-client/utils/assistant-typing.js.map +1 -0
- package/dist-client/utils/assistant-typing.test.d.ts +1 -0
- package/dist-client/utils/assistant-typing.test.js +30 -0
- package/dist-client/utils/assistant-typing.test.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.js +5 -12
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +1 -1
- package/dist-server/service/assistant-mode/index.js +2 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.js +9 -2
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
- package/dist-server/service/assistant-mode/screen-rules.js +10 -3
- package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
- package/dist-server/service/chat-message/chat-message-room.js +3 -1
- package/dist-server/service/chat-message/chat-message-room.js.map +1 -1
- package/dist-server/service/chat-message/chat-typing-subscription.d.ts +10 -0
- package/dist-server/service/chat-message/chat-typing-subscription.js +68 -0
- package/dist-server/service/chat-message/chat-typing-subscription.js.map +1 -0
- package/dist-server/service/chat-message/chat-typing.d.ts +28 -0
- package/dist-server/service/chat-message/chat-typing.js +40 -0
- package/dist-server/service/chat-message/chat-typing.js.map +1 -0
- package/dist-server/service/chat-session/attachment-tool-result.d.ts +1 -0
- package/dist-server/service/chat-session/attachment-tool-result.js +6 -0
- package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -1
- package/dist-server/service/chat-session/attachment-tools.js +2 -2
- package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +2 -1
- package/dist-server/service/chat-session/chat-session.js +1 -1
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-activity-broadcast.d.ts +12 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js +15 -0
- package/dist-server/service/chat-session/session-activity-broadcast.js.map +1 -1
- package/dist-server/service/chat-session/session-activity-publish.d.ts +2 -0
- package/dist-server/service/chat-session/session-activity-publish.js +5 -2
- package/dist-server/service/chat-session/session-activity-publish.js.map +1 -1
- package/dist-server/service/chat-session/session-visibility.d.ts +17 -2
- package/dist-server/service/chat-session/session-visibility.js +28 -3
- package/dist-server/service/chat-session/session-visibility.js.map +1 -1
- package/dist-server/service/chat-session/session-write-gate.d.ts +1 -1
- package/dist-server/service/chat-session/session-write-gate.js +1 -1
- package/dist-server/service/chat-session/session-write-gate.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 +16 -1
- package/dist-server/service/chat-session-participant/invite-notification.js +34 -5
- package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
- package/dist-server/service/chat-session-participant/mention-invite.d.ts +30 -4
- package/dist-server/service/chat-session-participant/mention-invite.js +78 -31
- package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -1
- package/dist-server/service/chat-session-participant/read-roster.js +5 -2
- package/dist-server/service/chat-session-participant/read-roster.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +27 -0
- package/dist-server/service/chat-session-resolver.js +226 -25
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/data-entry/entry-target.d.ts +6 -0
- package/dist-server/service/data-entry/entry-target.js.map +1 -1
- package/dist-server/service/data-entry/entry-tools.js +2 -2
- package/dist-server/service/data-entry/entry-tools.js.map +1 -1
- package/dist-server/service/data-entry/entry-update.js +1 -1
- package/dist-server/service/data-entry/entry-update.js.map +1 -1
- package/dist-server/service/data-read/list-read.d.ts +10 -0
- package/dist-server/service/data-read/list-read.js +12 -0
- package/dist-server/service/data-read/list-read.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +5 -0
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/index.d.ts +4 -2
- package/dist-server/service/index.js +9 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/testing/conversation-script.d.ts +1 -1
- package/dist-server/service/testing/conversation-script.js.map +1 -1
- package/dist-server/service/testing/mode-conformance.js +2 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/assistant-chat-resolver.ts +7 -12
- package/server/service/assistant-mode/index.ts +1 -1
- package/server/service/assistant-mode/mode-categories.ts +11 -3
- package/server/service/assistant-mode/mode-front.test.ts +53 -1
- package/server/service/assistant-mode/screen-rules.test.ts +7 -1
- package/server/service/assistant-mode/screen-rules.ts +11 -2
- package/server/service/chat-message/chat-message-room.test.ts +7 -2
- package/server/service/chat-message/chat-message-room.ts +3 -1
- package/server/service/chat-message/chat-typing-subscription.ts +52 -0
- package/server/service/chat-message/chat-typing.test.ts +45 -0
- package/server/service/chat-message/chat-typing.ts +48 -0
- package/server/service/chat-session/attachment-tool-result.test.ts +9 -1
- package/server/service/chat-session/attachment-tool-result.ts +7 -0
- package/server/service/chat-session/attachment-tools.ts +3 -3
- package/server/service/chat-session/chat-session.ts +3 -2
- package/server/service/chat-session/group-room.test.ts +233 -0
- package/server/service/chat-session/session-activity-broadcast.ts +20 -0
- package/server/service/chat-session/session-activity-publish.ts +6 -1
- package/server/service/chat-session/session-activity-receivers.test.ts +26 -0
- package/server/service/chat-session/session-visibility.test.ts +32 -1
- package/server/service/chat-session/session-visibility.ts +33 -4
- package/server/service/chat-session/session-write-gate.test.ts +7 -1
- package/server/service/chat-session/session-write-gate.ts +2 -2
- package/server/service/chat-session-participant/index.ts +2 -0
- package/server/service/chat-session-participant/invite-notification.test.ts +36 -1
- package/server/service/chat-session-participant/invite-notification.ts +43 -5
- package/server/service/chat-session-participant/mention-invite.test.ts +49 -1
- package/server/service/chat-session-participant/mention-invite.ts +82 -28
- package/server/service/chat-session-participant/read-roster.test.ts +13 -4
- package/server/service/chat-session-participant/read-roster.ts +6 -3
- package/server/service/chat-session-resolver.ts +204 -27
- package/server/service/data-entry/data-entry-update.test.ts +8 -0
- package/server/service/data-entry/entry-target.ts +6 -0
- package/server/service/data-entry/entry-tools.ts +2 -2
- package/server/service/data-entry/entry-update.ts +1 -1
- package/server/service/data-read/list-missing.test.ts +49 -0
- package/server/service/data-read/list-read.ts +18 -0
- package/server/service/data-read/read-target.ts +5 -0
- package/server/service/index.ts +6 -1
- package/server/service/testing/conversation-script.ts +1 -1
- package/server/service/testing/mode-conformance.ts +1 -0
- package/test/assistant-dock.test.ts +5 -1
- package/test/assistant-session-manager.test.ts +4 -2
- package/test/open-doors.test.ts +10 -5
- package/translations/en.json +29 -4
- package/translations/es.json +29 -4
- package/translations/ja.json +29 -4
- package/translations/ko.json +29 -4
- package/translations/ms.json +29 -4
- package/translations/zh.json +29 -4
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { replyDestination, quotedReply } from './reply-message.js'
|
|
2
|
-
import {
|
|
2
|
+
import { rosterName } from './roster-identity'
|
|
3
|
+
import { notify } from '@operato/layout'
|
|
4
|
+
import './assistant-invite.js'
|
|
5
|
+
import { TypingRoom } from '../utils/assistant-typing.js'
|
|
3
6
|
/**
|
|
4
7
|
* <ox-assistant-chat> — AI 도우미 채팅 컴포넌트 (Lit).
|
|
5
8
|
*
|
|
@@ -336,6 +339,7 @@ const CHAT_MESSAGE_SUBSCRIPTION = gql`
|
|
|
336
339
|
// C4 presence/roster — 세션 참여자 목록 + 마지막 접속(online 판정용).
|
|
337
340
|
const CHAT_PARTICIPANTS_QUERY = gql`
|
|
338
341
|
query ChatSessionParticipants($sessionId: String!) {
|
|
342
|
+
mayInviteToAISession(sessionId: $sessionId)
|
|
339
343
|
chatSessionParticipants(sessionId: $sessionId) {
|
|
340
344
|
id
|
|
341
345
|
role
|
|
@@ -344,10 +348,38 @@ const CHAT_PARTICIPANTS_QUERY = gql`
|
|
|
344
348
|
id
|
|
345
349
|
name
|
|
346
350
|
username
|
|
351
|
+
email
|
|
347
352
|
}
|
|
348
353
|
}
|
|
349
354
|
}
|
|
350
355
|
`
|
|
356
|
+
/*
|
|
357
|
+
* A group conversation (ADR-0073 decision 3) - bringing someone in and sending them out, and the room's
|
|
358
|
+
* "typing" line. The doors decide who may; the roster only offers what the server said this person may do.
|
|
359
|
+
*/
|
|
360
|
+
const INVITE_MUTATION = gql`
|
|
361
|
+
mutation InviteToAISession($sessionId: String!, $userIds: [String!]!) {
|
|
362
|
+
inviteToAISession(sessionId: $sessionId, userIds: $userIds)
|
|
363
|
+
}
|
|
364
|
+
`
|
|
365
|
+
const REMOVE_MUTATION = gql`
|
|
366
|
+
mutation RemoveFromAISession($sessionId: String!, $userId: String!) {
|
|
367
|
+
removeFromAISession(sessionId: $sessionId, userId: $userId)
|
|
368
|
+
}
|
|
369
|
+
`
|
|
370
|
+
const SAY_TYPING_MUTATION = gql`
|
|
371
|
+
mutation SayTyping($sessionId: String!) {
|
|
372
|
+
sayTyping(sessionId: $sessionId)
|
|
373
|
+
}
|
|
374
|
+
`
|
|
375
|
+
const CHAT_TYPING_SUBSCRIPTION = gql`
|
|
376
|
+
subscription ChatTyping($sessionId: String!) {
|
|
377
|
+
chatTyping(sessionId: $sessionId) {
|
|
378
|
+
userId
|
|
379
|
+
name
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
`
|
|
351
383
|
const TOUCH_PRESENCE_MUTATION = gql`
|
|
352
384
|
mutation TouchPresence($sessionId: String!) {
|
|
353
385
|
touchPresence(sessionId: $sessionId)
|
|
@@ -365,6 +397,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
365
397
|
@property({ type: String, attribute: 'session-id' })
|
|
366
398
|
sessionId?: string
|
|
367
399
|
@property({ attribute: false }) prepareSession?: (adopt: (id: string) => void) => Promise<string | undefined>
|
|
400
|
+
/** Who reads this conversation - 'open' · 'group' · 'private'. The host passes what its list says; the roster follows it. */
|
|
401
|
+
@property({ type: String }) visibility?: string
|
|
368
402
|
private adoptingSession = false
|
|
369
403
|
|
|
370
404
|
/**
|
|
@@ -939,8 +973,17 @@ export class OxAssistantChat extends LitElement {
|
|
|
939
973
|
|
|
940
974
|
/** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
|
|
941
975
|
@state()
|
|
942
|
-
private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string; username?: string } }> = []
|
|
976
|
+
private participants: Array<{ id: string; role: string; lastSeenAt?: string; user?: { id: string; name?: string; username?: string; email?: string } }> = []
|
|
943
977
|
@state() private rosterLoaded = false
|
|
978
|
+
/** May I bring someone in here - the server's answer (`mayInviteToAISession`); unknown until the roster is read. */
|
|
979
|
+
@state() private mayInvite?: boolean
|
|
980
|
+
/** The name box under the roster is open. */
|
|
981
|
+
@state() private inviting = false
|
|
982
|
+
/** Who is typing now, by name - kept by `typingRoom`, redrawn on each notice and as they fade. */
|
|
983
|
+
@state() private typingNames: string[] = []
|
|
984
|
+
private typingRoom = new TypingRoom()
|
|
985
|
+
private _typingSub?: { unsubscribe: () => void }
|
|
986
|
+
private _typingTimer?: ReturnType<typeof setInterval>
|
|
944
987
|
|
|
945
988
|
/** roster online 재평가 트리거 — heartbeat tick 마다 갱신(시간 경과 반영). */
|
|
946
989
|
@state()
|
|
@@ -1209,6 +1252,52 @@ export class OxAssistantChat extends LitElement {
|
|
|
1209
1252
|
.roster-chip.online .roster-dot {
|
|
1210
1253
|
background: #22c55e;
|
|
1211
1254
|
}
|
|
1255
|
+
.roster-remove {
|
|
1256
|
+
display: inline-flex;
|
|
1257
|
+
border: 0;
|
|
1258
|
+
padding: 0;
|
|
1259
|
+
margin-left: 1px;
|
|
1260
|
+
background: none;
|
|
1261
|
+
color: inherit;
|
|
1262
|
+
opacity: 0.6;
|
|
1263
|
+
cursor: pointer;
|
|
1264
|
+
border-radius: 50%;
|
|
1265
|
+
}
|
|
1266
|
+
.roster-remove:hover {
|
|
1267
|
+
opacity: 1;
|
|
1268
|
+
}
|
|
1269
|
+
.roster-remove md-icon {
|
|
1270
|
+
--md-icon-size: 11px;
|
|
1271
|
+
}
|
|
1272
|
+
.roster-invite {
|
|
1273
|
+
display: inline-flex;
|
|
1274
|
+
align-items: center;
|
|
1275
|
+
gap: 2px;
|
|
1276
|
+
padding: 1px 7px 1px 5px;
|
|
1277
|
+
border: 1px solid var(--md-sys-color-outline-variant, #cbd5e1);
|
|
1278
|
+
border-radius: 9px;
|
|
1279
|
+
background: none;
|
|
1280
|
+
color: inherit;
|
|
1281
|
+
font: inherit;
|
|
1282
|
+
font-size: 10px;
|
|
1283
|
+
cursor: pointer;
|
|
1284
|
+
}
|
|
1285
|
+
.roster-invite:hover {
|
|
1286
|
+
background: var(--md-sys-color-surface-container-high, #eef2f7);
|
|
1287
|
+
}
|
|
1288
|
+
.roster-invite md-icon {
|
|
1289
|
+
--md-icon-size: 12px;
|
|
1290
|
+
}
|
|
1291
|
+
.typing-line .typing-dots { gap: 3px; }
|
|
1292
|
+
.typing-line .typing-dots > span { width: 4px; height: 4px; }
|
|
1293
|
+
.typing-line {
|
|
1294
|
+
display: flex;
|
|
1295
|
+
align-items: center;
|
|
1296
|
+
gap: 6px;
|
|
1297
|
+
margin: 0 2px 4px;
|
|
1298
|
+
color: var(--md-sys-color-on-surface-variant, #475569);
|
|
1299
|
+
font-size: 11.5px;
|
|
1300
|
+
}
|
|
1212
1301
|
|
|
1213
1302
|
/* ── Session tabs ─────────────────────────────────── */
|
|
1214
1303
|
.session-tabs {
|
|
@@ -2920,6 +3009,10 @@ export class OxAssistantChat extends LitElement {
|
|
|
2920
3009
|
this.input = ''
|
|
2921
3010
|
this.participants = []
|
|
2922
3011
|
this.rosterLoaded = false
|
|
3012
|
+
this.mayInvite = undefined
|
|
3013
|
+
this.inviting = false
|
|
3014
|
+
this.typingRoom.clear()
|
|
3015
|
+
this.typingNames = []
|
|
2923
3016
|
this.replyingTo = undefined
|
|
2924
3017
|
this._seenMessageIds.clear() // 세션 전환 — 에코 dedup 컨텍스트 리셋
|
|
2925
3018
|
if (this.sessionId) {
|
|
@@ -2992,6 +3085,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
2992
3085
|
private _startMessageSubscription() {
|
|
2993
3086
|
this._stopMessageSubscription()
|
|
2994
3087
|
if (!this.sessionId) return
|
|
3088
|
+
this._startTypingSubscription()
|
|
2995
3089
|
try {
|
|
2996
3090
|
this._messageSub = client
|
|
2997
3091
|
.subscribe({ query: CHAT_MESSAGE_SUBSCRIPTION, variables: { sessionId: this.sessionId } })
|
|
@@ -3006,7 +3100,47 @@ export class OxAssistantChat extends LitElement {
|
|
|
3006
3100
|
}
|
|
3007
3101
|
}
|
|
3008
3102
|
|
|
3103
|
+
/** The room's "typing" line - notices of the others, which fade by themselves (`TypingRoom`). */
|
|
3104
|
+
private _startTypingSubscription() {
|
|
3105
|
+
try {
|
|
3106
|
+
this._typingSub = client
|
|
3107
|
+
.subscribe({ query: CHAT_TYPING_SUBSCRIPTION, variables: { sessionId: this.sessionId } })
|
|
3108
|
+
.subscribe({
|
|
3109
|
+
next: ({ data }: any) => {
|
|
3110
|
+
if (!data?.chatTyping) return
|
|
3111
|
+
this.typingRoom.heard(data.chatTyping)
|
|
3112
|
+
this._refreshTyping()
|
|
3113
|
+
},
|
|
3114
|
+
error: () => {
|
|
3115
|
+
/* no typing line - the conversation itself is unaffected */
|
|
3116
|
+
}
|
|
3117
|
+
})
|
|
3118
|
+
} catch {
|
|
3119
|
+
/* subscriptions unavailable */
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
private _refreshTyping() {
|
|
3124
|
+
this.typingNames = this.typingRoom.typing()
|
|
3125
|
+
if (this.typingNames.length && !this._typingTimer) {
|
|
3126
|
+
this._typingTimer = setInterval(() => this._refreshTyping(), 1_000)
|
|
3127
|
+
} else if (!this.typingNames.length && this._typingTimer) {
|
|
3128
|
+
clearInterval(this._typingTimer)
|
|
3129
|
+
this._typingTimer = undefined
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3009
3133
|
private _stopMessageSubscription() {
|
|
3134
|
+
try {
|
|
3135
|
+
this._typingSub?.unsubscribe()
|
|
3136
|
+
} catch {
|
|
3137
|
+
/* already closed */
|
|
3138
|
+
}
|
|
3139
|
+
this._typingSub = undefined
|
|
3140
|
+
if (this._typingTimer) {
|
|
3141
|
+
clearInterval(this._typingTimer)
|
|
3142
|
+
this._typingTimer = undefined
|
|
3143
|
+
}
|
|
3010
3144
|
if (this._messageSub) {
|
|
3011
3145
|
try {
|
|
3012
3146
|
this._messageSub.unsubscribe()
|
|
@@ -3027,6 +3161,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
3027
3161
|
* 자기 발신 메시지의 id 는 전송 reconcile 에서 이미 _seenMessageIds 에 등록됨.
|
|
3028
3162
|
*/
|
|
3029
3163
|
private _onRemoteMessage(m: any) {
|
|
3164
|
+
/* Their words arrived - they are not typing them any more. */
|
|
3165
|
+
if (this.typingRoom.spoke(m?.creator?.id)) this._refreshTyping()
|
|
3030
3166
|
const next = receiveRemoteMessage(this.lines as any, this._seenMessageIds, m)
|
|
3031
3167
|
if (!next) return
|
|
3032
3168
|
this._seenMessageIds = next.seen
|
|
@@ -3050,6 +3186,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
3050
3186
|
const result = await client.query({query:CHAT_PARTICIPANTS_QUERY,variables:{sessionId},fetchPolicy:'network-only'})
|
|
3051
3187
|
if (this.sessionId !== sessionId) return
|
|
3052
3188
|
this.participants=result.data?.chatSessionParticipants??[]
|
|
3189
|
+
this.mayInvite=result.data?.mayInviteToAISession === true
|
|
3053
3190
|
this.rosterLoaded=true
|
|
3054
3191
|
} catch {
|
|
3055
3192
|
if (this.sessionId === sessionId) this.rosterLoaded=false
|
|
@@ -3327,6 +3464,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
3327
3464
|
<button type="button" aria-label=${i18next.t('ai-assistant.button.cancel-reply')}
|
|
3328
3465
|
@click=${() => this.cancelReply()}><md-icon>close</md-icon></button>
|
|
3329
3466
|
</div>` : nothing}
|
|
3467
|
+
${this.renderTyping()}
|
|
3330
3468
|
${this.renderInviteNotice()}
|
|
3331
3469
|
${this.addressesPeople
|
|
3332
3470
|
? html`<div class="send-destination" aria-live="polite">
|
|
@@ -3509,7 +3647,8 @@ export class OxAssistantChat extends LitElement {
|
|
|
3509
3647
|
.map(([token]) => token)
|
|
3510
3648
|
if (incoming.length === 0) return nothing
|
|
3511
3649
|
|
|
3512
|
-
|
|
3650
|
+
/* What the server said this person may do here - no copy of the rule on the screen (`maySeat`). */
|
|
3651
|
+
const inviteState = !this.rosterLoaded || this.mayInvite === undefined ? 'checking' : this.mayInvite ? 'allowed' : 'denied'
|
|
3513
3652
|
const mayInvite = inviteState === 'allowed'
|
|
3514
3653
|
const names = incoming.map(token => `@${token}`).join(', ')
|
|
3515
3654
|
|
|
@@ -3522,10 +3661,15 @@ export class OxAssistantChat extends LitElement {
|
|
|
3522
3661
|
names,
|
|
3523
3662
|
defaultValue: '{names} will join this conversation when you send'
|
|
3524
3663
|
})
|
|
3525
|
-
:
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3664
|
+
: this.visibility === 'private'
|
|
3665
|
+
? i18next.t('ai-assistant.text.cannot-invite-private', {
|
|
3666
|
+
names,
|
|
3667
|
+
defaultValue: '{names} will not join — a private conversation is yours alone. Make it a group to bring people in'
|
|
3668
|
+
})
|
|
3669
|
+
: i18next.t('ai-assistant.text.cannot-invite-group', {
|
|
3670
|
+
names,
|
|
3671
|
+
defaultValue: '{names} will not join — only the person who started this group can bring someone in'
|
|
3672
|
+
})}
|
|
3529
3673
|
</span>
|
|
3530
3674
|
</div>
|
|
3531
3675
|
`
|
|
@@ -3662,6 +3806,7 @@ export class OxAssistantChat extends LitElement {
|
|
|
3662
3806
|
private onTextareaInput(e: Event) {
|
|
3663
3807
|
const ta = e.target as HTMLTextAreaElement
|
|
3664
3808
|
this.input = ta.value
|
|
3809
|
+
this._sayTyping()
|
|
3665
3810
|
if (this.inputDebounceTimer) clearTimeout(this.inputDebounceTimer)
|
|
3666
3811
|
this.inputDebounceTimer = setTimeout(() => {
|
|
3667
3812
|
this.refreshMentionPopup(ta)
|
|
@@ -4018,27 +4163,111 @@ export class OxAssistantChat extends LitElement {
|
|
|
4018
4163
|
}
|
|
4019
4164
|
|
|
4020
4165
|
/** C4 roster — 세션 참여자 + online 표시. 참여자 2명 미만이면 숨김(혼자면 의미 없음). */
|
|
4166
|
+
/**
|
|
4167
|
+
* Who is in the room. A group shows it always - its people are the point of it, and the owner brings them in
|
|
4168
|
+
* here; any other room once someone else has joined. The buttons follow what the server said this person may
|
|
4169
|
+
* do (`mayInviteToAISession`, the owner's role); the doors decide again.
|
|
4170
|
+
*/
|
|
4021
4171
|
private renderRoster() {
|
|
4022
|
-
if (!this.sessionId
|
|
4172
|
+
if (!this.sessionId) return nothing
|
|
4173
|
+
const group = this.visibility === 'group'
|
|
4174
|
+
if (!group && this.participants.length < 2) return nothing
|
|
4023
4175
|
const online = this.participants.filter(p => this._isOnline(p.lastSeenAt)).length
|
|
4176
|
+
/* Who I am is my email here, as everywhere in this chat - the credential carries no user id. */
|
|
4177
|
+
const myEmail = this._myEmail
|
|
4178
|
+
const iOwn = !!myEmail && this.participants.some(p => p.role === 'owner' && p.user?.email === myEmail)
|
|
4179
|
+
const inviteLabel = i18next.t('ai-assistant.button.invite', { defaultValue: 'Invite' })
|
|
4024
4180
|
return html`
|
|
4025
|
-
<div class="roster" role="group" aria-label
|
|
4181
|
+
<div class="roster" role="group" aria-label=${i18next.t('ai-assistant.label.participants', { defaultValue: 'People in this conversation' })}>
|
|
4026
4182
|
<span class="roster-count" title=${`${online} online / ${this.participants.length} total`}>
|
|
4027
4183
|
<md-icon>group</md-icon>${online}/${this.participants.length}
|
|
4028
4184
|
</span>
|
|
4029
4185
|
${this.participants.map(p => {
|
|
4030
4186
|
const name = rosterName(p,String(i18next.t('ai-assistant.text.participant-unavailable')))
|
|
4031
4187
|
const on = this._isOnline(p.lastSeenAt)
|
|
4188
|
+
const removable = iOwn && p.role === 'member' && !!p.user?.id
|
|
4032
4189
|
return html`<span
|
|
4033
4190
|
class="roster-chip ${on ? 'online' : 'offline'}"
|
|
4034
4191
|
title=${`${name} · ${p.role}${on ? ' · online' : ''}`}>
|
|
4035
4192
|
<span class="roster-dot"></span><span class="roster-name">${name}</span>
|
|
4193
|
+
${removable
|
|
4194
|
+
? html`<button
|
|
4195
|
+
type="button"
|
|
4196
|
+
class="roster-remove"
|
|
4197
|
+
aria-label=${i18next.t('ai-assistant.button.remove-participant', { name, defaultValue: 'Send {name} out' })}
|
|
4198
|
+
title=${i18next.t('ai-assistant.button.remove-participant', { name, defaultValue: 'Send {name} out' })}
|
|
4199
|
+
@click=${() => this._removeParticipant(p.user!.id, name)}><md-icon>close</md-icon></button>`
|
|
4200
|
+
: nothing}
|
|
4036
4201
|
</span>`
|
|
4037
4202
|
})}
|
|
4203
|
+
${this.mayInvite && !this.inviting
|
|
4204
|
+
? html`<button type="button" class="roster-invite" @click=${() => (this.inviting = true)}>
|
|
4205
|
+
<md-icon>person_add</md-icon>${inviteLabel}
|
|
4206
|
+
</button>`
|
|
4207
|
+
: nothing}
|
|
4038
4208
|
</div>
|
|
4209
|
+
${this.inviting
|
|
4210
|
+
? html`<ox-assistant-invite
|
|
4211
|
+
.search=${(query: string) => (this.userProvider ? Promise.resolve(this.userProvider(query)) : this.fetchDomainUsers(query))}
|
|
4212
|
+
.exclude=${this.participants.map(p => p.user?.id).filter(Boolean) as string[]}
|
|
4213
|
+
@assistant-invite-pick=${(e: CustomEvent) => this._invite(e.detail.userId, e.detail.name)}
|
|
4214
|
+
@assistant-invite-close=${() => (this.inviting = false)}></ox-assistant-invite>`
|
|
4215
|
+
: nothing}
|
|
4039
4216
|
`
|
|
4040
4217
|
}
|
|
4041
4218
|
|
|
4219
|
+
/** Bring someone in - the door seats them and tells them (`inviteToAISession`). */
|
|
4220
|
+
private async _invite(userId: string, name: string) {
|
|
4221
|
+
const sessionId = this.sessionId
|
|
4222
|
+
if (!sessionId || !userId) return
|
|
4223
|
+
try {
|
|
4224
|
+
const result = await client.mutate({ mutation: INVITE_MUTATION, variables: { sessionId, userIds: [userId] } })
|
|
4225
|
+
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
4226
|
+
if (this.sessionId !== sessionId) return
|
|
4227
|
+
this.inviting = false
|
|
4228
|
+
notify({ level: 'info', message: i18next.t('ai-assistant.text.invited', { name, defaultValue: '{name} can now read and write here, and has been told.' }) })
|
|
4229
|
+
await this._loadParticipants()
|
|
4230
|
+
} catch (error) {
|
|
4231
|
+
this.errorMessage = error instanceof Error ? error.message : String(error)
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
/** Send a member out - in a group they no longer read or write here. Bringing them back is inviting them again. */
|
|
4236
|
+
private async _removeParticipant(userId: string, name: string) {
|
|
4237
|
+
const sessionId = this.sessionId
|
|
4238
|
+
if (!sessionId) return
|
|
4239
|
+
try {
|
|
4240
|
+
const result = await client.mutate({ mutation: REMOVE_MUTATION, variables: { sessionId, userId } })
|
|
4241
|
+
if (result.errors?.length) throw new Error(result.errors[0].message)
|
|
4242
|
+
if (this.sessionId !== sessionId) return
|
|
4243
|
+
notify({ level: 'info', message: i18next.t('ai-assistant.text.removed-participant', { name, defaultValue: '{name} has left this conversation.' }) })
|
|
4244
|
+
await this._loadParticipants()
|
|
4245
|
+
} catch (error) {
|
|
4246
|
+
this.errorMessage = error instanceof Error ? error.message : String(error)
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
/** Tell the others I am typing - only where someone else could hear it, and not on every key (`TypingRoom`). */
|
|
4251
|
+
private _sayTyping() {
|
|
4252
|
+
const sessionId = this.sessionId
|
|
4253
|
+
if (!sessionId || this.visibility === 'private') return
|
|
4254
|
+
if (this.visibility !== 'open' && this.participants.length < 2) return
|
|
4255
|
+
if (!this.typingRoom.shouldSay(this.input)) return
|
|
4256
|
+
client.mutate({ mutation: SAY_TYPING_MUTATION, variables: { sessionId } }).catch(() => {
|
|
4257
|
+
/* a missed notice is a line that does not show */
|
|
4258
|
+
})
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
/** "Kim is typing…" - over the box, while their notices keep coming. */
|
|
4262
|
+
private renderTyping() {
|
|
4263
|
+
const names = this.typingNames
|
|
4264
|
+
if (!names.length) return nothing
|
|
4265
|
+
const who = names.length > 2 ? i18next.t('ai-assistant.text.typing-many', { count: names.length, defaultValue: '{count} people are typing' }) : i18next.t('ai-assistant.text.typing', { names: names.join(', '), defaultValue: '{names} typing' })
|
|
4266
|
+
return html`<div class="typing-line" role="status" aria-live="polite">
|
|
4267
|
+
<span class="typing-dots"><span></span><span></span><span></span></span><span>${who}</span>
|
|
4268
|
+
</div>`
|
|
4269
|
+
}
|
|
4270
|
+
|
|
4042
4271
|
private _onSessionTabClick(sessionId: string) {
|
|
4043
4272
|
if (sessionId === this.sessionId) return
|
|
4044
4273
|
this.dispatchEvent(
|
|
@@ -577,10 +577,12 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
577
577
|
if (!ask) return
|
|
578
578
|
|
|
579
579
|
if (!ask.continueCurrent && (ask.name || ask.context || ask.transcript?.length)) {
|
|
580
|
-
const id = await this.startCarried(ask).catch(
|
|
581
|
-
notify({ level: 'error', message: (
|
|
580
|
+
const id = await this.startCarried(ask).catch(() => {
|
|
581
|
+
notify({ level: 'error', message: t('carry-failed', 'The selected context could not be attached. Nothing was sent. Try again from the original screen.') })
|
|
582
582
|
return undefined
|
|
583
583
|
})
|
|
584
|
+
// Never send a context-dependent request into the previous conversation after a failed handoff.
|
|
585
|
+
if (!id) return
|
|
584
586
|
if (id) {
|
|
585
587
|
await this.reloadSessions()
|
|
586
588
|
this.controller.select(id)
|
|
@@ -605,10 +607,11 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
605
607
|
`,
|
|
606
608
|
variables: { t: anchor.anchorType, i: anchor.anchorId, n: ask.name || null }
|
|
607
609
|
})
|
|
610
|
+
if (created.errors?.length) throw new Error(created.errors[0].message)
|
|
608
611
|
const id = created.data?.createAISession?.id
|
|
609
|
-
if (!id)
|
|
612
|
+
if (!id) throw new Error('Session was not created')
|
|
610
613
|
if (ask.context) {
|
|
611
|
-
await client.mutate({
|
|
614
|
+
const attached = await client.mutate({
|
|
612
615
|
mutation: gql`
|
|
613
616
|
mutation ($id: String!, $c: String!) {
|
|
614
617
|
appendSessionNote(sessionId: $id, content: $c)
|
|
@@ -616,6 +619,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
616
619
|
`,
|
|
617
620
|
variables: { id, c: ask.context }
|
|
618
621
|
})
|
|
622
|
+
if (attached.errors?.length) throw new Error(attached.errors[0].message)
|
|
619
623
|
}
|
|
620
624
|
if (ask.transcript?.length) {
|
|
621
625
|
await client.mutate({
|
|
@@ -847,6 +851,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
|
|
|
847
851
|
html`<ox-assistant-chat
|
|
848
852
|
?hidden=${this.showingHistory}
|
|
849
853
|
.sessionId=${this.sessionId}
|
|
854
|
+
.visibility=${this.audience}
|
|
850
855
|
.prepareSession=${(adopt: (id: string) => void) => this.controller.prepareSession(adopt)}
|
|
851
856
|
.contextProvider=${() => {
|
|
852
857
|
/* The screen moved on between writing and sending - do not answer about the old one. */
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import '@material/web/icon/icon.js'
|
|
2
|
+
|
|
3
|
+
import { css, html, LitElement, nothing, type PropertyValues } from 'lit'
|
|
4
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
5
|
+
|
|
6
|
+
import { i18next } from '@operato/i18n'
|
|
7
|
+
|
|
8
|
+
export interface InviteCandidate {
|
|
9
|
+
id: string
|
|
10
|
+
name?: string | null
|
|
11
|
+
email?: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Bring someone into the conversation - a name box under the roster, and the people of the domain who match.
|
|
16
|
+
*
|
|
17
|
+
* It only picks. The chat calls the door (`inviteToAISession`), and the door decides: anyone in an open room, the
|
|
18
|
+
* owner in a group, nobody in a private one. People already in the room are not offered.
|
|
19
|
+
*
|
|
20
|
+
* Events:
|
|
21
|
+
* assistant-invite-pick { userId, name }
|
|
22
|
+
* assistant-invite-close
|
|
23
|
+
*/
|
|
24
|
+
@customElement('ox-assistant-invite')
|
|
25
|
+
export class AssistantInvite extends LitElement {
|
|
26
|
+
static styles = css`
|
|
27
|
+
:host { display: block; padding: 4px 8px 6px; border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0); }
|
|
28
|
+
.box { display: flex; align-items: center; gap: 4px; }
|
|
29
|
+
input { flex: 1; min-width: 0; font: inherit; font-size: 12px; padding: 3px 6px; border: 1px solid var(--md-sys-color-outline-variant, #cbd5e1); border-radius: 4px; background: var(--md-sys-color-surface, #fff); color: inherit; }
|
|
30
|
+
button { font: inherit; cursor: pointer; }
|
|
31
|
+
.close { border: 0; background: none; padding: 2px; border-radius: 4px; color: var(--md-sys-color-on-surface-variant); line-height: 0; }
|
|
32
|
+
.close md-icon { --md-icon-size: 16px; }
|
|
33
|
+
ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 160px; overflow: auto; }
|
|
34
|
+
li button { display: flex; gap: 6px; align-items: baseline; width: 100%; text-align: start; border: 0; border-radius: 4px; padding: 3px 6px; background: none; color: inherit; font-size: 12px; }
|
|
35
|
+
li button:hover, li button.active { background: var(--md-sys-color-surface-container-high, #eef2f7); }
|
|
36
|
+
.email { font-size: 11px; color: var(--md-sys-color-on-surface-variant); }
|
|
37
|
+
.note { margin-top: 4px; font-size: 11px; color: var(--md-sys-color-on-surface-variant); }
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
/** Finds the people of the domain by a part of a name or an email. */
|
|
41
|
+
@property({ attribute: false }) search?: (query: string) => Promise<InviteCandidate[]>
|
|
42
|
+
/** Ids already in the room - not offered. */
|
|
43
|
+
@property({ attribute: false }) exclude: string[] = []
|
|
44
|
+
|
|
45
|
+
@state() private query = ''
|
|
46
|
+
@state() private found: InviteCandidate[] = []
|
|
47
|
+
@state() private active = 0
|
|
48
|
+
@state() private searched = false
|
|
49
|
+
|
|
50
|
+
private generation = 0
|
|
51
|
+
private timer?: ReturnType<typeof setTimeout>
|
|
52
|
+
|
|
53
|
+
protected firstUpdated() {
|
|
54
|
+
this.renderRoot.querySelector('input')?.focus()
|
|
55
|
+
void this.lookUp()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
protected updated(changed: PropertyValues) {
|
|
59
|
+
if (changed.has('exclude') && changed.get('exclude') !== undefined) void this.lookUp()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
disconnectedCallback() {
|
|
63
|
+
clearTimeout(this.timer)
|
|
64
|
+
this.generation++
|
|
65
|
+
super.disconnectedCallback()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async lookUp() {
|
|
69
|
+
const generation = ++this.generation
|
|
70
|
+
const people = this.search ? await this.search(this.query.trim()).catch(() => []) : []
|
|
71
|
+
if (generation !== this.generation) return
|
|
72
|
+
const here = new Set(this.exclude)
|
|
73
|
+
this.found = people.filter(person => person?.id && !here.has(person.id)).slice(0, 8)
|
|
74
|
+
this.active = 0
|
|
75
|
+
this.searched = true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private onInput(value: string) {
|
|
79
|
+
this.query = value
|
|
80
|
+
clearTimeout(this.timer)
|
|
81
|
+
this.timer = setTimeout(() => void this.lookUp(), 150)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private onKeyDown(event: KeyboardEvent) {
|
|
85
|
+
if (event.key === 'Escape') {
|
|
86
|
+
event.stopPropagation()
|
|
87
|
+
this.emit('assistant-invite-close')
|
|
88
|
+
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
89
|
+
event.preventDefault()
|
|
90
|
+
const step = event.key === 'ArrowDown' ? 1 : -1
|
|
91
|
+
if (this.found.length) this.active = (this.active + step + this.found.length) % this.found.length
|
|
92
|
+
} else if (event.key === 'Enter' && !event.isComposing) {
|
|
93
|
+
event.preventDefault()
|
|
94
|
+
const person = this.found[this.active]
|
|
95
|
+
if (person) this.pick(person)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private pick(person: InviteCandidate) {
|
|
100
|
+
this.emit('assistant-invite-pick', { userId: person.id, name: person.name || person.email || '' })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private emit(name: string, detail?: unknown) {
|
|
104
|
+
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
render() {
|
|
108
|
+
const label = i18next.t('ai-assistant.text.invite-search', { defaultValue: 'Name or email' })
|
|
109
|
+
return html`
|
|
110
|
+
<div class="box">
|
|
111
|
+
<input
|
|
112
|
+
.value=${this.query}
|
|
113
|
+
placeholder=${label}
|
|
114
|
+
aria-label=${label}
|
|
115
|
+
@input=${(e: Event) => this.onInput((e.target as HTMLInputElement).value)}
|
|
116
|
+
@keydown=${(e: KeyboardEvent) => this.onKeyDown(e)} />
|
|
117
|
+
<button
|
|
118
|
+
class="close"
|
|
119
|
+
aria-label=${i18next.t('ai-assistant.button.close')}
|
|
120
|
+
title=${i18next.t('ai-assistant.button.close')}
|
|
121
|
+
@click=${() => this.emit('assistant-invite-close')}><md-icon>close</md-icon></button>
|
|
122
|
+
</div>
|
|
123
|
+
${this.found.length
|
|
124
|
+
? html`<ul role="listbox" aria-label=${label}>
|
|
125
|
+
${this.found.map(
|
|
126
|
+
(person, i) => html`<li>
|
|
127
|
+
<button role="option" aria-selected=${i === this.active ? 'true' : 'false'} class=${i === this.active ? 'active' : ''} @click=${() => this.pick(person)}>
|
|
128
|
+
<span>${person.name || person.email}</span>${person.name && person.email ? html`<span class="email">${person.email}</span>` : nothing}
|
|
129
|
+
</button>
|
|
130
|
+
</li>`
|
|
131
|
+
)}
|
|
132
|
+
</ul>`
|
|
133
|
+
: this.searched
|
|
134
|
+
? html`<div class="note" role="status">${i18next.t('ai-assistant.text.invite-none-found', { defaultValue: 'No one else here by that name.' })}</div>`
|
|
135
|
+
: nothing}
|
|
136
|
+
`
|
|
137
|
+
}
|
|
138
|
+
}
|