@things-factory/ai-assistant 10.1.47 → 10.1.52

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.
Files changed (160) hide show
  1. package/client/components/assistant-chat.ts +97 -19
  2. package/client/components/assistant-dock.ts +617 -0
  3. package/client/components/assistant-mode.ts +2 -0
  4. package/client/components/assistant-session-manager.ts +607 -0
  5. package/client/components/chat-echo-dedup.ts +4 -0
  6. package/client/components/chat-input-builder.ts +4 -1
  7. package/client/components/panel-aria.test.ts +13 -8
  8. package/client/index.ts +8 -0
  9. package/client/setup-assistant-dock.ts +118 -0
  10. package/client/utils/assistant-activity.ts +122 -0
  11. package/client/utils/assistant-ask.ts +44 -0
  12. package/client/utils/assistant-proposals.ts +50 -0
  13. package/client/utils/assistant-session-actions.ts +18 -0
  14. package/client/utils/assistant-session-admin.ts +167 -0
  15. package/client/utils/assistant-session-controller.ts +2 -0
  16. package/client/utils/assistant-session-transport.ts +6 -2
  17. package/client/utils/assistant-surface.ts +157 -0
  18. package/client/utils/attachment-policy.ts +71 -0
  19. package/dist-client/components/assistant-chat.d.ts +6 -0
  20. package/dist-client/components/assistant-chat.js +98 -20
  21. package/dist-client/components/assistant-chat.js.map +1 -1
  22. package/dist-client/components/assistant-dock.d.ts +74 -0
  23. package/dist-client/components/assistant-dock.js +641 -0
  24. package/dist-client/components/assistant-dock.js.map +1 -0
  25. package/dist-client/components/assistant-mode.d.ts +6 -0
  26. package/dist-client/components/assistant-mode.js.map +1 -1
  27. package/dist-client/components/assistant-session-manager.d.ts +77 -0
  28. package/dist-client/components/assistant-session-manager.js +652 -0
  29. package/dist-client/components/assistant-session-manager.js.map +1 -0
  30. package/dist-client/components/chat-echo-dedup.js +4 -0
  31. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  32. package/dist-client/components/chat-input-builder.d.ts +2 -0
  33. package/dist-client/components/chat-input-builder.js +2 -1
  34. package/dist-client/components/chat-input-builder.js.map +1 -1
  35. package/dist-client/components/panel-aria.test.js +12 -8
  36. package/dist-client/components/panel-aria.test.js.map +1 -1
  37. package/dist-client/index.d.ts +8 -0
  38. package/dist-client/index.js +8 -0
  39. package/dist-client/index.js.map +1 -1
  40. package/dist-client/setup-assistant-dock.d.ts +15 -0
  41. package/dist-client/setup-assistant-dock.js +119 -0
  42. package/dist-client/setup-assistant-dock.js.map +1 -0
  43. package/dist-client/tsconfig.tsbuildinfo +1 -1
  44. package/dist-client/utils/assistant-activity.d.ts +22 -0
  45. package/dist-client/utils/assistant-activity.js +104 -0
  46. package/dist-client/utils/assistant-activity.js.map +1 -0
  47. package/dist-client/utils/assistant-ask.d.ts +23 -0
  48. package/dist-client/utils/assistant-ask.js +18 -0
  49. package/dist-client/utils/assistant-ask.js.map +1 -0
  50. package/dist-client/utils/assistant-proposals.d.ts +29 -0
  51. package/dist-client/utils/assistant-proposals.js +22 -0
  52. package/dist-client/utils/assistant-proposals.js.map +1 -0
  53. package/dist-client/utils/assistant-session-actions.d.ts +12 -0
  54. package/dist-client/utils/assistant-session-actions.js +19 -0
  55. package/dist-client/utils/assistant-session-actions.js.map +1 -0
  56. package/dist-client/utils/assistant-session-admin.d.ts +72 -0
  57. package/dist-client/utils/assistant-session-admin.js +81 -0
  58. package/dist-client/utils/assistant-session-admin.js.map +1 -0
  59. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  60. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  61. package/dist-client/utils/assistant-session-transport.js +6 -2
  62. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  63. package/dist-client/utils/assistant-surface.d.ts +72 -0
  64. package/dist-client/utils/assistant-surface.js +99 -0
  65. package/dist-client/utils/assistant-surface.js.map +1 -0
  66. package/dist-client/utils/attachment-policy.d.ts +35 -0
  67. package/dist-client/utils/attachment-policy.js +56 -0
  68. package/dist-client/utils/attachment-policy.js.map +1 -0
  69. package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
  70. package/dist-server/service/assistant-chat-resolver.js +71 -3
  71. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  72. package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
  73. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
  74. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  75. package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
  76. package/dist-server/service/assistant-mode/resolve.js +6 -2
  77. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  78. package/dist-server/service/assistant-mode/types.d.ts +6 -0
  79. package/dist-server/service/assistant-mode/types.js +0 -9
  80. package/dist-server/service/assistant-mode/types.js.map +1 -1
  81. package/dist-server/service/chat-message/chat-message.d.ts +6 -0
  82. package/dist-server/service/chat-message/chat-message.js +19 -0
  83. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  84. package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
  85. package/dist-server/service/chat-session/attachment-policy.js +56 -0
  86. package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
  87. package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
  88. package/dist-server/service/chat-session/attachment-route.js +44 -0
  89. package/dist-server/service/chat-session/attachment-route.js.map +1 -0
  90. package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
  91. package/dist-server/service/chat-session/attachment-tools.js +59 -0
  92. package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
  93. package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
  94. package/dist-server/service/chat-session/session-attachments.js +84 -0
  95. package/dist-server/service/chat-session/session-attachments.js.map +1 -0
  96. package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
  97. package/dist-server/service/chat-session/session-inbox.js +32 -8
  98. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  99. package/dist-server/service/chat-session/session-purge.d.ts +14 -0
  100. package/dist-server/service/chat-session/session-purge.js +77 -0
  101. package/dist-server/service/chat-session/session-purge.js.map +1 -0
  102. package/dist-server/service/chat-session/session-retention.d.ts +6 -0
  103. package/dist-server/service/chat-session/session-retention.js +20 -0
  104. package/dist-server/service/chat-session/session-retention.js.map +1 -0
  105. package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
  106. package/dist-server/service/chat-session/session-visibility.js +11 -0
  107. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  108. package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
  109. package/dist-server/service/chat-session/session-write-gate.js +24 -0
  110. package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
  111. package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
  112. package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
  113. package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
  114. package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
  115. package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
  116. package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
  117. package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
  118. package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
  119. package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
  120. package/dist-server/service/chat-session-resolver.d.ts +17 -2
  121. package/dist-server/service/chat-session-resolver.js +135 -15
  122. package/dist-server/service/chat-session-resolver.js.map +1 -1
  123. package/dist-server/service/index.d.ts +5 -0
  124. package/dist-server/service/index.js +5 -0
  125. package/dist-server/service/index.js.map +1 -1
  126. package/dist-server/tsconfig.tsbuildinfo +1 -1
  127. package/package.json +9 -2
  128. package/server/service/assistant-chat-resolver.ts +71 -4
  129. package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
  130. package/server/service/assistant-mode/resolve.ts +9 -2
  131. package/server/service/assistant-mode/types.ts +6 -0
  132. package/server/service/chat-message/chat-message.ts +17 -0
  133. package/server/service/chat-session/attachment-policy.test.ts +42 -0
  134. package/server/service/chat-session/attachment-policy.ts +68 -0
  135. package/server/service/chat-session/attachment-route.ts +45 -0
  136. package/server/service/chat-session/attachment-tools.ts +63 -0
  137. package/server/service/chat-session/session-attachments.ts +89 -0
  138. package/server/service/chat-session/session-inbox.test.ts +71 -1
  139. package/server/service/chat-session/session-inbox.ts +61 -11
  140. package/server/service/chat-session/session-purge.test.ts +24 -0
  141. package/server/service/chat-session/session-purge.ts +81 -0
  142. package/server/service/chat-session/session-retention.ts +17 -0
  143. package/server/service/chat-session/session-visibility.ts +11 -0
  144. package/server/service/chat-session/session-write-gate.test.ts +53 -0
  145. package/server/service/chat-session/session-write-gate.ts +31 -0
  146. package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
  147. package/server/service/chat-session-participant/invite-notification.ts +24 -6
  148. package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
  149. package/server/service/chat-session-participant/participant-lookup.ts +22 -0
  150. package/server/service/chat-session-resolver.ts +130 -13
  151. package/server/service/index.ts +5 -0
  152. package/test/assistant-dock.test.ts +124 -0
  153. package/test/assistant-session-manager.test.ts +126 -0
  154. package/test/attachment-policy-agree.test.ts +32 -0
  155. package/test/open-doors.test.ts +5 -2
  156. package/translations/en.json +61 -1
  157. package/translations/ja.json +61 -1
  158. package/translations/ko.json +61 -1
  159. package/translations/ms.json +61 -1
  160. package/translations/zh.json +61 -1
