@things-factory/ai-assistant 10.1.154 → 10.1.155

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 (121) hide show
  1. package/client/bootstrap.ts +27 -0
  2. package/client/components/assistant-chat.ts +229 -178
  3. package/client/components/assistant-dock.ts +53 -48
  4. package/client/components/assistant-session-manager.ts +82 -100
  5. package/client/components/assistant-session-toolbar.ts +11 -3
  6. package/client/components/chat-input-builder.ts +8 -2
  7. package/client/settings/agent-settings-model.ts +164 -0
  8. package/client/settings/ai-agent-setting-let.ts +684 -0
  9. package/client/settings/ai-usage-setting-let.ts +252 -0
  10. package/client/settings/usage-model.ts +46 -0
  11. package/client/setup-assistant-dock.ts +2 -3
  12. package/client/utils/agent-choice.ts +56 -0
  13. package/client/utils/assistant-navigation.test.ts +14 -8
  14. package/client/utils/assistant-navigation.ts +8 -3
  15. package/client/utils/composer-frame.ts +28 -2
  16. package/client/utils/menu-room.ts +33 -0
  17. package/dist-client/bootstrap.d.ts +8 -0
  18. package/dist-client/bootstrap.js +26 -0
  19. package/dist-client/bootstrap.js.map +1 -0
  20. package/dist-client/components/assistant-chat.d.ts +32 -4
  21. package/dist-client/components/assistant-chat.js +234 -175
  22. package/dist-client/components/assistant-chat.js.map +1 -1
  23. package/dist-client/components/assistant-dock.d.ts +6 -8
  24. package/dist-client/components/assistant-dock.js +52 -60
  25. package/dist-client/components/assistant-dock.js.map +1 -1
  26. package/dist-client/components/assistant-session-manager.d.ts +8 -7
  27. package/dist-client/components/assistant-session-manager.js +84 -124
  28. package/dist-client/components/assistant-session-manager.js.map +1 -1
  29. package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
  30. package/dist-client/components/assistant-session-toolbar.js +10 -3
  31. package/dist-client/components/assistant-session-toolbar.js.map +1 -1
  32. package/dist-client/components/chat-input-builder.d.ts +4 -0
  33. package/dist-client/components/chat-input-builder.js +4 -2
  34. package/dist-client/components/chat-input-builder.js.map +1 -1
  35. package/dist-client/settings/agent-settings-model.d.ts +93 -0
  36. package/dist-client/settings/agent-settings-model.js +95 -0
  37. package/dist-client/settings/agent-settings-model.js.map +1 -0
  38. package/dist-client/settings/ai-agent-setting-let.d.ts +51 -0
  39. package/dist-client/settings/ai-agent-setting-let.js +672 -0
  40. package/dist-client/settings/ai-agent-setting-let.js.map +1 -0
  41. package/dist-client/settings/ai-usage-setting-let.d.ts +26 -0
  42. package/dist-client/settings/ai-usage-setting-let.js +276 -0
  43. package/dist-client/settings/ai-usage-setting-let.js.map +1 -0
  44. package/dist-client/settings/usage-model.d.ts +32 -0
  45. package/dist-client/settings/usage-model.js +25 -0
  46. package/dist-client/settings/usage-model.js.map +1 -0
  47. package/dist-client/setup-assistant-dock.d.ts +0 -1
  48. package/dist-client/setup-assistant-dock.js +0 -1
  49. package/dist-client/setup-assistant-dock.js.map +1 -1
  50. package/dist-client/tsconfig.tsbuildinfo +1 -1
  51. package/dist-client/utils/agent-choice.d.ts +24 -0
  52. package/dist-client/utils/agent-choice.js +38 -0
  53. package/dist-client/utils/agent-choice.js.map +1 -0
  54. package/dist-client/utils/assistant-navigation.d.ts +8 -3
  55. package/dist-client/utils/assistant-navigation.js +7 -2
  56. package/dist-client/utils/assistant-navigation.js.map +1 -1
  57. package/dist-client/utils/assistant-navigation.test.js +14 -8
  58. package/dist-client/utils/assistant-navigation.test.js.map +1 -1
  59. package/dist-client/utils/composer-frame.d.ts +23 -2
  60. package/dist-client/utils/composer-frame.js +20 -2
  61. package/dist-client/utils/composer-frame.js.map +1 -1
  62. package/dist-client/utils/menu-room.d.ts +13 -0
  63. package/dist-client/utils/menu-room.js +26 -0
  64. package/dist-client/utils/menu-room.js.map +1 -0
  65. package/dist-server/service/ai-agent-resolver.d.ts +82 -0
  66. package/dist-server/service/ai-agent-resolver.js +417 -0
  67. package/dist-server/service/ai-agent-resolver.js.map +1 -0
  68. package/dist-server/service/assistant-capability-resolver.d.ts +1 -1
  69. package/dist-server/service/assistant-capability-resolver.js +7 -7
  70. package/dist-server/service/assistant-capability-resolver.js.map +1 -1
  71. package/dist-server/service/assistant-chat-resolver.d.ts +2 -0
  72. package/dist-server/service/assistant-chat-resolver.js +32 -15
  73. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  74. package/dist-server/service/assistant-mode/resolve.d.ts +0 -1
  75. package/dist-server/service/assistant-mode/resolve.js +0 -1
  76. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  77. package/dist-server/service/assistant-mode/types.d.ts +1 -2
  78. package/dist-server/service/assistant-mode/types.js.map +1 -1
  79. package/dist-server/service/chat-session/chat-session.d.ts +2 -0
  80. package/dist-server/service/chat-session/chat-session.js +5 -0
  81. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  82. package/dist-server/service/index.d.ts +2 -1
  83. package/dist-server/service/index.js +3 -0
  84. package/dist-server/service/index.js.map +1 -1
  85. package/dist-server/service/testing/mode-conformance.js +0 -1
  86. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  87. package/dist-server/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +9 -7
  89. package/server/service/ai-agent-resolver.ts +330 -0
  90. package/server/service/assistant-capability-resolver.ts +7 -7
  91. package/server/service/assistant-chat-resolver.ts +32 -24
  92. package/server/service/assistant-mode/resolve.test.ts +0 -2
  93. package/server/service/assistant-mode/resolve.ts +0 -2
  94. package/server/service/assistant-mode/types.ts +1 -2
  95. package/server/service/chat-session/chat-session.ts +5 -0
  96. package/server/service/index.ts +3 -0
  97. package/server/service/testing/mode-conformance.ts +0 -1
  98. package/server/tool-registry-boot-report.test.ts +2 -0
  99. package/test/ai-agent-settings.test.ts +123 -0
  100. package/test/assistant-dock.test.ts +3 -2
  101. package/test/assistant-session-manager.test.ts +10 -4
  102. package/test/assistant-session-toolbar.test.ts +8 -0
  103. package/test/composer-frame.test.ts +40 -2
  104. package/test/menu-room.test.ts +33 -0
  105. package/test/open-doors.test.ts +3 -1
  106. package/test/session-discovery.test.ts +7 -6
  107. package/things-factory.config.js +2 -0
  108. package/translations/en.json +78 -12
  109. package/translations/es.json +78 -12
  110. package/translations/ja.json +78 -12
  111. package/translations/ko.json +78 -12
  112. package/translations/ms.json +78 -12
  113. package/translations/zh.json +78 -12
  114. package/client/components/chat-triggers.test.ts +0 -72
  115. package/client/components/chat-triggers.ts +0 -62
  116. package/dist-client/components/chat-triggers.d.ts +0 -16
  117. package/dist-client/components/chat-triggers.js +0 -42
  118. package/dist-client/components/chat-triggers.js.map +0 -1
  119. package/dist-client/components/chat-triggers.test.d.ts +0 -1
  120. package/dist-client/components/chat-triggers.test.js +0 -61
  121. package/dist-client/components/chat-triggers.test.js.map +0 -1
