@things-factory/ai-assistant 10.1.46 → 10.1.50
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 +100 -20
- package/client/components/assistant-dock.ts +617 -0
- package/client/components/assistant-mode.ts +29 -0
- package/client/components/assistant-session-manager.ts +607 -0
- package/client/components/chat-echo-dedup.ts +4 -0
- package/client/components/chat-input-builder.ts +12 -2
- package/client/components/panel-aria.test.ts +13 -8
- package/client/index.ts +10 -0
- package/client/setup-assistant-dock.ts +109 -0
- package/client/utils/assistant-activity.ts +122 -0
- package/client/utils/assistant-ask.ts +44 -0
- package/client/utils/assistant-capability-read.ts +24 -0
- package/client/utils/assistant-capability.test.ts +17 -0
- package/client/utils/assistant-capability.ts +19 -0
- package/client/utils/assistant-proposals.ts +50 -0
- package/client/utils/assistant-session-actions.ts +18 -0
- package/client/utils/assistant-session-admin.ts +167 -0
- package/client/utils/assistant-session-controller.ts +2 -0
- package/client/utils/assistant-session-transport.ts +6 -2
- package/client/utils/assistant-surface.ts +157 -0
- package/client/utils/attachment-policy.ts +71 -0
- package/dist-client/components/assistant-chat.d.ts +6 -0
- package/dist-client/components/assistant-chat.js +101 -21
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +74 -0
- package/dist-client/components/assistant-dock.js +641 -0
- package/dist-client/components/assistant-dock.js.map +1 -0
- package/dist-client/components/assistant-mode.d.ts +6 -0
- package/dist-client/components/assistant-mode.js +24 -0
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +77 -0
- package/dist-client/components/assistant-session-manager.js +652 -0
- package/dist-client/components/assistant-session-manager.js.map +1 -0
- package/dist-client/components/chat-echo-dedup.js +4 -0
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/chat-input-builder.d.ts +11 -0
- package/dist-client/components/chat-input-builder.js +4 -2
- package/dist-client/components/chat-input-builder.js.map +1 -1
- package/dist-client/components/panel-aria.test.js +12 -8
- package/dist-client/components/panel-aria.test.js.map +1 -1
- package/dist-client/index.d.ts +10 -0
- package/dist-client/index.js +10 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/setup-assistant-dock.d.ts +15 -0
- package/dist-client/setup-assistant-dock.js +110 -0
- package/dist-client/setup-assistant-dock.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-activity.d.ts +22 -0
- package/dist-client/utils/assistant-activity.js +104 -0
- package/dist-client/utils/assistant-activity.js.map +1 -0
- package/dist-client/utils/assistant-ask.d.ts +23 -0
- package/dist-client/utils/assistant-ask.js +18 -0
- package/dist-client/utils/assistant-ask.js.map +1 -0
- package/dist-client/utils/assistant-capability-read.d.ts +6 -0
- package/dist-client/utils/assistant-capability-read.js +23 -0
- package/dist-client/utils/assistant-capability-read.js.map +1 -0
- package/dist-client/utils/assistant-capability.d.ts +4 -0
- package/dist-client/utils/assistant-capability.js +17 -0
- package/dist-client/utils/assistant-capability.js.map +1 -0
- package/dist-client/utils/assistant-capability.test.d.ts +1 -0
- package/dist-client/utils/assistant-capability.test.js +16 -0
- package/dist-client/utils/assistant-capability.test.js.map +1 -0
- package/dist-client/utils/assistant-proposals.d.ts +29 -0
- package/dist-client/utils/assistant-proposals.js +22 -0
- package/dist-client/utils/assistant-proposals.js.map +1 -0
- package/dist-client/utils/assistant-session-actions.d.ts +12 -0
- package/dist-client/utils/assistant-session-actions.js +19 -0
- package/dist-client/utils/assistant-session-actions.js.map +1 -0
- package/dist-client/utils/assistant-session-admin.d.ts +72 -0
- package/dist-client/utils/assistant-session-admin.js +81 -0
- package/dist-client/utils/assistant-session-admin.js.map +1 -0
- package/dist-client/utils/assistant-session-controller.d.ts +2 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-session-transport.js +6 -2
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-client/utils/assistant-surface.d.ts +72 -0
- package/dist-client/utils/assistant-surface.js +99 -0
- package/dist-client/utils/assistant-surface.js.map +1 -0
- package/dist-client/utils/attachment-policy.d.ts +35 -0
- package/dist-client/utils/attachment-policy.js +56 -0
- package/dist-client/utils/attachment-policy.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.d.ts +1 -0
- package/dist-server/service/assistant-chat-resolver.js +72 -4
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +1 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +7 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +3 -0
- package/dist-server/service/assistant-mode/resolve.js +6 -2
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +6 -0
- package/dist-server/service/assistant-mode/types.js +0 -9
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +6 -0
- package/dist-server/service/chat-message/chat-message.js +19 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-session/attachment-policy.d.ts +44 -0
- package/dist-server/service/chat-session/attachment-policy.js +56 -0
- package/dist-server/service/chat-session/attachment-policy.js.map +1 -0
- package/dist-server/service/chat-session/attachment-route.d.ts +3 -0
- package/dist-server/service/chat-session/attachment-route.js +44 -0
- package/dist-server/service/chat-session/attachment-route.js.map +1 -0
- package/dist-server/service/chat-session/attachment-tools.d.ts +3 -0
- package/dist-server/service/chat-session/attachment-tools.js +59 -0
- package/dist-server/service/chat-session/attachment-tools.js.map +1 -0
- package/dist-server/service/chat-session/session-attachments.d.ts +36 -0
- package/dist-server/service/chat-session/session-attachments.js +84 -0
- package/dist-server/service/chat-session/session-attachments.js.map +1 -0
- package/dist-server/service/chat-session/session-inbox.d.ts +29 -0
- package/dist-server/service/chat-session/session-inbox.js +32 -8
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-purge.d.ts +14 -0
- package/dist-server/service/chat-session/session-purge.js +77 -0
- package/dist-server/service/chat-session/session-purge.js.map +1 -0
- package/dist-server/service/chat-session/session-retention.d.ts +6 -0
- package/dist-server/service/chat-session/session-retention.js +20 -0
- package/dist-server/service/chat-session/session-retention.js.map +1 -0
- package/dist-server/service/chat-session/session-visibility.d.ts +8 -0
- package/dist-server/service/chat-session/session-visibility.js +11 -0
- package/dist-server/service/chat-session/session-visibility.js.map +1 -1
- package/dist-server/service/chat-session/session-write-gate.d.ts +11 -0
- package/dist-server/service/chat-session/session-write-gate.js +24 -0
- package/dist-server/service/chat-session/session-write-gate.js.map +1 -0
- package/dist-server/service/chat-session-participant/invite-notification.d.ts +1 -1
- package/dist-server/service/chat-session-participant/invite-notification.js +21 -6
- package/dist-server/service/chat-session-participant/invite-notification.js.map +1 -1
- package/dist-server/service/chat-session-participant/participant-lookup-roles.d.ts +3 -0
- package/dist-server/service/chat-session-participant/participant-lookup-roles.js +10 -0
- package/dist-server/service/chat-session-participant/participant-lookup-roles.js.map +1 -0
- package/dist-server/service/chat-session-participant/participant-lookup.d.ts +2 -0
- package/dist-server/service/chat-session-participant/participant-lookup.js +26 -0
- package/dist-server/service/chat-session-participant/participant-lookup.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +17 -2
- package/dist-server/service/chat-session-resolver.js +135 -15
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/index.d.ts +6 -0
- package/dist-server/service/index.js +8 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +11 -4
- package/server/service/assistant-chat-resolver.ts +72 -5
- package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
- package/server/service/assistant-mode/resolve.ts +9 -2
- package/server/service/assistant-mode/types.ts +6 -0
- package/server/service/chat-message/chat-message.ts +17 -0
- package/server/service/chat-message/fold-session-history.test.ts +18 -0
- package/server/service/chat-session/attachment-policy.test.ts +42 -0
- package/server/service/chat-session/attachment-policy.ts +68 -0
- package/server/service/chat-session/attachment-route.ts +45 -0
- package/server/service/chat-session/attachment-tools.ts +63 -0
- package/server/service/chat-session/session-attachments.ts +89 -0
- package/server/service/chat-session/session-inbox.test.ts +71 -1
- package/server/service/chat-session/session-inbox.ts +61 -11
- package/server/service/chat-session/session-purge.test.ts +24 -0
- package/server/service/chat-session/session-purge.ts +81 -0
- package/server/service/chat-session/session-retention.ts +17 -0
- package/server/service/chat-session/session-visibility.ts +11 -0
- package/server/service/chat-session/session-write-gate.test.ts +53 -0
- package/server/service/chat-session/session-write-gate.ts +31 -0
- package/server/service/chat-session-participant/invite-notification.test.ts +15 -0
- package/server/service/chat-session-participant/invite-notification.ts +24 -6
- package/server/service/chat-session-participant/participant-lookup-roles.ts +6 -0
- package/server/service/chat-session-participant/participant-lookup.ts +22 -0
- package/server/service/chat-session-resolver.ts +130 -13
- package/server/service/index.ts +6 -0
- package/test/assistant-dock.test.ts +118 -0
- package/test/assistant-session-manager.test.ts +126 -0
- package/test/attachment-policy-agree.test.ts +32 -0
- package/test/open-doors.test.ts +5 -2
- package/translations/en.json +61 -1
- package/translations/ja.json +61 -1
- package/translations/ko.json +61 -1
- package/translations/ms.json +61 -1
- package/translations/zh.json +61 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.50",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -26,16 +26,23 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@apollo/client": "^3.13.0",
|
|
29
|
+
"@material/web": "^2.0.0",
|
|
29
30
|
"@operato/graphql": "^10.0.0",
|
|
30
31
|
"@operato/i18n": "^10.0.0",
|
|
32
|
+
"@operato/layout": "^10.0.0",
|
|
33
|
+
"@operato/popup": "^10.16.0",
|
|
34
|
+
"@operato/shell": "^10.0.0",
|
|
31
35
|
"@operato/styles": "^10.0.0",
|
|
32
36
|
"@things-factory/ai-client-base": "^10.1.39",
|
|
33
|
-
"@things-factory/
|
|
37
|
+
"@things-factory/apptool-base": "^10.1.47",
|
|
38
|
+
"@things-factory/attachment-base": "^10.1.47",
|
|
39
|
+
"@things-factory/auth-base": "^10.1.47",
|
|
34
40
|
"@things-factory/env": "^10.1.20",
|
|
35
|
-
"@things-factory/shell": "^10.1.
|
|
41
|
+
"@things-factory/shell": "^10.1.47",
|
|
36
42
|
"dompurify": "^3.0.0",
|
|
37
43
|
"graphql-scalars": "^1.22.0",
|
|
38
44
|
"graphql-tag": "^2.12.6",
|
|
45
|
+
"graphql-upload": "^15.0.2",
|
|
39
46
|
"lit": "^3.1.2",
|
|
40
47
|
"marked": "^15.0.0"
|
|
41
48
|
},
|
|
@@ -44,5 +51,5 @@
|
|
|
44
51
|
"copyfiles": "^2.4.1",
|
|
45
52
|
"rimraf": "^5.0.0"
|
|
46
53
|
},
|
|
47
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "4700d39d70eb5fae4ad56cf47ea0b966c8516ae2"
|
|
48
55
|
}
|
|
@@ -53,6 +53,11 @@ import { judgeSessionKind } from './chat-session/session-kind.js'
|
|
|
53
53
|
import { readerGate } from './chat-message/reader-gate.js'
|
|
54
54
|
import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
|
|
55
55
|
import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
|
|
56
|
+
import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
|
|
57
|
+
import { assertMayWriteSession } from './chat-session/session-write-gate.js'
|
|
58
|
+
import { attachmentHistoryLine, refuseAttachments } from './chat-session/attachment-policy.js'
|
|
59
|
+
import { attachmentForModel, describeAttachment, findChatAttachment } from './chat-session/session-attachments.js'
|
|
60
|
+
import { getAssistantMode } from './assistant-mode/registry.js'
|
|
56
61
|
import { resolveModeSettings } from './assistant-mode/resolve.js'
|
|
57
62
|
import { requestTranslator } from './request-translator.js'
|
|
58
63
|
|
|
@@ -121,6 +126,12 @@ class AssistantChatInput {
|
|
|
121
126
|
})
|
|
122
127
|
truncateAfterMessageId?: string
|
|
123
128
|
|
|
129
|
+
@Field(() => [String], {
|
|
130
|
+
nullable: true,
|
|
131
|
+
description: 'Files stored with this conversation by attachToConversation, sent with this turn.'
|
|
132
|
+
})
|
|
133
|
+
attachmentIds?: string[]
|
|
134
|
+
|
|
124
135
|
}
|
|
125
136
|
|
|
126
137
|
@ObjectType()
|
|
@@ -225,6 +236,16 @@ export class AssistantChatResolver {
|
|
|
225
236
|
if (input.sessionId) {
|
|
226
237
|
const found = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } as any })
|
|
227
238
|
if (!found) throw new Error(`ChatSession ${input.sessionId} not found`)
|
|
239
|
+
/*
|
|
240
|
+
* A private conversation is written by its people only - and here more than anywhere: the answer is
|
|
241
|
+
* built on this conversation's history, so an outsider's question would read it back to them.
|
|
242
|
+
*/
|
|
243
|
+
await assertMayWriteSession({
|
|
244
|
+
session: found as any,
|
|
245
|
+
userId: user?.id,
|
|
246
|
+
isParticipant: () => isSessionParticipant(domain.id, found.id!, user?.id),
|
|
247
|
+
t: (key, options) => t(key, options as any)
|
|
248
|
+
})
|
|
228
249
|
if (found.anchorType === 'figure' && input.hostContext?.figureId !== found.anchorId) {
|
|
229
250
|
throw new Error(
|
|
230
251
|
context.t('ai-assistant.text.other-figure-history', {
|
|
@@ -265,6 +286,26 @@ export class AssistantChatResolver {
|
|
|
265
286
|
const lastUserMessage = input.messages[input.messages.length - 1]
|
|
266
287
|
let userMessageId: string | undefined
|
|
267
288
|
|
|
289
|
+
/*
|
|
290
|
+
* Files sent with this turn (design/ai/shared-dock.md §9). They were stored with this conversation by
|
|
291
|
+
* attachToConversation; one that is not this conversation's is refused, never read. The policy is the
|
|
292
|
+
* mode's - checked again here, since the door is the authority, not the screen.
|
|
293
|
+
*/
|
|
294
|
+
const attachmentRows: any[] = []
|
|
295
|
+
if (input.attachmentIds?.length) {
|
|
296
|
+
if (!session) throw new Error(t('ai-assistant.text.attachments-need-session', { defaultValue: 'attachments need a saved conversation.' }))
|
|
297
|
+
for (const id of input.attachmentIds) {
|
|
298
|
+
const row = await findChatAttachment(domain.id, session.id!, id)
|
|
299
|
+
if (!row) throw new Error(t('ai-assistant.text.attachment-not-in-session', { defaultValue: 'an attachment does not belong to this conversation.' }))
|
|
300
|
+
attachmentRows.push(row)
|
|
301
|
+
}
|
|
302
|
+
const refusal = refuseAttachments(
|
|
303
|
+
getAssistantMode(input.mode.kind)?.attachments,
|
|
304
|
+
attachmentRows.map(row => ({ name: row.name, type: row.mimetype, size: Number(row.size) }))
|
|
305
|
+
)
|
|
306
|
+
if (refusal) throw new Error(t(`ai-assistant.text.${refusal.code}`, { ...(refusal as any), defaultValue: refusal.code }))
|
|
307
|
+
}
|
|
308
|
+
|
|
268
309
|
if (session && lastUserMessage?.role === 'user') {
|
|
269
310
|
const previousLast = await messageRepo.findOne({
|
|
270
311
|
where: { session: { id: session.id } as any },
|
|
@@ -280,10 +321,11 @@ export class AssistantChatResolver {
|
|
|
280
321
|
* from the request — two sources for one fact can disagree.
|
|
281
322
|
*/
|
|
282
323
|
addressee: 'assistant',
|
|
324
|
+
attachments: attachmentRows.length ? JSON.stringify(attachmentRows.map(describeAttachment)) : undefined,
|
|
283
325
|
creator: user,
|
|
284
326
|
updater: user,
|
|
285
327
|
parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
|
|
286
|
-
})
|
|
328
|
+
} as any)
|
|
287
329
|
userMessageId = saved.id
|
|
288
330
|
|
|
289
331
|
/*
|
|
@@ -312,7 +354,7 @@ export class AssistantChatResolver {
|
|
|
312
354
|
visibility: (session as any).visibility
|
|
313
355
|
})
|
|
314
356
|
if (invite.invited.length > 0) {
|
|
315
|
-
const payload = buildInviteNotification(invite.invited, undefined, user)
|
|
357
|
+
const payload = buildInviteNotification(invite.invited, undefined, user, (session as any)?.name)
|
|
316
358
|
try {
|
|
317
359
|
if (payload) await (context.state as any)?.notify?.(payload)
|
|
318
360
|
} catch {
|
|
@@ -373,7 +415,14 @@ export class AssistantChatResolver {
|
|
|
373
415
|
storedRows.map(message => ({
|
|
374
416
|
id: message.id,
|
|
375
417
|
role: message.role,
|
|
376
|
-
|
|
418
|
+
/*
|
|
419
|
+
* A file sent in an earlier turn is not sent again - one line names it, and the model opens it
|
|
420
|
+
* with readChatAttachment when it needs to (cost is a usage axis, ADR-0061 decision 6).
|
|
421
|
+
*/
|
|
422
|
+
content:
|
|
423
|
+
message.id !== userMessageId && (message as any).attachmentsJson?.length
|
|
424
|
+
? [message.content, ...(message as any).attachmentsJson.map(attachmentHistoryLine)].join('\n')
|
|
425
|
+
: message.content,
|
|
377
426
|
senderId: (message as any).creator?.id,
|
|
378
427
|
senderName: (message as any).creator?.name,
|
|
379
428
|
addressee: (message as any).addressee
|
|
@@ -390,7 +439,25 @@ export class AssistantChatResolver {
|
|
|
390
439
|
)
|
|
391
440
|
: input.messages.map(message => ({ role: message.role as any, content: message.content }))
|
|
392
441
|
|
|
393
|
-
if (
|
|
442
|
+
if (attachmentRows.length && conversation.length > 0) {
|
|
443
|
+
const last = conversation[conversation.length - 1]
|
|
444
|
+
if (last.role === 'user' && typeof last.content === 'string') {
|
|
445
|
+
const parts: any[] = [{ type: 'text', text: last.content }]
|
|
446
|
+
for (const row of attachmentRows) {
|
|
447
|
+
const file = await attachmentForModel(row)
|
|
448
|
+
if (file.type === 'image') {
|
|
449
|
+
parts.push({ type: 'image', source: { kind: 'base64', data: file.data, mediaType: file.mediaType } })
|
|
450
|
+
} else {
|
|
451
|
+
parts.push({
|
|
452
|
+
type: 'text',
|
|
453
|
+
text: `[attachment ${row.id}: ${file.name}${file.truncated ? ' - cut, the file is longer' : ''}]\n${file.text}`
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
last.content = parts as any
|
|
458
|
+
}
|
|
459
|
+
} else if (input.hostContext?.image?.data && conversation.length > 0) {
|
|
460
|
+
/* The screen-image path (figure's rendered view) - not a file of the conversation. */
|
|
394
461
|
const last = conversation[conversation.length - 1]
|
|
395
462
|
if (last.role === 'user' && typeof last.content === 'string') {
|
|
396
463
|
const raw = input.hostContext.image
|
|
@@ -493,7 +560,7 @@ export class AssistantChatResolver {
|
|
|
493
560
|
domainId: domain.id,
|
|
494
561
|
client,
|
|
495
562
|
maxTurns: LLM_HISTORY_MAX_TURNS,
|
|
496
|
-
locale: context.state?.
|
|
563
|
+
locale: context.state?.lng
|
|
497
564
|
})
|
|
498
565
|
}
|
|
499
566
|
|
|
@@ -55,6 +55,12 @@ class AssistantModeView {
|
|
|
55
55
|
|
|
56
56
|
@Field({ nullable: true, description: 'The line shown once the host reports a proposal staged.' })
|
|
57
57
|
stagedNotice?: string
|
|
58
|
+
|
|
59
|
+
@Field(() => GraphQLJSON, {
|
|
60
|
+
nullable: true,
|
|
61
|
+
description: "What this conversation accepts as attachments: { kinds: ('image'|'text')[], maxBytes, maxFiles }. Null: none."
|
|
62
|
+
})
|
|
63
|
+
attachments?: any
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
@ObjectType({ description: 'A conversation a screen may open for what it is showing.' })
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AttachmentPolicy } from '../chat-session/attachment-policy.js'
|
|
1
2
|
/**
|
|
2
3
|
* What the door and the screen each take from a mode.
|
|
3
4
|
*
|
|
@@ -51,7 +52,10 @@ export function resolveModeSettings(ref: AssistantModeRef, t: Translate): ModeSe
|
|
|
51
52
|
|
|
52
53
|
return {
|
|
53
54
|
systemPrompt: mode.systemPrompt,
|
|
54
|
-
|
|
55
|
+
/* A mode that takes attachments can open the files sent in earlier turns (design/ai/shared-dock.md §9-4). */
|
|
56
|
+
toolCategories: mode.attachments?.kinds?.length
|
|
57
|
+
? [...(mode.toolCategories ?? []), 'chat-attachments']
|
|
58
|
+
: mode.toolCategories,
|
|
55
59
|
requireToolOnFirstTurn: mode.requireToolOnFirstTurn,
|
|
56
60
|
model: mode.model,
|
|
57
61
|
temperature: mode.temperature,
|
|
@@ -77,6 +81,8 @@ export interface AssistantModeView {
|
|
|
77
81
|
examples?: Array<{ label: string; items: string[] }>
|
|
78
82
|
footerNotice?: string
|
|
79
83
|
stagedNotice?: string
|
|
84
|
+
/** What the screen may attach - the same policy the server enforces. */
|
|
85
|
+
attachments?: AttachmentPolicy
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
/** What the screen shows, in the words of the request's locale. */
|
|
@@ -100,6 +106,7 @@ export function assistantModeView(kind: string, t: Translate): AssistantModeView
|
|
|
100
106
|
items: group.itemKeys.map(key => t(key))
|
|
101
107
|
})),
|
|
102
108
|
footerNotice: mode.footerNoticeKey ? t(mode.footerNoticeKey) : undefined,
|
|
103
|
-
stagedNotice: mode.stagedNoticeKey ? t(mode.stagedNoticeKey) : undefined
|
|
109
|
+
stagedNotice: mode.stagedNoticeKey ? t(mode.stagedNoticeKey) : undefined,
|
|
110
|
+
attachments: mode.attachments
|
|
104
111
|
}
|
|
105
112
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AttachmentPolicy } from '../chat-session/attachment-policy.js'
|
|
1
2
|
/**
|
|
2
3
|
* What a conversation is, declared by the app server that owns the screen (ADR-0061 decision 7).
|
|
3
4
|
*
|
|
@@ -94,6 +95,11 @@ export interface AssistantModeSpec {
|
|
|
94
95
|
*/
|
|
95
96
|
exampleKeys?: Array<{ labelKey: string; itemKeys: string[] }>
|
|
96
97
|
|
|
98
|
+
/**
|
|
99
|
+
* What this conversation accepts as attachments (design/ai/shared-dock.md §9-1). Absent: nothing - the
|
|
100
|
+
* screen offers no attach button, so no file is taken that the model would never see.
|
|
101
|
+
*/
|
|
102
|
+
attachments?: AttachmentPolicy
|
|
97
103
|
/** i18n key for the line under an empty conversation. */
|
|
98
104
|
footerNoticeKey?: string
|
|
99
105
|
/** i18n key for the line shown once the host reports a proposal staged. */
|
|
@@ -160,6 +160,23 @@ export class ChatMessage {
|
|
|
160
160
|
*
|
|
161
161
|
* 형태: string[] 를 JSON-stringify. 비었으면 null(= 접지 정상).
|
|
162
162
|
*/
|
|
163
|
+
/**
|
|
164
|
+
* The files sent with this turn - `[{ id, name, mimetype, size }]` as JSON (design/ai/shared-dock.md §9-2).
|
|
165
|
+
* The files themselves live in attachment-base under the conversation and are opened only through it.
|
|
166
|
+
*/
|
|
167
|
+
@Column({ type: 'text', nullable: true })
|
|
168
|
+
attachments?: string
|
|
169
|
+
|
|
170
|
+
@Field(() => GraphQLJSON, { nullable: true, description: 'Files sent with this turn: [{ id, name, mimetype, size }].' })
|
|
171
|
+
get attachmentsJson(): any {
|
|
172
|
+
if (!this.attachments) return null
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(this.attachments)
|
|
175
|
+
} catch {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
163
180
|
@Column({ type: 'varchar', length: 1024, nullable: true })
|
|
164
181
|
groundingWarnings?: string
|
|
165
182
|
|
|
@@ -96,3 +96,21 @@ describe('rowsAfterWatermark', () => {
|
|
|
96
96
|
expect(where[1]).toMatchObject({ session: { id: 's1' }, createdAt: at })
|
|
97
97
|
})
|
|
98
98
|
})
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* The summary is written in the conversation's language only if the language reaches it. The i18n
|
|
102
|
+
* middleware leaves it on `ctx.state.lng` (`i18n-base` koa-i18next `setLanguage`); nothing sets
|
|
103
|
+
* `state.locale`. The door read `state.locale`, so every summary was asked for with no language and
|
|
104
|
+
* a Korean deliberation could be summarised in English (found 2026-09-24).
|
|
105
|
+
*/
|
|
106
|
+
describe('the door hands the fold the request language', () => {
|
|
107
|
+
it('★ reads state.lng — the key the i18n middleware sets', () => {
|
|
108
|
+
const { readFileSync } = require('fs')
|
|
109
|
+
const { join } = require('path')
|
|
110
|
+
const resolver: string = readFileSync(join(__dirname, '..', 'assistant-chat-resolver.ts'), 'utf8')
|
|
111
|
+
const call = resolver.slice(resolver.indexOf('void foldSessionHistory({'), resolver.indexOf('})', resolver.indexOf('void foldSessionHistory({')))
|
|
112
|
+
|
|
113
|
+
expect(call).toMatch(/locale: context\.state\?\.lng\b/)
|
|
114
|
+
expect(resolver).not.toMatch(/state\?\.locale\b/)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* What a conversation accepts as attachments - design/ai/shared-dock.md §9-1. */
|
|
2
|
+
import { attachmentHistoryLine, attachmentKindOf, refuseAttachments } from './attachment-policy'
|
|
3
|
+
|
|
4
|
+
describe('attachment policy', () => {
|
|
5
|
+
const policy = { kinds: ['image', 'text'] as any, maxBytes: 1000, maxFiles: 2 }
|
|
6
|
+
|
|
7
|
+
it('a mode that declares nothing takes nothing - no file the model would never see', () => {
|
|
8
|
+
expect(refuseAttachments(undefined, [{ name: 'a.png', type: 'image/png', size: 1 }])).toEqual({ code: 'attachments-not-accepted' })
|
|
9
|
+
expect(refuseAttachments({ kinds: [] }, [{ name: 'a.png', type: 'image/png' }])).toEqual({ code: 'attachments-not-accepted' })
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('no files is no refusal', () => {
|
|
13
|
+
expect(refuseAttachments(undefined, [])).toBeNull()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('kinds come from the type, or the name when the browser did not know the type', () => {
|
|
17
|
+
expect(attachmentKindOf({ type: 'image/jpeg' })).toBe('image')
|
|
18
|
+
expect(attachmentKindOf({ type: 'text/csv' })).toBe('text')
|
|
19
|
+
expect(attachmentKindOf({ type: '', name: 'report.CSV' })).toBe('text')
|
|
20
|
+
expect(attachmentKindOf({ type: 'application/pdf', name: 'a.pdf' })).toBeNull()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('each limit refuses by name, before sending', () => {
|
|
24
|
+
expect(refuseAttachments(policy, [{ name: 'a.pdf', type: 'application/pdf', size: 1 }])).toMatchObject({ code: 'attachment-kind-not-accepted', name: 'a.pdf' })
|
|
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 })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('a kind outside the declared ones is refused', () => {
|
|
30
|
+
expect(refuseAttachments({ kinds: ['image'] }, [{ name: 'a.csv', type: 'text/csv', size: 1 }])).toMatchObject({ code: 'attachment-kind-not-accepted' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('files within the policy go', () => {
|
|
34
|
+
expect(refuseAttachments(policy, [{ name: 'a.png', type: 'image/png', size: 10 }, { name: 'b.csv', type: 'text/csv', size: 10 }])).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('the line an earlier turn keeps names the file and how to open it', () => {
|
|
38
|
+
expect(attachmentHistoryLine({ id: 'f1', name: 'press.csv', mimetype: 'text/csv', size: 120 })).toBe(
|
|
39
|
+
'[attachment f1: press.csv (text/csv, 120 bytes) - readChatAttachment to open it]'
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What a conversation accepts as attachments - declared by its mode (design/ai/shared-dock.md §9-1).
|
|
3
|
+
*
|
|
4
|
+
* Pure, so the rule is tested alone and the screen can say "no" before anything is sent. A mode that
|
|
5
|
+
* declares nothing accepts nothing: the chat once took .csv and .json, carried them, and no code ever
|
|
6
|
+
* showed them to the model - the person got an answer about a file it had never seen (AI standard §7).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type AttachmentKind = 'image' | 'text'
|
|
10
|
+
|
|
11
|
+
export interface AttachmentPolicy {
|
|
12
|
+
kinds: AttachmentKind[]
|
|
13
|
+
/** Per file. */
|
|
14
|
+
maxBytes?: number
|
|
15
|
+
/** Per message. */
|
|
16
|
+
maxFiles?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024
|
|
20
|
+
export const DEFAULT_MAX_FILES = 5
|
|
21
|
+
/** How much of a text file reaches the model in the turn it was sent. */
|
|
22
|
+
export const TEXT_CHARS_FOR_MODEL = 20_000
|
|
23
|
+
|
|
24
|
+
const TEXT_TYPES = ['text/plain', 'text/csv', 'text/markdown', 'application/json']
|
|
25
|
+
const TEXT_EXTENSIONS = ['.txt', '.csv', '.md', '.json']
|
|
26
|
+
|
|
27
|
+
/** Which kind a file is, from its type - and its name when the browser did not know the type. */
|
|
28
|
+
export function attachmentKindOf(file: { type?: string; name?: string }): AttachmentKind | null {
|
|
29
|
+
const type = (file.type || '').toLowerCase()
|
|
30
|
+
if (type.startsWith('image/')) return 'image'
|
|
31
|
+
if (TEXT_TYPES.includes(type)) return 'text'
|
|
32
|
+
const name = (file.name || '').toLowerCase()
|
|
33
|
+
if (TEXT_EXTENSIONS.some(ext => name.endsWith(ext))) return 'text'
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type AttachmentRefusal =
|
|
38
|
+
| { code: 'attachments-not-accepted' }
|
|
39
|
+
| { code: 'attachment-kind-not-accepted'; name: string; kinds: AttachmentKind[] }
|
|
40
|
+
| { code: 'attachment-too-large'; name: string; maxBytes: number }
|
|
41
|
+
| { code: 'too-many-attachments'; maxFiles: number }
|
|
42
|
+
|
|
43
|
+
/** `null` when every file may go; otherwise the first reason, by name - said before sending, never cut silently. */
|
|
44
|
+
export function refuseAttachments(
|
|
45
|
+
policy: AttachmentPolicy | null | undefined,
|
|
46
|
+
files: { name?: string; type?: string; size?: number }[]
|
|
47
|
+
): AttachmentRefusal | null {
|
|
48
|
+
if (!files.length) return null
|
|
49
|
+
if (!policy || !policy.kinds?.length) return { code: 'attachments-not-accepted' }
|
|
50
|
+
|
|
51
|
+
const maxFiles = policy.maxFiles ?? DEFAULT_MAX_FILES
|
|
52
|
+
if (files.length > maxFiles) return { code: 'too-many-attachments', maxFiles }
|
|
53
|
+
|
|
54
|
+
const maxBytes = policy.maxBytes ?? DEFAULT_MAX_BYTES
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
const kind = attachmentKindOf(file)
|
|
57
|
+
if (!kind || !policy.kinds.includes(kind)) {
|
|
58
|
+
return { code: 'attachment-kind-not-accepted', name: file.name || '', kinds: policy.kinds }
|
|
59
|
+
}
|
|
60
|
+
if ((file.size ?? 0) > maxBytes) return { code: 'attachment-too-large', name: file.name || '', maxBytes }
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The line history keeps for a file once its turn is past - the model reads the file again only by asking. */
|
|
66
|
+
export function attachmentHistoryLine(file: { id: string; name?: string; mimetype?: string; size?: number | string }): string {
|
|
67
|
+
return `[attachment ${file.id}: ${file.name || 'unnamed'} (${file.mimetype || 'unknown type'}, ${file.size ?? '?'} bytes) - readChatAttachment to open it]`
|
|
68
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where a person opens a conversation's file - GET /chat-attachments/:sessionId/:attachmentId.
|
|
3
|
+
*
|
|
4
|
+
* Domain-private (sign-in and tenancy required), and the conversation's own read gate on top: a private
|
|
5
|
+
* conversation's file opens for its participants only, a deleted conversation's for nobody (design/ai/
|
|
6
|
+
* shared-dock.md §9-3). attachment-base's own route is public and unauthenticated, so chat files are
|
|
7
|
+
* never linked through it. `private` caching - a shared cache must not keep a private conversation's file.
|
|
8
|
+
*/
|
|
9
|
+
import { STORAGE } from '@things-factory/attachment-base'
|
|
10
|
+
import { getRepository } from '@things-factory/shell'
|
|
11
|
+
|
|
12
|
+
import { ChatSession } from './chat-session.js'
|
|
13
|
+
import { mayReadSession } from './session-visibility.js'
|
|
14
|
+
import { findChatAttachment } from './session-attachments.js'
|
|
15
|
+
import { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'
|
|
16
|
+
|
|
17
|
+
export const CHAT_ATTACHMENT_ROUTE = 'chat-attachments'
|
|
18
|
+
|
|
19
|
+
/** The address a screen uses for one file of a conversation. */
|
|
20
|
+
export function chatAttachmentUrl(sessionId: string, attachmentId: string): string {
|
|
21
|
+
return `/${CHAT_ATTACHMENT_ROUTE}/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachmentId)}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
process.on('bootstrap-module-domain-private-route' as any, (_app: any, routes: any) => {
|
|
25
|
+
routes.get(`/${CHAT_ATTACHMENT_ROUTE}/:sessionId/:attachmentId`, async (context: any, next: any) => {
|
|
26
|
+
const { domain, user } = context.state
|
|
27
|
+
const { sessionId, attachmentId } = context.params
|
|
28
|
+
|
|
29
|
+
/* A soft-deleted conversation is not found here - its files close with it (§9-5). */
|
|
30
|
+
const session: any = await getRepository(ChatSession).findOne({ where: { id: sessionId, domain: { id: domain.id } } as any })
|
|
31
|
+
const readable = !!session && mayReadSession(session.visibility, await isSessionParticipant(domain.id, sessionId, user?.id))
|
|
32
|
+
const row: any = readable ? await findChatAttachment(domain.id, sessionId, attachmentId) : undefined
|
|
33
|
+
|
|
34
|
+
if (!row) {
|
|
35
|
+
/* One answer for "none", "not yours" and "deleted" - telling them apart would say what exists. */
|
|
36
|
+
context.status = 404
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
context.set('Cache-Control', 'private, max-age=3600')
|
|
41
|
+
context.set('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(row.name)}`)
|
|
42
|
+
if (row.mimetype) context.type = row.mimetype
|
|
43
|
+
await STORAGE.sendFile(context, row.path, next)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* readChatAttachment - the model opens a file sent in an earlier turn (design/ai/shared-dock.md §9-4).
|
|
3
|
+
*
|
|
4
|
+
* A file reaches the model in full only in the turn it was sent; later turns carry one line naming it.
|
|
5
|
+
* When the model needs it again it asks here. The caller must be able to read the conversation the file
|
|
6
|
+
* belongs to - the same gate as reading its messages (ADR-0073 decision 1) - so a file id from elsewhere
|
|
7
|
+
* opens nothing.
|
|
8
|
+
*
|
|
9
|
+
* The category is opened for every mode that declares an attachment policy (resolve.ts).
|
|
10
|
+
*/
|
|
11
|
+
import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
|
|
12
|
+
import { Attachment } from '@things-factory/attachment-base'
|
|
13
|
+
import { getRepository } from '@things-factory/shell'
|
|
14
|
+
|
|
15
|
+
import { ChatSession } from './chat-session.js'
|
|
16
|
+
import { mayReadSession } from './session-visibility.js'
|
|
17
|
+
import { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'
|
|
18
|
+
import { attachmentForModel, CHAT_ATTACHMENT_REF } from './session-attachments.js'
|
|
19
|
+
|
|
20
|
+
export const CHAT_ATTACHMENT_CATEGORY = 'chat-attachments'
|
|
21
|
+
|
|
22
|
+
const readChatAttachment: ToolSpec = {
|
|
23
|
+
kind: 'read',
|
|
24
|
+
name: 'readChatAttachment',
|
|
25
|
+
doors: ['Query.chatMessages'],
|
|
26
|
+
description:
|
|
27
|
+
'Open a file that was sent earlier in this conversation. History names such files as "[attachment <id>: <name> ...]"; ' +
|
|
28
|
+
'pass that id. An image comes back as a description request, a text file as its text (cut when long - the answer says so).',
|
|
29
|
+
schema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: { attachmentId: { type: 'string', description: 'The id from the "[attachment <id>: ...]" line.' } },
|
|
32
|
+
required: ['attachmentId']
|
|
33
|
+
},
|
|
34
|
+
builder: async (args: any, context: any) => {
|
|
35
|
+
const { domain, user } = context?.state ?? {}
|
|
36
|
+
const id = typeof args?.attachmentId === 'string' ? args.attachmentId.trim() : ''
|
|
37
|
+
if (!domain?.id || !id) return { absent: 'no-attachment', message: 'no attachment id was given.' }
|
|
38
|
+
|
|
39
|
+
const row: any = await getRepository(Attachment).findOne({ where: { id, domain: { id: domain.id }, refType: CHAT_ATTACHMENT_REF } as any })
|
|
40
|
+
const session: any = row && (await getRepository(ChatSession).findOne({ where: { id: row.refBy, domain: { id: domain.id } } as any }))
|
|
41
|
+
if (!row || !session) return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }
|
|
42
|
+
|
|
43
|
+
const participant = await isSessionParticipant(domain.id, session.id, user?.id)
|
|
44
|
+
if (!mayReadSession(session.visibility, participant)) {
|
|
45
|
+
/* Same answer as absent - saying "it exists but is private" would itself leak. */
|
|
46
|
+
return { absent: 'not-found', attachmentId: id, message: 'there is no such attachment in a conversation here.' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const file = await attachmentForModel(row)
|
|
50
|
+
return file.type === 'image'
|
|
51
|
+
? { attachmentId: id, name: file.name, type: 'image', note: 'images are shown to you only in the turn they were sent; ask the person to send it again to look at it.' }
|
|
52
|
+
: { attachmentId: id, name: file.name, type: 'text', text: file.text, ...(file.truncated ? { note: 'the file is longer; this is its beginning.' } : {}) }
|
|
53
|
+
}
|
|
54
|
+
} as ToolSpec
|
|
55
|
+
|
|
56
|
+
export const chatAttachmentCategory: ToolCategory = {
|
|
57
|
+
name: CHAT_ATTACHMENT_CATEGORY,
|
|
58
|
+
guidance:
|
|
59
|
+
'- A line "[attachment <id>: <name> ...]" names a file sent in an earlier turn. Call readChatAttachment with that id before answering about its contents; never guess what a file says.',
|
|
60
|
+
specs: [readChatAttachment]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
registerToolCategory(chatAttachmentCategory)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A conversation's files - stored in attachment-base, opened only through the conversation.
|
|
3
|
+
*
|
|
4
|
+
* Files are kept with `refType: 'chat-session'`, `refBy: <session id>` (design/ai/shared-dock.md §9-2).
|
|
5
|
+
* They are never handed out through attachment-base's own route: that route is public and unauthenticated
|
|
6
|
+
* (routes.ts, "TODO make this secure"), so a private conversation's file would leave the conversation.
|
|
7
|
+
* Every read here passes the conversation's gate first (§9-3); this module only does the storage part.
|
|
8
|
+
*/
|
|
9
|
+
import { Attachment, STORAGE, createAttachment } from '@things-factory/attachment-base'
|
|
10
|
+
import { getRepository } from '@things-factory/shell'
|
|
11
|
+
|
|
12
|
+
import { attachmentKindOf, TEXT_CHARS_FOR_MODEL } from './attachment-policy.js'
|
|
13
|
+
|
|
14
|
+
export const CHAT_ATTACHMENT_REF = 'chat-session'
|
|
15
|
+
|
|
16
|
+
export interface StoredChatAttachment {
|
|
17
|
+
id: string
|
|
18
|
+
name: string
|
|
19
|
+
mimetype: string
|
|
20
|
+
size: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function describeAttachment(row: any): StoredChatAttachment {
|
|
24
|
+
return { id: row.id, name: row.name, mimetype: row.mimetype, size: Number(row.size) || 0 }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Stores uploaded files under the conversation. The caller has passed the write gate and the mode's policy. */
|
|
28
|
+
export async function saveChatAttachments(sessionId: string, files: any[], context: any): Promise<StoredChatAttachment[]> {
|
|
29
|
+
const saved: StoredChatAttachment[] = []
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const row = await createAttachment(null, { attachment: { file, category: 'chat', refType: CHAT_ATTACHMENT_REF, refBy: sessionId } }, context)
|
|
32
|
+
saved.push(describeAttachment(row))
|
|
33
|
+
}
|
|
34
|
+
return saved
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One file of the conversation - `undefined` when it is not this conversation's. */
|
|
38
|
+
export async function findChatAttachment(domainId: string, sessionId: string, attachmentId: string): Promise<any | undefined> {
|
|
39
|
+
return (
|
|
40
|
+
(await getRepository(Attachment).findOne({
|
|
41
|
+
where: { id: attachmentId, domain: { id: domainId }, refType: CHAT_ATTACHMENT_REF, refBy: sessionId } as any
|
|
42
|
+
})) ?? undefined
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What the model gets for a file: an image as bytes, a text file as its text (cut at TEXT_CHARS_FOR_MODEL,
|
|
48
|
+
* and the cut is said), anything else as a line that names it.
|
|
49
|
+
*/
|
|
50
|
+
export async function attachmentForModel(row: any): Promise<
|
|
51
|
+
| { type: 'image'; data: Buffer; mediaType: string; name: string }
|
|
52
|
+
| { type: 'text'; text: string; name: string; truncated: boolean }
|
|
53
|
+
> {
|
|
54
|
+
const kind = attachmentKindOf({ type: row.mimetype, name: row.name })
|
|
55
|
+
if (kind === 'image') {
|
|
56
|
+
const data = await STORAGE.readFile(row.path)
|
|
57
|
+
return { type: 'image', data: Buffer.isBuffer(data) ? data : Buffer.from(data), mediaType: row.mimetype, name: row.name }
|
|
58
|
+
}
|
|
59
|
+
const content = String(await STORAGE.readFile(row.path, 'utf8'))
|
|
60
|
+
const truncated = content.length > TEXT_CHARS_FOR_MODEL
|
|
61
|
+
return { type: 'text', text: truncated ? content.slice(0, TEXT_CHARS_FOR_MODEL) : content, name: row.name, truncated }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Removes these files from storage and their rows. */
|
|
65
|
+
export async function removeAttachmentRows(ids: string[]): Promise<void> {
|
|
66
|
+
if (!ids.length) return
|
|
67
|
+
const rows = await getRepository(Attachment).find({ where: ids.map(id => ({ id, refType: CHAT_ATTACHMENT_REF })) as any })
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
try {
|
|
70
|
+
await STORAGE.deleteFile((row as any).path)
|
|
71
|
+
} catch {
|
|
72
|
+
/* already gone from storage */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (rows.length) await getRepository(Attachment).delete(rows.map(row => (row as any).id))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Removes a conversation's files from storage and their rows - the purge's part (§9-5). */
|
|
79
|
+
export async function removeSessionFiles(session: { id?: string; domain?: { id?: string } }): Promise<void> {
|
|
80
|
+
const rows = await getRepository(Attachment).find({ where: { refType: CHAT_ATTACHMENT_REF, refBy: session.id } as any })
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
try {
|
|
83
|
+
await STORAGE.deleteFile((row as any).path)
|
|
84
|
+
} catch {
|
|
85
|
+
/* A file already gone from storage must not keep its row alive. */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (rows.length) await getRepository(Attachment).delete(rows.map(row => (row as any).id))
|
|
89
|
+
}
|