@@ -14,13 +14,15 @@ import { In } from 'typeorm'
14
14
 
15
15
  import { ChatSession } from './chat-session/chat-session.js'
16
16
  import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
17
- import { buildInbox, applyHidden, activityPatch, searchInbox, INBOX_SCAN_CAP } from './chat-session/session-inbox.js'
17
+ import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
18
18
  import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
19
19
  import { roomMessageBody } from './chat-message/room-message.js'
20
20
  import { readerGate } from './chat-message/reader-gate.js'
21
21
  import { initialVisibility, mayReadSession, SESSION_VISIBILITIES } from './chat-session/session-visibility.js'
22
22
  import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
23
23
  import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
24
+ import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
25
+ import { assertMayWriteSession } from './chat-session/session-write-gate.js'
24
26
 
25
27
  /**
26
28
  * 초대 알림 — 발송은 호스트의 기전을 그대로 쓴다(중복 구현 금지).
@@ -28,9 +30,9 @@ import { buildInviteNotification } from './chat-session-participant/invite-notif
28
30
  * 알림이 안 가도 초대 자체는 이미 일어났다. 그래서 실패를 삼키되, 초대를 되돌리지는 않는다 —
29
31
  * 참여자 목록이 정본이고 알림은 그것을 알리는 편지다.
30
32
  */
