cozy-search 2.2.30 → 2.3.0

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 (36) hide show
  1. package/README.md +44 -0
  2. package/dist/components/Assistant/AssistantSelection.js +1 -1
  3. package/dist/components/CozyAssistantRuntimeProvider.js +20 -9
  4. package/dist/components/KnowledgeBase/KnowledgeBaseChip.d.ts +2 -1
  5. package/dist/components/KnowledgeBase/KnowledgeBaseChip.js +2 -1
  6. package/dist/components/KnowledgeBase/KnowledgeBaseSection.js +1 -1
  7. package/dist/components/KnowledgeBase/conversationAssistant.d.ts +6 -0
  8. package/dist/components/KnowledgeBase/conversationAssistant.js +42 -0
  9. package/dist/components/KnowledgeBase/conversationAssistant.spec.d.ts +1 -0
  10. package/dist/components/KnowledgeBase/conversationAssistant.spec.js +80 -0
  11. package/dist/components/KnowledgeBase/knowledgeBase.d.ts +8 -0
  12. package/dist/components/KnowledgeBase/knowledgeBase.js +57 -11
  13. package/dist/components/KnowledgeBase/knowledgeBase.spec.js +92 -0
  14. package/dist/components/KnowledgeBase/provisioning.d.ts +24 -0
  15. package/dist/components/KnowledgeBase/provisioning.js +645 -0
  16. package/dist/components/KnowledgeBase/provisioning.spec.d.ts +1 -0
  17. package/dist/components/KnowledgeBase/provisioning.spec.js +489 -0
  18. package/dist/components/KnowledgeBase/ragIndexing.d.ts +21 -0
  19. package/dist/components/KnowledgeBase/ragIndexing.js +415 -0
  20. package/dist/components/KnowledgeBase/ragIndexing.spec.d.ts +1 -0
  21. package/dist/components/KnowledgeBase/ragIndexing.spec.js +268 -0
  22. package/dist/components/KnowledgeBase/useDefaultAssistantId.d.ts +1 -0
  23. package/dist/components/KnowledgeBase/useDefaultAssistantId.js +38 -0
  24. package/dist/components/KnowledgeBase/useDefaultAssistantId.spec.d.ts +1 -0
  25. package/dist/components/KnowledgeBase/useDefaultAssistantId.spec.js +69 -0
  26. package/dist/components/KnowledgeBase/useSelectedAssistantKnowledgeBase.d.ts +1 -0
  27. package/dist/components/KnowledgeBase/useSelectedAssistantKnowledgeBase.js +6 -2
  28. package/dist/components/TwakeKnowledges/TwakeKnowledgeSelector.js +2 -0
  29. package/dist/components/Views/CreateAssistantDialog.js +6 -12
  30. package/dist/components/Views/DeleteAssistantDialog.js +20 -12
  31. package/dist/components/Views/EditAssistantDialog.js +13 -7
  32. package/dist/components/queries.d.ts +8 -0
  33. package/dist/components/queries.js +21 -2
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.js +35 -1
  36. package/package.json +2 -2
