@things-factory/ai-assistant 10.1.177 → 10.1.179
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.
- package/client/components/assistant-chat.ts +19 -12
- package/client/utils/attachment-policy.ts +41 -32
- package/dist-client/components/assistant-chat.d.ts +1 -0
- package/dist-client/components/assistant-chat.js +14 -11
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/attachment-policy.d.ts +10 -6
- package/dist-client/utils/attachment-policy.js +40 -39
- package/dist-client/utils/attachment-policy.js.map +1 -1
- package/dist-server/service/chat-session/attachment-policy.js +8 -2
- package/dist-server/service/chat-session/attachment-policy.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -9
- package/server/service/chat-session/attachment-policy.test.ts +1 -1
- package/server/service/chat-session/attachment-policy.ts +7 -2
- package/test/assistant-dock.test.ts +1 -1
- package/test/attachment-one-rule.test.ts +46 -0
- 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(
|
|
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
|
-
|
|
3245
|
+
quiet
|
|
3246
|
+
outside-trigger
|
|
3247
|
+
hide-filelist
|
|
3240
3248
|
multiple
|
|
3241
3249
|
accept=${this.usesAttachmentPolicy ? acceptOf(this.modeView?.attachments) : 'image/*'}
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
2891
|
-
if (
|
|
2892
|
-
this.onAttachFiles(
|
|
2893
|
-
|
|
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')?.
|
|
3034
|
+
@click=${() => this.renderRoot.querySelector('#chat-file-input')?.pick()}>
|
|
3032
3035
|
<md-icon>attach_file</md-icon>
|
|
3033
3036
|
</button>`
|
|
3034
3037
|
: nothing}
|