31
- async function notifyInvited(context: any, invited: string[], inviter: any): Promise<void> {
33
+ async function notifyInvited(context: any, invited: string[], inviter: any, sessionName?: string): Promise<void> {
32
34
  if (invited.length === 0) return
33
- const payload = buildInviteNotification(invited, undefined, inviter)
35
+ const payload = buildInviteNotification(invited, undefined, inviter, sessionName)
34
36
  if (!payload) return
35
37
  try {
36
38
  await context.state?.notify?.(payload)
@@ -38,11 +40,27 @@ async function notifyInvited(context: any, invited: string[], inviter: any): Pro
38
40
  /* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다. */
39
41
  }
40
42
  }
43
+
44
+ /** Every door that writes into a conversation passes this first (`session-write-gate.ts`). */
45
+ async function writeGate(session: any, context: any, tx?: any): Promise<void> {
46
+ const { domain, user } = context.state
47
+ await assertMayWriteSession({
48
+ session,
49
+ userId: user?.id,
50
+ isParticipant: () => isSessionParticipant(domain.id, session.id, user?.id, tx),
51
+ t: (key, options) => (context.t ? context.t(key, options) : String(options?.defaultValue ?? key))
52
+ })
53
+ }
41
54
  import { formatProposalOutcomeLine, PROPOSAL_VERDICTS, type ProposalOutcome } from './chat-message/proposal-outcome.js'
42
55
  import { publishSessionActivity } from './chat-session/session-activity-publish.js'
43
56
  import { ChatMessage } from './chat-message/chat-message.js'
44
57
  import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
45
58
  import { publishChatMessage } from './chat-message/chat-message-publish.js'
59
+ import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
60
+ import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
61
+ import { getAssistantMode } from './assistant-mode/registry.js'
62
+ import { refuseAttachments, type AttachmentRefusal } from './chat-session/attachment-policy.js'
63
+ import { removeAttachmentRows, saveChatAttachments } from './chat-session/session-attachments.js'
46
64
 
47
65
  @ObjectType({
48
66
  description:
@@ -95,6 +113,51 @@ class ChatSessionInboxEntry {
95
113
  /* 숨김은 나에게만 적용되고 되돌릴 수 있다 — 필터를 켠 사용자가 무엇이 숨겨진 것인지 알아야 한다. */
96
114
  @Field({ nullable: true, description: 'Whether this viewer hid the session from their own list (only returned when showHidden is on).' })
97
115
  hidden?: boolean
116
+
117
+ /* 목록이 공개 범위를 그리고, owner 만 그것을 바꾸는 단추를 받는다 — 판정은 setSessionVisibility 가 다시 한다. */
118
+ @Field({ description: "Who may read the session: 'open' (everyone in the domain) or 'private' (participants only)." })
119
+ visibility!: string
120
+
121
+ @Field({ description: 'Whether this viewer is the owner of the session - only the owner may change its visibility.' })
122
+ owner!: boolean
123
+ }
124
+
125
+ @ObjectType({ description: 'A file stored with a conversation - opened only through the conversation.' })
126
+ export class ChatAttachmentInfo {
127
+ @Field(type => ID, { description: 'Attachment id.' })
128
+ id!: string
129
+
130
+ @Field({ description: 'File name.' })
131
+ name!: string
132
+
133
+ @Field({ description: 'MIME type.' })
134
+ mimetype!: string
135
+
136
+ @Field(type => Int, { description: 'Size in bytes.' })
137
+ size!: number
138
+ }
139
+
140
+ /** A refusal said in the reader's words - code first, the sentence from it (ADR-0054). */
141
+ function attachmentRefusalMessage(refusal: AttachmentRefusal, context: any): string {
142
+ const t = (key: string, options: any) => (context.t ? context.t(key, options) : String(options?.defaultValue ?? key))
143
+ switch (refusal.code) {
144
+ case 'attachments-not-accepted':
145
+ return t('ai-assistant.text.attachments-not-accepted', { defaultValue: 'this conversation does not take attachments.' })
146
+ case 'attachment-kind-not-accepted':
147
+ return t('ai-assistant.text.attachment-kind-not-accepted', {
148
+ name: refusal.name,
149
+ kinds: refusal.kinds.join(', '),
150
+ defaultValue: '{name} cannot be attached here - this conversation takes {kinds}.'
151
+ })
152
+ case 'attachment-too-large':
153
+ return t('ai-assistant.text.attachment-too-large', {
154
+ name: refusal.name,
155
+ max: Math.round(refusal.maxBytes / 1024 / 1024),
156
+ defaultValue: '{name} is larger than {max} MB.'
157
+ })
158
+ case 'too-many-attachments':
159
+ return t('ai-assistant.text.too-many-attachments', { max: refusal.maxFiles, defaultValue: 'at most {max} files can be sent at once.' })
160
+ }
98
161
  }
99
162
 
100
163
  @Resolver()
@@ -146,9 +209,16 @@ export class ChatSessionResolver {
146
209
  @Arg('search', { nullable: true }) search?: string,
147
210
  @Arg('mineOnly', { nullable: true }) mineOnly?: boolean,
148
211
  @Arg('limit', () => Int, { nullable: true }) limit?: number,
149
- @Arg('offset', () => Int, { nullable: true }) offset?: number
212
+ @Arg('offset', () => Int, { nullable: true }) offset?: number,
213
+ /* The manager's filters (shared dock §2-6) - the total takes the same, or "show more" miscounts. */
214
+ @Arg('kind', { nullable: true, description: 'Only this conversation kind (mode).' }) kind?: string,
215
+ @Arg('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' }) participantOnly?: boolean,
216
+ @Arg('visibility', { nullable: true, description: "Only 'open' or only 'private'." }) visibility?: string,
217
+ @Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean
150
218
  ): Promise<ChatSessionInboxEntry[]> {
151
- const filtered = await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly })
219
+ const filtered = await this._inboxFiltered(anchorType, anchorId, context, {
220
+ showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly
221
+ })
152
222
  const from = Math.max(0, offset ?? 0)
153
223
  const size = Math.min(Math.max(1, limit ?? 50), 200)
154
224
  return filtered.slice(from, from + size) as any
@@ -171,9 +241,15 @@ export class ChatSessionResolver {
171
241
  @Ctx() context: ResolverContext,
172
242
  @Arg('showHidden', { nullable: true }) showHidden?: boolean,
173
243
  @Arg('search', { nullable: true }) search?: string,
174
- @Arg('mineOnly', { nullable: true }) mineOnly?: boolean
244
+ @Arg('mineOnly', { nullable: true }) mineOnly?: boolean,
245
+ @Arg('kind', { nullable: true, description: 'Only this conversation kind (mode).' }) kind?: string,
246
+ @Arg('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' }) participantOnly?: boolean,
247
+ @Arg('visibility', { nullable: true, description: "Only 'open' or only 'private'." }) visibility?: string,
248
+ @Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean
175
249
  ): Promise<number> {
176
- return (await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly })).length
250
+ return (
251
+ await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly })
252
+ ).length
177
253
  }
178
254
 
179
255
  /** 필터 적용(페이지 전) — 목록·개수가 **같은 규칙**을 쓰도록 한 곳에 둔다(어긋나면 "더 보기" 가 거짓말이 된다). */
