@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
@@ -0,0 +1,157 @@
1
+ /*
2
+ * What the screen shows - the one way a screen tells the AI dock (design/ai/shared-dock.md §3).
3
+ *
4
+ * Before this there were five ways: `context.subject` (warehouse), `BoardEditHost` (twin), `StationAITarget`
5
+ * (plant), window events (figure) and the board modeller's own panel. The dock now reads one answer:
6
+ *
7
+ * 1. an editor that holds a draft provides a surface provideAssistantSurface(owner, surface)
8
+ * 2. a page that shows one thing says so in its context get context() { return { subject: { type, id } } }
9
+ * and the app says once how such a subject becomes a surface registerSubjectSurface(type, make)
10
+ * 3. otherwise the app's home registerAssistantHome(surface)
11
+ *
12
+ * The first that answers wins. The dock follows changes through `subscribeAssistantSurface`.
13
+ */
14
+ import { store } from '@operato/shell'
15
+
16
+ /** The row a conversation hangs on - its session anchor (ADR-0072 decision 6). */
17
+ export interface AssistantAnchor {
18
+ anchorType: string
19
+ anchorId: string
20
+ }
21
+
22
+ /** No anchor could stand - said to the person as a fact and what to do. */
23
+ export interface AssistantStuck {
24
+ stuck: string
25
+ message: string
26
+ }
27
+
28
+ export type AnchorResult = AssistantAnchor | AssistantStuck
29
+
30
+ export function isAnchor(result: AnchorResult | undefined): result is AssistantAnchor {
31
+ return !!result && 'anchorId' in result && !!result.anchorId
32
+ }
33
+
34
+ /** For a surface that authors a board - the chat gets the board, the dock applies its patches. */
35
+ export interface BoardSurface {
36
+ currentBoard?: () => any
37
+ boardProvider?: () => any
38
+ selectedRefids?: () => number[]
39
+ knownTypes?: () => string[]
40
+ categories?: () => any[]
41
+ componentSchemas?: () => any
42
+ searchProvider?: (query: string) => any[]
43
+ applyPatch(detail: any): Promise<{ applied: number; missed: number; note?: string }> | { applied: number; missed: number; note?: string }
44
+ revertPatch?(patchId: string): Promise<void> | void
45
+ applyAction?(detail: any): Promise<void> | void
46
+ /** The chat door for board authoring, until board-ai joins the neutral door (ADR-0061 decision 0-1). */
47
+ chatEndpoint?: 'boardAIChat' | 'assistantChat'
48
+ }
49
+
50
+ export interface AssistantSurface {
51
+ /** Identity - a different key is a different conversation. */
52
+ key: string
53
+ /** Matched against the server's mode `subjects` (assistantModes(subject)). */
54
+ subject: string
55
+ /** The session anchor, or why none could stand. May ask the server (a row uuid, a domain id). */
56
+ anchor(): Promise<AnchorResult> | AnchorResult
57
+ /** Words for the header - never a raw identifier. */
58
+ label(): string
59
+ detail?(): string
60
+ icon?: string
61
+ /** Read at the moment of sending - never cached, so a draft cannot go stale. */
62
+ hostContext?(): Record<string, unknown>
63
+ board?: BoardSurface
64
+ /** The dock opened another conversation for this surface - e.g. the board modeller records its direct edits under it. */
65
+ sessionChanged?(sessionId: string): void
66
+ }
67
+
68
+ type Listener = () => void
69
+
70
+ const provided = new Map<object, AssistantSurface>()
71
+ let lastOwner: object | undefined
72
+ const subjectMakers = new Map<string, (subject: { type: string; id?: string }) => AssistantSurface>()
73
+ let home: AssistantSurface | undefined
74
+ const listeners = new Set<Listener>()
75
+
76
+ function changed() {
77
+ listeners.forEach(listener => {
78
+ try {
79
+ listener()
80
+ } catch {
81
+ /* one listener's error does not stop the others */
82
+ }
83
+ })
84
+ }
85
+
86
+ /** An editor with a draft says it is on screen. The last one provided wins until it is cleared. */
87
+ export function provideAssistantSurface(owner: object, surface: AssistantSurface): void {
88
+ provided.set(owner, surface)
89
+ lastOwner = owner
90
+ changed()
91
+ }
92
+
93
+ /** The editor is gone or inactive. A later provider that replaced it is left alone. */
94
+ export function clearAssistantSurface(owner: object): void {
95
+ if (!provided.delete(owner)) return
96
+ if (lastOwner === owner) lastOwner = [...provided.keys()].pop()
97
+ changed()
98
+ }
99
+
100
+ /** The editor's draft or identity changed - the dock reads it again. */
101
+ export function notifyAssistantSurface(owner: object): void {
102
+ if (provided.has(owner)) changed()
103
+ }
104
+
105
+ /** How a page's `context.subject` of this type becomes a surface - declared once by the app. */
106
+ export function registerSubjectSurface(type: string, make: (subject: { type: string; id?: string }) => AssistantSurface): void {
107
+ subjectMakers.set(type, make)
108
+ changed()
109
+ }
110
+
111
+ /** The app's own conversation when the screen shows nothing in particular - a factory, a site, a catalog. */
112
+ export function registerAssistantHome(surface: AssistantSurface): void {
113
+ home = surface
114
+ changed()
115
+ }
116
+
117
+ export function subscribeAssistantSurface(listener: Listener): () => void {
118
+ listeners.add(listener)
119
+ return () => listeners.delete(listener)
120
+ }
121
+
122
+ let pageSubjectKey = ''
123
+ let pageSurface: AssistantSurface | undefined
124
+
125
+ function surfaceFromPage(): AssistantSurface | undefined {
126
+ const subject = (store.getState() as any)?.route?.context?.subject
127
+ const type = subject?.type
128
+ const make = type ? subjectMakers.get(type) : undefined
129
+ if (!make) return undefined
130
+ const key = JSON.stringify([type, subject?.id ?? ''])
131
+ if (key !== pageSubjectKey) {
132
+ pageSubjectKey = key
133
+ pageSurface = make({ type, id: subject?.id })
134
+ }
135
+ return pageSurface
136
+ }
137
+
138
+ /** What the dock talks about now: a provided editor, else the page's subject, else the app's home. */
139
+ export function currentAssistantSurface(): AssistantSurface | undefined {
140
+ return (lastOwner && provided.get(lastOwner)) || surfaceFromPage() || home
141
+ }
142
+
143
+ /** The app's home surface - for the header's "back to the app's conversation". */
144
+ export function assistantHomeSurface(): AssistantSurface | undefined {
145
+ return home
146
+ }
147
+
148
+ /* The page context lives in the store; a page change is a surface change. */
149
+ let lastRouteSubject = ''
150
+ store.subscribe(() => {
151
+ const subject = (store.getState() as any)?.route?.context?.subject
152
+ const key = JSON.stringify([subject?.type ?? '', subject?.id ?? ''])
153
+ if (key !== lastRouteSubject) {
154
+ lastRouteSubject = key
155
+ changed()
156
+ }
157
+ })
@@ -0,0 +1,71 @@
1
+ /*
2
+ * What a conversation accepts as attachments - the screen's copy of the server rule
3
+ * (server/service/chat-session/attachment-policy.ts, design/ai/shared-dock.md §9-1).
4
+ *
5
+ * The screen checks first so the person hears "no" before anything is sent; the door checks again and is
6
+ * the authority. `test/attachment-policy-agree.test.ts` runs both copies over one table so they cannot drift.
7
+ */
8
+
9
+ export type AttachmentKind = 'image' | 'text'
10
+
11
+ export interface AttachmentPolicy {
12
+ kinds: AttachmentKind[]
13
+ maxBytes?: number
14
+ maxFiles?: number
15
+ }
16
+
17
+ export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024
18
+ export const DEFAULT_MAX_FILES = 5
19
+
20
+ const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']
21
+ const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']
22
+
23
+ export function attachmentKindOf(file: { type?: string; name?: string }): AttachmentKind | null {
24
+ const type = (file.type || '').toLowerCase()
25
+ if (type.startsWith('image/')) return 'image'
26
+ if (TEXT_TYPES.includes(type)) return 'text'
27
+ const name = (file.name || '').toLowerCase()
28
+ if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext))) return 'text'
29
+ return null
30
+ }
31
+
32
+ export type AttachmentRefusal =
33
+ | { code: 'attachments-not-accepted' }
34
+ | { code: 'attachment-kind-not-accepted'; name: string; kinds: AttachmentKind[] }
35
+ | { code: 'attachment-too-large'; name: string; maxBytes: number }
36
+ | { code: 'too-many-attachments'; maxFiles: number }
37
+
38
+ export function refuseAttachments(
39
+ policy: AttachmentPolicy | null | undefined,
40
+ files: { name?: string; type?: string; size?: number }[]
41
+ ): AttachmentRefusal | null {
42
+ if (!files.length) return null
43
+ if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }
44
+
45
+ const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES
46
+ if (files.length > maxFiles) return { code: 'too-many-attachments', maxFiles }
47
+
48
+ const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES
49
+ for (const file of files) {
50
+ const kind = attachmentKindOf(file)
51
+ if (!kind || !policy.kinds.includes(kind)) {
52
+ return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }
53
+ }
54
+ if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }
55
+ }
56
+ return null
57
+ }
58
+
59
+ /** The file picker's `accept` for a policy. */
60
+ export function acceptOf(policy: AttachmentPolicy | null | undefined): string {
61
+ const accept: string[] = []
62
+ if (policy?.kinds?.includes('image')) accept.push('image/*')
63
+ if (policy?.kinds?.includes('text')) accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES)
64
+ return accept.join(',')
65
+ }
66
+
67
+ /** Where a conversation's file opens - the gated route, under the page's domain path. */
68
+ export function chatAttachmentUrl(sessionId: string, attachmentId: string): string {
69
+ const domainPath = /^\/domain\/[^/]+/.exec(location.pathname)?.[0] ?? ''
70
+ return `${domainPath}/chat-attachments/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachmentId)}`
71
+ }
@@ -226,6 +226,12 @@ export declare class OxAssistantChat extends LitElement {
226
226
  private attachments;
227
227
  /** 드래그 오버 상태 (시각 피드백용) */
228
228
  private dragOver;
229
+ /** This conversation talks to the neutral door - its attachments follow the mode's policy (§9). */
230
+ private get usesAttachmentPolicy();
231
+ /** Whether the attach button stands: a mode that declares nothing takes nothing (the board door keeps its images). */
232
+ private get acceptsAttachments();
233
+ /** A refusal in the reader's words - the same keys the door uses. */
234
+ private attachmentRefusalText;
229
235
  private onAttachFiles;
230
236
  private readFileAsBase64;
231
237
  private removeAttachment;
@@ -27,6 +27,7 @@ import { i18next } from '@operato/i18n';
27
27
  import { ScrollbarStyles } from '@operato/styles';
28
28
  import gql from 'graphql-tag';
29
29
  import { renderMarkdown, injectMentionRefids, injectMentionUserIds, stripMentionRefids } from './markdown.js';
30
+ import { acceptOf, chatAttachmentUrl, refuseAttachments } from '../utils/attachment-policy.js';
30
31
  import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js';
31
32
  import { receiveRemoteMessage, reconcileSentLine, remoteMessageToLine } from './chat-echo-dedup.js';
32
33
  import './mention-popup.js';
@@ -80,6 +81,17 @@ const BOARD_AI_CHAT_MUTATION = gql `
80
81
  * front of the model this turn, so a surface that granted a category and sees it missing knows
81
82
  * the registration never happened, instead of reading a vague answer as a refusal.
82
83
  */
84
+ /* Files go to the conversation first; the turn then names them (design/ai/shared-dock.md §9). */
85
+ const ATTACH_TO_CONVERSATION_MUTATION = gql `
86
+ mutation AttachToConversation($sessionId: String!, $files: [Upload!]!, $kind: String) {
87
+ attachToConversation(sessionId: $sessionId, files: $files, kind: $kind) {
88
+ id
89
+ name
90
+ mimetype
91
+ size
92
+ }
93
+ }
94
+ `;
83
95
  const ASSISTANT_CHAT_MUTATION = gql `
84
96
  mutation AssistantChat($input: AssistantChatInput!) {
85
97
  assistantChat(input: $input) {
@@ -133,6 +145,7 @@ const ASSISTANT_MODE_QUERY = gql `
133
145
  examples
134
146
  footerNotice
135
147
  stagedNotice
148
+ attachments
136
149
  }
137
150
  }
138
151
  `;
@@ -172,6 +185,7 @@ const CHAT_MESSAGES_QUERY = gql `
172
185
  relatedPatchId
173
186
  toolUsagesJson
174
187
  groundingWarningsJson
188
+ attachmentsJson
175
189
  createdAt
176
190
  redacted
177
191
  creator {
@@ -192,6 +206,7 @@ const CHAT_MESSAGE_SUBSCRIPTION = gql `
192
206
  relatedPatchId
193
207
  toolUsagesJson
194
208
  groundingWarningsJson
209
+ attachmentsJson
195
210
  createdAt
196
211
  redacted
197
212
  creator {
@@ -405,10 +420,37 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
405
420
  * `attachments` 짐 자체는 그대로 둔다: figure 의 도구가 거기서 이미지를 찾는다
406
421
  * (`figure-tools.ts` — `ctx.host.image` 가 없을 때의 갈래).
407
422
  */
423
+ /** This conversation talks to the neutral door - its attachments follow the mode's policy (§9). */
424
+ get usesAttachmentPolicy() {
425
+ return this.conversation.chatEndpoint === 'assistantChat';
426
+ }
427
+ /** Whether the attach button stands: a mode that declares nothing takes nothing (the board door keeps its images). */
428
+ get acceptsAttachments() {
429
+ return !this.usesAttachmentPolicy || !!this.modeView?.attachments?.kinds?.length;
430
+ }
431
+ /** A refusal in the reader's words - the same keys the door uses. */
432
+ attachmentRefusalText(refusal) {
433
+ const params = { ...refusal };
434
+ if (refusal.code === 'attachment-too-large')
435
+ params.max = Math.round(refusal.maxBytes / 1024 / 1024);
436
+ if (refusal.code === 'too-many-attachments')
437
+ params.max = refusal.maxFiles;
438
+ if (refusal.code === 'attachment-kind-not-accepted')
439
+ params.kinds = refusal.kinds.join(', ');
440
+ return String(i18next.t(`ai-assistant.text.${refusal.code}`, { ...params, defaultValue: refusal.code }));
441
+ }
408
442
  async onAttachFiles(files) {
409
443
  if (!files || files.length === 0)
410
444
  return;
411
445
  const list = Array.from(files);
446
+ if (this.usesAttachmentPolicy) {
447
+ /* Said before anything is sent - never cut silently (§9-1). */
448
+ const refusal = refuseAttachments(this.modeView?.attachments, [...this.attachments, ...list].map(f => ({ name: f.name, type: f.type, size: f.size })));
449
+ if (refusal) {
450
+ this.errorMessage = this.attachmentRefusalText(refusal);
451
+ return;
452
+ }
453
+ }
412
454
  const newAttachments = [];
413
455
  for (const file of list) {
414
456
  const id = 'att-' + Math.random().toString(36).substring(2, 9);
@@ -2547,19 +2589,24 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
2547
2589
  ${line.attachments && line.attachments.length > 0
2548
2590
  ? html `
2549
2591
  <div class="msg-attachments">
2550
- ${line.attachments.map(att => att.type?.startsWith('image/') || att.url?.startsWith('data:image/') || att.base64
2551
- ? html `
2552
- <div class="msg-attachment-img-wrap" title=${att.name}>
2553
- <img src=${att.url || att.base64} alt=${att.name} />
2592
+ ${line.attachments.map(att => {
2593
+ /* A stored file opens through the conversation's own route (§9-3); a fresh one shows its local copy. */
2594
+ const href = att.url || att.base64 || (att.id && this.sessionId ? chatAttachmentUrl(this.sessionId, att.id) : '');
2595
+ /* By type - a text file carries base64 too, and drew as a broken image (found on :3700). */
2596
+ return att.type?.startsWith('image/') || att.url?.startsWith('data:image/') || att.base64?.startsWith('data:image/')
2597
+ ? html `
2598
+ <a class="msg-attachment-img-wrap" title=${att.name} href=${href} target="_blank" rel="noopener">
2599
+ <img src=${href} alt=${att.name} />
2554
2600
  <span class="msg-attachment-label">${att.name}</span>
2555
- </div>
2601
+ </a>
2556
2602
  `
2557
- : html `
2558
- <div class="msg-attachment-file-chip" title=${att.name}>
2603
+ : html `
2604
+ <a class="msg-attachment-file-chip" title=${att.name} href=${href || nothing} target="_blank" rel="noopener">
2559
2605
  <md-icon>description</md-icon>
2560
2606
  <span>${att.name}</span>
2561
- </div>
2562
- `)}
2607
+ </a>
2608
+ `;
2609
+ })}
2563
2610
  </div>
2564
2611
  `
2565
2612
  : nothing}
@@ -2632,7 +2679,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
2632
2679
  id="chat-file-input"
2633
2680
  type="file"
2634
2681
  multiple
2635
- accept="image/*"
2682
+ accept=${this.usesAttachmentPolicy ? acceptOf(this.modeView?.attachments) : 'image/*'}
2636
2683
  style="display: none"
2637
2684
  @change=${(e) => {
2638
2685
  const input = e.target;
@@ -2642,14 +2689,16 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
2642
2689
  }
2643
2690
  }}
2644
2691
  />
2645
- <button
2646
- type="button"
2647
- class="action"
2648
- @click=${() => this.renderRoot.querySelector('#chat-file-input')?.click()}
2649
- title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'attach an image (drag & drop or paste supported)' })}>
2650
- <md-icon>attach_file</md-icon>
2651
- ${i18next.t('ai-assistant.button.attach', { defaultValue: 'Attach' })}
2652
- </button>
2692
+ ${this.acceptsAttachments
2693
+ ? html `<button
2694
+ type="button"
2695
+ class="action"
2696
+ @click=${() => this.renderRoot.querySelector('#chat-file-input')?.click()}
2697
+ title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'attach an image (drag & drop or paste supported)' })}>
2698
+ <md-icon>attach_file</md-icon>
2699
+ ${i18next.t('ai-assistant.button.attach', { defaultValue: 'Attach' })}
2700
+ </button>`
2701
+ : nothing}
2653
2702
  ${hasMessages
2654
2703
  ? html `
2655
2704
  <button
@@ -3933,6 +3982,34 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
3933
3982
  }
3934
3983
  this.busy = false;
3935
3984
  }
3985
+ /* The neutral door takes stored files by id: upload them to the conversation first (§9-2). */
3986
+ let attachmentIds;
3987
+ if (attachmentsToSend.length > 0 && this.usesAttachmentPolicy) {
3988
+ if (!this.sessionId) {
3989
+ this.errorMessage = i18next.t('ai-assistant.text.attachments-need-session', { defaultValue: 'attachments need a saved conversation.' });
3990
+ this.attachments = attachmentsToSend;
3991
+ return;
3992
+ }
3993
+ this.busy = true;
3994
+ try {
3995
+ const uploaded = await client.mutate({
3996
+ mutation: ATTACH_TO_CONVERSATION_MUTATION,
3997
+ variables: { sessionId: this.sessionId, files: attachmentsToSend.map(a => a.file), kind: this.conversation.kind },
3998
+ /* The shell's client sends multipart only when asked - without it a File travels as {} and is refused. */
3999
+ context: { hasUpload: true }
4000
+ });
4001
+ if (uploaded.errors?.length)
4002
+ throw new Error(uploaded.errors.map(error => error.message).join('\n'));
4003
+ attachmentIds = (uploaded.data?.attachToConversation ?? []).map((file) => file.id);
4004
+ }
4005
+ catch (error) {
4006
+ this.errorMessage = error instanceof Error ? error.message : String(error);
4007
+ this.attachments = attachmentsToSend;
4008
+ this.busy = false;
4009
+ return;
4010
+ }
4011
+ this.busy = false;
4012
+ }
3936
4013
  let requestContext;
3937
4014
  let liveBoard;
3938
4015
  let requestBase;
@@ -3986,7 +4063,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
3986
4063
  // 가짜 pending 응답 표시 (UX 부드럽게)
3987
4064
  this.lines = [...this.lines, { role: 'assistant', content: '', pending: true, _localId: asstLocalId }];
3988
4065
  try {
3989
- if (attachmentsToSend.length > 0) {
4066
+ if (attachmentsToSend.length > 0 && !attachmentIds) {
3990
4067
  requestContext.hostContext = {
3991
4068
  ...requestContext.hostContext,
3992
4069
  attachments: attachmentsToSend.map(a => ({
@@ -4022,7 +4099,8 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
4022
4099
  history,
4023
4100
  mode: { kind: this.conversation.kind, target: this.conversation.target },
4024
4101
  truncateAfterMessageId: this._truncateAfterId,
4025
- hostContext: requestContext.hostContext
4102
+ hostContext: requestContext.hostContext,
4103
+ attachmentIds
4026
4104
  })
4027
4105
  : buildChatMutationInput({
4028
4106
  sessionId: this.sessionId,