@things-factory/ai-assistant 10.1.178 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.178",
3
+ "version": "10.1.179",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -29,18 +29,19 @@
29
29
  "@material/web": "^2.0.0",
30
30
  "@operato/graphql": "^10.0.0",
31
31
  "@operato/i18n": "^10.0.0",
32
- "@operato/input": "^10.0.0",
32
+ "@operato/input": "^10.29.37",
33
33
  "@operato/layout": "^10.20.0",
34
34
  "@operato/popup": "^10.16.0",
35
35
  "@operato/shell": "^10.0.0",
36
36
  "@operato/styles": "^10.0.0",
37
- "@things-factory/ai-client-base": "^10.1.178",
38
- "@things-factory/apptool-base": "^10.1.178",
39
- "@things-factory/attachment-base": "^10.1.178",
40
- "@things-factory/auth-base": "^10.1.178",
37
+ "@operato/utils": "^10.29.38",
38
+ "@things-factory/ai-client-base": "^10.1.179",
39
+ "@things-factory/apptool-base": "^10.1.179",
40
+ "@things-factory/attachment-base": "^10.1.179",
41
+ "@things-factory/auth-base": "^10.1.179",
41
42
  "@things-factory/env": "^10.1.149",
42
- "@things-factory/setting-base": "^10.1.178",
43
- "@things-factory/shell": "^10.1.178",
43
+ "@things-factory/setting-base": "^10.1.179",
44
+ "@things-factory/shell": "^10.1.179",
44
45
  "dompurify": "^3.0.0",
45
46
  "exceljs": "4.2.0",
46
47
  "graphql-scalars": "^1.22.0",
@@ -54,5 +55,5 @@
54
55
  "copyfiles": "^2.4.1",
55
56
  "rimraf": "^5.0.0"
56
57
  },
57
- "gitHead": "d57448d3f3045f72a9f24b877a3e306eb34cbaf1"
58
+ "gitHead": "4c8263b08e06b2b15f11dc0575c61a280a0d9e27"
58
59
  }
