@things-factory/ai-assistant 10.1.47 → 10.1.50

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 +109 -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 +110 -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 +118 -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,617 @@
1
+ import '@material/web/icon/icon.js'
2
+ import './assistant-chat.js'
3
+ import './assistant-session-toolbar.js'
4
+ import './assistant-session-manager.js'
5
+
6
+ import gql from 'graphql-tag'
7
+ import { LitElement, css, html, nothing, type TemplateResult } from 'lit'
8
+ import { customElement, property, state } from 'lit/decorators.js'
9
+ import { keyed } from 'lit/directives/keyed.js'
10
+
11
+ import { client } from '@operato/graphql'
12
+ import { i18next, localize } from '@operato/i18n'
13
+ import { closeOverlay, notify } from '@operato/layout'
14
+ import { OxPrompt } from '@operato/popup'
15
+ import { AppController, store } from '@operato/shell'
16
+ import { ScrollbarStyles } from '@operato/styles'
17
+
18
+ import { followPickedConversation, type AssistantMode } from './assistant-mode.js'
19
+ import { AssistantSessionController, type AssistantSession, type AssistantSessionTarget } from '../utils/assistant-session-controller.js'
20
+ import { assistantSessionTransport } from '../utils/assistant-session-transport.js'
21
+ import { readAiCapability } from '../utils/assistant-capability-read.js'
22
+ import type { AiCapability } from '../utils/assistant-capability.js'
23
+ import { setOpenSession, subscribeSessionActivity } from '../utils/assistant-activity.js'
24
+ import {
25
+ assistantHomeSurface,
26
+ currentAssistantSurface,
27
+ isAnchor,
28
+ subscribeAssistantSurface,
29
+ type AnchorResult,
30
+ type AssistantSurface
31
+ } from '../utils/assistant-surface.js'
32
+ import { runProposal, type ProposalContext, type ProposalOutcome } from '../utils/assistant-proposals.js'
33
+ import { subscribeAssistantAsk, takeAssistantAsk, type AssistantAsk } from '../utils/assistant-ask.js'
34
+
35
+ export const ASSISTANT_DOCK_VIEWPART = 'assistant-dock'
36
+
37
+ const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
38
+ i18next.t(`ai-assistant.dock.${key}`, { defaultValue, ...options })
39
+
40
+ const MODES_QUERY = gql`
41
+ query ($subject: String!) {
42
+ assistantModes(subject: $subject) {
43
+ kind
44
+ label
45
+ isDefault
46
+ }
47
+ }
48
+ `
49
+
50
+ async function modesFor(subject: string): Promise<{ kind: string; label?: string; isDefault?: boolean }[]> {
51
+ const response = await client.query({ query: MODES_QUERY, variables: { subject }, fetchPolicy: 'network-only' })
52
+ if (response.errors?.length) throw new Error(response.errors[0].message)
53
+ return response.data?.assistantModes ?? []
54
+ }
55
+
56
+ /**
57
+ * The AI dock - the same in every operato application (design/ai/shared-dock.md).
58
+ *
59
+ * ── What is here and what is not ────────────────────────────────────────────────
60
+ * here where it stands, whether an AI answers, which surface and anchor, which conversation (mode),
61
+ * sessions and their management, unread, proposals and their outcome, asks from other screens
62
+ * the app what its screens show (surfaces, assistant-surface.ts), how its proposal kinds apply
63
+ * (assistant-proposals.ts), and on the server its modes and tools
64
+ *
65
+ * Nothing in this file names an application or a proposal kind. A behaviour one app needs and another does
66
+ * not is a surface field or a handler, never a branch here.
67
+ */
68
+ @customElement('ox-assistant-dock')
69
+ export class AssistantDock extends localize(i18next)(LitElement) {
70
+ static styles = [
71
+ ScrollbarStyles,
72
+ css`
73
+ :host {
74
+ display: flex;
75
+ flex-direction: column;
76
+ width: 420px;
77
+ max-width: 100vw;
78
+ box-sizing: border-box;
79
+ height: 100%;
80
+ min-height: 0;
81
+ background: var(--md-sys-color-surface-container-lowest);
82
+ border-left: 1px solid var(--md-sys-color-outline-variant);
83
+ color: var(--md-sys-color-on-surface);
84
+ font-size: 13px;
85
+ }
86
+
87
+ /* Below 1440px the page keeps its full width and only the dock floats. The DOM stays - the chat does not reconnect. */
88
+ @media (max-width: 1439.98px) {
89
+ :host {
90
+ position: fixed;
91
+ inset: 0 0 0 auto;
92
+ height: 100vh;
93
+ height: 100dvh;
94
+ z-index: 10;
95
+ box-shadow: var(--md-sys-elevation-shadow-3, -4px 0 16px rgb(0 0 0 / 20%));
96
+ }
97
+ }
98
+
99
+ @media (max-width: 599.98px) {
100
+ :host {
101
+ width: 100vw;
102
+ }
103
+ }
104
+
105
+ .scope {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 6px;
109
+ min-height: 40px;
110
+ padding: 5px 8px;
111
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
112
+ }
113
+
114
+ .scope > md-icon {
115
+ --md-icon-size: 18px;
116
+ flex: none;
117
+ color: var(--md-sys-color-primary);
118
+ }
119
+
120
+ .what {
121
+ display: grid;
122
+ flex: 1;
123
+ min-width: 0;
124
+ font-size: 12px;
125
+ font-weight: 650;
126
+ line-height: 1.25;
127
+ }
128
+
129
+ .what span {
130
+ overflow: hidden;
131
+ text-overflow: ellipsis;
132
+ white-space: nowrap;
133
+ }
134
+
135
+ .detail {
136
+ color: var(--md-sys-color-on-surface-variant);
137
+ font-size: 10.5px;
138
+ font-weight: 400;
139
+ }
140
+
141
+ .sbtn {
142
+ display: inline-flex;
143
+ align-items: center;
144
+ flex: none;
145
+ padding: 4px;
146
+ border: none;
147
+ border-radius: 8px;
148
+ background: none;
149
+ color: var(--md-sys-color-on-surface-variant);
150
+ cursor: pointer;
151
+ }
152
+
153
+ .sbtn:hover {
154
+ background: var(--md-sys-color-surface-container-high);
155
+ color: var(--md-sys-color-on-surface);
156
+ }
157
+
158
+ .sbtn md-icon {
159
+ --md-icon-size: 18px;
160
+ }
161
+
162
+ ox-assistant-session-toolbar {
163
+ flex: none;
164
+ min-width: 0;
165
+ padding: 3px 8px;
166
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
167
+ --border-color: var(--md-sys-color-outline-variant);
168
+ --main-section-background-color: var(--md-sys-color-surface-container);
169
+ --primary-color: var(--md-sys-color-primary);
170
+ font: inherit;
171
+ font-size: 12px;
172
+ }
173
+
174
+ ox-assistant-chat,
175
+ ox-assistant-session-manager {
176
+ flex: 1;
177
+ min-height: 0;
178
+ }
179
+
180
+ /* No conversation could stand - say what stopped it, never an empty panel. */
181
+ .stuck {
182
+ margin: auto;
183
+ padding: 20px;
184
+ text-align: center;
185
+ line-height: 1.7;
186
+ color: var(--md-sys-color-on-surface-variant);
187
+ }
188
+
189
+ .stuck .sbtn {
190
+ margin-top: 6px;
191
+ gap: 4px;
192
+ font: inherit;
193
+ }
194
+ `
195
+ ]
196
+
197
+ /** The viewpart this dock lives in - for closing it. */
198
+ @property({ type: String }) viewpart = ASSISTANT_DOCK_VIEWPART
199
+
200
+ @state() private capability: AiCapability = 'unknown'
201
+ @state() private asked = false
202
+ @state() private surface?: AssistantSurface
203
+ @state() private anchor?: AnchorResult
204
+ @state() private kind = ''
205
+ /** kind → its label, for the manager's rows. */
206
+ @state() private kindLabels: Record<string, string> = {}
207
+ @state() private sessions: AssistantSession[] = []
208
+ @state() private sessionId = ''
209
+ @state() private loading = false
210
+ @state() private failure = ''
211
+ @state() private showingHistory = false
212
+ @state() private conversationView = 0
213
+ @state() private asking?: { ask?: string; draft?: string; requestId: string }
214
+
215
+ private app = new AppController(this)
216
+ /** The kind the person picked; kept while the surface stays the same. */
217
+ private chosenKind = ''
218
+ private revision = 0
219
+ /** What the conversation was opened for - a send is refused when the screen moved on. */
220
+ private openedFor = ''
221
+ private unsubscribers: (() => void)[] = []
222
+ private controller = new AssistantSessionController(
223
+ assistantSessionTransport,
224
+ next => {
225
+ if (next.sessionId !== this.sessionId) {
226
+ setOpenSession(next.sessionId)
227
+ this.surface?.sessionChanged?.(next.sessionId)
228
+ }
229
+ this.sessions = next.sessions
230
+ this.sessionId = next.sessionId
231
+ this.loading = next.loading
232
+ this.failure = next.error
233
+ },
234
+ { getItem: key => sessionStorage.getItem(key), setItem: (key, value) => sessionStorage.setItem(key, value) },
235
+ { lazy: true }
236
+ )
237
+
238
+ private onStartNewSession = () => void this.newConversation()
239
+
240
+ connectedCallback() {
241
+ super.connectedCallback()
242
+ this.addEventListener('assistant-start-new-session', this.onStartNewSession)
243
+ this.unsubscribers.push(
244
+ subscribeAssistantSurface(() => this.follow()),
245
+ subscribeAssistantAsk(() => void this.takeAsk()),
246
+ subscribeSessionActivity(activity => {
247
+ /* A rename or a new conversation elsewhere on this anchor - the tabs read again. */
248
+ if (activity.kind === 'meta' && isAnchor(this.anchor) && activity.anchorId === this.anchor.anchorId) void this.reloadSessions()
249
+ })
250
+ )
251
+ this.follow(true)
252
+ }
253
+
254
+ disconnectedCallback() {
255
+ this.revision++
256
+ this.removeEventListener('assistant-start-new-session', this.onStartNewSession)
257
+ this.unsubscribers.splice(0).forEach(unsubscribe => unsubscribe())
258
+ setOpenSession('')
259
+ this.controller.clear()
260
+ super.disconnectedCallback()
261
+ }
262
+
263
+ private scopeKey(surface = this.surface) {
264
+ return JSON.stringify([this.app.contextPath, surface?.key ?? ''])
265
+ }
266
+
267
+ /** The screen changed - a different surface or domain is a different conversation. */
268
+ private follow(force = false) {
269
+ const next = currentAssistantSurface()
270
+ if (!force && next === this.surface && this.scopeKey(next) === this.openedFor) return
271
+ if (next?.key !== this.surface?.key) this.chosenKind = ''
272
+ this.surface = next
273
+ void this.open()
274
+ }
275
+
276
+ private async open(): Promise<void> {
277
+ const ticket = ++this.revision
278
+ this.conversationView++
279
+ this.showingHistory = false
280
+ void this.controller.open(undefined)
281
+ this.loading = true
282
+ this.failure = ''
283
+ this.anchor = undefined
284
+ this.openedFor = this.scopeKey()
285
+
286
+ try {
287
+ if (this.capability !== 'configured') {
288
+ this.capability = await readAiCapability()
289
+ this.asked = true
290
+ }
291
+ if (ticket !== this.revision || this.capability !== 'configured') return
292
+
293
+ const surface = this.surface
294
+ if (!surface) return
295
+
296
+ const anchor = await surface.anchor()
297
+ if (ticket !== this.revision) return
298
+ this.anchor = anchor
299
+ if (!isAnchor(anchor)) return
300
+
301
+ const modes = await modesFor(surface.subject)
302
+ if (ticket !== this.revision) return
303
+ this.kindLabels = Object.fromEntries(modes.map(mode => [mode.kind, mode.label || mode.kind]))
304
+ const chosen = modes.find(mode => mode.kind === this.chosenKind) ?? modes.find(mode => mode.isDefault) ?? modes[0]
305
+ this.kind = chosen?.kind ?? ''
306
+ if (!this.kind) {
307
+ this.anchor = { stuck: 'no-conversation', message: t('no-conversation', 'there is no conversation for this screen.') }
308
+ return
309
+ }
310
+
311
+ const user = (store.getState() as any).auth?.user
312
+ const userId = user?.id || user?.email
313
+ if (!userId) throw new Error(t('no-user', 'your sign-in could not be read. sign in again.'))
314
+
315
+ await this.controller.open(this.target(userId, anchor))
316
+ await this.takeAsk()
317
+ } catch (error) {
318
+ if (ticket === this.revision) this.failure = (error as Error).message
319
+ } finally {
320
+ if (ticket === this.revision) this.loading = false
321
+ }
322
+ }
323
+
324
+ private target(userId: string, anchor: { anchorType: string; anchorId: string }): AssistantSessionTarget {
325
+ /* The conversation kind is part of the key: two conversations on one anchor never share a session (ADR-0072 decision 3). */
326
+ return { scope: JSON.stringify([this.app.contextPath, userId, this.kind]), anchorType: anchor.anchorType, anchorId: anchor.anchorId, kind: this.kind }
327
+ }
328
+
329
+ private async reloadSessions() {
330
+ const target = this.controller.state.target
331
+ if (target) await this.controller.open(target, true)
332
+ }
333
+
334
+ private async newConversation() {
335
+ if (this.loading || !isAnchor(this.anchor)) return
336
+ this.showingHistory = false
337
+ const revision = this.controller.state.revision
338
+ await this.controller.reset()
339
+ if (revision !== this.controller.state.revision) this.conversationView++
340
+ }
341
+
342
+ private selectSession(id: string, pickedKind?: string) {
343
+ this.showingHistory = false
344
+ const follow = followPickedConversation(pickedKind, this.kind)
345
+ if (follow) {
346
+ /* A conversation of another kind on this anchor - the dock follows it rather than refusing it. */
347
+ this.chosenKind = follow
348
+ void this.open().then(() => this.controller.select(id) && this.conversationView++)
349
+ return
350
+ }
351
+ const previous = this.sessionId
352
+ if (this.controller.select(id) && previous !== id) this.conversationView++
353
+ }
354
+
355
+ private onModeChange = (event: CustomEvent) => {
356
+ const kind = event.detail?.kind
357
+ if (!kind || kind === this.kind) return
358
+ this.chosenKind = kind
359
+ void this.open()
360
+ }
361
+
362
+ /* ── asks from other screens ────────────────────────────────────────────────────── */
363
+
364
+ private async takeAsk() {
365
+ if (!isAnchor(this.anchor) || this.loading) return
366
+ const ask: AssistantAsk | undefined = takeAssistantAsk()
367
+ if (!ask) return
368
+
369
+ if (!ask.continueCurrent && (ask.name || ask.context || ask.transcript?.length)) {
370
+ const id = await this.startCarried(ask)
371
+ if (id) {
372
+ await this.reloadSessions()
373
+ this.controller.select(id)
374
+ }
375
+ } else if (!ask.continueCurrent) {
376
+ await this.newConversation()
377
+ }
378
+ this.conversationView++
379
+ this.asking = { ask: ask.ask, draft: ask.draft, requestId: String(Date.now()) }
380
+ }
381
+
382
+ /** A new conversation carrying what the other screen had - its name, a note, earlier turns. */
383
+ private async startCarried(ask: AssistantAsk): Promise<string | undefined> {
384
+ const anchor = this.anchor as { anchorType: string; anchorId: string }
385
+ const created = await client.mutate({
386
+ mutation: gql`
387
+ mutation ($t: String!, $i: String!, $n: String) {
388
+ createAISession(anchorType: $t, anchorId: $i, name: $n) {
389
+ id
390
+ }
391
+ }
392
+ `,
393
+ variables: { t: anchor.anchorType, i: anchor.anchorId, n: ask.name || null }
394
+ })
395
+ const id = created.data?.createAISession?.id
396
+ if (!id) return undefined
397
+ if (ask.context) {
398
+ await client.mutate({
399
+ mutation: gql`
400
+ mutation ($id: String!, $c: String!) {
401
+ appendSessionNote(sessionId: $id, content: $c)
402
+ }
403
+ `,
404
+ variables: { id, c: ask.context }
405
+ })
406
+ }
407
+ if (ask.transcript?.length) {
408
+ await client.mutate({
409
+ mutation: gql`
410
+ mutation ($id: String!, $turns: JSON!) {
411
+ appendSessionTurns(sessionId: $id, turns: $turns)
412
+ }
413
+ `,
414
+ variables: { id, turns: ask.transcript.map(turn => ({ role: turn.role === 'ai' ? 'assistant' : 'user', content: turn.content })) }
415
+ })
416
+ }
417
+ return id
418
+ }
419
+
420
+ /* ── proposals ───────────────────────────────────────────────────────────────────── */
421
+
422
+ private proposalContext(sessionId?: string): ProposalContext {
423
+ return {
424
+ sessionId,
425
+ surface: this.surface,
426
+ confirm: async ({ title, text, confirmText, cancelText, type }) =>
427
+ !!(await OxPrompt.open({
428
+ type: type ?? 'warning',
429
+ title,
430
+ text,
431
+ confirmButton: { text: confirmText ?? t('confirm', 'OK') },
432
+ cancelButton: { text: cancelText ?? t('cancel', 'Cancel') }
433
+ })),
434
+ notify: (level, message) => notify({ level, message })
435
+ }
436
+ }
437
+
438
+ private async report(sessionId: string | undefined, proposalKey: string | undefined, outcome: ProposalOutcome) {
439
+ if (!sessionId || !proposalKey) return
440
+ try {
441
+ await client.mutate({
442
+ mutation: gql`
443
+ mutation ($id: String!, $key: String!, $outcome: JSON!) {
444
+ reportProposalOutcome(sessionId: $id, proposalKey: $key, outcome: $outcome)
445
+ }
446
+ `,
447
+ variables: { id: sessionId, key: proposalKey, outcome }
448
+ })
449
+ } catch {
450
+ /* the card then says it heard nothing - never "done" */
451
+ }
452
+ }
453
+
454
+ /** Every proposal ends in exactly one reported outcome - a kind nobody handles is `unavailable`. */
455
+ private onProposal = async (event: CustomEvent) => {
456
+ const { proposal, sessionId, proposalKey } = event.detail ?? {}
457
+ const outcome = await runProposal(proposal, this.proposalContext(sessionId))
458
+ if (outcome.verdict === 'unavailable' && outcome.errorCode === 'unknown-proposal-kind') {
459
+ notify({ level: 'error', message: t('proposal-not-runnable', 'this proposal cannot be run on this screen.') })
460
+ }
461
+ await this.report(sessionId, proposalKey, outcome)
462
+ }
463
+
464
+ /* ── board surfaces: the chat's scene edits land on the open board ───────────────── */
465
+
466
+ private onScenePatch = async (event: CustomEvent) => {
467
+ const board = this.surface?.board
468
+ if (!board) return
469
+ const result = await board.applyPatch(event.detail)
470
+ if (result?.missed) notify({ level: 'info', message: t('patch-missed', '{missed} of the changes could not be applied.', { missed: result.missed }) })
471
+ }
472
+
473
+ private onBoardRevert = (event: CustomEvent) => void this.surface?.board?.revertPatch?.(event.detail?.patchId)
474
+
475
+ private onSceneAction = (event: CustomEvent) => void this.surface?.board?.applyAction?.(event.detail)
476
+
477
+ /* ── drawing ─────────────────────────────────────────────────────────────────────── */
478
+
479
+ private stuckLine(): string {
480
+ if (this.capability === 'not-configured') return t('not-configured', 'no AI is set up for this site. an administrator can set one up in the AI settings.')
481
+ if (this.capability === 'unknown' && this.asked && !this.loading && !this.failure) return t('capability-unknown', 'whether AI is set up here could not be read.')
482
+ if (this.capability === 'configured' && !this.surface && !this.loading) return t('no-surface', 'this screen has no conversation.')
483
+ const anchor = this.anchor
484
+ if (anchor && !isAnchor(anchor)) return anchor.message
485
+ return ''
486
+ }
487
+
488
+ private header(): TemplateResult {
489
+ const surface = this.surface
490
+ const home = assistantHomeSurface()
491
+ const ready = this.capability === 'configured' && isAnchor(this.anchor)
492
+ return html`
493
+ <div class="scope">
494
+ <md-icon>${surface?.icon || 'forum'}</md-icon>
495
+ <span class="what">
496
+ <span>${surface?.label() || t('title', 'Assistant')}</span>
497
+ ${surface?.detail ? html`<span class="detail">${surface.detail()}</span>` : nothing}
498
+ </span>
499
+ ${home && surface && home !== surface
500
+ ? html`<button class="sbtn" title=${t('to-home', "back to this application's conversation")} @click=${() => this.useSurface(home)}>
501
+ <md-icon>home</md-icon>
502
+ </button>`
503
+ : nothing}
504
+ ${ready
505
+ ? html`<button
506
+ class="sbtn"
507
+ title=${this.showingHistory ? t('back-to-conversation', 'back to the conversation') : t('conversations', 'conversations')}
508
+ @click=${() => (this.showingHistory = !this.showingHistory)}
509
+ >
510
+ <md-icon>${this.showingHistory ? 'forum' : 'list'}</md-icon>
511
+ </button>`
512
+ : nothing}
513
+ <button class="sbtn" title=${t('rebind', 'follow this screen again')} @click=${() => this.follow(true)}>
514
+ <md-icon>refresh</md-icon>
515
+ </button>
516
+ <button class="sbtn" title=${t('close', 'close')} @click=${() => closeOverlay(this.viewpart)}>
517
+ <md-icon>close</md-icon>
518
+ </button>
519
+ </div>
520
+ `
521
+ }
522
+
523
+ /** The home button: talk about the application instead of this screen, until the screen changes. */
524
+ private useSurface(surface: AssistantSurface) {
525
+ this.surface = surface
526
+ this.chosenKind = ''
527
+ void this.open()
528
+ }
529
+
530
+ render(): TemplateResult {
531
+ const anchor = this.anchor
532
+ const ready = this.capability === 'configured' && isAnchor(anchor)
533
+ const stuck = this.stuckLine()
534
+ const surface = this.surface
535
+ const openedFor = this.openedFor
536
+ const board = surface?.board
537
+
538
+ const mode: AssistantMode | undefined = ready ? { kind: this.kind, target: (anchor as any).anchorId, hostContext: {} } : undefined
539
+
540
+ return html`
541
+ ${this.header()}
542
+ ${ready && this.showingHistory
543
+ ? html`<ox-assistant-session-manager
544
+ .anchorType=${(anchor as any).anchorType}
545
+ .anchorId=${(anchor as any).anchorId}
546
+ .sessionId=${this.sessionId}
547
+ .kind=${this.kind}
548
+ .kindLabels=${this.kindLabels}
549
+ @assistant-session-select=${(event: CustomEvent) => this.selectSession(event.detail.sessionId, event.detail.kind)}
550
+ @assistant-session-reset=${() => this.newConversation()}
551
+ @assistant-session-changed=${() => this.reloadSessions()}
552
+ ></ox-assistant-session-manager>`
553
+ : nothing}
554
+ ${ready && !this.showingHistory
555
+ ? html`<ox-assistant-session-toolbar
556
+ .sessions=${this.sessions}
557
+ .sessionId=${this.sessionId}
558
+ .loading=${this.loading}
559
+ .error=${this.failure}
560
+ historyEnabled
561
+ @assistant-session-select=${(event: CustomEvent) => this.selectSession(event.detail.sessionId)}
562
+ @assistant-session-reset=${() => this.newConversation()}
563
+ @assistant-session-history=${() => (this.showingHistory = true)}
564
+ @assistant-session-retry=${() => this.open()}
565
+ ></ox-assistant-session-toolbar>`
566
+ : nothing}
567
+ ${ready && !this.showingHistory && !this.loading && !this.failure
568
+ ? keyed(
569
+ this.conversationView,
570
+ html`<ox-assistant-chat
571
+ .sessionId=${this.sessionId}
572
+ .prepareSession=${(adopt: (id: string) => void) => this.controller.prepareSession(adopt)}
573
+ .contextProvider=${() => {
574
+ /* The screen moved on between writing and sending - do not answer about the old one. */
575
+ if (openedFor !== this.scopeKey(currentAssistantSurface() ?? surface)) throw new Error(t('screen-changed', 'the screen changed while you were writing. send it again.'))
576
+ return { hostContext: surface?.hostContext?.() ?? {} }
577
+ }}
578
+ .mode=${mode}
579
+ .subject=${surface?.subject}
580
+ .chatEndpoint=${board?.chatEndpoint ?? 'assistantChat'}
581
+ ?board-tools=${!!board}
582
+ .currentBoard=${board?.currentBoard?.()}
583
+ .boardProvider=${board?.boardProvider}
584
+ .selectedRefids=${board?.selectedRefids?.() ?? []}
585
+ .knownTypes=${board?.knownTypes?.()}
586
+ .categories=${board?.categories?.()}
587
+ .componentSchemas=${board?.componentSchemas?.()}
588
+ .searchProvider=${board?.searchProvider}
589
+ .autoAsk=${this.asking?.ask}
590
+ .autoAskRequestId=${this.asking?.requestId}
591
+ .draft=${this.asking?.draft}
592
+ .draftRequestId=${this.asking?.requestId}
593
+ @assistant-mode-change=${this.onModeChange}
594
+ @assistant-proposal-execute=${this.onProposal}
595
+ @scene-edit-patch=${this.onScenePatch}
596
+ @board-edit-revert=${this.onBoardRevert}
597
+ @scene-action-execute=${this.onSceneAction}
598
+ ></ox-assistant-chat>`
599
+ )
600
+ : nothing}
601
+ ${stuck
602
+ ? html`<div class="stuck">
603
+ ${stuck}
604
+ ${this.capability === 'unknown'
605
+ ? html`<div><button class="sbtn" @click=${() => this.open()}><md-icon>refresh</md-icon>${t('retry', 'try again')}</button></div>`
606
+ : nothing}
607
+ </div>`
608
+ : nothing}
609
+ ${this.failure && !stuck && !ready
610
+ ? html`<div class="stuck">
611
+ ${this.failure}
612
+ <div><button class="sbtn" @click=${() => this.open()}><md-icon>refresh</md-icon>${t('retry', 'try again')}</button></div>
613
+ </div>`
614
+ : nothing}
615
+ `
616
+ }
617
+ }
@@ -53,6 +53,8 @@ export interface AssistantModeView {
53
53
  examples?: Array<{ label: string; items: string[] }>
54
54
  footerNotice?: string
55
55
  stagedNotice?: string
56
+ /** What this conversation accepts as attachments; absent - none (design/ai/shared-dock.md §9-1). */
57
+ attachments?: { kinds: ('image' | 'text')[]; maxBytes?: number; maxFiles?: number }
56
58
  }
57
59
 
58
60
  /** True when these two describe different conversations, so the old one should be closed. */