@things-factory/ai-assistant 10.1.178 → 10.1.181

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 (27) hide show
  1. package/client/components/assistant-chat.ts +19 -12
  2. package/client/utils/attachment-policy.ts +41 -32
  3. package/dist-client/components/assistant-chat.d.ts +1 -0
  4. package/dist-client/components/assistant-chat.js +14 -11
  5. package/dist-client/components/assistant-chat.js.map +1 -1
  6. package/dist-client/tsconfig.tsbuildinfo +1 -1
  7. package/dist-client/utils/attachment-policy.d.ts +10 -6
  8. package/dist-client/utils/attachment-policy.js +40 -39
  9. package/dist-client/utils/attachment-policy.js.map +1 -1
  10. package/dist-server/service/chat-session/attachment-policy.js +8 -2
  11. package/dist-server/service/chat-session/attachment-policy.js.map +1 -1
  12. package/dist-server/service/data-read/list-read.d.ts +2 -2
  13. package/dist-server/service/data-read/list-read.js +5 -7
  14. package/dist-server/service/data-read/list-read.js.map +1 -1
  15. package/dist-server/service/data-read/read-target.d.ts +2 -0
  16. package/dist-server/service/data-read/read-target.js +11 -3
  17. package/dist-server/service/data-read/read-target.js.map +1 -1
  18. package/dist-server/tsconfig.tsbuildinfo +1 -1
  19. package/package.json +14 -13
  20. package/server/service/chat-session/attachment-policy.test.ts +1 -1
  21. package/server/service/chat-session/attachment-policy.ts +7 -2
  22. package/server/service/data-read/data-read.test.ts +41 -14
  23. package/server/service/data-read/list-read.ts +7 -9
  24. package/server/service/data-read/read-target.ts +15 -3
  25. package/test/assistant-dock.test.ts +1 -1
  26. package/test/attachment-one-rule.test.ts +46 -0
  27. package/test/attachment-policy-agree.test.ts +4 -1
@@ -35,6 +35,8 @@ import {
35
35
  stripMentionRefids
36
36
  } from './markdown.js'