@@ -0,0 +1,684 @@
1
+ import '@material/web/icon/icon.js'
2
+ import '@things-factory/setting-base/dist-client/layout/settinglet.js'
3
+
4
+ import gql from 'graphql-tag'
5
+ import { css, html, LitElement, nothing } from 'lit'
6
+ import { customElement, state } from 'lit/decorators.js'
7
+ import { client } from '@operato/graphql'
8
+ import { i18next, localize } from '@operato/i18n'
9
+ import { OxPrompt } from '@operato/popup'
10
+ import '@operato/popup/ox-popup-menu.js'
11
+ import { OxPopupMenu } from '@operato/popup/ox-popup-menu.js'
12
+ import { ButtonContainerStyles, FormFieldStyles } from '@operato/styles'
13
+
14
+ import { providerLabel } from '../utils/agent-choice.js'
15
+ import {
16
+ type AgentDraft,
17
+ type AgentSettings,
18
+ type AgentView,
19
+ draftOf,
20
+ groupByOwner,
21
+ saveInput,
22
+ type SaveChange
23
+ } from './agent-settings-model.js'
24
+
25
+ const t = (key: string, defaultValue: string, options: Record<string, unknown> = {}) =>
26
+ i18next.t(`ai-assistant.agent.${key}`, { ...options, defaultValue })
27
+
28
+ const SETTINGS_QUERY = gql`
29
+ query AiAgentSettings {
30
+ aiAgentSettings {
31
+ chain {
32
+ id
33
+ name
34
+ }
35
+ agents {
36
+ id
37
+ name
38
+ provider
39
+ model
40
+ baseURL
41
+ organization
42
+ active
43
+ owner {
44
+ id
45
+ name
46
+ }
47
+ inherited
48
+ turnedOff
49
+ keyState
50
+ keyDomain {
51
+ id
52
+ name
53
+ }
54
+ keyHint
55
+ usable
56
+ isDefault
57
+ usedBelow {
58
+ id
59
+ name
60
+ }
61
+ }
62
+ defaultAgentId
63
+ defaultFrom
64
+ state
65
+ reason
66
+ }
67
+ }
68
+ `
69
+
70
+ const MODELS_QUERY = gql`
71
+ query AiProviderModels($provider: String!, $apiKey: String, $agentId: String, $baseURL: String, $organization: String) {
72
+ aiProviderModels(provider: $provider, apiKey: $apiKey, agentId: $agentId, baseURL: $baseURL, organization: $organization)
73
+ }
74
+ `
75
+
76
+ const SAVE_MUTATION = gql`
77
+ mutation SaveAiAgents($input: AiAgentsInput!) {
78
+ saveAiAgents(input: $input)
79
+ }
80
+ `
81
+
82
+ const PROVIDERS = ['openai', 'anthropic', 'gemini']
83
+
84
+ type ModelList = { state: 'idle' | 'loading' | 'ready' | 'failed'; models: string[]; reason?: string }
85
+
86
+ /**
87
+ * A domain's AI agents - the card in Settings › {domain} settings (design/ai/agent-settings.md §7-1, §7-2).
88
+ *
89
+ * The first line says whether AI works here and, if not, the one thing that is missing. The list is grouped by the
90
+ * domain that declared each agent: this domain's own, editable, then each domain above, read-only, closest first. A key
91
+ * is written and never shown again; the model is only ever picked from the list the provider gives that key.
92
+ */
93
+ @customElement('ai-agent-setting-let')
94
+ export class AiAgentSettingLet extends localize(i18next)(LitElement) {
95
+ static styles = [
96
+ /* The button look comes from the action bar's styles, which also restyle select and text inputs - the form
97
+ field rules come after them, so every field in the card has the standard field look. */
98
+ ButtonContainerStyles,
99
+ FormFieldStyles,
100
+ css`
101
+ :host {
102
+ display: block;
103
+ }
104
+ [slot='content'] {
105
+ display: grid;
106
+ gap: 12px;
107
+ text-transform: none;
108
+ }
109
+ .head {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 8px;
113
+ }
114
+ .status {
115
+ flex: 1;
116
+ font-size: 12px;
117
+ color: var(--md-sys-color-on-surface-variant);
118
+ }
119
+ .status.off {
120
+ color: var(--md-sys-color-error);
121
+ }
122
+ .group-title {
123
+ margin: 4px 0 0;
124
+ font-size: 11px;
125
+ font-weight: 600;
126
+ color: var(--md-sys-color-on-surface-variant);
127
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
128
+ padding-bottom: 2px;
129
+ }
130
+ .row {
131
+ display: grid;
132
+ grid-template-columns: 20px minmax(100px, 1.2fr) 70px minmax(120px, 1.4fr) minmax(110px, 1fr) auto auto 28px;
133
+ align-items: center;
134
+ gap: 8px;
135
+ min-height: 32px;
136
+ font-size: 13px;
137
+ }
138
+ .row.muted > :not(.more-menu) {
139
+ opacity: 0.55;
140
+ }
141
+ .name {
142
+ font-weight: 600;
143
+ overflow: hidden;
144
+ text-overflow: ellipsis;
145
+ white-space: nowrap;
146
+ }
147
+ .model {
148
+ font-family: var(--md-sys-typescale-mono-font, monospace);
149
+ font-size: 12px;
150
+ overflow: hidden;
151
+ text-overflow: ellipsis;
152
+ white-space: nowrap;
153
+ }
154
+ .key {
155
+ font-size: 12px;
156
+ white-space: nowrap;
157
+ }
158
+ .key.missing {
159
+ color: var(--md-sys-color-error);
160
+ font-weight: 600;
161
+ }
162
+ .use {
163
+ display: inline-flex;
164
+ align-items: center;
165
+ gap: 4px;
166
+ font-size: 12px;
167
+ white-space: nowrap;
168
+ }
169
+ .below {
170
+ border: none;
171
+ background: none;
172
+ padding: 0;
173
+ height: auto;
174
+ font-size: 11px;
175
+ font-weight: 400;
176
+ color: var(--md-sys-color-primary);
177
+ cursor: pointer;
178
+ }
179
+ .more-menu {
180
+ border: none;
181
+ background: none;
182
+ height: auto;
183
+ padding: 2px 6px;
184
+ font-size: 14px;
185
+ font-weight: 400;
186
+ color: var(--md-sys-color-on-surface-variant);
187
+ }
188
+ form.editor {
189
+ display: grid;
190
+ grid-template-columns: repeat(2, minmax(0, 1fr));
191
+ gap: 12px;
192
+ padding: 12px;
193
+ border: 1px solid var(--md-sys-color-outline-variant);
194
+ border-radius: var(--md-sys-shape-corner-small);
195
+ background: var(--md-sys-color-surface-container-lowest);
196
+ }
197
+ @media (max-width: 600px) {
198
+ form.editor {
199
+ grid-template-columns: 1fr;
200
+ }
201
+ }
202
+ form.editor label {
203
+ display: grid;
204
+ gap: 4px;
205
+ font-size: 11px;
206
+ color: var(--md-sys-color-on-surface-variant);
207
+ }
208
+ form.editor input,
209
+ form.editor select {
210
+ height: var(--form-element-height-medium, 32px);
211
+ box-sizing: border-box;
212
+ font-size: 13px;
213
+ }
214
+ .with-action {
215
+ display: flex;
216
+ gap: 4px;
217
+ }
218
+ .with-action > select {
219
+ flex: 1;
220
+ min-width: 0;
221
+ }
222
+ .hint {
223
+ font-size: 11px;
224
+ color: var(--md-sys-color-on-surface-variant);
225
+ }
226
+ .error {
227
+ font-size: 11.5px;
228
+ color: var(--md-sys-color-error);
229
+ }
230
+ details.advanced {
231
+ grid-column: 1 / -1;
232
+ font-size: 12px;
233
+ }
234
+ details.advanced[open] > div {
235
+ display: grid;
236
+ grid-template-columns: repeat(2, minmax(0, 1fr));
237
+ gap: 12px;
238
+ margin-top: 8px;
239
+ }
240
+ .actions {
241
+ grid-column: 1 / -1;
242
+ display: flex;
243
+ justify-content: flex-end;
244
+ gap: 8px;
245
+ }
246
+ button.primary {
247
+ background: var(--md-sys-color-primary);
248
+ color: var(--md-sys-color-on-primary);
249
+ border-color: var(--md-sys-color-primary);
250
+ }
251
+ button.primary md-icon {
252
+ color: inherit;
253
+ }
254
+ .key-override {
255
+ display: flex;
256
+ gap: 8px;
257
+ align-items: center;
258
+ padding: 8px 0 8px 28px;
259
+ }
260
+ .key-override input {
261
+ flex: 1;
262
+ height: var(--form-element-height-medium, 32px);
263
+ box-sizing: border-box;
264
+ }
265
+ `
266
+ ]
267
+
268
+ @state() private settings?: AgentSettings
269
+ @state() private loadFailed?: string
270
+ @state() private draft?: AgentDraft
271
+ @state() private models: ModelList = { state: 'idle', models: [] }
272
+ @state() private saving = false
273
+ @state() private saveError?: string
274
+ @state() private keyFor?: string
275
+ @state() private keyDraft = ''
276
+
277
+ connectedCallback() {
278
+ super.connectedCallback()
279
+ this.load()
280
+ }
281
+
282
+ private async load() {
283
+ this.loadFailed = undefined
284
+ try {
285
+ const response = await client.query({ query: SETTINGS_QUERY, fetchPolicy: 'network-only' })
286
+ this.settings = answered(response, 'aiAgentSettings')
287
+ } catch (error: any) {
288
+ this.loadFailed = messageOf(error)
289
+ }
290
+ }
291
+
292
+ render() {
293
+ return html`
294
+ <setting-let>
295
+ <div slot="title">${t('title', 'AI agents')}</div>
296
+ <div slot="content">${this.renderContent()}</div>
297
+ </setting-let>
298
+ `
299
+ }
300
+
301
+ private renderContent() {
302
+ if (this.loadFailed) {
303
+ return html`
304
+ <div class="head">
305
+ <span class="status off">${t('load-failed', 'Could not read the AI agents.')} ${this.loadFailed}</span>
306
+ <button type="button" @click=${() => this.load()}><md-icon>refresh</md-icon>${t('retry', 'Try again')}</button>
307
+ </div>
308
+ `
309
+ }
310
+ if (!this.settings) return html`<span class="status">${t('loading', 'Reading…')}</span>`
311
+
312
+ const groups = groupByOwner(this.settings)
313
+ return html`
314
+ <div class="head">
315
+ ${this.renderStatus()}
316
+ <button type="button" ?disabled=${!!this.draft || this.saving} @click=${() => this.startNew()}>
317
+ <md-icon>add</md-icon>${t('add', 'Agent')}
318
+ </button>
319
+ </div>
320
+ ${this.draft && !this.draft.id ? this.renderEditor() : nothing}
321
+ ${groups.map(
322
+ group => html`
323
+ ${groups.length > 1 || group.inherited
324
+ ? html`<div class="group-title">
325
+ ${group.inherited ? t('inherited-from', 'Inherited from {path}', { path: group.path }) : t('this-domain', 'This domain')}
326
+ </div>`
327
+ : nothing}
328
+ ${group.agents.map(agent => html`${this.renderRow(agent)} ${this.draft?.id === agent.id ? this.renderEditor() : nothing}
329
+ ${this.keyFor === agent.id ? this.renderKeyOverride(agent) : nothing}`)}
330
+ `
331
+ )}
332
+ `
333
+ }
334
+
335
+ private renderStatus() {
336
+ const settings = this.settings!
337
+ if (settings.state === 'configured') {
338
+ const usable = settings.agents.filter(one => one.usable).length
339
+ const chosen = settings.agents.find(one => one.id === settings.defaultAgentId)
340
+ return html`<span class="status">
341
+ ${t('status-on', 'AI is on here · {count} agents to pick · default {name}', { count: usable, name: chosen?.name ?? '' })}
342
+ </span>`
343
+ }
344
+ const why =
345
+ settings.reason === 'no-agent-key'
346
+ ? t('status-no-key', 'No agent has an API key, so AI is off here.')
347
+ : settings.reason === 'no-usable-agent'
348
+ ? t('status-all-off', 'Every agent is turned off, so AI is off here.')
349
+ : t('status-no-agent', 'There is no AI agent, so AI is off here.')
350
+ return html`<span class="status off">${why}</span>`
351
+ }
352
+
353
+ private renderRow(agent: AgentView) {
354
+ const settings = this.settings!
355
+ const editable = !agent.inherited
356
+ const muted = !agent.usable
357
+ return html`
358
+ <div class="row ${muted ? 'muted' : ''}">
359
+ <input
360
+ type="radio"
361
+ name="default"
362
+ aria-label=${t('default', 'Default')}
363
+ title=${t('default-hint', 'The agent a person gets before picking one')}
364
+ .checked=${agent.id === settings.defaultAgentId}
365
+ ?disabled=${!agent.usable || this.saving}
366
+ @change=${() => this.save({ kind: 'default', agentId: agent.id })} />
367
+ <span class="name" title=${agent.name}>${agent.name}</span>
368
+ <span>${providerLabel(agent.provider)}</span>
369
+ <span class="model" title=${agent.model}>${agent.model}</span>
370
+ ${this.renderKey(agent)}
371
+ ${editable
372
+ ? html`<label class="use">
373
+ <input type="checkbox" .checked=${agent.active} ?disabled=${this.saving} @change=${(e: Event) => this.onActive(agent, (e.target as HTMLInputElement).checked)} />
374
+ ${t('use', 'Use')}
375
+ </label>`
376
+ : html`<label class="use">
377
+ <input type="checkbox" .checked=${!agent.turnedOff} ?disabled=${this.saving} @change=${(e: Event) => this.save({ kind: 'turn-off', agentId: agent.id, off: !(e.target as HTMLInputElement).checked })} />
378
+ ${t('use-here', 'Use here')}
379
+ </label>`}
380
+ ${editable && agent.usedBelow.length
381
+ ? html`<button type="button" class="below" title=${agent.usedBelow.map(one => one.name).join(', ')} @click=${() => this.showUsers(agent)}>
382
+ ${t('used-below', '{count} below', { count: agent.usedBelow.length })}
383
+ </button>`
384
+ : html`<span></span>`}
385
+ ${this.renderMenu(agent)}
386
+ </div>
387
+ `
388
+ }
389
+
390
+ private renderKey(agent: AgentView) {
391
+ switch (agent.keyState) {
392
+ case 'own':
393
+ return html`<span class="key">${agent.keyHint}</span>`
394
+ case 'overridden':
395
+ return html`<span class="key">${t('key-this-domain', 'This domain\'s key {hint}', { hint: agent.keyHint ?? '' })}</span>`
396
+ case 'inherited':
397
+ return html`<span class="key">${t('key-of', '{domain} key', { domain: agent.keyDomain?.name ?? '' })}</span>`
398
+ default:
399
+ return html`<span class="key missing">${t('key-missing', 'No key')}</span>`
400
+ }
401
+ }
402
+
403
+ /** The row's actions in the platform's popup menu - one at a time, closed by a press outside or Escape. */
404
+ private renderMenu(agent: AgentView) {
405
+ return html`<button
406
+ type="button"
407
+ class="more-menu"
408
+ aria-label=${t('more', 'More')}
409
+ title=${t('more', 'More')}
410
+ ?disabled=${this.saving}
411
+ @click=${(e: MouseEvent) => this.openMenu(e, agent)}>⋯</button>`
412
+ }
413
+
414
+ private openMenu(event: MouseEvent, agent: AgentView) {
415
+ const at = (event.currentTarget as HTMLElement).getBoundingClientRect()
416
+ OxPopupMenu.open({
417
+ template: agent.inherited
418
+ ? html`
419
+ <ox-popup-menuitem
420
+ label=${agent.keyState === 'overridden' ? t('replace-own-key', "Replace this domain's key") : t('use-own-key', "Use this domain's key")}
421
+ @select=${() => this.startKeyOverride(agent)}></ox-popup-menuitem>
422
+ ${agent.keyState === 'overridden'
423
+ ? html`<ox-popup-menuitem
424
+ label=${t('back-to-key', 'Back to the {domain} key', { domain: agent.owner.name })}
425
+ @select=${() => this.clearKeyOverride(agent)}></ox-popup-menuitem>`
426
+ : nothing}
427
+ `
428
+ : html`
429
+ ${this.draft ? nothing : html`<ox-popup-menuitem label=${t('edit', 'Edit')} @select=${() => this.startEdit(agent)}></ox-popup-menuitem>`}
430
+ <ox-popup-menuitem label=${t('delete', 'Delete')} @select=${() => this.deleteAgent(agent)}></ox-popup-menuitem>
431
+ `,
432
+ top: at.bottom,
433
+ right: window.innerWidth - at.right,
434
+ parent: document.body
435
+ })
436
+ }
437
+
438
+ private renderKeyOverride(agent: AgentView) {
439
+ return html`
440
+ <div class="key-override">
441
+ <input
442
+ type="password"
443
+ autocomplete="new-password"
444
+ placeholder=${t('key-placeholder', 'API key of this domain')}
445
+ .value=${this.keyDraft}
446
+ @input=${(e: Event) => (this.keyDraft = (e.target as HTMLInputElement).value)} />
447
+ <button type="button" @click=${() => ((this.keyFor = undefined), (this.keyDraft = ''))}>${t('cancel', 'Cancel')}</button>
448
+ <button type="button" class="primary" ?disabled=${!this.keyDraft.trim() || this.saving} @click=${() => this.save({ kind: 'key-override', agentId: agent.id, apiKey: this.keyDraft.trim() })}>
449
+ ${t('save', 'Save')}
450
+ </button>
451
+ </div>
452
+ ${this.saveError ? html`<div class="error">${this.saveError}</div>` : nothing}
453
+ `
454
+ }
455
+
456
+ private renderEditor() {
457
+ const draft = this.draft!
458
+ const stored = this.settings!.agents.find(one => one.id === draft.id)
459
+ const hasStoredKey = !!stored && stored.keyState === 'own' && draft.provider === stored.provider
460
+ const modelLocked = !draft.apiKey.trim() && !hasStoredKey
461
+ const offered = this.models.state === 'ready' ? this.models.models : []
462
+ const modelGone = this.models.state === 'ready' && !!draft.model && !offered.includes(draft.model)
463
+ const canSave = !!draft.name.trim() && !!draft.model && !modelLocked && !modelGone && this.models.state === 'ready' && !this.saving
464
+
465
+ return html`
466
+ <form class="editor" @submit=${(e: Event) => (e.preventDefault(), canSave && this.save({ kind: 'upsert', draft }))}>
467
+ <label>
468
+ ${t('name', 'Name')}
469
+ <input .value=${draft.name} @input=${(e: Event) => this.patch({ name: (e.target as HTMLInputElement).value })} />
470
+ </label>
471
+ <label>
472
+ ${t('provider', 'Provider')}
473
+ <select @change=${(e: Event) => this.onProvider((e.target as HTMLSelectElement).value)}>
474
+ ${PROVIDERS.map(one => html`<option value=${one} ?selected=${one === draft.provider}>${providerLabel(one)}</option>`)}
475
+ </select>
476
+ </label>
477
+ <label>
478
+ ${t('api-key', 'API key')}
479
+ <input
480
+ type="password"
481
+ autocomplete="new-password"
482
+ placeholder=${hasStoredKey ? t('key-stored', 'Stored key {hint} - enter only to change it', { hint: stored?.keyHint ?? '' }) : ''}
483
+ .value=${draft.apiKey}
484
+ @input=${(e: Event) => this.patch({ apiKey: (e.target as HTMLInputElement).value })}
485
+ @change=${() => this.loadModels()} />
486
+ </label>
487
+ <label>
488
+ ${t('model', 'Model')}
489
+ <span class="with-action">
490
+ <select ?disabled=${modelLocked || this.models.state !== 'ready'} @change=${(e: Event) => this.patch({ model: (e.target as HTMLSelectElement).value })}>
491
+ ${!draft.model || modelGone ? html`<option value="" selected disabled>${modelGone ? draft.model : t('pick-model', 'Pick a model')}</option>` : nothing}
492
+ ${offered.map(one => html`<option value=${one} ?selected=${one === draft.model}>${one}</option>`)}
493
+ </select>
494
+ <button type="button" aria-label=${t('reload-models', 'Read the models again')} title=${t('reload-models', 'Read the models again')} ?disabled=${modelLocked || this.models.state === 'loading'} @click=${() => this.loadModels()}>
495
+ <md-icon>refresh</md-icon>
496
+ </button>
497
+ </span>
498
+ ${modelLocked
499
+ ? html`<span class="hint">${t('enter-key-first', 'Enter the API key to pick a model.')}</span>`
500
+ : this.models.state === 'loading'
501
+ ? html`<span class="hint">${t('reading-models', 'Reading the provider\'s models…')}</span>`
502
+ : this.models.state === 'failed'
503
+ ? html`<span class="error">${this.models.reason}</span>`
504
+ : modelGone
505
+ ? html`<span class="error">${t('model-gone', 'The provider no longer offers this model. Pick another.')}</span>`
506
+ : nothing}
507
+ </label>
508
+ <details class="advanced" ?open=${!!(draft.baseURL || draft.organization)}>
509
+ <summary>${t('advanced', 'Advanced')}</summary>
510
+ <div>
511
+ <label>
512
+ ${t('base-url', 'Base URL')}
513
+ <input .value=${draft.baseURL} placeholder="https://" @input=${(e: Event) => this.patch({ baseURL: (e.target as HTMLInputElement).value })} @change=${() => this.loadModels()} />
514
+ </label>
515
+ ${draft.provider === 'openai'
516
+ ? html`<label>
517
+ ${t('organization', 'Organization')}
518
+ <input .value=${draft.organization} @input=${(e: Event) => this.patch({ organization: (e.target as HTMLInputElement).value })} @change=${() => this.loadModels()} />
519
+ </label>`
520
+ : nothing}
521
+ </div>
522
+ </details>
523
+ ${this.saveError ? html`<div class="error" style="grid-column: 1 / -1">${this.saveError}</div>` : nothing}
524
+ <div class="actions">
525
+ <button type="button" @click=${() => this.cancelEdit()}>${t('cancel', 'Cancel')}</button>
526
+ <button type="submit" class="primary" ?disabled=${!canSave}>${t('save', 'Save')}</button>
527
+ </div>
528
+ </form>
529
+ `
530
+ }
531
+
532
+ private startNew() {
533
+ this.saveError = undefined
534
+ this.draft = draftOf()
535
+ this.models = { state: 'idle', models: [] }
536
+ }
537
+
538
+ private startEdit(agent: AgentView) {
539
+ this.saveError = undefined
540
+ this.draft = draftOf(agent)
541
+ this.loadModels()
542
+ }
543
+
544
+ private cancelEdit() {
545
+ this.draft = undefined
546
+ this.saveError = undefined
547
+ this.models = { state: 'idle', models: [] }
548
+ }
549
+
550
+ private patch(change: Partial<AgentDraft>) {
551
+ this.draft = { ...this.draft!, ...change }
552
+ }
553
+
554
+ /** A provider decides the model list, so changing it empties the model. */
555
+ private onProvider(provider: string) {
556
+ this.patch({ provider, model: '', organization: provider === 'openai' ? this.draft!.organization : '' })
557
+ this.models = { state: 'idle', models: [] }
558
+ this.loadModels()
559
+ }
560
+
561
+ private modelsToken = 0
562
+
563
+ private async loadModels() {
564
+ const draft = this.draft
565
+ if (!draft) return
566
+ const stored = this.settings?.agents.find(one => one.id === draft.id && !one.inherited)
567
+ const useStored = !draft.apiKey.trim() && stored?.keyState === 'own' && stored.provider === draft.provider
568
+ if (!draft.apiKey.trim() && !useStored) {
569
+ this.models = { state: 'idle', models: [] }
570
+ return
571
+ }
572
+ const token = ++this.modelsToken
573
+ this.models = { state: 'loading', models: [] }
574
+ try {
575
+ const response = await client.query({
576
+ query: MODELS_QUERY,
577
+ fetchPolicy: 'network-only',
578
+ variables: {
579
+ provider: draft.provider,
580
+ ...(draft.apiKey.trim() ? { apiKey: draft.apiKey.trim() } : { agentId: draft.id }),
581
+ ...(draft.baseURL.trim() && { baseURL: draft.baseURL.trim() }),
582
+ ...(draft.organization.trim() && { organization: draft.organization.trim() })
583
+ }
584
+ })
585
+ if (token !== this.modelsToken) return
586
+ this.models = { state: 'ready', models: answered(response, 'aiProviderModels') ?? [] }
587
+ } catch (error: any) {
588
+ if (token !== this.modelsToken) return
589
+ this.models = { state: 'failed', models: [], reason: messageOf(error) }
590
+ }
591
+ }
592
+
593
+ private startKeyOverride(agent: AgentView) {
594
+ this.saveError = undefined
595
+ this.keyDraft = ''
596
+ this.keyFor = agent.id
597
+ }
598
+
599
+ private async clearKeyOverride(agent: AgentView) {
600
+ const accepted = await confirm(
601
+ t('back-to-key-title', 'Go back to the {domain} key?', { domain: agent.owner.name }),
602
+ t('back-to-key-text', 'This domain\'s key for "{name}" is deleted, and the key of {domain} answers again.', {
603
+ name: agent.name,
604
+ domain: agent.owner.name
605
+ })
606
+ )
607
+ if (accepted) await this.save({ kind: 'key-clear', agentId: agent.id })
608
+ }
609
+
610
+ /** Turning an agent off reaches every domain below that uses it - they are named before it happens. */
611
+ private async onActive(agent: AgentView, active: boolean) {
612
+ if (!active && agent.usedBelow.length) {
613
+ const accepted = await confirm(
614
+ t('turn-off-title', 'Turn "{name}" off?', { name: agent.name }),
615
+ t('turn-off-text', 'It stops answering here and in {domains}.', { domains: agent.usedBelow.map(one => one.name).join(', ') })
616
+ )
617
+ if (!accepted) return this.requestUpdate()
618
+ }
619
+ await this.save({ kind: 'active', agentId: agent.id, active })
620
+ }
621
+
622
+ private async deleteAgent(agent: AgentView) {
623
+ const below = agent.usedBelow.length
624
+ ? ' ' + t('delete-below', 'It also stops answering in {domains}.', { domains: agent.usedBelow.map(one => one.name).join(', ') })
625
+ : ''
626
+ const accepted = await confirm(
627
+ t('delete-title', 'Delete "{name}"?', { name: agent.name }),
628
+ t('delete-text', 'Its API key is deleted with it. Usage already recorded stays.') + below
629
+ )
630
+ if (accepted) await this.save({ kind: 'delete', agentId: agent.id })
631
+ }
632
+
633
+ private async showUsers(agent: AgentView) {
634
+ await OxPrompt.open({
635
+ type: 'info',
636
+ title: t('used-below-title', 'Domains below using "{name}"', { name: agent.name }),
637
+ text: agent.usedBelow.map(one => one.name).join(', '),
638
+ confirmButton: { text: t('close', 'Close') }
639
+ })
640
+ }
641
+
642
+ private async save(change: SaveChange) {
643
+ this.saving = true
644
+ this.saveError = undefined
645
+ try {
646
+ answered(await client.mutate({ mutation: SAVE_MUTATION, variables: { input: saveInput(this.settings!, change) } }), 'saveAiAgents')
647
+ if (change.kind === 'upsert') this.cancelEdit()
648
+ if (change.kind === 'key-override') ((this.keyFor = undefined), (this.keyDraft = ''))
649
+ await this.load()
650
+ } catch (error: any) {
651
+ this.saveError = messageOf(error)
652
+ if (change.kind !== 'upsert' && change.kind !== 'key-override') {
653
+ await OxPrompt.open({ type: 'error', title: t('save-failed', 'Not saved'), text: this.saveError, confirmButton: { text: t('close', 'Close') } })
654
+ await this.load()
655
+ }
656
+ } finally {
657
+ this.saving = false
658
+ }
659
+ }
660
+ }
661
+
662
+ /**
663
+ * The data of a query or mutation. This client answers a refusal in `errors` (errorPolicy 'all') instead of
664
+ * throwing, so it is turned into a throw here - the server's sentence, already in the person's language.
665
+ */
666
+ function answered(result: any, field: string): any {
667
+ if (result?.errors?.length) throw new Error(result.errors[0].message)
668
+ return result?.data?.[field]
669
+ }
670
+
671
+ async function confirm(title: string, text: string): Promise<boolean> {
672
+ return !!(await OxPrompt.open({
673
+ type: 'warning',
674
+ title,
675
+ text,
676
+ confirmButton: { text: t('confirm', 'Confirm') },
677
+ cancelButton: { text: t('cancel', 'Cancel') }
678
+ }))
679
+ }
680
+
681
+ /** The server's sentence for a refusal - already in the person's language. */
682
+ function messageOf(error: any): string {
683
+ return error?.graphQLErrors?.[0]?.message ?? error?.message ?? String(error)
684
+ }