@@ -23,7 +23,7 @@ describe('attachment policy', () => {
23
23
  it('each limit refuses by name, before sending', () => {
24
24
  expect(refuseAttachments(policy, [{ name: 'a.pdf', type: 'application/pdf', size: 1 }])).toMatchObject({ code: 'attachment-kind-not-accepted', name: 'a.pdf' })
25
25
  expect(refuseAttachments(policy, [{ name: 'big.png', type: 'image/png', size: 1001 }])).toMatchObject({ code: 'attachment-too-large', name: 'big.png', maxBytes: 1000 })
26
- expect(refuseAttachments(policy, [{}, {}, {}] as any)).toEqual({ code: 'too-many-attachments', maxFiles: 2 })
26
+ expect(refuseAttachments(policy, [{ name: 'a.png', type: 'image/png', size: 1 }, { name: 'b.png', type: 'image/png', size: 1 }, { name: 'c.png', type: 'image/png', size: 1 }])).toEqual({ code: 'too-many-attachments', maxFiles: 2 })
27
27
  })
28
28
 
29
29
  it('a kind outside the declared ones is refused', () => {
@@ -64,16 +64,21 @@ export function refuseAttachments(
64
64
  if (!files.length) return null
65
65
  if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }
66
66
 
67
+ /*
68
+ * File by file, in the order sent: its kind, then its size, then whether it is one too many. The screen's picking
69
+ * rule (@operato/utils sortPickedFiles) says them in this order, so the same batch hears the same "no" from both.
70
+ */
67
71
  const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES
68
- if (files.length > maxFiles) return { code: 'too-many-attachments', maxFiles }
69
-
70
72
  const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES
73
+ let taken = 0
71
74
  for (const file of files) {
72
75
  const kind = attachmentKindOf(file)
73
76
  if (!kind || !takes(policy, kind)) {
74
77
  return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }
75
78
  }
76
79
  if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }
80
+ if (taken >= maxFiles) return { code: 'too-many-attachments', maxFiles }
81
+ taken++
77
82
  }
78
83
  return null
79
84
  }
@@ -139,7 +139,7 @@ describe('attachments in the chat follow the mode', () => {
139
139
  })
140
140
 
141
141
  it('said before sending, then uploaded to the conversation and sent by id', () => {
142
- assert.match(chat, /refuseAttachments\(this\.modeView\?\.attachments/)
142
+ assert.match(chat, /refuseAttachments\(\s*this\.modeView\?\.attachments/)
143
143
  assert.match(chat, /mutation: ATTACH_TO_CONVERSATION_MUTATION/)
144
144
  /* Without it the client sends the File as {} and the server refuses the upload (found on :3700, 2026-09-25). */
145
145
  assert.match(chat, /context: \{ hasUpload: true \}/)
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from 'fs'
2
+ import { join } from 'path'
3
+
4
+ import * as screen from '../client/utils/attachment-policy'
5
+ import * as server from '../server/service/chat-session/attachment-policy'
6
+
7
+ /*
8
+ * A conversation's attachments are refused on the screen by the one file-picking rule (@operato/utils
9
+ * sortPickedFiles, ADR-0061 2026-10-17); the mode's policy is a limit, narrowed by the server's upload limit. The
10
+ * screen's "no" is only so the person hears it early - the door still refuses by the mode's policy on its own.
11
+ */
12
+ const read = (file: string) => readFileSync(join(__dirname, '..', file), 'utf8')
13
+
14
+ describe('one rule for a conversation attachment', () => {
15
+ const policy = { kinds: ['image'], maxBytes: 100, maxFiles: 5 } as any
16
+
17
+ it('★ the screen applies the tighter of the mode policy and the server upload limit', () => {
18
+ const file = [{ name: 'a.png', type: 'image/png', size: 60 }]
19
+ expect(screen.refuseAttachments(policy, file)).toBeNull()
20
+ expect(screen.refuseAttachments(policy, file, { maxFileSize: 50 })).toEqual({ code: 'attachment-too-large', name: 'a.png', maxBytes: 50 })
21
+ expect(screen.refuseAttachments(policy, file, { maxFileSize: 1000 })).toBeNull()
22
+ expect(screen.refuseAttachments(policy, [{ name: 'b.png', type: 'image/png', size: 200 }], { maxFileSize: 1000 })).toMatchObject({ maxBytes: 100 })
23
+ })
24
+
25
+ it('★ the screen judges with the shared rule, not a copy of its own', () => {
26
+ const source = read('client/utils/attachment-policy.ts')
27
+ expect(source).toMatch(/import \{ narrowLimits, sortPickedFiles, type FileUploadLimits \} from '@operato\/utils\/file-acceptance\.js'/)
28
+ expect(source).not.toMatch(/function takes\(|export function attachmentKindOf/)
29
+ })
30
+
31
+ it('★ the door still refuses by the mode policy itself, before and after saving', () => {
32
+ expect(server.refuseAttachments(policy, [{ name: 'a.pdf', type: 'application/pdf', size: 1 }])).toMatchObject({ code: 'attachment-kind-not-accepted' })
33
+ const door = read('server/service/chat-session-resolver.ts')
34
+ expect(door).toMatch(/import \{ refuseAttachments, type AttachmentRefusal \} from '\.\/chat-session\/attachment-policy\.js'/)
35
+ expect(door.match(/refuseAttachments\(policy, /g)?.length).toBe(2)
36
+ })
37
+
38
+ it('★ the attach button opens the one picker; paste and drop take the same rule', () => {
39
+ const chat = read('client/components/assistant-chat.ts')
40
+ expect(chat).not.toMatch(/type="file"/)
41
+ expect(chat).toMatch(/<ox-input-file\s+id="chat-file-input"\s+quiet\s+outside-trigger/)
42
+ expect(chat).toMatch(/\(this\.renderRoot\.querySelector\('#chat-file-input'\) as any\)\?\.pick\(\)/)
43
+ expect(chat).toMatch(/this\.onAttachFiles\(e\.dataTransfer\.files\)/)
44
+ expect(chat).toMatch(/await fileUploadLimits\(\)\s*\)/)
45
+ })
46
+ })
@@ -13,7 +13,10 @@ const fileSets = [
13
13
  [{ name: 'report.CSV', type: '', size: 10 }],
14
14
  [{ name: 'a.pdf', type: 'application/pdf', size: 10 }],
15
15
  [{ name: 'big.png', type: 'image/png', size: 101 }],
16
- [{ name: 'a.png', type: 'image/png', size: 1 }, { name: 'b.png', type: 'image/png', size: 1 }, { name: 'c.png', type: 'image/png', size: 1 }]
16
+ [{ name: 'a.png', type: 'image/png', size: 1 }, { name: 'b.png', type: 'image/png', size: 1 }, { name: 'c.png', type: 'image/png', size: 1 }],
17
+ /* Mixed batches: the same "no" in the same order - kind, then size, then one too many, file by file. */
18
+ [{ name: 'a.png', type: 'image/png', size: 1 }, { name: 'b.png', type: 'image/png', size: 1 }, { name: 'c.pdf', type: 'application/pdf', size: 1 }],
19
+ [{ name: 'a.png', type: 'image/png', size: 1 }, { name: 'b.png', type: 'image/png', size: 1 }, { name: 'c.png', type: 'image/png', size: 101 }]
17
20
  ]
18
21
 
19
22
  describe('the screen and the door refuse the same things', () => {