@@ -181,11 +257,11 @@ export class ChatSessionResolver {
181
257
  anchorType: string,
182
258
  anchorId: string,
183
259
  context: ResolverContext,
184
- opts: { showHidden?: boolean; search?: string; mineOnly?: boolean }
260
+ opts: { showHidden?: boolean; search?: string } & InboxFilter
185
261
  ): Promise<ChatSessionInboxEntry[]> {
186
262
  const all = await this._inboxEntries(anchorType, anchorId, context)
187
263
  let entries = opts.showHidden ? all : all.filter(e => !e.hidden)
188
- if (opts.mineOnly) entries = entries.filter(e => (e as any).mine)
264
+ entries = filterInbox(entries as any, opts) as any
189
265
  return searchInbox(entries as any, opts.search) as any
190
266
  }
191
267
 
@@ -520,10 +596,9 @@ export class ChatSessionResolver {
520
596
  * 참여자만. 여기서 막히는 것은 **방**이고, 방에 들어온 뒤 값을 읽는 일은 아래 문 판정이 따로 본다.
521
597
  */
522
598
  if ((session as any).visibility === 'private') {
523
- const part = await getRepository(ChatSessionParticipant).findOne({
524
- where: { session: { id: sessionId } as any, user: { id: user.id } as any, domain: { id: domain.id } as any }
525
- })
526
- if (!mayReadSession((session as any).visibility, !!part)) return []
599
+ /* A hide-only `viewer` row is not being in the room - it must not open a private conversation. */
600
+ const part = await isSessionParticipant(domain.id, sessionId, user?.id)
601
+ if (!mayReadSession((session as any).visibility, part)) return []
527
602
  }
528
603
 
529
604
  const rows = await getRepository(ChatMessage).find({
@@ -636,6 +711,8 @@ export class ChatSessionResolver {
636
711
  const repo = getRepository(ChatSession, tx)
637
712
  const session = await repo.findOneBy({ id: sessionId, domain: { id: domain.id } })
638
713
  if (!session) throw new Error(`ChatSession ${sessionId} not found`)
714
+ /* The board-era rename door had no gate at all - a private conversation's name was anyone's to change. */
715
+ await writeGate(session, context, tx)
639
716
  await repo.update(sessionId, { name: name.trim() || null, updater: user } as any)
640
717
  return true
641
718
  }
@@ -655,6 +732,7 @@ export class ChatSessionResolver {
655
732
  const { domain, user, tx } = context.state
656
733
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
657
734
  if (!session) throw new Error(`ChatSession ${sessionId} not found`)
735
+ await writeGate(session, context, tx)
658
736
 
659
737
  const key = (proposalKey ?? '').trim()
660
738
  if (!key) return false
@@ -689,6 +767,42 @@ export class ChatSessionResolver {
689
767
  return true
690
768
  }
691
769
 
770
+ /**
771
+ * Stores files with a conversation before the turn that carries them (design/ai/shared-dock.md §9).
772
+ * Passes the write gate - a private conversation takes files from its people only - and the mode's
773
+ * attachment policy. The size is known only once stored, so a file over the limit is removed and refused.
774
+ */
775
+ @Directive('@transaction')
776
+ @Mutation(() => [ChatAttachmentInfo], {
777
+ description:
778
+ "Store files with a conversation, to be sent with the next turn (assistantChat attachmentIds). Private conversations take files from their participants only; what is accepted is the conversation mode's attachment policy."
779
+ })
780
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
781
+ async attachToConversation(
782
+ @Arg('sessionId') sessionId: string,
783
+ @Arg('files', () => [GraphQLUpload]) files: FileUpload[],
784
+ @Ctx() context: ResolverContext,
785
+ @Arg('kind', { nullable: true, description: 'The conversation kind, for a session not stamped with one yet.' }) kind?: string
786
+ ): Promise<ChatAttachmentInfo[]> {
787
+ const { domain, tx } = context.state
788
+ const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
789
+ if (!session) throw new Error(`ChatSession ${sessionId} not found`)
790
+ await writeGate(session, context, tx)
791
+
792
+ const policy = getAssistantMode((session as any).kind || kind || '')?.attachments
793
+ const uploads = await Promise.all(files)
794
+ const before = refuseAttachments(policy, uploads.map(upload => ({ name: upload.filename, type: upload.mimetype })))
795
+ if (before) throw new Error(attachmentRefusalMessage(before, context))
796
+
797
+ const saved = await saveChatAttachments(session.id!, uploads, context)
798
+ const after = refuseAttachments(policy, saved.map(file => ({ name: file.name, type: file.mimetype, size: file.size })))
799
+ if (after) {
800
+ await removeAttachmentRows(saved.map(file => file.id))
801
+ throw new Error(attachmentRefusalMessage(after, context))
802
+ }
803
+ return saved
804
+ }
805
+
692
806
  @Directive('@transaction')
693
807
  @Mutation(() => String, {
694
808
  nullable: true,
@@ -704,6 +818,7 @@ export class ChatSessionResolver {
704
818
  const { domain, user, tx } = context.state
705
819
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
706
820
  if (!session) throw new Error(`ChatSession ${sessionId} not found`)
821
+ await writeGate(session, context, tx)
707
822
 
708
823
  const body = roomMessageBody(content)
709
824
  if (!body) return null
@@ -772,6 +887,7 @@ export class ChatSessionResolver {
772
887
  const { domain, user, tx } = context.state
773
888
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
774
889
  if (!session) throw new Error(`ChatSession ${sessionId} not found`)
890
+ await writeGate(session, context, tx)
775
891
  /* 프롬프트가 한 기록으로 폭증하지 않도록 자른다 — 맥락 전달이 목적이고 전문 보존이 아니다. */
776
892
  const body = (content ?? '').trim().slice(0, 4000)
777
893
  if (!body) return false
@@ -822,6 +938,7 @@ export class ChatSessionResolver {
822
938
  const { domain, user, tx } = context.state
823
939
  const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
824
940
  if (!session) throw new Error(`ChatSession ${sessionId} not found`)
941
+ await writeGate(session, context, tx)
825
942
 
826
943
  /* 옮겨온 대화는 **그 대화의 발언 그대로** 들어간다 — 한 덩어리 기록으로 뭉치면 사람이 읽을 수 없고
827
944
  * (화면 하나를 잡아먹는 이탤릭 덩어리), 마크다운도 죽는다. 대신 개수·길이는 제한한다: 맥락 전달이
@@ -7,6 +7,9 @@ import { ChatMessageSubscription } from './chat-message/chat-message-subscriptio
7
7
  import { entities as ChatSessionEntities } from './chat-session/index.js'
8
8
  import { entities as ChatMessageEntities } from './chat-message/index.js'
9
9
  import { entities as ChatSessionParticipantEntities } from './chat-session-participant/index.js'
10
+ import './chat-session/attachment-tools.js'
11
+ import './chat-session/session-purge.js'
12
+ import './chat-session/attachment-route.js'
10
13
 
11
14
  export * from './types.js'
12
15
  export * from './chat-session-resolver.js'
@@ -29,6 +32,8 @@ export * from './chat-session/session-visibility.js'
29
32
  export * from './chat-session/index.js'
30
33
  /* 순수 로직들 — 호스트(board-ai 등)가 같은 규칙을 쓰라고 공개한다. 베끼면 한쪽만 고쳐진다. */
31
34
  export * from './chat-session/session-inbox.js'
35
+ export * from './chat-session/attachment-policy.js'
36
+ export * from './chat-session/session-purge.js'
32
37
  export * from './chat-session/session-activity-publish.js'
33
38
  export * from './chat-session/session-activity-broadcast.js'
34
39
  export * from './chat-message/llm-history.js'
@@ -0,0 +1,124 @@
1
+ /*
2
+ * The shared AI dock's contract (design/ai/shared-dock.md §8) - checked once here instead of in every app.
3
+ * The dock is read as text: importing it would pull the shell and i18n, which the runner cannot load.
4
+ */
5
+ import assert from 'node:assert/strict'
6
+ import { readFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+
9
+ import { runProposal, registerProposalHandler } from '../client/utils/assistant-proposals'
10
+
11
+ const read = (file: string) => readFileSync(join(__dirname, file), 'utf8')
12
+ const code = (file: string) => read(file).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
13
+ const dock = code('../client/components/assistant-dock.ts')
14
+ /* Not comment-stripped: the chat has strings like 'image/*' that a comment regex would eat. */
15
+ const chat = read('../client/components/assistant-chat.ts')
16
+
17
+ describe('no empty panel', () => {
18
+ it('not configured, unreadable, no surface and a stuck anchor are each said', () => {
19
+ for (const key of ['not-configured', 'capability-unknown', 'no-surface']) assert.match(dock, new RegExp(`t\\('${key}'`))
20
+ assert.match(dock, /if \(anchor && !isAnchor\(anchor\)\) return anchor\.message/)
21
+ assert.match(dock, /readAiCapability\(\)/)
22
+ })
23
+
24
+ it('an unreadable capability offers a retry', () => {
25
+ assert.match(dock, /this\.capability === 'unknown'\s*\?\s*html`<div><button class="sbtn" @click=\$\{\(\) => this\.open\(\)\}>/)
26
+ })
27
+ })
28
+
29
+ describe('the dock opens on every screen', () => {
30
+ it('it stays on editors that bleed wide - the board modeller hides the aside bar', () => {
31
+ assert.match(code('../client/setup-assistant-dock.ts'), /persistent: true/)
32
+ })
33
+ })
34
+
35
+ describe('every request the chat makes is taken', () => {
36
+ it('new conversation, mode change and proposal execute', () => {
37
+ assert.match(dock, /addEventListener\('assistant-start-new-session'/)
38
+ assert.match(dock, /@assistant-mode-change=\$\{this\.onModeChange\}/)
39
+ assert.match(dock, /@assistant-proposal-execute=\$\{this\.onProposal\}/)
40
+ })
41
+
42
+ it('a board surface gets the scene edits, reverts and actions', () => {
43
+ for (const event of ['scene-edit-patch', 'board-edit-revert', 'scene-action-execute']) assert.match(dock, new RegExp(`@${event}=`))
44
+ })
45
+
46
+ it('the toolbar opens the manager, and the manager asks with the same events', () => {
47
+ assert.match(dock, /historyEnabled/)
48
+ assert.match(dock, /@assistant-session-history=/)
49
+ assert.match(dock, /<ox-assistant-session-manager/)
50
+ })
51
+ })
52
+
53
+ describe('every proposal ends in one reported outcome', () => {
54
+ it('the dock reports whatever the handler returned', () => {
55
+ assert.match(dock, /const outcome = await runProposal\(proposal, this\.proposalContext\(sessionId\)\)[\s\S]*await this\.report\(sessionId, proposalKey, outcome\)/)
56
+ })
57
+
58
+ it('a kind nobody handles is unavailable, by name', async () => {
59
+ const outcome = await runProposal({ tool: 'proposeNothingKnown' }, {} as any)
60
+ assert.deepEqual(outcome, { verdict: 'unavailable', errorCode: 'unknown-proposal-kind' })
61
+ })
62
+
63
+ it('a handler that throws is refused with its message, never a silent pass', async () => {
64
+ registerProposalHandler('proposeThrows', async () => {
65
+ throw new Error('kernel-refused')
66
+ })
67
+ assert.deepEqual(await runProposal({ tool: 'proposeThrows' }, {} as any), { verdict: 'refused', errorCode: 'kernel-refused' })
68
+ })
69
+
70
+ it('a handler that answers nothing is not taken as done', async () => {
71
+ registerProposalHandler('proposeSilent', async () => undefined as any)
72
+ assert.deepEqual(await runProposal({ tool: 'proposeSilent' }, {} as any), { verdict: 'unavailable', errorCode: 'no-outcome' })
73
+ })
74
+
75
+ it('a handler that asks the person uses the platform confirm', () => {
76
+ assert.match(dock, /OxPrompt\.open\(/)
77
+ })
78
+ })
79
+
80
+ describe('sessions', () => {
81
+ it('the key carries the conversation kind', () => {
82
+ assert.match(dock, /scope: JSON\.stringify\(\[this\.app\.contextPath, userId, this\.kind\]\)/)
83
+ })
84
+
85
+ it('a picked conversation of another kind is followed, not refused', () => {
86
+ assert.match(dock, /followPickedConversation\(pickedKind, this\.kind\)/)
87
+ })
88
+ })
89
+
90
+ describe('the screen is checked at send time', () => {
91
+ it('a send after the screen moved on is refused', () => {
92
+ assert.match(dock, /if \(openedFor !== this\.scopeKey\(currentAssistantSurface\(\) \?\? surface\)\) throw new Error/)
93
+ })
94
+
95
+ it('the draft is read at send time, never cached', () => {
96
+ assert.match(dock, /return \{ hostContext: surface\?\.hostContext\?\.\(\) \?\? \{\} \}/)
97
+ })
98
+ })
99
+
100
+ describe('the dock knows no application', () => {
101
+ it('no app names and no proposal kinds in the dock', () => {
102
+ assert.doesNotMatch(dock, /\b(twin|plant|figure|warehouse|ecs|operato-[a-z]+)\b/i)
103
+ assert.doesNotMatch(dock, /'propose[A-Z]\w*'/)
104
+ })
105
+ })
106
+
107
+ describe('attachments in the chat follow the mode', () => {
108
+ it('no attach button when the mode declares nothing (the board door keeps its images)', () => {
109
+ assert.match(chat, /this\.acceptsAttachments\s*\?\s*html`<button/)
110
+ assert.match(chat, /return !this\.usesAttachmentPolicy \|\| !!this\.modeView\?\.attachments\?\.kinds\?\.length/)
111
+ })
112
+
113
+ it('said before sending, then uploaded to the conversation and sent by id', () => {
114
+ assert.match(chat, /refuseAttachments\(this\.modeView\?\.attachments/)
115
+ assert.match(chat, /mutation: ATTACH_TO_CONVERSATION_MUTATION/)
116
+ /* Without it the client sends the File as {} and the server refuses the upload (found on :3700, 2026-09-25). */
117
+ assert.match(chat, /context: \{ hasUpload: true \}/)
118
+ assert.match(chat, /attachmentIds\s*\n\s*\}\)/)
119
+ })
120
+
121
+ it('a stored file opens through the conversation route', () => {
122
+ assert.match(chat, /chatAttachmentUrl\(this\.sessionId, att\.id\)/)
123
+ })
124
+ })
@@ -0,0 +1,126 @@
1
+ /*
2
+ * The conversation manager - one list every dock opens (`<ox-assistant-session-manager>`).
3
+ *
4
+ * It was written once inside operato-twin's dock, and plant, figure, warehouse and ecs had only tabs.
5
+ * These pin what moved here: who may do what, that nothing irreversible happens without the row
6
+ * asking, that opening and starting stay the host's, and that every word exists in every locale.
7
+ */
8
+ import assert from 'node:assert/strict'
9
+ import { readFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ import { sessionActions } from '../client/utils/assistant-session-actions.ts'
13
+
14
+ const read = (file: string) => readFileSync(join(__dirname, file), 'utf8')
15
+ const manager = read('../client/components/assistant-session-manager.ts')
16
+ const admin = read('../client/utils/assistant-session-admin.ts')
17
+
18
+ describe('who may do what - the server hints, read in one place', () => {
19
+ it('only the creator is offered delete', () => {
20
+ assert.equal(sessionActions({ id: 'a', mine: true }).remove, true)
21
+ assert.equal(sessionActions({ id: 'a', mine: false }).remove, false)
22
+ assert.equal(sessionActions({ id: 'a' }).remove, false)
23
+ })
24
+
25
+ it('only the owner is offered private / public', () => {
26
+ assert.equal(sessionActions({ id: 'a', owner: true }).setVisibility, true)
27
+ assert.equal(sessionActions({ id: 'a', mine: true, owner: false }).setVisibility, false)
28
+ })
29
+
30
+ it('a hidden one offers unhide, a shown one hide - anyone, for their own list', () => {
31
+ assert.deepEqual(
32
+ [sessionActions({ id: 'a', hidden: true }).unhide, sessionActions({ id: 'a', hidden: true }).hide],
33
+ [true, false]
34
+ )
35
+ assert.deepEqual([sessionActions({ id: 'a' }).unhide, sessionActions({ id: 'a' }).hide], [false, true])
36
+ })
37
+
38
+ it('a non-participant is not offered rename - the server refuses it', () => {
39
+ assert.equal(sessionActions({ id: 'a', participant: false }).rename, false)
40
+ assert.equal(sessionActions({ id: 'a', participant: true }).rename, true)
41
+ })
42
+ })
43
+
44
+ describe('the doors are the framework ones, as they are', () => {
45
+ it('each operation goes through its own mutation', () => {
46
+ for (const door of ['chatSessionInbox', 'chatSessionInboxTotal', 'hiddenSessionCount', 'renameAISession', 'hideAISession', 'deleteAISession', 'setSessionVisibility']) {
47
+ assert.match(admin, new RegExp(door), door)
48
+ }
49
+ })
50
+
51
+ it('the list asks for visibility and owner, so a row can say private and offer the switch', () => {
52
+ assert.match(admin, /\bvisibility\b[\s\S]*\bowner\b|\bowner\b[\s\S]*\bvisibility\b/)
53
+ })
54
+
55
+ it('the element calls no door itself - it goes through the admin it was given', () => {
56
+ assert.doesNotMatch(manager, /client\.(mutate|query)|gql`/)
57
+ })
58
+ })
59
+
60
+ describe('nothing irreversible without asking', () => {
61
+ it('delete and making public ask first with the platform confirm; making private, hiding and renaming do not', () => {
62
+ assert.match(manager, /import \{ OxPrompt \} from '@operato\/popup'/)
63
+ assert.match(manager, /this\.askRemove\(entry, name\)/)
64
+ assert.match(manager, /this\.askOpen\(entry, name\)/)
65
+ assert.match(manager, /@click=\$\{\(\) => this\.setVisibility\(entry, 'private'\)\}/)
66
+ })
67
+
68
+ it('no browser dialog - they sit outside the theme', () => {
69
+ assert.doesNotMatch(manager, /\bconfirm\(|\balert\(|\bprompt\(/)
70
+ })
71
+
72
+ it('the confirm reads [Cancel][OK]', () => {
73
+ assert.match(manager, /cancelButton: \{ text: t\('cancel', 'Cancel'\) \}/)
74
+ })
75
+
76
+ it('a refusal or a failure is said, never passed silently', () => {
77
+ assert.match(manager, /if \(!\(await run\(\)\)\) this\.error = refused/)
78
+ assert.match(manager, /this\.error = \(error as Error\)\.message/)
79
+ })
80
+ })
81
+
82
+ describe('the filters (shared dock §2-6)', () => {
83
+ it('this kind by default, every kind on request; whose, visibility, unread, hidden', () => {
84
+ assert.match(manager, /kind: this\.allKinds \? '' : this\.kind/)
85
+ for (const filter of ['whose', 'visibility', 'includeHidden', 'unreadOnly']) assert.match(manager, new RegExp(`${filter}: this\\.${filter}`))
86
+ })
87
+
88
+ it('a changed filter starts from the first page', () => {
89
+ assert.match(manager, /private refilter\(change: \(\) => void\) \{\s*change\(\)\s*this\.pageSize = PAGE/)
90
+ })
91
+
92
+ it('picking a row says which kind it is, so the dock can follow it', () => {
93
+ assert.match(manager, /this\.emit\('assistant-session-select', \{ sessionId: entry\.id, kind: entry\.kind \}\)/)
94
+ })
95
+ })
96
+
97
+ describe('opening and starting stay the host\'s', () => {
98
+ it('the element asks with the toolbar\'s own events', () => {
99
+ assert.match(manager, /this\.emit\('assistant-session-select', \{ sessionId: entry\.id, kind: entry\.kind \}\)/)
100
+ assert.match(manager, /this\.emit\('assistant-session-reset'\)/)
101
+ assert.match(manager, /this\.emit\('assistant-session-changed', \{ sessionId: entry\.id, change \}\)/)
102
+ assert.match(manager, /bubbles: true, composed: true/)
103
+ })
104
+ })
105
+
106
+ describe('every word is in every locale', () => {
107
+ const keys = [...new Set([...manager.matchAll(/\bt\('([a-z-]+)'/g)].map(match => `ai-assistant.session.${match[1]}`))]
108
+
109
+ it('the parser finds the words', () => {
110
+ assert.ok(keys.length >= 20, `found only ${keys.length}`)
111
+ })
112
+
113
+ for (const locale of ['en', 'ko', 'ja', 'zh', 'ms']) {
114
+ it(locale, () => {
115
+ const dictionary = JSON.parse(read(`../translations/${locale}.json`))
116
+ assert.deepEqual(keys.filter(key => !dictionary[key]), [])
117
+ })
118
+ }
119
+ })
120
+
121
+ describe('rename takes the keys', () => {
122
+ it('the rename box is focused and selected when it opens - autofocus works once per document', () => {
123
+ assert.match(manager, /changed\.has\('renamingId'\) && this\.renamingId/)
124
+ assert.match(manager, /input\?\.focus\(\)/)
125
+ })
126
+ })
@@ -0,0 +1,32 @@
1
+ /*
2
+ * The screen's copy of the attachment rule and the server's are one rule (design/ai/shared-dock.md §9-1):
3
+ * the screen says "no" before sending, the door refuses the same things. Both run over one table.
4
+ */
5
+ import * as server from '../server/service/chat-session/attachment-policy'
6
+ import * as screen from '../client/utils/attachment-policy'
7
+
8
+ const policies = [undefined, { kinds: [] }, { kinds: ['image'] }, { kinds: ['image', 'text'], maxBytes: 100, maxFiles: 2 }] as any[]
9
+ const fileSets = [
10
+ [],
11
+ [{ name: 'a.png', type: 'image/png', size: 10 }],
12
+ [{ name: 'a.csv', type: 'text/csv', size: 10 }],
13
+ [{ name: 'report.CSV', type: '', size: 10 }],
14
+ [{ name: 'a.pdf', type: 'application/pdf', size: 10 }],
15
+ [{ name: 'big.png', type: 'image/png', size: 101 }],
16
+ [{ name: 'a.png', type: 'image/png', size: 1 }, { name: 'b.png', type: 'image/png', size: 1 }, { name: 'c.png', type: 'image/png', size: 1 }]
17
+ ]
18
+
19
+ describe('the screen and the door refuse the same things', () => {
20
+ for (const policy of policies) {
21
+ for (const files of fileSets) {
22
+ it(`${JSON.stringify(policy)} × ${files.map(f => f.name).join(',') || 'none'}`, () => {
23
+ expect(screen.refuseAttachments(policy, files)).toEqual(server.refuseAttachments(policy, files))
24
+ })
25
+ }
26
+ }
27
+
28
+ it('the same defaults', () => {
29
+ expect(screen.DEFAULT_MAX_BYTES).toBe(server.DEFAULT_MAX_BYTES)
30
+ expect(screen.DEFAULT_MAX_FILES).toBe(server.DEFAULT_MAX_FILES)
31
+ })
32
+ })
@@ -138,11 +138,14 @@ describe('what the walk itself has to be true for', () => {
138
138
  })
139
139
 
140
140
  it('the census, in plain numbers', () => {
141
- /* 2026-09-24: 27 root fields, 25 declared, 2 open — both streams. */
141
+ /*
142
+ * 2026-09-24: 27 root fields, 25 declared, 2 open — both streams.
143
+ * 2026-09-25: 28 — attachToConversation (a conversation's files, shared dock §9), declared.
144
+ */
142
145
  const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
143
146
  const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
144
147
 
145
- expect(all).toBe(27)
148
+ expect(all).toBe(28)
146
149
  expect(open).toBe(2)
147
150
  })
148
151
  })
@@ -110,5 +110,65 @@
110
110
  "ai-assistant.text.answer-needs-permission": "you do not have permission to see this answer. ask an administrator for access.",
111
111
  "ai-assistant.label.conversation": "conversation",
112
112
  "ai-assistant.text.switch-conversation": "switch to {name}",
113
- "ai-assistant.text.session-kind-mismatch": "this conversation is {sessionKind}, and you are writing as {requested}. open the conversation you mean."
113
+ "ai-assistant.text.session-kind-mismatch": "this conversation is {sessionKind}, and you are writing as {requested}. open the conversation you mean.",
114
+ "ai-assistant.session.search": "Search names and last messages",
115
+ "ai-assistant.session.filter": "Which conversations",
116
+ "ai-assistant.session.filter-all": "All",
117
+ "ai-assistant.session.filter-mine": "Started by me",
118
+ "ai-assistant.session.filter-hidden": "Including hidden",
119
+ "ai-assistant.session.failed": "could not load the conversation list.",
120
+ "ai-assistant.session.no-match": "No conversation matches \"{q}\".",
121
+ "ai-assistant.session.none": "No conversations yet.",
122
+ "ai-assistant.session.load-more": "Show more ({rest})",
123
+ "ai-assistant.session.hidden-count": "{count} hidden",
124
+ "ai-assistant.session.untitled": "Untitled conversation",
125
+ "ai-assistant.session.private": "Private - only its participants read it",
126
+ "ai-assistant.session.open": "Public - everyone in this domain reads it",
127
+ "ai-assistant.session.rename": "Rename",
128
+ "ai-assistant.session.make-open": "Make public",
129
+ "ai-assistant.session.make-private": "Make private",
130
+ "ai-assistant.session.hide": "Hide from my list",
131
+ "ai-assistant.session.unhide": "Show in my list",
132
+ "ai-assistant.session.remove": "Delete",
133
+ "ai-assistant.session.cancel": "Cancel",
134
+ "ai-assistant.session.confirm-remove": "Delete \"{name}\"? It disappears for every participant and cannot be brought back.",
135
+ "ai-assistant.session.confirm-open": "Make \"{name}\" public? Everyone in this domain will be able to read what is already written.",
136
+ "ai-assistant.session.rename-refused": "this conversation could not be renamed.",
137
+ "ai-assistant.session.hide-refused": "this conversation could not be hidden.",
138
+ "ai-assistant.session.visibility-refused": "only the owner of this conversation can change who reads it.",
139
+ "ai-assistant.session.remove-refused": "this conversation could not be deleted.",
140
+ "ai-assistant.text.attachments-not-accepted": "this conversation does not take attachments.",
141
+ "ai-assistant.text.attachment-kind-not-accepted": "{name} cannot be attached here - this conversation takes {kinds}.",
142
+ "ai-assistant.text.attachment-too-large": "{name} is larger than {max} MB.",
143
+ "ai-assistant.text.too-many-attachments": "at most {max} files can be sent at once.",
144
+ "ai-assistant.text.attachments-need-session": "attachments need a saved conversation.",
145
+ "ai-assistant.text.attachment-not-in-session": "an attachment does not belong to this conversation.",
146
+ "ai-assistant.dock.title": "Assistant",
147
+ "ai-assistant.dock.open": "Ask AI",
148
+ "ai-assistant.dock.no-conversation": "there is no conversation for this screen.",
149
+ "ai-assistant.dock.no-user": "your sign-in could not be read. sign in again.",
150
+ "ai-assistant.dock.confirm": "OK",
151
+ "ai-assistant.dock.cancel": "Cancel",
152
+ "ai-assistant.dock.proposal-not-runnable": "this proposal cannot be run on this screen.",
153
+ "ai-assistant.dock.patch-missed": "{missed} of the changes could not be applied.",
154
+ "ai-assistant.dock.not-configured": "no AI is set up for this site. an administrator can set one up in the AI settings.",
155
+ "ai-assistant.dock.capability-unknown": "whether AI is set up here could not be read.",
156
+ "ai-assistant.dock.no-surface": "this screen has no conversation.",
157
+ "ai-assistant.dock.to-home": "back to this application's conversation",
158
+ "ai-assistant.dock.back-to-conversation": "back to the conversation",
159
+ "ai-assistant.dock.conversations": "conversations",
160
+ "ai-assistant.dock.rebind": "follow this screen again",
161
+ "ai-assistant.dock.close": "close",
162
+ "ai-assistant.dock.screen-changed": "the screen changed while you were writing. send it again.",
163
+ "ai-assistant.dock.retry": "try again",
164
+ "ai-assistant.session.filter-kind": "Which kind",
165
+ "ai-assistant.session.filter-this-kind": "This conversation",
166
+ "ai-assistant.session.filter-all-kinds": "All kinds here",
167
+ "ai-assistant.session.filter-whose": "Whose",
168
+ "ai-assistant.session.filter-participant": "I'm in",
169
+ "ai-assistant.session.filter-visibility": "Visibility",
170
+ "ai-assistant.session.filter-any-visibility": "Public and private",
171
+ "ai-assistant.session.filter-open": "Public",
172
+ "ai-assistant.session.filter-private": "Private",
173
+ "ai-assistant.session.filter-unread": "Unread"
114
174
  }