37
37
  import { acceptOf, chatAttachmentUrl, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js'
38
+ import { fileUploadLimits } from '@operato/utils/file-acceptance.js'
39
+ import '@operato/input/ox-input-file.js'
38
40
  import { pastedTableFile } from '../utils/pasted-table.js'
39
41
  import { appendWords, composerHeight, composerKeyAction, composerSuggestion, sendShortcut, tableAsWords } from '../utils/composer-frame.js'
40
42
  import { readSpreadsheet, spreadsheetKindOf, type SpreadsheetRefusal } from '../utils/spreadsheet-text.js'
@@ -659,8 +661,12 @@ export class OxAssistantChat extends LitElement {
659
661
  list = read.files
660
662
  }
661
663
  if (this.usesAttachmentPolicy) {
662
- /* Said before anything is sent - never cut silently (§9-1). */
663
- const refusal = refuseAttachments(this.modeView?.attachments, [...this.attachments, ...list].map(f => ({ name: f.name, type: f.type, size: f.size })))
664
+ /* Said before anything is sent - never cut silently (§9-1). The one picking rule, the server's upload limit included. */
665
+ const refusal = refuseAttachments(
666
+ this.modeView?.attachments,
667
+ [...this.attachments, ...list].map(f => ({ name: f.name, type: f.type, size: f.size })),
668
+ await fileUploadLimits()
669
+ )
664
670
  if (refusal) {
665
671
  this.errorMessage = this.attachmentRefusalText(refusal)
666
672
  return
@@ -3234,20 +3240,21 @@ export class OxAssistantChat extends LitElement {
3234
3240
  `
3235
3241
  : nothing}
3236
3242
  <div class="actions-row">
3237
- <input
3243
+ <ox-input-file
3238
3244
  id="chat-file-input"
3239
- type="file"
3245
+ quiet
3246
+ outside-trigger
3247
+ hide-filelist
3240
3248
  multiple
3241
3249
  accept=${this.usesAttachmentPolicy ? acceptOf(this.modeView?.attachments) : 'image/*'}
3242
- style="display: none"
3243
- @change=${(e: Event) => {
3244
- const input = e.target as HTMLInputElement
3245
- if (input.files?.length) {
3246
- this.onAttachFiles(input.files)
3247
- input.value = ''
3250
+ @change=${(e: CustomEvent) => {
3251
+ const files: File[] = e.detail ?? []
3252
+ if (files.length) {
3253
+ this.onAttachFiles(files)
3254
+ ;(e.target as any).reset()
3248
3255
  }
3249
3256
  }}
3250
- />
3257
+ ></ox-input-file>
3251
3258
  ${hasMessages
3252
3259
  ? html`
3253
3260
  <button
@@ -3385,7 +3392,7 @@ export class OxAssistantChat extends LitElement {
3385
3392
  class="attach"
3386
3393
  aria-label=${i18next.t('ai-assistant.button.attach', { defaultValue: 'Attach' })}
3387
3394
  title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'attach an image (drag & drop or paste supported)' })}
3388
- @click=${() => this.renderRoot.querySelector<HTMLInputElement>('#chat-file-input')?.click()}>
3395
+ @click=${() => (this.renderRoot.querySelector('#chat-file-input') as any)?.pick()}>
3389
3396
  <md-icon>attach_file</md-icon>
3390
3397
  </button>`
3391
3398
  : nothing}
@@ -1,10 +1,15 @@
1
1
  /*
2
- * What a conversation accepts as attachments - the screen's copy of the server rule
2
+ * What a conversation accepts as attachments - the screen's side of the server rule
3
3
  * (server/service/chat-session/attachment-policy.ts, design/ai/shared-dock.md §9-1).
4
4
  *
5
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.
6
+ * the authority. `test/attachment-policy-agree.test.ts` runs both over one table so they cannot drift.
7
+ *
8
+ * The screen's check is the one file-picking rule (@operato/utils `sortPickedFiles`, ADR-0061 2026-10-17): the mode's
9
+ * kinds become an `accept` (the table below, the one place), its size and count become limits, narrowed by the
10
+ * server's upload limits. What stays here is the mode's words: kinds, and a spreadsheet read into text.
7
11
  */
12
+ import { narrowLimits, sortPickedFiles, type FileUploadLimits } from '@operato/utils/file-acceptance.js'
8
13
 
9
14
  /*
10
15
  * 'spreadsheet' is a kind a mode opens, never a file the door receives: the screen reads each .xlsx sheet into CSV
@@ -27,60 +32,64 @@ export const TEXT_CHARS_FOR_MODEL = 20_000
27
32
 
28
33
  const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']
29
34
  const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']
35
+ const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
30
36
 
31
- export function attachmentKindOf(file: { type?: string; name?: string }): AttachmentKind | null {
32
- const type = (file.type || '').toLowerCase()
33
- if (type.startsWith('image/')) return 'image'
34
- if (TEXT_TYPES.includes(type)) return 'text'
35
- const name = (file.name || '').toLowerCase()
36
- if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext))) return 'text'
37
- return null
37
+ /** Each kind as `accept` patterns - the one table from the mode's kinds to what a picker takes. */
38
+ const KIND_ACCEPT: Record<AttachmentKind, string[]> = {
39
+ image: ['image/*'],
40
+ text: [...TEXT_EXTENSIONS, ...TEXT_TYPES],
41
+ spreadsheet: ['.xlsx', XLSX_TYPE]
38
42
  }
39
43
 
44
+ /** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */
45
+ const acceptFor = (kinds: AttachmentKind[]) => [...new Set(kinds.flatMap(kind => KIND_ACCEPT[kind]))].join(',')
46
+
40
47
  export type AttachmentRefusal =
41
48
  | { code: 'attachments-not-accepted' }
42
49
  | { code: 'attachment-kind-not-accepted'; name: string; kinds: AttachmentKind[] }
43
50
  | { code: 'attachment-too-large'; name: string; maxBytes: number }
44
51
  | { code: 'too-many-attachments'; maxFiles: number }
45
52
 
46
- /** A kind the policy takes - text also when it takes spreadsheets, which arrive as the text of their sheets. */
47
- function takes(policy: AttachmentPolicy, kind: AttachmentKind): boolean {
48
- return policy.kinds.includes(kind) || (kind === 'text' && policy.kinds.includes('spreadsheet'))
49
- }
50
-
51
53
  /** The characters of a text file the model reads under this policy. */
52
54
  export function textCharsOf(policy: AttachmentPolicy | null | undefined): number {
53
55
  return policy?.maxChars ?? TEXT_CHARS_FOR_MODEL
54
56
  }
55
57
 
58
+ /** The mode's size and count as picker limits. */
59
+ export function limitsOf(policy: AttachmentPolicy | null | undefined): FileUploadLimits {
60
+ return { maxFileSize: policy?.maxBytes ?? DEFAULT_MAX_BYTES, maxFiles: policy?.maxFiles ?? DEFAULT_MAX_FILES }
61
+ }
62
+
63
+ /**
64
+ * Whether these files may go, by the one picking rule. `server` is the server's upload limit (shell
65
+ * `fileUploadLimits`); the tighter of it and the mode's policy applies. The first reason, by name: file by file, its
66
+ * kind, then its size, then whether it is one too many - the door says them in the same order.
67
+ */
56
68
  export function refuseAttachments(
57
69
  policy: AttachmentPolicy | null | undefined,
58
- files: { name?: string; type?: string; size?: number }[]
70
+ files: { name?: string; type?: string; size?: number }[],
71
+ server?: FileUploadLimits
59
72
  ): AttachmentRefusal | null {
60
73
  if (!files.length) return null
61
74
  if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }
62
75
 
63
- const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES
64
- if (files.length > maxFiles) return { code: 'too-many-attachments', maxFiles }
76
+ /* What is sent: a spreadsheet arrives as the text of its sheets. */
77
+ const sent = policy.kinds.map(kind => (kind === 'spreadsheet' ? 'text' : kind))
78
+ const limits = narrowLimits(limitsOf(policy), server)
79
+ const { refused } = sortPickedFiles(files, { accept: acceptFor(sent), limits })
80
+ if (!refused.length) return null
65
81
 
66
- const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES
67
- for (const file of files) {
68
- const kind = attachmentKindOf(file)
69
- if (!kind || !takes(policy, kind)) {
70
- return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }
71
- }
72
- if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }
73
- }
74
- return null
82
+ const [first] = refused
83
+ if (first.reason === 'count') return { code: 'too-many-attachments', maxFiles: limits.maxFiles! }
84
+ return first.reason === 'type'
85
+ ? { code: 'attachment-kind-not-accepted', name: first.file.name || '', kinds: policy.kinds }
86
+ : { code: 'attachment-too-large', name: first.file.name || '', maxBytes: limits.maxFileSize! }
75
87
  }
76
88
 
77
- /** The file picker's `accept` for a policy. */
89
+ /** The file picker's `accept` for a policy - a spreadsheet as the person picks it (.xlsx). */
78
90
  export function acceptOf(policy: AttachmentPolicy | null | undefined): string {
79
- const accept: string[] = []
80
- if (policy?.kinds?.includes('image')) accept.push('image/*')
81
- if (policy?.kinds?.includes('text') || policy?.kinds?.includes('spreadsheet')) accept.push(...TEXT_EXTENSIONS, ...TEXT_TYPES)
82
- if (policy?.kinds?.includes('spreadsheet')) accept.push('.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
83
- return accept.join(',')
91
+ const kinds = policy?.kinds ?? []
92
+ return acceptFor(kinds.includes('spreadsheet') ? [...kinds, 'text'] : kinds)
84
93
  }
85
94
 
86
95
  /** Where a conversation's file opens - the gated route, under the page's domain path. */
@@ -17,6 +17,7 @@ import '@material/web/icon/icon.js';
17
17
  import './assistant-session-toolbar.js';
18
18
  import { type AssistantRequestContext } from '../utils/assistant-request-context.js';
19
19
  import { LitElement } from 'lit';
20
+ import '@operato/input/ox-input-file.js';
20
21
  import './mention-popup.js';
21
22
  import { type FilterResult } from './mention-popup.js';
22
23
  import { type AssistantMode } from './assistant-mode.js';
@@ -30,6 +30,8 @@ import { ScrollbarStyles } from '@operato/styles';
30
30
  import gql from 'graphql-tag';
31
31
  import { renderMarkdown, injectMentionRefids, injectMentionUserIds, stripMentionRefids } from './markdown.js';
32
32
  import { acceptOf, chatAttachmentUrl, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js';
33
+ import { fileUploadLimits } from '@operato/utils/file-acceptance.js';
34
+ import '@operato/input/ox-input-file.js';
33
35
  import { pastedTableFile } from '../utils/pasted-table.js';
34
36
  import { appendWords, composerHeight, composerKeyAction, composerSuggestion, sendShortcut, tableAsWords } from '../utils/composer-frame.js';
35
37
  import { readSpreadsheet, spreadsheetKindOf } from '../utils/spreadsheet-text.js';
@@ -465,8 +467,8 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
465
467
  list = read.files;
466
468
  }
467
469
  if (this.usesAttachmentPolicy) {
468
- /* Said before anything is sent - never cut silently (§9-1). */
469
- const refusal = refuseAttachments(this.modeView?.attachments, [...this.attachments, ...list].map(f => ({ name: f.name, type: f.type, size: f.size })));
470
+ /* Said before anything is sent - never cut silently (§9-1). The one picking rule, the server's upload limit included. */
471
+ const refusal = refuseAttachments(this.modeView?.attachments, [...this.attachments, ...list].map(f => ({ name: f.name, type: f.type, size: f.size })), await fileUploadLimits());
470
472
  if (refusal) {
471
473
  this.errorMessage = this.attachmentRefusalText(refusal);
472
474
  return;
@@ -2880,20 +2882,21 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
2880
2882
  `
2881
2883
  : nothing}
2882
2884
  <div class="actions-row">
2883
- <input
2885
+ <ox-input-file
2884
2886
  id="chat-file-input"
2885
- type="file"
2887
+ quiet
2888
+ outside-trigger
2889
+ hide-filelist
2886
2890
  multiple
2887
2891
  accept=${this.usesAttachmentPolicy ? acceptOf(this.modeView?.attachments) : 'image/*'}
2888
- style="display: none"
2889
2892
  @change=${(e) => {
2890
- const input = e.target;
2891
- if (input.files?.length) {
2892
- this.onAttachFiles(input.files);
2893
- input.value = '';
2893
+ const files = e.detail ?? [];
2894
+ if (files.length) {
2895
+ this.onAttachFiles(files);
2896
+ e.target.reset();
2894
2897
  }
2895
2898
  }}
2896
- />
2899
+ ></ox-input-file>
2897
2900
  ${hasMessages
2898
2901
  ? html `
2899
2902
  <button
@@ -3028,7 +3031,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
3028
3031
  class="attach"
3029
3032
  aria-label=${i18next.t('ai-assistant.button.attach', { defaultValue: 'Attach' })}
3030
3033
  title=${i18next.t('ai-assistant.tooltip.attach-file', { defaultValue: 'attach an image (drag & drop or paste supported)' })}
3031
- @click=${() => this.renderRoot.querySelector('#chat-file-input')?.click()}>
3034
+ @click=${() => this.renderRoot.querySelector('#chat-file-input')?.pick()}>
3032
3035
  <md-icon>attach_file</md-icon>
3033
3036
  </button>`
3034
3037
  : nothing}