@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
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.181",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -27,20 +27,21 @@
27
27
  "dependencies": {
28
28
  "@apollo/client": "^3.13.0",
29
29
  "@material/web": "^2.0.0",
30
- "@operato/graphql": "^10.0.0",
30
+ "@operato/graphql": "^10.29.39",
31
31
  "@operato/i18n": "^10.0.0",
32
- "@operato/input": "^10.0.0",
33
- "@operato/layout": "^10.20.0",
34
- "@operato/popup": "^10.16.0",
35
- "@operato/shell": "^10.0.0",
32
+ "@operato/input": "^10.29.39",
33
+ "@operato/layout": "^10.29.39",
34
+ "@operato/popup": "^10.29.39",
35
+ "@operato/shell": "^10.29.39",
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.39",
38
+ "@things-factory/ai-client-base": "^10.1.181",
39
+ "@things-factory/apptool-base": "^10.1.179",
40
+ "@things-factory/attachment-base": "^10.1.181",
41
+ "@things-factory/auth-base": "^10.1.181",
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.181",
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": "a4387dbc9c5be49ac7bd0e85a59fbcd650f47138"
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
  }
@@ -58,7 +58,7 @@ beforeEach(() => {
58
58
  })
59
59
 
60
60
  describe('reading a list', () => {
61
- it('★ the conditions become the application\'s filters - the model never names the query\'s columns', async () => {
61
+ it("★ the conditions become the application's filters - the model never names the query's columns", async () => {
62
62
  const result: any = await queryRecords(target, { from: '2026-04-01', to: '2026-09-30', flow: 'EXPENSE' }, {}, run)
63
63
  expect(calls[0].filters).toEqual([
64
64
  { name: 'transactionDate', operator: 'gte', value: '2026-04-01' },
@@ -124,7 +124,7 @@ describe('the screen card', () => {
124
124
  })
125
125
 
126
126
  describe('declaring and offering', () => {
127
- it('★ the tools are locked by the list query\'s door, and a mode opens them by key', () => {
127
+ it("★ the tools are locked by the list query's door, and a mode opens them by key", () => {
128
128
  const category = readCategoryOf(target, () => run)
129
129
  expect(category.name).toBe(readCategoryName(target.key))
130
130
  expect(category.specs.map(spec => spec.name)).toEqual([queryToolName(target.key), screenToolName(target.key)])
@@ -196,7 +196,7 @@ describe('a list declared in one line', () => {
196
196
  narrowing: { year: 'year', month: 'month', flow: 'type' },
197
197
  search: 'memo and counterparty',
198
198
  route: 'cash-transaction-list',
199
- screenTakes: ['year', 'month', 'flow', 'text']
199
+ screenQuery: true
200
200
  }
201
201
 
202
202
  it('★ becomes a target with the conditions its fields allow - and only those', async () => {
@@ -207,22 +207,44 @@ describe('a list declared in one line', () => {
207
207
  const bare: ReadTarget = listReadTarget({ ...line, key: 'office.bare', narrowing: undefined, dateField: undefined, search: undefined, columns: [{ name: 'name', type: 'string' }] })
208
208
  expect(bare.criteria).toEqual([])
209
209
  expect(bare.query).not.toContain('$search')
210
- expect(bare.screen!.takes).toEqual([])
210
+ expect(bare.screen!.takes).toEqual(['id'])
211
211
  })
212
212
 
213
- it('★ the to day takes the whole day of a date-time, and the screen gets only what it reads', () => {
213
+ it('the screen uses the same predicates as a read, including ranges outside its filter form', () => {
214
214
  const made: ReadTarget = listReadTarget(line)
215
215
  const to = made.criteria.find(one => one.name === 'to')!
216
- expect(to.filter('2026-09-30')).toEqual({ name: 'transactionDate', operator: 'lte', value: '2026-09-30T23:59:59.999' })
217
- expect(made.screen!.params({ year: 2026, month: 9, flow: 'EXPENSE', text: '외주', from: '2026-01-01' })).toEqual({ year: 2026, month: 9, flow: 'EXPENSE', search: '외주' })
218
- expect(made.screen!.params({ month: 9 })).toEqual({})
219
- const plain: ReadTarget = listReadTarget({ ...line, key: 'plant.x', screenTakes: undefined })
216
+ expect(to.filter('2026-09-30')).toEqual({
217
+ name: 'transactionDate',
218
+ operator: 'lte',
219
+ value: '2026-09-30T23:59:59.999'
220
+ })
221
+ const criteria = { year: 2026, month: 9, flow: 'EXPENSE', text: '외주', from: '2026-01-01', minAmount: 100000 }
222
+ const address = screenAddress(made, criteria)!
223
+ const query = new URLSearchParams(address.href.split('?')[1])
224
+ expect(query.has('year')).toBe(false)
225
+ expect(query.has('month')).toBe(false)
226
+ expect(query.has('flow')).toBe(false)
227
+ expect(JSON.parse(query.get('filters')!)).toEqual([
228
+ { name: 'transactionDate', operator: 'gte', value: '2026-01-01' },
229
+ { name: 'year', operator: 'eq', value: 2026 },
230
+ { name: 'month', operator: 'eq', value: 9 },
231
+ { name: 'type', operator: 'eq', value: 'EXPENSE' },
232
+ { name: 'amount', operator: 'gte', value: 100000 }
233
+ ])
234
+ expect(query.get('search')).toBe('외주')
235
+ expect(address.notApplied).toEqual([])
236
+ const plain: ReadTarget = listReadTarget({ ...line, key: 'plant.x', screenQuery: false })
220
237
  expect(plain.screen!.takes).toEqual([])
221
- expect(plain.screen!.params({ year: 2026 })).toEqual({})
238
+ expect(screenAddress(plain, { year: 2026 })!.notApplied).toEqual(['year'])
222
239
  })
223
240
 
224
- it('★ a fixed filter is on every read, out of the model\'s reach - a condition narrows the list, never widens it', async () => {
225
- const made: ReadTarget = listReadTarget({ ...line, key: 'office.spending', listQuery: 'cashTransactions', fixedFilters: [{ name: 'type', operator: 'eq', value: 'EXPENSE' }] })
241
+ it("★ a fixed filter is on every read, out of the model's reach - a condition narrows the list, never widens it", async () => {
242
+ const made: ReadTarget = listReadTarget({
243
+ ...line,
244
+ key: 'office.spending',
245
+ listQuery: 'cashTransactions',
246
+ fixedFilters: [{ name: 'type', operator: 'eq', value: 'EXPENSE' }]
247
+ })
226
248
  declareReadTarget(made)
227
249
  expect(made.criteria.map(one => one.name)).not.toContain('type')
228
250
  const all: any = await queryRecords(made, {}, {}, run)
@@ -246,7 +268,12 @@ describe('amounts in more than one currency', () => {
246
268
  ...target,
247
269
  key: 'office.fx-transaction',
248
270
  query: target.query.replace('customerName account', 'customerName currency account'),
249
- columns: [...target.columns.map(one => (one.name === 'amount' ? { ...one, labelKey: 'field.amount', currency: 'currency' } : one)), { name: 'currency', type: 'string' }]
271
+ columns: [
272
+ ...target.columns.map(one =>
273
+ one.name === 'amount' ? { ...one, labelKey: 'field.amount', currency: 'currency' } : one
274
+ ),
275
+ { name: 'currency', type: 'string' }
276
+ ]
250
277
  }
251
278
  const fxt = (i: number, amount: number, currency: string | null, customerName: string) => ({ ...tx(i, '2026-06-01', 'INCOME', amount, customerName), currency })
252
279
 
@@ -257,7 +284,7 @@ describe('amounts in more than one currency', () => {
257
284
  expect(JSON.stringify(result.sums)).not.toMatch(/1100/)
258
285
  })
259
286
 
260
- it('a line without its currency is summed as unknown, never as the domain\'s own', async () => {
287
+ it("a line without its currency is summed as unknown, never as the domain's own", async () => {
261
288
  ledger = [fxt(1, 1000, 'KRW', 'A'), fxt(2, 7, null, 'B')]
262
289
  const result: any = await queryRecords(fx, {}, {}, run)
263
290
  expect(result.sums.amount).toEqual({ KRW: 1000, unknown: 7 })
@@ -31,8 +31,8 @@ export interface ListRead {
31
31
  search?: string
32
32
  /** The screen's route. Left out, the list has no screen card. */
33
33
  route?: string
34
- /** The conditions the screen reads from its address - `year`, `month`, `flow` and `text` (as `search`). Empty: it opens as it is. */
35
- screenTakes?: ('year' | 'month' | 'flow' | 'text')[]
34
+ /** The route has a URL-bound grid. Otherwise conditions are disclosed as not applied on its screen card. */
35
+ screenQuery?: boolean
36
36
  }
37
37
 
38
38
  /** The money column a minimum or maximum amount means - `amount` when there is one, else the first summed column. */
@@ -70,7 +70,9 @@ export function listReadTarget(list: ListRead): ReadTarget {
70
70
  : [])
71
71
  ] as ReadCriterion[]
72
72
 
73
- const takes = (list.screenTakes ?? []).filter(name => (name === 'text' ? search : !!narrowing[name]))
73
+ const takes = list.screenQuery
74
+ ? ['id', ...criteria.map(criterion => criterion.name), ...(search ? ['text'] : [])]
75
+ : []
74
76
  return {
75
77
  key: list.key,
76
78
  labelKey: list.title,
@@ -90,12 +92,8 @@ export function listReadTarget(list: ListRead): ReadTarget {
90
92
  route: list.route,
91
93
  labelKey: list.title,
92
94
  takes,
93
- params: (c: Record<string, any>) => ({
94
- ...(takes.includes('year') ? { year: c.year } : {}),
95
- ...(takes.includes('month') && c.year ? { month: c.month } : {}),
96
- ...(takes.includes('flow') ? { flow: c.flow } : {}),
97
- ...(takes.includes('text') ? { search: c.text } : {})
98
- })
95
+ queryState: !!list.screenQuery,
96
+ params: () => ({})
99
97
  }
100
98
  }
101
99
  : {})
@@ -10,6 +10,7 @@
10
10
  * Pure but for the GraphQL runner, which comes in as an argument - the rules are tested without a server.
11
11
  */
12
12
  import { readFieldValue, rootFieldOf, type EntryField } from '../data-entry/entry-target.js'
13
+ import { encodeListQuery } from '@operato/utils/list-query.js'
13
14
  import type { GraphqlRunner } from '../data-entry/entry-update.js'
14
15
  import { parse } from 'graphql'
15
16
 
@@ -41,6 +42,8 @@ export interface ReadColumn {
41
42
  }
42
43
 
43
44
  export interface ReadScreen {
45
+ /** A URL-bound grid accepts the same predicates used by readRows, including conditions outside its form. */
46
+ queryState?: boolean
44
47
  /** The application's route of the list screen (`cash-transaction-list`). */
45
48
  route: string
46
49
  /** The conditions the screen takes, as its address's query arguments. A condition left out is said on the card. */
@@ -109,7 +112,7 @@ export function declareReadTarget(target: ReadTarget): void {
109
112
  if (!column.total || column.type !== 'number') throw new Error(`column '${column.name}' of '${target.key}' names a currency but is not a summed number`)
110
113
  if (target.columns.find(one => one.name === column.currency)?.type !== 'string') throw new Error(`column '${column.name}' of '${target.key}' takes its currency from '${column.currency}', which is no string column`)
111
114
  }
112
- const names = new Set<string>()
115
+ const names = new Set<string>(['id'])
113
116
  for (const criterion of target.criteria) {
114
117
  if (criterion.name === 'id') throw new Error(`read target '${target.key}' declares 'id' - every target takes it`)
115
118
  if (criterion.name === 'text' && target.search) throw new Error(`read target '${target.key}' declares 'text' and search - search is the text condition`)
@@ -213,7 +216,9 @@ export function readCriteria(target: ReadTarget, args: any): { criteria: Record<
213
216
  export function filtersOf(target: ReadTarget, criteria: Record<string, unknown>): ReadFilter[] {
214
217
  return [
215
218
  ...(typeof criteria.id === 'string' ? [{ name: 'id', operator: 'eq', value: criteria.id }] : []),
216
- ...target.criteria.flatMap(criterion => (criterion.name in criteria ? [criterion.filter(criteria[criterion.name])].flat() : []))
219
+ ...target.criteria.flatMap(criterion =>
220
+ criterion.name in criteria ? [criterion.filter(criteria[criterion.name])].flat() : []
221
+ )
217
222
  ]
218
223
  }
219
224
 
@@ -331,7 +336,14 @@ export function screenAddress(target: ReadTarget, criteria: Record<string, unkno
331
336
  const params = new URLSearchParams()
332
337
  for (const [name, value] of Object.entries(screen.params(criteria))) if (value !== undefined && value !== null && value !== '') params.set(name, String(value))
333
338
  const given = Object.keys(criteria)
334
- const query = params.toString()
339
+ const applicable = Object.fromEntries(Object.entries(criteria).filter(([name]) => screen.takes.includes(name)))
340
+ const query = screen.queryState
341
+ ? encodeListQuery({
342
+ filters: filtersOf(target, applicable),
343
+ search: typeof applicable.text === 'string' ? applicable.text : undefined,
344
+ sorters: target.sortings
345
+ })
346
+ : params.toString()
335
347
  return {
336
348
  href: query ? `${screen.route}?${query}` : screen.route,
337
349
  applied: given.filter(name => screen.takes.includes(name)),
@@ -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', () => {