@things-factory/ai-assistant 10.1.47 → 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 +97 -19
- package/client/components/assistant-dock.ts +617 -0
- package/client/components/assistant-mode.ts +2 -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 +4 -1
- package/client/components/panel-aria.test.ts +13 -8
- package/client/index.ts +8 -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-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 +98 -20
- 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.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 +2 -0
- package/dist-client/components/chat-input-builder.js +2 -1
- 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 +8 -0
- package/dist-client/index.js +8 -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-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 +71 -3
- 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 +5 -0
- package/dist-server/service/index.js +5 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -2
- package/server/service/assistant-chat-resolver.ts +71 -4
- 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-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 +5 -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
|
@@ -14,13 +14,15 @@ import { In } from 'typeorm'
|
|
|
14
14
|
|
|
15
15
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
16
16
|
import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
|
|
17
|
-
import { buildInbox, applyHidden, activityPatch, searchInbox, INBOX_SCAN_CAP } from './chat-session/session-inbox.js'
|
|
17
|
+
import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
|
|
18
18
|
import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
|
|
19
19
|
import { roomMessageBody } from './chat-message/room-message.js'
|
|
20
20
|
import { readerGate } from './chat-message/reader-gate.js'
|
|
21
21
|
import { initialVisibility, mayReadSession, SESSION_VISIBILITIES } from './chat-session/session-visibility.js'
|
|
22
22
|
import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
|
|
23
23
|
import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
|
|
24
|
+
import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
|
|
25
|
+
import { assertMayWriteSession } from './chat-session/session-write-gate.js'
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* 초대 알림 — 발송은 호스트의 기전을 그대로 쓴다(중복 구현 금지).
|
|
@@ -28,9 +30,9 @@ import { buildInviteNotification } from './chat-session-participant/invite-notif
|
|
|
28
30
|
* 알림이 안 가도 초대 자체는 이미 일어났다. 그래서 실패를 삼키되, 초대를 되돌리지는 않는다 —
|
|
29
31
|
* 참여자 목록이 정본이고 알림은 그것을 알리는 편지다.
|
|
30
32
|
*/
|
|
31
|
-
async function notifyInvited(context: any, invited: string[], inviter: any): Promise<void> {
|
|
33
|
+
async function notifyInvited(context: any, invited: string[], inviter: any, sessionName?: string): Promise<void> {
|
|
32
34
|
if (invited.length === 0) return
|
|
33
|
-
const payload = buildInviteNotification(invited, undefined, inviter)
|
|
35
|
+
const payload = buildInviteNotification(invited, undefined, inviter, sessionName)
|
|
34
36
|
if (!payload) return
|
|
35
37
|
try {
|
|
36
38
|
await context.state?.notify?.(payload)
|
|
@@ -38,11 +40,27 @@ async function notifyInvited(context: any, invited: string[], inviter: any): Pro
|
|
|
38
40
|
/* 편지가 못 갔다고 방에 들어온 사실이 취소되지는 않는다. */
|
|
39
41
|
}
|
|
40
42
|
}
|
|
43
|
+
|
|
44
|
+
/** Every door that writes into a conversation passes this first (`session-write-gate.ts`). */
|
|
45
|
+
async function writeGate(session: any, context: any, tx?: any): Promise<void> {
|
|
46
|
+
const { domain, user } = context.state
|
|
47
|
+
await assertMayWriteSession({
|
|
48
|
+
session,
|
|
49
|
+
userId: user?.id,
|
|
50
|
+
isParticipant: () => isSessionParticipant(domain.id, session.id, user?.id, tx),
|
|
51
|
+
t: (key, options) => (context.t ? context.t(key, options) : String(options?.defaultValue ?? key))
|
|
52
|
+
})
|
|
53
|
+
}
|
|
41
54
|
import { formatProposalOutcomeLine, PROPOSAL_VERDICTS, type ProposalOutcome } from './chat-message/proposal-outcome.js'
|
|
42
55
|
import { publishSessionActivity } from './chat-session/session-activity-publish.js'
|
|
43
56
|
import { ChatMessage } from './chat-message/chat-message.js'
|
|
44
57
|
import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
|
|
45
58
|
import { publishChatMessage } from './chat-message/chat-message-publish.js'
|
|
59
|
+
import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
|
|
60
|
+
import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
|
|
61
|
+
import { getAssistantMode } from './assistant-mode/registry.js'
|
|
62
|
+
import { refuseAttachments, type AttachmentRefusal } from './chat-session/attachment-policy.js'
|
|
63
|
+
import { removeAttachmentRows, saveChatAttachments } from './chat-session/session-attachments.js'
|
|
46
64
|
|
|
47
65
|
@ObjectType({
|
|
48
66
|
description:
|
|
@@ -95,6 +113,51 @@ class ChatSessionInboxEntry {
|
|
|
95
113
|
/* 숨김은 나에게만 적용되고 되돌릴 수 있다 — 필터를 켠 사용자가 무엇이 숨겨진 것인지 알아야 한다. */
|
|
96
114
|
@Field({ nullable: true, description: 'Whether this viewer hid the session from their own list (only returned when showHidden is on).' })
|
|
97
115
|
hidden?: boolean
|
|
116
|
+
|
|
117
|
+
/* 목록이 공개 범위를 그리고, owner 만 그것을 바꾸는 단추를 받는다 — 판정은 setSessionVisibility 가 다시 한다. */
|
|
118
|
+
@Field({ description: "Who may read the session: 'open' (everyone in the domain) or 'private' (participants only)." })
|
|
119
|
+
visibility!: string
|
|
120
|
+
|
|
121
|
+
@Field({ description: 'Whether this viewer is the owner of the session - only the owner may change its visibility.' })
|
|
122
|
+
owner!: boolean
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@ObjectType({ description: 'A file stored with a conversation - opened only through the conversation.' })
|
|
126
|
+
export class ChatAttachmentInfo {
|
|
127
|
+
@Field(type => ID, { description: 'Attachment id.' })
|
|
128
|
+
id!: string
|
|
129
|
+
|
|
130
|
+
@Field({ description: 'File name.' })
|
|
131
|
+
name!: string
|
|
132
|
+
|
|
133
|
+
@Field({ description: 'MIME type.' })
|
|
134
|
+
mimetype!: string
|
|
135
|
+
|
|
136
|
+
@Field(type => Int, { description: 'Size in bytes.' })
|
|
137
|
+
size!: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A refusal said in the reader's words - code first, the sentence from it (ADR-0054). */
|
|
141
|
+
function attachmentRefusalMessage(refusal: AttachmentRefusal, context: any): string {
|
|
142
|
+
const t = (key: string, options: any) => (context.t ? context.t(key, options) : String(options?.defaultValue ?? key))
|
|
143
|
+
switch (refusal.code) {
|
|
144
|
+
case 'attachments-not-accepted':
|
|
145
|
+
return t('ai-assistant.text.attachments-not-accepted', { defaultValue: 'this conversation does not take attachments.' })
|
|
146
|
+
case 'attachment-kind-not-accepted':
|
|
147
|
+
return t('ai-assistant.text.attachment-kind-not-accepted', {
|
|
148
|
+
name: refusal.name,
|
|
149
|
+
kinds: refusal.kinds.join(', '),
|
|
150
|
+
defaultValue: '{name} cannot be attached here - this conversation takes {kinds}.'
|
|
151
|
+
})
|
|
152
|
+
case 'attachment-too-large':
|
|
153
|
+
return t('ai-assistant.text.attachment-too-large', {
|
|
154
|
+
name: refusal.name,
|
|
155
|
+
max: Math.round(refusal.maxBytes / 1024 / 1024),
|
|
156
|
+
defaultValue: '{name} is larger than {max} MB.'
|
|
157
|
+
})
|
|
158
|
+
case 'too-many-attachments':
|
|
159
|
+
return t('ai-assistant.text.too-many-attachments', { max: refusal.maxFiles, defaultValue: 'at most {max} files can be sent at once.' })
|
|
160
|
+
}
|
|
98
161
|
}
|
|
99
162
|
|
|
100
163
|
@Resolver()
|
|
@@ -146,9 +209,16 @@ export class ChatSessionResolver {
|
|
|
146
209
|
@Arg('search', { nullable: true }) search?: string,
|
|
147
210
|
@Arg('mineOnly', { nullable: true }) mineOnly?: boolean,
|
|
148
211
|
@Arg('limit', () => Int, { nullable: true }) limit?: number,
|
|
149
|
-
@Arg('offset', () => Int, { nullable: true }) offset?: number
|
|
212
|
+
@Arg('offset', () => Int, { nullable: true }) offset?: number,
|
|
213
|
+
/* The manager's filters (shared dock §2-6) - the total takes the same, or "show more" miscounts. */
|
|
214
|
+
@Arg('kind', { nullable: true, description: 'Only this conversation kind (mode).' }) kind?: string,
|
|
215
|
+
@Arg('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' }) participantOnly?: boolean,
|
|
216
|
+
@Arg('visibility', { nullable: true, description: "Only 'open' or only 'private'." }) visibility?: string,
|
|
217
|
+
@Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean
|
|
150
218
|
): Promise<ChatSessionInboxEntry[]> {
|
|
151
|
-
const filtered = await this._inboxFiltered(anchorType, anchorId, context, {
|
|
219
|
+
const filtered = await this._inboxFiltered(anchorType, anchorId, context, {
|
|
220
|
+
showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly
|
|
221
|
+
})
|
|
152
222
|
const from = Math.max(0, offset ?? 0)
|
|
153
223
|
const size = Math.min(Math.max(1, limit ?? 50), 200)
|
|
154
224
|
return filtered.slice(from, from + size) as any
|
|
@@ -171,9 +241,15 @@ export class ChatSessionResolver {
|
|
|
171
241
|
@Ctx() context: ResolverContext,
|
|
172
242
|
@Arg('showHidden', { nullable: true }) showHidden?: boolean,
|
|
173
243
|
@Arg('search', { nullable: true }) search?: string,
|
|
174
|
-
@Arg('mineOnly', { nullable: true }) mineOnly?: boolean
|
|
244
|
+
@Arg('mineOnly', { nullable: true }) mineOnly?: boolean,
|
|
245
|
+
@Arg('kind', { nullable: true, description: 'Only this conversation kind (mode).' }) kind?: string,
|
|
246
|
+
@Arg('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' }) participantOnly?: boolean,
|
|
247
|
+
@Arg('visibility', { nullable: true, description: "Only 'open' or only 'private'." }) visibility?: string,
|
|
248
|
+
@Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean
|
|
175
249
|
): Promise<number> {
|
|
176
|
-
return (
|
|
250
|
+
return (
|
|
251
|
+
await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly })
|
|
252
|
+
).length
|
|
177
253
|
}
|
|
178
254
|
|
|
179
255
|
/** 필터 적용(페이지 전) — 목록·개수가 **같은 규칙**을 쓰도록 한 곳에 둔다(어긋나면 "더 보기" 가 거짓말이 된다). */
|
|
@@ -181,11 +257,11 @@ export class ChatSessionResolver {
|
|
|
181
257
|
anchorType: string,
|
|
182
258
|
anchorId: string,
|
|
183
259
|
context: ResolverContext,
|
|
184
|
-
opts: { showHidden?: boolean; search?: string
|
|
260
|
+
opts: { showHidden?: boolean; search?: string } & InboxFilter
|
|
185
261
|
): Promise<ChatSessionInboxEntry[]> {
|
|
186
262
|
const all = await this._inboxEntries(anchorType, anchorId, context)
|
|
187
263
|
let entries = opts.showHidden ? all : all.filter(e => !e.hidden)
|
|
188
|
-
|
|
264
|
+
entries = filterInbox(entries as any, opts) as any
|
|
189
265
|
return searchInbox(entries as any, opts.search) as any
|
|
190
266
|
}
|
|
191
267
|
|
|
@@ -520,10 +596,9 @@ export class ChatSessionResolver {
|
|
|
520
596
|
* 참여자만. 여기서 막히는 것은 **방**이고, 방에 들어온 뒤 값을 읽는 일은 아래 문 판정이 따로 본다.
|
|
521
597
|
*/
|
|
522
598
|
if ((session as any).visibility === 'private') {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if (!mayReadSession((session as any).visibility, !!part)) return []
|
|
599
|
+
/* A hide-only `viewer` row is not being in the room - it must not open a private conversation. */
|
|
600
|
+
const part = await isSessionParticipant(domain.id, sessionId, user?.id)
|
|
601
|
+
if (!mayReadSession((session as any).visibility, part)) return []
|
|
527
602
|
}
|
|
528
603
|
|
|
529
604
|
const rows = await getRepository(ChatMessage).find({
|
|
@@ -636,6 +711,8 @@ export class ChatSessionResolver {
|
|
|
636
711
|
const repo = getRepository(ChatSession, tx)
|
|
637
712
|
const session = await repo.findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
638
713
|
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
714
|
+
/* The board-era rename door had no gate at all - a private conversation's name was anyone's to change. */
|
|
715
|
+
await writeGate(session, context, tx)
|
|
639
716
|
await repo.update(sessionId, { name: name.trim() || null, updater: user } as any)
|
|
640
717
|
return true
|
|
641
718
|
}
|
|
@@ -655,6 +732,7 @@ export class ChatSessionResolver {
|
|
|
655
732
|
const { domain, user, tx } = context.state
|
|
656
733
|
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
657
734
|
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
735
|
+
await writeGate(session, context, tx)
|
|
658
736
|
|
|
659
737
|
const key = (proposalKey ?? '').trim()
|
|
660
738
|
if (!key) return false
|
|
@@ -689,6 +767,42 @@ export class ChatSessionResolver {
|
|
|
689
767
|
return true
|
|
690
768
|
}
|
|
691
769
|
|
|
770
|
+
/**
|
|
771
|
+
* Stores files with a conversation before the turn that carries them (design/ai/shared-dock.md §9).
|
|
772
|
+
* Passes the write gate - a private conversation takes files from its people only - and the mode's
|
|
773
|
+
* attachment policy. The size is known only once stored, so a file over the limit is removed and refused.
|
|
774
|
+
*/
|
|
775
|
+
@Directive('@transaction')
|
|
776
|
+
@Mutation(() => [ChatAttachmentInfo], {
|
|
777
|
+
description:
|
|
778
|
+
"Store files with a conversation, to be sent with the next turn (assistantChat attachmentIds). Private conversations take files from their participants only; what is accepted is the conversation mode's attachment policy."
|
|
779
|
+
})
|
|
780
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
781
|
+
async attachToConversation(
|
|
782
|
+
@Arg('sessionId') sessionId: string,
|
|
783
|
+
@Arg('files', () => [GraphQLUpload]) files: FileUpload[],
|
|
784
|
+
@Ctx() context: ResolverContext,
|
|
785
|
+
@Arg('kind', { nullable: true, description: 'The conversation kind, for a session not stamped with one yet.' }) kind?: string
|
|
786
|
+
): Promise<ChatAttachmentInfo[]> {
|
|
787
|
+
const { domain, tx } = context.state
|
|
788
|
+
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
789
|
+
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
790
|
+
await writeGate(session, context, tx)
|
|
791
|
+
|
|
792
|
+
const policy = getAssistantMode((session as any).kind || kind || '')?.attachments
|
|
793
|
+
const uploads = await Promise.all(files)
|
|
794
|
+
const before = refuseAttachments(policy, uploads.map(upload => ({ name: upload.filename, type: upload.mimetype })))
|
|
795
|
+
if (before) throw new Error(attachmentRefusalMessage(before, context))
|
|
796
|
+
|
|
797
|
+
const saved = await saveChatAttachments(session.id!, uploads, context)
|
|
798
|
+
const after = refuseAttachments(policy, saved.map(file => ({ name: file.name, type: file.mimetype, size: file.size })))
|
|
799
|
+
if (after) {
|
|
800
|
+
await removeAttachmentRows(saved.map(file => file.id))
|
|
801
|
+
throw new Error(attachmentRefusalMessage(after, context))
|
|
802
|
+
}
|
|
803
|
+
return saved
|
|
804
|
+
}
|
|
805
|
+
|
|
692
806
|
@Directive('@transaction')
|
|
693
807
|
@Mutation(() => String, {
|
|
694
808
|
nullable: true,
|
|
@@ -704,6 +818,7 @@ export class ChatSessionResolver {
|
|
|
704
818
|
const { domain, user, tx } = context.state
|
|
705
819
|
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
706
820
|
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
821
|
+
await writeGate(session, context, tx)
|
|
707
822
|
|
|
708
823
|
const body = roomMessageBody(content)
|
|
709
824
|
if (!body) return null
|
|
@@ -772,6 +887,7 @@ export class ChatSessionResolver {
|
|
|
772
887
|
const { domain, user, tx } = context.state
|
|
773
888
|
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
774
889
|
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
890
|
+
await writeGate(session, context, tx)
|
|
775
891
|
/* 프롬프트가 한 기록으로 폭증하지 않도록 자른다 — 맥락 전달이 목적이고 전문 보존이 아니다. */
|
|
776
892
|
const body = (content ?? '').trim().slice(0, 4000)
|
|
777
893
|
if (!body) return false
|
|
@@ -822,6 +938,7 @@ export class ChatSessionResolver {
|
|
|
822
938
|
const { domain, user, tx } = context.state
|
|
823
939
|
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
824
940
|
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
941
|
+
await writeGate(session, context, tx)
|
|
825
942
|
|
|
826
943
|
/* 옮겨온 대화는 **그 대화의 발언 그대로** 들어간다 — 한 덩어리 기록으로 뭉치면 사람이 읽을 수 없고
|
|
827
944
|
* (화면 하나를 잡아먹는 이탤릭 덩어리), 마크다운도 죽는다. 대신 개수·길이는 제한한다: 맥락 전달이
|
package/server/service/index.ts
CHANGED
|
@@ -7,6 +7,9 @@ import { ChatMessageSubscription } from './chat-message/chat-message-subscriptio
|
|
|
7
7
|
import { entities as ChatSessionEntities } from './chat-session/index.js'
|
|
8
8
|
import { entities as ChatMessageEntities } from './chat-message/index.js'
|
|
9
9
|
import { entities as ChatSessionParticipantEntities } from './chat-session-participant/index.js'
|
|
10
|
+
import './chat-session/attachment-tools.js'
|
|
11
|
+
import './chat-session/session-purge.js'
|
|
12
|
+
import './chat-session/attachment-route.js'
|
|
10
13
|
|
|
11
14
|
export * from './types.js'
|
|
12
15
|
export * from './chat-session-resolver.js'
|
|
@@ -29,6 +32,8 @@ export * from './chat-session/session-visibility.js'
|
|
|
29
32
|
export * from './chat-session/index.js'
|
|
30
33
|
/* 순수 로직들 — 호스트(board-ai 등)가 같은 규칙을 쓰라고 공개한다. 베끼면 한쪽만 고쳐진다. */
|
|
31
34
|
export * from './chat-session/session-inbox.js'
|
|
35
|
+
export * from './chat-session/attachment-policy.js'
|
|
36
|
+
export * from './chat-session/session-purge.js'
|
|
32
37
|
export * from './chat-session/session-activity-publish.js'
|
|
33
38
|
export * from './chat-session/session-activity-broadcast.js'
|
|
34
39
|
export * from './chat-message/llm-history.js'
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The shared AI dock's contract (design/ai/shared-dock.md §8) - checked once here instead of in every app.
|
|
3
|
+
* The dock is read as text: importing it would pull the shell and i18n, which the runner cannot load.
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { runProposal, registerProposalHandler } from '../client/utils/assistant-proposals'
|
|
10
|
+
|
|
11
|
+
const read = (file: string) => readFileSync(join(__dirname, file), 'utf8')
|
|
12
|
+
const code = (file: string) => read(file).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
|
13
|
+
const dock = code('../client/components/assistant-dock.ts')
|
|
14
|
+
/* Not comment-stripped: the chat has strings like 'image/*' that a comment regex would eat. */
|
|
15
|
+
const chat = read('../client/components/assistant-chat.ts')
|
|
16
|
+
|
|
17
|
+
describe('no empty panel', () => {
|
|
18
|
+
it('not configured, unreadable, no surface and a stuck anchor are each said', () => {
|
|
19
|
+
for (const key of ['not-configured', 'capability-unknown', 'no-surface']) assert.match(dock, new RegExp(`t\\('${key}'`))
|
|
20
|
+
assert.match(dock, /if \(anchor && !isAnchor\(anchor\)\) return anchor\.message/)
|
|
21
|
+
assert.match(dock, /readAiCapability\(\)/)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('an unreadable capability offers a retry', () => {
|
|
25
|
+
assert.match(dock, /this\.capability === 'unknown'\s*\?\s*html`<div><button class="sbtn" @click=\$\{\(\) => this\.open\(\)\}>/)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('every request the chat makes is taken', () => {
|
|
30
|
+
it('new conversation, mode change and proposal execute', () => {
|
|
31
|
+
assert.match(dock, /addEventListener\('assistant-start-new-session'/)
|
|
32
|
+
assert.match(dock, /@assistant-mode-change=\$\{this\.onModeChange\}/)
|
|
33
|
+
assert.match(dock, /@assistant-proposal-execute=\$\{this\.onProposal\}/)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('a board surface gets the scene edits, reverts and actions', () => {
|
|
37
|
+
for (const event of ['scene-edit-patch', 'board-edit-revert', 'scene-action-execute']) assert.match(dock, new RegExp(`@${event}=`))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('the toolbar opens the manager, and the manager asks with the same events', () => {
|
|
41
|
+
assert.match(dock, /historyEnabled/)
|
|
42
|
+
assert.match(dock, /@assistant-session-history=/)
|
|
43
|
+
assert.match(dock, /<ox-assistant-session-manager/)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('every proposal ends in one reported outcome', () => {
|
|
48
|
+
it('the dock reports whatever the handler returned', () => {
|
|
49
|
+
assert.match(dock, /const outcome = await runProposal\(proposal, this\.proposalContext\(sessionId\)\)[\s\S]*await this\.report\(sessionId, proposalKey, outcome\)/)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('a kind nobody handles is unavailable, by name', async () => {
|
|
53
|
+
const outcome = await runProposal({ tool: 'proposeNothingKnown' }, {} as any)
|
|
54
|
+
assert.deepEqual(outcome, { verdict: 'unavailable', errorCode: 'unknown-proposal-kind' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('a handler that throws is refused with its message, never a silent pass', async () => {
|
|
58
|
+
registerProposalHandler('proposeThrows', async () => {
|
|
59
|
+
throw new Error('kernel-refused')
|
|
60
|
+
})
|
|
61
|
+
assert.deepEqual(await runProposal({ tool: 'proposeThrows' }, {} as any), { verdict: 'refused', errorCode: 'kernel-refused' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('a handler that answers nothing is not taken as done', async () => {
|
|
65
|
+
registerProposalHandler('proposeSilent', async () => undefined as any)
|
|
66
|
+
assert.deepEqual(await runProposal({ tool: 'proposeSilent' }, {} as any), { verdict: 'unavailable', errorCode: 'no-outcome' })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('a handler that asks the person uses the platform confirm', () => {
|
|
70
|
+
assert.match(dock, /OxPrompt\.open\(/)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('sessions', () => {
|
|
75
|
+
it('the key carries the conversation kind', () => {
|
|
76
|
+
assert.match(dock, /scope: JSON\.stringify\(\[this\.app\.contextPath, userId, this\.kind\]\)/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('a picked conversation of another kind is followed, not refused', () => {
|
|
80
|
+
assert.match(dock, /followPickedConversation\(pickedKind, this\.kind\)/)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('the screen is checked at send time', () => {
|
|
85
|
+
it('a send after the screen moved on is refused', () => {
|
|
86
|
+
assert.match(dock, /if \(openedFor !== this\.scopeKey\(currentAssistantSurface\(\) \?\? surface\)\) throw new Error/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('the draft is read at send time, never cached', () => {
|
|
90
|
+
assert.match(dock, /return \{ hostContext: surface\?\.hostContext\?\.\(\) \?\? \{\} \}/)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('the dock knows no application', () => {
|
|
95
|
+
it('no app names and no proposal kinds in the dock', () => {
|
|
96
|
+
assert.doesNotMatch(dock, /\b(twin|plant|figure|warehouse|ecs|operato-[a-z]+)\b/i)
|
|
97
|
+
assert.doesNotMatch(dock, /'propose[A-Z]\w*'/)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('attachments in the chat follow the mode', () => {
|
|
102
|
+
it('no attach button when the mode declares nothing (the board door keeps its images)', () => {
|
|
103
|
+
assert.match(chat, /this\.acceptsAttachments\s*\?\s*html`<button/)
|
|
104
|
+
assert.match(chat, /return !this\.usesAttachmentPolicy \|\| !!this\.modeView\?\.attachments\?\.kinds\?\.length/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('said before sending, then uploaded to the conversation and sent by id', () => {
|
|
108
|
+
assert.match(chat, /refuseAttachments\(this\.modeView\?\.attachments/)
|
|
109
|
+
assert.match(chat, /mutation: ATTACH_TO_CONVERSATION_MUTATION/)
|
|
110
|
+
/* Without it the client sends the File as {} and the server refuses the upload (found on :3700, 2026-09-25). */
|
|
111
|
+
assert.match(chat, /context: \{ hasUpload: true \}/)
|
|
112
|
+
assert.match(chat, /attachmentIds\s*\n\s*\}\)/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('a stored file opens through the conversation route', () => {
|
|
116
|
+
assert.match(chat, /chatAttachmentUrl\(this\.sessionId, att\.id\)/)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The conversation manager - one list every dock opens (`<ox-assistant-session-manager>`).
|
|
3
|
+
*
|
|
4
|
+
* It was written once inside operato-twin's dock, and plant, figure, warehouse and ecs had only tabs.
|
|
5
|
+
* These pin what moved here: who may do what, that nothing irreversible happens without the row
|
|
6
|
+
* asking, that opening and starting stay the host's, and that every word exists in every locale.
|
|
7
|
+
*/
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { sessionActions } from '../client/utils/assistant-session-actions.ts'
|
|
13
|
+
|
|
14
|
+
const read = (file: string) => readFileSync(join(__dirname, file), 'utf8')
|
|
15
|
+
const manager = read('../client/components/assistant-session-manager.ts')
|
|
16
|
+
const admin = read('../client/utils/assistant-session-admin.ts')
|
|
17
|
+
|
|
18
|
+
describe('who may do what - the server hints, read in one place', () => {
|
|
19
|
+
it('only the creator is offered delete', () => {
|
|
20
|
+
assert.equal(sessionActions({ id: 'a', mine: true }).remove, true)
|
|
21
|
+
assert.equal(sessionActions({ id: 'a', mine: false }).remove, false)
|
|
22
|
+
assert.equal(sessionActions({ id: 'a' }).remove, false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('only the owner is offered private / public', () => {
|
|
26
|
+
assert.equal(sessionActions({ id: 'a', owner: true }).setVisibility, true)
|
|
27
|
+
assert.equal(sessionActions({ id: 'a', mine: true, owner: false }).setVisibility, false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('a hidden one offers unhide, a shown one hide - anyone, for their own list', () => {
|
|
31
|
+
assert.deepEqual(
|
|
32
|
+
[sessionActions({ id: 'a', hidden: true }).unhide, sessionActions({ id: 'a', hidden: true }).hide],
|
|
33
|
+
[true, false]
|
|
34
|
+
)
|
|
35
|
+
assert.deepEqual([sessionActions({ id: 'a' }).unhide, sessionActions({ id: 'a' }).hide], [false, true])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('a non-participant is not offered rename - the server refuses it', () => {
|
|
39
|
+
assert.equal(sessionActions({ id: 'a', participant: false }).rename, false)
|
|
40
|
+
assert.equal(sessionActions({ id: 'a', participant: true }).rename, true)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('the doors are the framework ones, as they are', () => {
|
|
45
|
+
it('each operation goes through its own mutation', () => {
|
|
46
|
+
for (const door of ['chatSessionInbox', 'chatSessionInboxTotal', 'hiddenSessionCount', 'renameAISession', 'hideAISession', 'deleteAISession', 'setSessionVisibility']) {
|
|
47
|
+
assert.match(admin, new RegExp(door), door)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('the list asks for visibility and owner, so a row can say private and offer the switch', () => {
|
|
52
|
+
assert.match(admin, /\bvisibility\b[\s\S]*\bowner\b|\bowner\b[\s\S]*\bvisibility\b/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('the element calls no door itself - it goes through the admin it was given', () => {
|
|
56
|
+
assert.doesNotMatch(manager, /client\.(mutate|query)|gql`/)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('nothing irreversible without asking', () => {
|
|
61
|
+
it('delete and making public ask first with the platform confirm; making private, hiding and renaming do not', () => {
|
|
62
|
+
assert.match(manager, /import \{ OxPrompt \} from '@operato\/popup'/)
|
|
63
|
+
assert.match(manager, /this\.askRemove\(entry, name\)/)
|
|
64
|
+
assert.match(manager, /this\.askOpen\(entry, name\)/)
|
|
65
|
+
assert.match(manager, /@click=\$\{\(\) => this\.setVisibility\(entry, 'private'\)\}/)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('no browser dialog - they sit outside the theme', () => {
|
|
69
|
+
assert.doesNotMatch(manager, /\bconfirm\(|\balert\(|\bprompt\(/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('the confirm reads [Cancel][OK]', () => {
|
|
73
|
+
assert.match(manager, /cancelButton: \{ text: t\('cancel', 'Cancel'\) \}/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('a refusal or a failure is said, never passed silently', () => {
|
|
77
|
+
assert.match(manager, /if \(!\(await run\(\)\)\) this\.error = refused/)
|
|
78
|
+
assert.match(manager, /this\.error = \(error as Error\)\.message/)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('the filters (shared dock §2-6)', () => {
|
|
83
|
+
it('this kind by default, every kind on request; whose, visibility, unread, hidden', () => {
|
|
84
|
+
assert.match(manager, /kind: this\.allKinds \? '' : this\.kind/)
|
|
85
|
+
for (const filter of ['whose', 'visibility', 'includeHidden', 'unreadOnly']) assert.match(manager, new RegExp(`${filter}: this\\.${filter}`))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('a changed filter starts from the first page', () => {
|
|
89
|
+
assert.match(manager, /private refilter\(change: \(\) => void\) \{\s*change\(\)\s*this\.pageSize = PAGE/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('picking a row says which kind it is, so the dock can follow it', () => {
|
|
93
|
+
assert.match(manager, /this\.emit\('assistant-session-select', \{ sessionId: entry\.id, kind: entry\.kind \}\)/)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('opening and starting stay the host\'s', () => {
|
|
98
|
+
it('the element asks with the toolbar\'s own events', () => {
|
|
99
|
+
assert.match(manager, /this\.emit\('assistant-session-select', \{ sessionId: entry\.id, kind: entry\.kind \}\)/)
|
|
100
|
+
assert.match(manager, /this\.emit\('assistant-session-reset'\)/)
|
|
101
|
+
assert.match(manager, /this\.emit\('assistant-session-changed', \{ sessionId: entry\.id, change \}\)/)
|
|
102
|
+
assert.match(manager, /bubbles: true, composed: true/)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('every word is in every locale', () => {
|
|
107
|
+
const keys = [...new Set([...manager.matchAll(/\bt\('([a-z-]+)'/g)].map(match => `ai-assistant.session.${match[1]}`))]
|
|
108
|
+
|
|
109
|
+
it('the parser finds the words', () => {
|
|
110
|
+
assert.ok(keys.length >= 20, `found only ${keys.length}`)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
for (const locale of ['en', 'ko', 'ja', 'zh', 'ms']) {
|
|
114
|
+
it(locale, () => {
|
|
115
|
+
const dictionary = JSON.parse(read(`../translations/${locale}.json`))
|
|
116
|
+
assert.deepEqual(keys.filter(key => !dictionary[key]), [])
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('rename takes the keys', () => {
|
|
122
|
+
it('the rename box is focused and selected when it opens - autofocus works once per document', () => {
|
|
123
|
+
assert.match(manager, /changed\.has\('renamingId'\) && this\.renamingId/)
|
|
124
|
+
assert.match(manager, /input\?\.focus\(\)/)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The screen's copy of the attachment rule and the server's are one rule (design/ai/shared-dock.md §9-1):
|
|
3
|
+
* the screen says "no" before sending, the door refuses the same things. Both run over one table.
|
|
4
|
+
*/
|
|
5
|
+
import * as server from '../server/service/chat-session/attachment-policy'
|
|
6
|
+
import * as screen from '../client/utils/attachment-policy'
|
|
7
|
+
|
|
8
|
+
const policies = [undefined, { kinds: [] }, { kinds: ['image'] }, { kinds: ['image', 'text'], maxBytes: 100, maxFiles: 2 }] as any[]
|
|
9
|
+
const fileSets = [
|
|
10
|
+
[],
|
|
11
|
+
[{ name: 'a.png', type: 'image/png', size: 10 }],
|
|
12
|
+
[{ name: 'a.csv', type: 'text/csv', size: 10 }],
|
|
13
|
+
[{ name: 'report.CSV', type: '', size: 10 }],
|
|
14
|
+
[{ name: 'a.pdf', type: 'application/pdf', size: 10 }],
|
|
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 }]
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
describe('the screen and the door refuse the same things', () => {
|
|
20
|
+
for (const policy of policies) {
|
|
21
|
+
for (const files of fileSets) {
|
|
22
|
+
it(`${JSON.stringify(policy)} × ${files.map(f => f.name).join(',') || 'none'}`, () => {
|
|
23
|
+
expect(screen.refuseAttachments(policy, files)).toEqual(server.refuseAttachments(policy, files))
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it('the same defaults', () => {
|
|
29
|
+
expect(screen.DEFAULT_MAX_BYTES).toBe(server.DEFAULT_MAX_BYTES)
|
|
30
|
+
expect(screen.DEFAULT_MAX_FILES).toBe(server.DEFAULT_MAX_FILES)
|
|
31
|
+
})
|
|
32
|
+
})
|
package/test/open-doors.test.ts
CHANGED
|
@@ -138,11 +138,14 @@ describe('what the walk itself has to be true for', () => {
|
|
|
138
138
|
})
|
|
139
139
|
|
|
140
140
|
it('the census, in plain numbers', () => {
|
|
141
|
-
/*
|
|
141
|
+
/*
|
|
142
|
+
* 2026-09-24: 27 root fields, 25 declared, 2 open — both streams.
|
|
143
|
+
* 2026-09-25: 28 — attachToConversation (a conversation's files, shared dock §9), declared.
|
|
144
|
+
*/
|
|
142
145
|
const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
|
|
143
146
|
const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
|
|
144
147
|
|
|
145
|
-
expect(all).toBe(
|
|
148
|
+
expect(all).toBe(28)
|
|
146
149
|
expect(open).toBe(2)
|
|
147
150
|
})
|
|
148
151
|
})
|
package/translations/en.json
CHANGED
|
@@ -110,5 +110,65 @@
|
|
|
110
110
|
"ai-assistant.text.answer-needs-permission": "you do not have permission to see this answer. ask an administrator for access.",
|
|
111
111
|
"ai-assistant.label.conversation": "conversation",
|
|
112
112
|
"ai-assistant.text.switch-conversation": "switch to {name}",
|
|
113
|
-
"ai-assistant.text.session-kind-mismatch": "this conversation is {sessionKind}, and you are writing as {requested}. open the conversation you mean."
|
|
113
|
+
"ai-assistant.text.session-kind-mismatch": "this conversation is {sessionKind}, and you are writing as {requested}. open the conversation you mean.",
|
|
114
|
+
"ai-assistant.session.search": "Search names and last messages",
|
|
115
|
+
"ai-assistant.session.filter": "Which conversations",
|
|
116
|
+
"ai-assistant.session.filter-all": "All",
|
|
117
|
+
"ai-assistant.session.filter-mine": "Started by me",
|
|
118
|
+
"ai-assistant.session.filter-hidden": "Including hidden",
|
|
119
|
+
"ai-assistant.session.failed": "could not load the conversation list.",
|
|
120
|
+
"ai-assistant.session.no-match": "No conversation matches \"{q}\".",
|
|
121
|
+
"ai-assistant.session.none": "No conversations yet.",
|
|
122
|
+
"ai-assistant.session.load-more": "Show more ({rest})",
|
|
123
|
+
"ai-assistant.session.hidden-count": "{count} hidden",
|
|
124
|
+
"ai-assistant.session.untitled": "Untitled conversation",
|
|
125
|
+
"ai-assistant.session.private": "Private - only its participants read it",
|
|
126
|
+
"ai-assistant.session.open": "Public - everyone in this domain reads it",
|
|
127
|
+
"ai-assistant.session.rename": "Rename",
|
|
128
|
+
"ai-assistant.session.make-open": "Make public",
|
|
129
|
+
"ai-assistant.session.make-private": "Make private",
|
|
130
|
+
"ai-assistant.session.hide": "Hide from my list",
|
|
131
|
+
"ai-assistant.session.unhide": "Show in my list",
|
|
132
|
+
"ai-assistant.session.remove": "Delete",
|
|
133
|
+
"ai-assistant.session.cancel": "Cancel",
|
|
134
|
+
"ai-assistant.session.confirm-remove": "Delete \"{name}\"? It disappears for every participant and cannot be brought back.",
|
|
135
|
+
"ai-assistant.session.confirm-open": "Make \"{name}\" public? Everyone in this domain will be able to read what is already written.",
|
|
136
|
+
"ai-assistant.session.rename-refused": "this conversation could not be renamed.",
|
|
137
|
+
"ai-assistant.session.hide-refused": "this conversation could not be hidden.",
|
|
138
|
+
"ai-assistant.session.visibility-refused": "only the owner of this conversation can change who reads it.",
|
|
139
|
+
"ai-assistant.session.remove-refused": "this conversation could not be deleted.",
|
|
140
|
+
"ai-assistant.text.attachments-not-accepted": "this conversation does not take attachments.",
|
|
141
|
+
"ai-assistant.text.attachment-kind-not-accepted": "{name} cannot be attached here - this conversation takes {kinds}.",
|
|
142
|
+
"ai-assistant.text.attachment-too-large": "{name} is larger than {max} MB.",
|
|
143
|
+
"ai-assistant.text.too-many-attachments": "at most {max} files can be sent at once.",
|
|
144
|
+
"ai-assistant.text.attachments-need-session": "attachments need a saved conversation.",
|
|
145
|
+
"ai-assistant.text.attachment-not-in-session": "an attachment does not belong to this conversation.",
|
|
146
|
+
"ai-assistant.dock.title": "Assistant",
|
|
147
|
+
"ai-assistant.dock.open": "Ask AI",
|
|
148
|
+
"ai-assistant.dock.no-conversation": "there is no conversation for this screen.",
|
|
149
|
+
"ai-assistant.dock.no-user": "your sign-in could not be read. sign in again.",
|
|
150
|
+
"ai-assistant.dock.confirm": "OK",
|
|
151
|
+
"ai-assistant.dock.cancel": "Cancel",
|
|
152
|
+
"ai-assistant.dock.proposal-not-runnable": "this proposal cannot be run on this screen.",
|
|
153
|
+
"ai-assistant.dock.patch-missed": "{missed} of the changes could not be applied.",
|
|
154
|
+
"ai-assistant.dock.not-configured": "no AI is set up for this site. an administrator can set one up in the AI settings.",
|
|
155
|
+
"ai-assistant.dock.capability-unknown": "whether AI is set up here could not be read.",
|
|
156
|
+
"ai-assistant.dock.no-surface": "this screen has no conversation.",
|
|
157
|
+
"ai-assistant.dock.to-home": "back to this application's conversation",
|
|
158
|
+
"ai-assistant.dock.back-to-conversation": "back to the conversation",
|
|
159
|
+
"ai-assistant.dock.conversations": "conversations",
|
|
160
|
+
"ai-assistant.dock.rebind": "follow this screen again",
|
|
161
|
+
"ai-assistant.dock.close": "close",
|
|
162
|
+
"ai-assistant.dock.screen-changed": "the screen changed while you were writing. send it again.",
|
|
163
|
+
"ai-assistant.dock.retry": "try again",
|
|
164
|
+
"ai-assistant.session.filter-kind": "Which kind",
|
|
165
|
+
"ai-assistant.session.filter-this-kind": "This conversation",
|
|
166
|
+
"ai-assistant.session.filter-all-kinds": "All kinds here",
|
|
167
|
+
"ai-assistant.session.filter-whose": "Whose",
|
|
168
|
+
"ai-assistant.session.filter-participant": "I'm in",
|
|
169
|
+
"ai-assistant.session.filter-visibility": "Visibility",
|
|
170
|
+
"ai-assistant.session.filter-any-visibility": "Public and private",
|
|
171
|
+
"ai-assistant.session.filter-open": "Public",
|
|
172
|
+
"ai-assistant.session.filter-private": "Private",
|
|
173
|
+
"ai-assistant.session.filter-unread": "Unread"
|
|
114
174
|
}
|