@@ -0,0 +1,489 @@
1
+ import { models } from 'cozy-client'
2
+ import flag from 'cozy-flags'
3
+
4
+ import {
5
+ assistantIdFromName,
6
+ ensureProvisionedAssistants,
7
+ getDefaultProvisionedAssistantId,
8
+ isMagicFolderId,
9
+ resolveProvisionedFolder
10
+ } from './provisioning'
11
+
12
+ jest.mock('cozy-client', () => ({
13
+ ...jest.requireActual('cozy-client'),
14
+ models: {
15
+ ...jest.requireActual('cozy-client').models,
16
+ folder: { getReferencedFolder: jest.fn() }
17
+ }
18
+ }))
19
+
20
+ jest.mock('cozy-flags', () => jest.fn())
21
+
22
+ const notFound = () => Object.assign(new Error('not_found'), { status: 404 })
23
+ const conflict = () => Object.assign(new Error('conflict'), { status: 409 })
24
+
25
+ const makeClient = ({
26
+ assistants = {},
27
+ files = {},
28
+ ensuredDirId = 'created-dir'
29
+ } = {}) => {
30
+ const filesCollection = {
31
+ statById: jest.fn(async id => {
32
+ if (!files[id]) throw notFound()
33
+ return { data: files[id] }
34
+ }),
35
+ ensureDirectoryExists: jest.fn().mockResolvedValue(ensuredDirId)
36
+ }
37
+ const client = {
38
+ collection: jest.fn(doctype => {
39
+ if (doctype === 'io.cozy.files') return filesCollection
40
+ throw new Error(`unexpected collection ${doctype}`)
41
+ }),
42
+ query: jest.fn(async definition => {
43
+ if (definition.doctype === 'io.cozy.ai.chat.assistants') {
44
+ const doc = assistants[definition.id]
45
+ if (!doc) throw notFound()
46
+ return { data: doc }
47
+ }
48
+ throw new Error(`unexpected query ${definition.doctype}`)
49
+ }),
50
+ save: jest.fn(async doc => {
51
+ const saved = { ...doc, _id: doc._id || 'acc-new', _rev: '1-a' }
52
+ // The real saveKnowledgeBase re-reads the assistant it just created.
53
+ if (doc._type === 'io.cozy.ai.chat.assistants')
54
+ assistants[doc._id] = saved
55
+ return { data: saved }
56
+ }),
57
+ destroy: jest.fn(async doc => ({ data: doc }))
58
+ }
59
+ return { client, filesCollection }
60
+ }
61
+
62
+ describe('assistantIdFromName', () => {
63
+ it('normalizes accents, case, spaces and punctuation', () => {
64
+ expect(assistantIdFromName('Mes documents')).toBe('mes-documents')
65
+ expect(assistantIdFromName('Réunions')).toBe('reunions')
66
+ expect(assistantIdFromName(' Été / Hiver 2026! ')).toBe('ete-hiver-2026')
67
+ expect(assistantIdFromName('___secret')).toBe('secret')
68
+ })
69
+
70
+ it('returns an empty string when nothing usable remains', () => {
71
+ expect(assistantIdFromName('***')).toBe('')
72
+ expect(assistantIdFromName('')).toBe('')
73
+ expect(assistantIdFromName(undefined)).toBe('')
74
+ })
75
+ })
76
+
77
+ describe('isMagicFolderId', () => {
78
+ it('recognizes io.cozy.apps/<slug>', () => {
79
+ expect(isMagicFolderId('io.cozy.apps/meet')).toBe(true)
80
+ expect(isMagicFolderId('a1b2c3')).toBe(false)
81
+ expect(isMagicFolderId('io.cozy.apps/')).toBe(false)
82
+ })
83
+
84
+ it('recognizes the nested magic folders', () => {
85
+ expect(isMagicFolderId('io.cozy.apps/administrative/papers')).toBe(true)
86
+ expect(isMagicFolderId('io.cozy.apps/photos/upload')).toBe(true)
87
+ })
88
+ })
89
+
90
+ describe('resolveProvisionedFolder', () => {
91
+ beforeEach(() => models.folder.getReferencedFolder.mockReset())
92
+
93
+ it('uses an existing folder by id', async () => {
94
+ const { client } = makeClient({
95
+ files: { 'dir-1': { _id: 'dir-1', type: 'directory', trashed: false } }
96
+ })
97
+ expect(await resolveProvisionedFolder(client, { dirId: 'dir-1' })).toBe(
98
+ 'dir-1'
99
+ )
100
+ })
101
+
102
+ it('refuses a trashed folder, a file, or a missing id without fallback', async () => {
103
+ const { client, filesCollection } = makeClient({
104
+ files: {
105
+ // A real DirDoc has no `trashed` field: only its path says so.
106
+ trashed: {
107
+ _id: 'trashed',
108
+ type: 'directory',
109
+ path: '/.cozy_trash/Docs'
110
+ },
111
+ flagged: { _id: 'flagged', type: 'directory', trashed: true },
112
+ file: { _id: 'file', type: 'file', trashed: false }
113
+ }
114
+ })
115
+ expect(
116
+ await resolveProvisionedFolder(client, { dirId: 'trashed', dirName: 'X' })
117
+ ).toBeNull()
118
+ expect(
119
+ await resolveProvisionedFolder(client, { dirId: 'flagged', dirName: 'X' })
120
+ ).toBeNull()
121
+ expect(
122
+ await resolveProvisionedFolder(client, { dirId: 'file', dirName: 'X' })
123
+ ).toBeNull()
124
+ expect(
125
+ await resolveProvisionedFolder(client, { dirId: 'nope', dirName: 'X' })
126
+ ).toBeNull()
127
+ expect(filesCollection.ensureDirectoryExists).not.toHaveBeenCalled()
128
+ })
129
+
130
+ it('resolves a magic folder through getReferencedFolder', async () => {
131
+ const { client } = makeClient()
132
+ models.folder.getReferencedFolder.mockResolvedValue({ _id: 'meet-dir' })
133
+
134
+ expect(
135
+ await resolveProvisionedFolder(client, { dirId: 'io.cozy.apps/meet' })
136
+ ).toBe('meet-dir')
137
+ expect(models.folder.getReferencedFolder).toHaveBeenCalledWith(client, {
138
+ _type: 'io.cozy.apps',
139
+ _id: 'io.cozy.apps/meet'
140
+ })
141
+ })
142
+
143
+ it('returns null when the magic folder does not exist yet', async () => {
144
+ const { client } = makeClient()
145
+ models.folder.getReferencedFolder.mockResolvedValue(null)
146
+ expect(
147
+ await resolveProvisionedFolder(client, { dirId: 'io.cozy.apps/meet' })
148
+ ).toBeNull()
149
+ })
150
+
151
+ it('finds or creates the folder by name at the root', async () => {
152
+ const { client, filesCollection } = makeClient({
153
+ ensuredDirId: 'docs-dir'
154
+ })
155
+ expect(await resolveProvisionedFolder(client, { dirName: 'Docs' })).toBe(
156
+ 'docs-dir'
157
+ )
158
+ expect(filesCollection.ensureDirectoryExists).toHaveBeenCalledWith('/Docs')
159
+ })
160
+
161
+ it('accepts the root folder without looking it up', async () => {
162
+ const { client, filesCollection } = makeClient()
163
+ expect(
164
+ await resolveProvisionedFolder(client, {
165
+ dirId: 'io.cozy.files.root-dir'
166
+ })
167
+ ).toBe('io.cozy.files.root-dir')
168
+ expect(filesCollection.statById).not.toHaveBeenCalled()
169
+ })
170
+ })
171
+
172
+ describe('ensureProvisionedAssistants', () => {
173
+ beforeEach(() => {
174
+ models.folder.getReferencedFolder.mockReset()
175
+ })
176
+
177
+ it('creates the folder, the account, the assistant and its knowledge base', async () => {
178
+ const { client } = makeClient({ ensuredDirId: 'docs-dir' })
179
+
180
+ const result = await ensureProvisionedAssistants(client, [
181
+ { name: 'Mes documents', dirName: 'Docs', prompt: 'Hello' }
182
+ ])
183
+
184
+ expect(result).toEqual({
185
+ created: ['mes-documents'],
186
+ ensured: [],
187
+ skipped: []
188
+ })
189
+ // 1st save: the openrag account; 2nd save: the assistant; 3rd: knowledgeBase.
190
+ const saved = client.save.mock.calls.map(([doc]) => doc)
191
+ expect(saved[0]).toMatchObject({
192
+ _type: 'io.cozy.accounts',
193
+ account_type: 'openrag',
194
+ identifier: 'accountName',
195
+ auth: { accountName: 'Twake' },
196
+ data: { model: 'openrag' }
197
+ })
198
+ expect(saved[1]).toMatchObject({
199
+ _type: 'io.cozy.ai.chat.assistants',
200
+ _id: 'mes-documents',
201
+ name: 'Mes documents',
202
+ prompt: 'Hello',
203
+ icon: null,
204
+ relationships: {
205
+ provider: {
206
+ data: {
207
+ _type: 'io.cozy.accounts',
208
+ metadata: { providerId: 'openrag' }
209
+ }
210
+ }
211
+ }
212
+ })
213
+ expect(saved[2]).toMatchObject({
214
+ _id: 'mes-documents',
215
+ knowledgeBase: [{ doctype: 'io.cozy.files', dirId: 'docs-dir' }]
216
+ })
217
+ })
218
+
219
+ it('provisions a whole-Drive assistant from the root dirId', async () => {
220
+ const { client, filesCollection } = makeClient()
221
+
222
+ const result = await ensureProvisionedAssistants(client, [
223
+ { name: 'Everything', dirId: 'io.cozy.files.root-dir' }
224
+ ])
225
+
226
+ expect(result.created).toEqual(['everything'])
227
+ expect(filesCollection.statById).not.toHaveBeenCalled()
228
+ const saved = client.save.mock.calls.map(([doc]) => doc)
229
+ expect(saved[2].knowledgeBase).toEqual([
230
+ { doctype: 'io.cozy.files', dirId: 'io.cozy.files.root-dir' }
231
+ ])
232
+ })
233
+
234
+ it('creates one openrag account per assistant', async () => {
235
+ const { client } = makeClient()
236
+
237
+ await ensureProvisionedAssistants(client, [
238
+ { name: 'Docs', dirName: 'Docs' },
239
+ { name: 'Photos', dirName: 'Photos' }
240
+ ])
241
+
242
+ const saved = client.save.mock.calls.map(([doc]) => doc)
243
+ expect(saved.filter(doc => doc._type === 'io.cozy.accounts')).toHaveLength(
244
+ 2
245
+ )
246
+ // The knowledge base re-save spreads the created doc: count creations.
247
+ const assistants = saved.filter(
248
+ doc => doc._type === 'io.cozy.ai.chat.assistants' && !doc.knowledgeBase
249
+ )
250
+ expect(assistants.map(doc => doc.relationships.provider.data._id)).toEqual([
251
+ 'acc-new',
252
+ 'acc-new'
253
+ ])
254
+ })
255
+
256
+ it('skips a second entry whose name derives to the same id', async () => {
257
+ const { client } = makeClient()
258
+
259
+ const result = await ensureProvisionedAssistants(client, [
260
+ { name: 'Réunions', dirName: 'Réunions' },
261
+ { name: 'reunions', dirName: 'Autre' }
262
+ ])
263
+
264
+ expect(result).toEqual({
265
+ created: ['reunions'],
266
+ ensured: [],
267
+ skipped: [{ id: 'reunions', reason: 'duplicate assistant id' }]
268
+ })
269
+ const saved = client.save.mock.calls.map(([doc]) => doc)
270
+ expect(saved[2].knowledgeBase).toEqual([
271
+ { doctype: 'io.cozy.files', dirId: 'created-dir' }
272
+ ])
273
+ })
274
+
275
+ it('leaves an existing assistant with a live folder alone', async () => {
276
+ const { client } = makeClient({
277
+ assistants: {
278
+ docs: {
279
+ _id: 'docs',
280
+ _type: 'io.cozy.ai.chat.assistants',
281
+ knowledgeBase: [{ doctype: 'io.cozy.files', dirId: 'dir-1' }]
282
+ }
283
+ },
284
+ files: { 'dir-1': { _id: 'dir-1', type: 'directory', trashed: false } }
285
+ })
286
+
287
+ const result = await ensureProvisionedAssistants(client, [
288
+ { name: 'Docs', dirName: 'Docs' }
289
+ ])
290
+
291
+ expect(result.ensured).toEqual(['docs'])
292
+ expect(client.save).not.toHaveBeenCalled()
293
+ })
294
+
295
+ it('re-attaches the flag folder to an assistant that lost it', async () => {
296
+ const { client, filesCollection } = makeClient({
297
+ assistants: {
298
+ docs: { _id: 'docs', _type: 'io.cozy.ai.chat.assistants' }
299
+ },
300
+ ensuredDirId: 'docs-dir'
301
+ })
302
+
303
+ const result = await ensureProvisionedAssistants(client, [
304
+ { name: 'Docs', dirName: 'Docs' }
305
+ ])
306
+
307
+ expect(result).toEqual({ created: [], ensured: ['docs'], skipped: [] })
308
+ expect(filesCollection.ensureDirectoryExists).toHaveBeenCalledWith('/Docs')
309
+ // saveKnowledgeBase re-attached the folder.
310
+ expect(client.save).toHaveBeenCalledWith(
311
+ expect.objectContaining({
312
+ _id: 'docs',
313
+ knowledgeBase: [{ doctype: 'io.cozy.files', dirId: 'docs-dir' }]
314
+ })
315
+ )
316
+ })
317
+
318
+ it('re-attaches the flag folder to an assistant that fell back to the root', async () => {
319
+ const { client } = makeClient({
320
+ assistants: {
321
+ docs: {
322
+ _id: 'docs',
323
+ _type: 'io.cozy.ai.chat.assistants',
324
+ knowledgeBase: [
325
+ { doctype: 'io.cozy.files', dirId: 'io.cozy.files.root-dir' }
326
+ ]
327
+ }
328
+ },
329
+ ensuredDirId: 'docs-dir'
330
+ })
331
+
332
+ const result = await ensureProvisionedAssistants(client, [
333
+ { name: 'Docs', dirName: 'Docs' }
334
+ ])
335
+
336
+ expect(result).toEqual({ created: [], ensured: ['docs'], skipped: [] })
337
+ expect(client.save).toHaveBeenCalledWith(
338
+ expect.objectContaining({
339
+ _id: 'docs',
340
+ knowledgeBase: [{ doctype: 'io.cozy.files', dirId: 'docs-dir' }]
341
+ })
342
+ )
343
+ })
344
+
345
+ it('leaves a whole-Drive assistant alone when the flag asks for the root', async () => {
346
+ const { client } = makeClient({
347
+ assistants: {
348
+ docs: {
349
+ _id: 'docs',
350
+ _type: 'io.cozy.ai.chat.assistants',
351
+ knowledgeBase: [
352
+ { doctype: 'io.cozy.files', dirId: 'io.cozy.files.root-dir' }
353
+ ]
354
+ }
355
+ }
356
+ })
357
+
358
+ const result = await ensureProvisionedAssistants(client, [
359
+ { name: 'Docs', dirId: 'io.cozy.files.root-dir' }
360
+ ])
361
+
362
+ expect(result).toEqual({ created: [], ensured: ['docs'], skipped: [] })
363
+ expect(client.save).not.toHaveBeenCalled()
364
+ })
365
+
366
+ it('skips an existing assistant whose folder is trashed', async () => {
367
+ const { client } = makeClient({
368
+ assistants: {
369
+ docs: {
370
+ _id: 'docs',
371
+ _type: 'io.cozy.ai.chat.assistants',
372
+ knowledgeBase: [{ doctype: 'io.cozy.files', dirId: 'dir-1' }]
373
+ }
374
+ },
375
+ files: {
376
+ 'dir-1': {
377
+ _id: 'dir-1',
378
+ type: 'directory',
379
+ path: '/.cozy_trash/Docs'
380
+ }
381
+ }
382
+ })
383
+
384
+ const result = await ensureProvisionedAssistants(client, [
385
+ { name: 'Docs', dirName: 'Docs' }
386
+ ])
387
+
388
+ expect(result.ensured).toEqual([])
389
+ expect(result.skipped).toEqual([
390
+ { id: 'docs', reason: 'folder dir-1 is missing or trashed' }
391
+ ])
392
+ })
393
+
394
+ it('treats a save conflict as an existing assistant', async () => {
395
+ const { client } = makeClient({ ensuredDirId: 'docs-dir' })
396
+ let queries = 0
397
+ const original = client.query.getMockImplementation()
398
+ client.query.mockImplementation(async definition => {
399
+ if (definition.doctype === 'io.cozy.ai.chat.assistants') {
400
+ queries += 1
401
+ if (queries === 1) throw notFound()
402
+ return {
403
+ data: {
404
+ _id: 'docs',
405
+ knowledgeBase: [{ doctype: 'io.cozy.files', dirId: 'docs-dir' }]
406
+ }
407
+ }
408
+ }
409
+ return original(definition)
410
+ })
411
+ client.save.mockImplementation(async doc => {
412
+ if (doc._type === 'io.cozy.ai.chat.assistants') throw conflict()
413
+ return { data: { ...doc, _id: 'acc-new' } }
414
+ })
415
+ client.collection('io.cozy.files').statById.mockResolvedValue({
416
+ data: { _id: 'docs-dir', type: 'directory', trashed: false }
417
+ })
418
+
419
+ const result = await ensureProvisionedAssistants(client, [
420
+ { name: 'Docs', dirName: 'Docs' }
421
+ ])
422
+
423
+ expect(result.created).toEqual([])
424
+ expect(result.ensured).toEqual(['docs'])
425
+ // The account made for the losing side is an orphan: removed.
426
+ expect(client.destroy).toHaveBeenCalledWith(
427
+ expect.objectContaining({ _type: 'io.cozy.accounts', _id: 'acc-new' })
428
+ )
429
+ })
430
+
431
+ it('skips invalid entries and keeps going after a failure', async () => {
432
+ const { client } = makeClient({ ensuredDirId: 'docs-dir' })
433
+ client
434
+ .collection('io.cozy.files')
435
+ .ensureDirectoryExists.mockRejectedValueOnce(new Error('boom'))
436
+ .mockResolvedValue('docs-dir')
437
+
438
+ const result = await ensureProvisionedAssistants(client, [
439
+ { name: '***', dirName: 'X' },
440
+ { name: 'No folder' },
441
+ { name: 'First', dirName: 'A' },
442
+ { name: 'Second', dirName: 'B' }
443
+ ])
444
+
445
+ expect(result.created).toEqual(['second'])
446
+ expect(result.skipped).toEqual([
447
+ { id: '', reason: 'invalid name' },
448
+ { id: 'no-folder', reason: 'dirId or dirName is required' },
449
+ { id: 'first', reason: 'boom' }
450
+ ])
451
+ })
452
+
453
+ it('returns an empty result for an absent or empty flag', async () => {
454
+ const { client } = makeClient()
455
+ expect(await ensureProvisionedAssistants(client, undefined)).toEqual({
456
+ created: [],
457
+ ensured: [],
458
+ skipped: []
459
+ })
460
+ expect(await ensureProvisionedAssistants(client, [])).toEqual({
461
+ created: [],
462
+ ensured: [],
463
+ skipped: []
464
+ })
465
+ })
466
+ })
467
+
468
+ describe('getDefaultProvisionedAssistantId', () => {
469
+ beforeEach(() => flag.mockReset())
470
+
471
+ it('returns null without the flag or without a default entry', () => {
472
+ flag.mockReturnValue(null)
473
+ expect(getDefaultProvisionedAssistantId()).toBeNull()
474
+ flag.mockReturnValue('not-an-array')
475
+ expect(getDefaultProvisionedAssistantId()).toBeNull()
476
+ flag.mockReturnValue([{ name: 'Docs', dirName: 'Docs' }])
477
+ expect(getDefaultProvisionedAssistantId()).toBeNull()
478
+ })
479
+
480
+ it('derives the id of the first default entry with a valid name', () => {
481
+ flag.mockReturnValue([
482
+ { name: '!!!', dirName: 'X', default: true },
483
+ { name: 'Mes documents', dirName: 'Docs', default: true },
484
+ { name: 'Autre', dirName: 'Autre', default: true }
485
+ ])
486
+ expect(getDefaultProvisionedAssistantId()).toBe('mes-documents')
487
+ expect(flag).toHaveBeenCalledWith('rag.assistants.autoprovision')
488
+ })
489
+ })
@@ -0,0 +1,21 @@
1
+ export const RAG_INDEX_WORKER: "rag-index";
2
+ export const RAG_INDEX_FILES_DEBOUNCE: "30s";
3
+ export function makeRagIndexTriggerAttributes(doctype: any): {
4
+ worker: string;
5
+ message: {
6
+ doctype: string;
7
+ };
8
+ debounce?: string | undefined;
9
+ type: string;
10
+ arguments: any;
11
+ };
12
+ export function findRagIndexTriggers(client: any): Promise<{
13
+ files: object | null;
14
+ assistants: object | null;
15
+ }>;
16
+ export function migrateAssistantsWithoutFolder(client: any): Promise<string[]>;
17
+ export function setupRagIndexing(client: import("cozy-client").CozyClient): Promise<{
18
+ triggers: string[];
19
+ migrated: string[];
20
+ errors: Error[];
21
+ }>;