@things-factory/ai-assistant 10.1.214 → 10.1.215
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-dock.ts +6 -3
- package/client/components/assistant-session-manager.ts +263 -12
- package/client/utils/assistant-session-actions.ts +12 -0
- package/client/utils/assistant-session-admin.ts +70 -5
- package/dist-client/components/assistant-dock.js +6 -3
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +37 -0
- package/dist-client/components/assistant-session-manager.js +281 -13
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-session-actions.d.ts +15 -0
- package/dist-client/utils/assistant-session-actions.js +11 -0
- package/dist-client/utils/assistant-session-actions.js.map +1 -1
- package/dist-client/utils/assistant-session-admin.d.ts +32 -1
- package/dist-client/utils/assistant-session-admin.js +36 -5
- package/dist-client/utils/assistant-session-admin.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.js +1 -1
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +1 -1
- package/dist-server/service/assistant-mode/index.js +2 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +15 -0
- package/dist-server/service/assistant-mode/mode-categories.js +7 -1
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/chat-session/session-cleanup.d.ts +28 -0
- package/dist-server/service/chat-session/session-cleanup.js +69 -0
- package/dist-server/service/chat-session/session-cleanup.js.map +1 -0
- package/dist-server/service/chat-session/session-inbox.d.ts +15 -0
- package/dist-server/service/chat-session/session-inbox.js +11 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-purge.d.ts +4 -0
- package/dist-server/service/chat-session/session-purge.js +45 -11
- package/dist-server/service/chat-session/session-purge.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +24 -2
- package/dist-server/service/chat-session-resolver.js +193 -13
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/data-read/list-read.d.ts +3 -1
- package/dist-server/service/data-read/list-read.js +27 -2
- package/dist-server/service/data-read/list-read.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +16 -0
- package/dist-server/service/data-read/read-target.js +18 -1
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.js +3 -1
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/assistant-chat-resolver.ts +2 -2
- package/server/service/assistant-mode/conversation-category.test.ts +15 -2
- package/server/service/assistant-mode/index.ts +1 -1
- package/server/service/assistant-mode/mode-categories.ts +17 -1
- package/server/service/assistant-mode/open-subject-door.test.ts +3 -1
- package/server/service/chat-session/session-cleanup.test.ts +83 -0
- package/server/service/chat-session/session-cleanup.ts +69 -0
- package/server/service/chat-session/session-inbox.ts +13 -0
- package/server/service/chat-session/session-purge.ts +46 -10
- package/server/service/chat-session-resolver.ts +150 -14
- package/server/service/data-read/list-read.ts +30 -3
- package/server/service/data-read/list-status.test.ts +46 -0
- package/server/service/data-read/read-target.ts +32 -1
- package/server/service/data-read/read-tools.ts +2 -1
- package/test/assistant-dock.test.ts +12 -1
- package/test/assistant-session-manager.test.ts +53 -1
- package/test/open-doors.test.ts +3 -1
- package/translations/en.json +37 -1
- package/translations/es.json +37 -1
- package/translations/ja.json +37 -1
- package/translations/ko.json +37 -1
- package/translations/ms.json +37 -1
- package/translations/zh.json +37 -1
|
@@ -10,11 +10,12 @@ import { User } from '@things-factory/auth-base'
|
|
|
10
10
|
import { buildDomainUsersQueryBuilder } from '@things-factory/auth-base/dist-server/utils/get-domain-users.js'
|
|
11
11
|
|
|
12
12
|
import { getRepository, pubsub } from '@things-factory/shell'
|
|
13
|
-
import {
|
|
13
|
+
import { config } from '@things-factory/env'
|
|
14
|
+
import { In, IsNull, Like, Not } from 'typeorm'
|
|
14
15
|
|
|
15
16
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
16
17
|
import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
|
|
17
|
-
import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, searchReadableMessages, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
|
|
18
|
+
import { buildInbox, applyHidden, activityPatch, listedEntries, searchInbox, filterInbox, searchReadableMessages, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
|
|
18
19
|
import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
|
|
19
20
|
import { roomMessageBody } from './chat-message/room-message.js'
|
|
20
21
|
import { readerGate } from './chat-message/reader-gate.js'
|
|
@@ -26,6 +27,17 @@ import { sendInviteNotification } from './chat-session-participant/invite-notifi
|
|
|
26
27
|
import { readRoster } from './chat-session-participant/read-roster.js'
|
|
27
28
|
import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
|
|
28
29
|
import { assertMayWriteSession } from './chat-session/session-write-gate.js'
|
|
30
|
+
import { archiveDaysOf, cleanupReason, idleDays, isArchived } from './chat-session/session-cleanup.js'
|
|
31
|
+
import { DAY_MS, retentionDaysOf } from './chat-session/session-retention.js'
|
|
32
|
+
|
|
33
|
+
/** The keeping periods the config asks for (shared-dock.md §9-5, §2-7). */
|
|
34
|
+
function keeping(): { deletedDays: number | null; archiveDays: number | null } {
|
|
35
|
+
const options = config.get('ai-assistant', {}) ?? {}
|
|
36
|
+
return { deletedDays: retentionDaysOf(options.sessionRetentionDays), archiveDays: archiveDaysOf(options.sessionArchiveDays) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One request reads the list once - the list, its total and its counts ask together and share it. */
|
|
40
|
+
const inboxReads = new WeakMap<object, Map<string, Promise<any[]>>>()
|
|
29
41
|
|
|
30
42
|
/** 초대 알림 — 발송은 호스트의 기전을 그대로 쓴다(`sendInviteNotification`, 문 셋이 같은 한 곳). */
|
|
31
43
|
async function notifyInvited(context: any, invited: string[], inviter: any, sessionName?: string): Promise<void> {
|
|
@@ -129,6 +141,43 @@ class ChatSessionInboxEntry {
|
|
|
129
141
|
|
|
130
142
|
@Field({ description: 'Whether this viewer is the owner of the session - only the owner may change its visibility.' })
|
|
131
143
|
owner!: boolean
|
|
144
|
+
|
|
145
|
+
/* No activity for the archive period: out of the list until asked for or searched. A new message brings it back. */
|
|
146
|
+
@Field({ nullable: true, description: 'Whether the session has had no activity for the archive period (config sessionArchiveDays, default 60) - left out of the list unless includeArchived or a search asks for it.' })
|
|
147
|
+
archived?: boolean
|
|
148
|
+
|
|
149
|
+
@Field({ nullable: true, description: "Why the list suggests clearing this session: 'empty' (never asked anything) or 'inactive' (no activity for 30 days). Only on sessions this viewer created." })
|
|
150
|
+
cleanupReason?: string
|
|
151
|
+
|
|
152
|
+
@Field(type => Int, { nullable: true, description: 'Whole days since the last activity, or since the start when there is none.' })
|
|
153
|
+
idleDays?: number
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@ObjectType({ description: 'A session the caller deleted that is still kept - it can be brought back until it is emptied.' })
|
|
157
|
+
class DeletedSessionEntry {
|
|
158
|
+
@Field(type => ID, { description: 'Session id.' })
|
|
159
|
+
id!: string
|
|
160
|
+
|
|
161
|
+
@Field({ nullable: true, description: 'Session name.' })
|
|
162
|
+
name?: string
|
|
163
|
+
|
|
164
|
+
@Field({ nullable: true, description: 'Which conversation this session is - the registered mode kind.' })
|
|
165
|
+
kind?: string
|
|
166
|
+
|
|
167
|
+
@Field({ description: 'When it was deleted.' })
|
|
168
|
+
deletedAt!: Date
|
|
169
|
+
|
|
170
|
+
@Field({ nullable: true, description: 'When the daily job empties it for good; none when emptying is turned off.' })
|
|
171
|
+
purgesAt?: Date
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@ObjectType({ description: 'How long conversations are kept - what the list says when it deletes or archives.' })
|
|
175
|
+
class SessionKeeping {
|
|
176
|
+
@Field(type => Int, { nullable: true, description: 'Days a deleted session can still be brought back; none when deleted sessions are never emptied.' })
|
|
177
|
+
deletedDays?: number
|
|
178
|
+
|
|
179
|
+
@Field(type => Int, { nullable: true, description: 'Days without activity after which a session is archived; none when archiving is off.' })
|
|
180
|
+
archiveDays?: number
|
|
132
181
|
}
|
|
133
182
|
|
|
134
183
|
@ObjectType({ description: 'A file stored with a conversation - opened only through the conversation.' })
|
|
@@ -230,10 +279,12 @@ export class ChatSessionResolver {
|
|
|
230
279
|
@Arg('kind', { nullable: true, description: 'Only this conversation kind (mode).' }) kind?: string,
|
|
231
280
|
@Arg('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' }) participantOnly?: boolean,
|
|
232
281
|
@Arg('visibility', { nullable: true, description: "Only 'open' or only 'private'." }) visibility?: string,
|
|
233
|
-
@Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean
|
|
282
|
+
@Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean,
|
|
283
|
+
@Arg('includeArchived', { nullable: true, description: 'Also the archived ones - no activity for the archive period.' }) includeArchived?: boolean,
|
|
284
|
+
@Arg('cleanupOnly', { nullable: true, description: "Only the caller's own the list suggests clearing (empty, or inactive for 30 days), hidden and archived ones included." }) cleanupOnly?: boolean
|
|
234
285
|
): Promise<ChatSessionInboxEntry[]> {
|
|
235
286
|
const filtered = await this._inboxFiltered(anchorType, anchorId, context, {
|
|
236
|
-
showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly
|
|
287
|
+
showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly, includeArchived, cleanupOnly
|
|
237
288
|
})
|
|
238
289
|
const from = Math.max(0, offset ?? 0)
|
|
239
290
|
const size = Math.min(Math.max(1, limit ?? 50), 200)
|
|
@@ -261,10 +312,14 @@ export class ChatSessionResolver {
|
|
|
261
312
|
@Arg('kind', { nullable: true, description: 'Only this conversation kind (mode).' }) kind?: string,
|
|
262
313
|
@Arg('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' }) participantOnly?: boolean,
|
|
263
314
|
@Arg('visibility', { nullable: true, description: "Only 'open' or only 'private'." }) visibility?: string,
|
|
264
|
-
@Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean
|
|
315
|
+
@Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean,
|
|
316
|
+
@Arg('includeArchived', { nullable: true, description: 'Also the archived ones - no activity for the archive period.' }) includeArchived?: boolean,
|
|
317
|
+
@Arg('cleanupOnly', { nullable: true, description: "Only the caller's own the list suggests clearing." }) cleanupOnly?: boolean
|
|
265
318
|
): Promise<number> {
|
|
266
319
|
return (
|
|
267
|
-
await this._inboxFiltered(anchorType, anchorId, context, {
|
|
320
|
+
await this._inboxFiltered(anchorType, anchorId, context, {
|
|
321
|
+
showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly, includeArchived, cleanupOnly
|
|
322
|
+
})
|
|
268
323
|
).length
|
|
269
324
|
}
|
|
270
325
|
|
|
@@ -273,10 +328,10 @@ export class ChatSessionResolver {
|
|
|
273
328
|
anchorType: string,
|
|
274
329
|
anchorId: string,
|
|
275
330
|
context: ResolverContext,
|
|
276
|
-
opts: { showHidden?: boolean; search?: string } & InboxFilter
|
|
331
|
+
opts: { showHidden?: boolean; search?: string; includeArchived?: boolean; cleanupOnly?: boolean } & InboxFilter
|
|
277
332
|
): Promise<ChatSessionInboxEntry[]> {
|
|
278
333
|
const all = await this._inboxEntries(anchorType, anchorId, context)
|
|
279
|
-
let entries =
|
|
334
|
+
let entries = listedEntries(all, opts)
|
|
280
335
|
entries = filterInbox(entries as any, opts) as any
|
|
281
336
|
if (!opts.search?.trim() || !entries.length) return entries
|
|
282
337
|
// Search only readable rooms, then pass their messages through the SAME reader gate.
|
|
@@ -315,8 +370,81 @@ export class ChatSessionResolver {
|
|
|
315
370
|
return all.filter(e => e.hidden).length
|
|
316
371
|
}
|
|
317
372
|
|
|
373
|
+
/* Like the hidden count: an archived conversation must not be forgotten, so the list says how many wait there. */
|
|
374
|
+
@Query(() => Int, { description: 'Number of sessions on this anchor that are archived (no activity for the archive period) and not hidden by the caller.' })
|
|
375
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
376
|
+
async archivedSessionCount(@Arg('anchorType') anchorType: string, @Arg('anchorId') anchorId: string, @Ctx() context: ResolverContext): Promise<number> {
|
|
377
|
+
return (await this._inboxEntries(anchorType, anchorId, context)).filter(e => e.archived && !e.hidden).length
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
@Query(() => Int, { description: "Number of the caller's own sessions on this anchor the list suggests clearing - empty, or inactive for 30 days." })
|
|
381
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
382
|
+
async cleanupSessionCount(@Arg('anchorType') anchorType: string, @Arg('anchorId') anchorId: string, @Ctx() context: ResolverContext): Promise<number> {
|
|
383
|
+
return (await this._inboxEntries(anchorType, anchorId, context)).filter(e => e.cleanupReason).length
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
@Query(() => SessionKeeping, { description: 'How long deleted sessions can be brought back and after how long an idle one is archived.' })
|
|
387
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
388
|
+
aiSessionKeeping(): SessionKeeping {
|
|
389
|
+
const { deletedDays, archiveDays } = keeping()
|
|
390
|
+
return { deletedDays: deletedDays ?? undefined, archiveDays: archiveDays ?? undefined }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** What I deleted here and can still bring back - newest first. Only the creator deletes, so only the creator sees them. */
|
|
394
|
+
@Query(() => [DeletedSessionEntry], { description: 'Sessions on this anchor that the caller deleted and that are still kept, newest deletion first. They can be brought back with restoreAISession until they are emptied.' })
|
|
395
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
|
|
396
|
+
async deletedAISessions(@Arg('anchorType') anchorType: string, @Arg('anchorId') anchorId: string, @Ctx() context: ResolverContext): Promise<DeletedSessionEntry[]> {
|
|
397
|
+
const { domain, user } = context.state
|
|
398
|
+
if (!user?.id) return []
|
|
399
|
+
const { deletedDays } = keeping()
|
|
400
|
+
const rows = await getRepository(ChatSession).find({
|
|
401
|
+
where: { ...anchorWhere(domain.id, anchorType, anchorId), creator: { id: user.id }, deletedAt: Not(IsNull()) } as any,
|
|
402
|
+
withDeleted: true,
|
|
403
|
+
order: { deletedAt: 'DESC' } as any,
|
|
404
|
+
take: INBOX_SCAN_CAP
|
|
405
|
+
})
|
|
406
|
+
return rows.map(row => ({
|
|
407
|
+
id: row.id!,
|
|
408
|
+
name: (row as any).name ?? undefined,
|
|
409
|
+
kind: (row as any).kind ?? undefined,
|
|
410
|
+
deletedAt: (row as any).deletedAt,
|
|
411
|
+
purgesAt: deletedDays === null ? undefined : new Date(new Date((row as any).deletedAt).getTime() + deletedDays * DAY_MS)
|
|
412
|
+
}))
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Bringing a deleted conversation back - its creator, while it is kept. Its messages and seats were never touched. */
|
|
416
|
+
@Mutation(() => Boolean, { description: 'Bring back a session the caller deleted, while it is still kept (before the daily job empties it). Creator only, like deleting.' })
|
|
417
|
+
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
418
|
+
async restoreAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
419
|
+
const { domain, user } = context.state
|
|
420
|
+
const repo = getRepository(ChatSession)
|
|
421
|
+
const session = await repo.findOne({ where: { id: sessionId, domain: { id: domain.id } } as any, withDeleted: true })
|
|
422
|
+
if (!session || !(session as any).deletedAt) return false
|
|
423
|
+
if (!user?.id || (session as any).creatorId !== user.id) {
|
|
424
|
+
throw new Error('Only the user who created this session may bring it back.')
|
|
425
|
+
}
|
|
426
|
+
await repo.restore(sessionId)
|
|
427
|
+
publishSessionActivity({
|
|
428
|
+
domainId: domain.id,
|
|
429
|
+
sessionId,
|
|
430
|
+
kind: 'meta',
|
|
431
|
+
anchorType: (session as any).anchorType,
|
|
432
|
+
anchorId: (session as any).anchorId,
|
|
433
|
+
actorId: user.id
|
|
434
|
+
})
|
|
435
|
+
return true
|
|
436
|
+
}
|
|
437
|
+
|
|
318
438
|
/** 목록 조립 — 숨김 표시까지 붙인 **전체**. 목록·개수 두 진입점이 같은 규칙을 쓰도록 한 곳에 둔다. */
|
|
319
|
-
private
|
|
439
|
+
private _inboxEntries(anchorType: string, anchorId: string, context: ResolverContext): Promise<ChatSessionInboxEntry[]> {
|
|
440
|
+
const reads = inboxReads.get(context) ?? new Map<string, Promise<any[]>>()
|
|
441
|
+
inboxReads.set(context, reads)
|
|
442
|
+
const key = `${anchorType}\u0000${anchorId}`
|
|
443
|
+
if (!reads.has(key)) reads.set(key, this._readInbox(anchorType, anchorId, context))
|
|
444
|
+
return reads.get(key)!
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private async _readInbox(
|
|
320
448
|
anchorType: string,
|
|
321
449
|
anchorId: string,
|
|
322
450
|
context: ResolverContext
|
|
@@ -339,10 +467,18 @@ export class ChatSessionResolver {
|
|
|
339
467
|
const hiddenAt = new Map(rows.map(r => [(r as any).sessionId as string, r.hiddenAt]))
|
|
340
468
|
/* 내가 만든 대화인지 표시 — 삭제 버튼을 보일지 결정하는 UI 힌트(권한은 서버가 다시 판정한다). */
|
|
341
469
|
const createdByMe = new Set(sessions.filter(s => (s as any).creatorId === user?.id).map(s => s.id))
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
470
|
+
const now = new Date()
|
|
471
|
+
const { archiveDays } = keeping()
|
|
472
|
+
const entries = buildInbox(sessions as any, participations as any).map(entry => {
|
|
473
|
+
const mine = createdByMe.has(entry.id)
|
|
474
|
+
return {
|
|
475
|
+
...entry,
|
|
476
|
+
mine,
|
|
477
|
+
idleDays: idleDays(entry, now),
|
|
478
|
+
archived: isArchived(entry, now, archiveDays),
|
|
479
|
+
cleanupReason: cleanupReason({ ...entry, mine }, now) ?? undefined
|
|
480
|
+
}
|
|
481
|
+
})
|
|
346
482
|
/* showHidden=true 로 불러 **전체**를 얻는다 — 숨김 여부 판정을 두 곳에 복제하지 않기 위함. */
|
|
347
483
|
const shown = applyHidden(entries as any, hiddenAt, true) as any[]
|
|
348
484
|
|
|
@@ -374,7 +510,7 @@ export class ChatSessionResolver {
|
|
|
374
510
|
*/
|
|
375
511
|
@Mutation(() => Boolean, {
|
|
376
512
|
description:
|
|
377
|
-
'Delete a chat session — creator only, since
|
|
513
|
+
'Delete a chat session — creator only, since it disappears for every participant. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete: the creator can bring it back with restoreAISession until the daily job empties it (sessionRetentionDays). Broadcast as meta activity so every list drops it.'
|
|
378
514
|
})
|
|
379
515
|
@Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
|
|
380
516
|
async deleteAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
@@ -23,7 +23,7 @@ export interface ListRead {
|
|
|
23
23
|
dateField?: string
|
|
24
24
|
/** Integer year and month fields, and an in/out field (INCOME | EXPENSE) - the `year`, `month`, `flow` conditions. */
|
|
25
25
|
narrowing?: { year?: string; month?: string; flow?: string }
|
|
26
|
-
/** A field holding the period as YYYY-MM - the `period` condition. */
|
|
26
|
+
/** A field holding the period as YYYY-MM - the `period` condition, and `periodFrom` / `periodTo` for a range of months. */
|
|
27
27
|
monthField?: string
|
|
28
28
|
/** A status field and its values - the `status` condition. */
|
|
29
29
|
status?: { field: string; values: string[] }
|
|
@@ -39,6 +39,8 @@ export interface ListRead {
|
|
|
39
39
|
* searched the asker's own name (2026-10-24).
|
|
40
40
|
*/
|
|
41
41
|
missing?: { name: string; description: string; filters: ReadFilter[] }[]
|
|
42
|
+
/** The rows are the person's own, whoever reads them (`myExpenditureRequests`) - ReadTarget.mine. */
|
|
43
|
+
mine?: boolean
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/** The money column a minimum or maximum amount means - `amount` when there is one, else the first summed column. */
|
|
@@ -63,10 +65,34 @@ export function listReadTarget(list: ListRead): ReadTarget {
|
|
|
63
65
|
? [{ name: 'flow', type: 'enum', values: ['INCOME', 'EXPENSE'], labelKey: 'ai-assistant.read.flow', description: 'money in or out', filter: (v: any) => ({ name: narrowing.flow!, operator: 'eq', value: v }) }]
|
|
64
66
|
: []),
|
|
65
67
|
...(list.monthField
|
|
66
|
-
? [
|
|
68
|
+
? [
|
|
69
|
+
{ name: 'period', type: 'string', format: 'month', labelKey: 'ai-assistant.read.period', description: 'a month as YYYY-MM', filter: (v: any) => ({ name: list.monthField!, operator: 'eq', value: v }) },
|
|
70
|
+
/* A range of months - "the last six months" of a list kept by month (2026-10-25: only one fixed month could be read). */
|
|
71
|
+
{ name: 'periodFrom', type: 'string', format: 'month', labelKey: 'ai-assistant.read.period-from', description: 'the first month, inclusive, as YYYY-MM', filter: (v: any) => ({ name: list.monthField!, operator: 'gte', value: v }) },
|
|
72
|
+
{ name: 'periodTo', type: 'string', format: 'month', labelKey: 'ai-assistant.read.period-to', description: 'the last month, inclusive, as YYYY-MM', filter: (v: any) => ({ name: list.monthField!, operator: 'lte', value: v }) }
|
|
73
|
+
]
|
|
67
74
|
: []),
|
|
68
75
|
...(list.status
|
|
69
|
-
? [
|
|
76
|
+
? [
|
|
77
|
+
{
|
|
78
|
+
name: 'status',
|
|
79
|
+
type: 'enum',
|
|
80
|
+
values: list.status.values,
|
|
81
|
+
many: true,
|
|
82
|
+
labelKey: 'ai-assistant.read.status',
|
|
83
|
+
description: 'the statuses to read',
|
|
84
|
+
filter: (v: any) => (Array.isArray(v) && v.length > 1 ? { name: list.status!.field, operator: 'in', value: v } : { name: list.status!.field, operator: 'eq', value: [v].flat()[0] })
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'statusNot',
|
|
88
|
+
type: 'enum',
|
|
89
|
+
values: list.status.values,
|
|
90
|
+
many: true,
|
|
91
|
+
labelKey: 'ai-assistant.read.status-not',
|
|
92
|
+
description: 'the statuses to leave out - every other status is read',
|
|
93
|
+
filter: (v: any) => ({ name: list.status!.field, operator: 'notin', value: [v].flat() })
|
|
94
|
+
}
|
|
95
|
+
]
|
|
70
96
|
: []),
|
|
71
97
|
...(list.missing?.length
|
|
72
98
|
? [
|
|
@@ -101,6 +127,7 @@ export function listReadTarget(list: ListRead): ReadTarget {
|
|
|
101
127
|
criteria,
|
|
102
128
|
...(list.fixedFilters?.length ? { fixedFilters: list.fixedFilters } : {}),
|
|
103
129
|
columns: list.columns,
|
|
130
|
+
...(list.mine ? { mine: true } : {}),
|
|
104
131
|
rowsOf: data => ({ rows: data?.[list.listQuery]?.items ?? [], total: data?.[list.listQuery]?.total ?? 0 }),
|
|
105
132
|
...(search ? { search: { description: `the ${list.search}` } } : {}),
|
|
106
133
|
...(list.dateField ? { sortings: [{ name: list.dateField, desc: true }] } : {}),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A list's status condition takes several statuses, and the statuses to leave out (2026-10-25): asked for the
|
|
3
|
+
* expenditure requests not approved, rejected or withdrawn, the model read the list once per status and stopped.
|
|
4
|
+
*/
|
|
5
|
+
import { listReadTarget } from './list-read'
|
|
6
|
+
import { filtersOf, readCriteria } from './read-target'
|
|
7
|
+
|
|
8
|
+
const target = listReadTarget({
|
|
9
|
+
key: 'office.expenditure-request',
|
|
10
|
+
title: 'menu.expenditure-request-list',
|
|
11
|
+
name: '지출결의',
|
|
12
|
+
listQuery: 'expenditureRequests',
|
|
13
|
+
selection: 'id status totalAmount',
|
|
14
|
+
columns: [
|
|
15
|
+
{ name: 'status', type: 'string' },
|
|
16
|
+
{ name: 'totalAmount', type: 'number', total: true }
|
|
17
|
+
],
|
|
18
|
+
status: { field: 'status', values: ['DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'WITHDRAWN'] }
|
|
19
|
+
})
|
|
20
|
+
const filters = (args: any) => {
|
|
21
|
+
const { criteria, refused } = readCriteria(target, args)
|
|
22
|
+
return refused.length ? { refused } : filtersOf(target, criteria)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('a status condition', () => {
|
|
26
|
+
it('★ one status is eq, several are in', () => {
|
|
27
|
+
expect(filters({ status: 'SUBMITTED' })).toEqual([{ name: 'status', operator: 'eq', value: 'SUBMITTED' }])
|
|
28
|
+
expect(filters({ status: ['SUBMITTED'] })).toEqual([{ name: 'status', operator: 'eq', value: 'SUBMITTED' }])
|
|
29
|
+
expect(filters({ status: ['DRAFT', 'SUBMITTED'] })).toEqual([{ name: 'status', operator: 'in', value: ['DRAFT', 'SUBMITTED'] }])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('★ the statuses to leave out are notin - every other status is read', () => {
|
|
33
|
+
expect(filters({ statusNot: ['APPROVED', 'REJECTED', 'WITHDRAWN'] })).toEqual([{ name: 'status', operator: 'notin', value: ['APPROVED', 'REJECTED', 'WITHDRAWN'] }])
|
|
34
|
+
expect(filters({ statusNot: 'APPROVED' })).toEqual([{ name: 'status', operator: 'notin', value: ['APPROVED'] }])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('a status the list does not have is refused, in a list too', () => {
|
|
38
|
+
expect(filters({ statusNot: ['APPROVED', 'PAID'] })).toEqual({ refused: [{ name: 'statusNot', code: 'not-in-list' }] })
|
|
39
|
+
expect(filters({ status: ['NOPE'] })).toEqual({ refused: [{ name: 'status', code: 'not-in-list' }] })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('a condition that takes one value refuses a list', () => {
|
|
43
|
+
const { refused } = readCriteria(target, { minAmount: [1, 2] })
|
|
44
|
+
expect(refused).toEqual([{ name: 'minAmount', code: 'one-value' }])
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -23,6 +23,13 @@ export interface ReadFilter {
|
|
|
23
23
|
/** A condition the model may set - read with the entry fields' rules, turned into the query's filters by the application. */
|
|
24
24
|
export interface ReadCriterion extends Omit<EntryField, 'required' | 'reference' | 'total'> {
|
|
25
25
|
filter: (value: any) => ReadFilter | ReadFilter[]
|
|
26
|
+
/** A string condition holding a month as YYYY-MM - a value of another shape is refused (`not-a-month`). */
|
|
27
|
+
format?: 'month'
|
|
28
|
+
/**
|
|
29
|
+
* The condition takes several values at once - its filter is given the list. Asked for the expenditure requests not
|
|
30
|
+
* approved, rejected or withdrawn, the model read the list once per status and stopped (2026-10-25).
|
|
31
|
+
*/
|
|
32
|
+
many?: boolean
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
export interface ReadColumn {
|
|
@@ -81,6 +88,11 @@ export interface ReadTarget {
|
|
|
81
88
|
/** Orders the rows; the query's own order when left out. */
|
|
82
89
|
sortings?: { name: string; desc?: boolean }[]
|
|
83
90
|
screen?: ReadScreen
|
|
91
|
+
/**
|
|
92
|
+
* The rows are the person's own, whoever reads them (내 지출결의) - the query answers for the one asking. A dashboard
|
|
93
|
+
* card of it shows each viewer their own rows and takes no filter on a person (ADR-0160).
|
|
94
|
+
*/
|
|
95
|
+
mine?: boolean
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
/** At most this many rows are read - the AI 작업판 table's own limit (workspace-blocks MAX_TABLE_ROWS). */
|
|
@@ -165,6 +177,8 @@ export interface DetailArg {
|
|
|
165
177
|
required?: boolean
|
|
166
178
|
/** A record's id: the axis (or axes) of the record, whose key `<axis>:<id>` the model may have been shown (EntryField.axis). */
|
|
167
179
|
axis?: string | readonly string[]
|
|
180
|
+
/** A string argument holding a month as YYYY-MM. */
|
|
181
|
+
format?: 'month'
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
/**
|
|
@@ -192,6 +206,8 @@ export interface DetailRead {
|
|
|
192
206
|
* (ADR-0160 decision 2). The card keeps only `key`; where the rows are and what their columns are is read here.
|
|
193
207
|
*/
|
|
194
208
|
lists?: DetailList[]
|
|
209
|
+
/** The answer is the person's own, whoever asks (나의 전략 업무, 내 급여) - ReadTarget.mine. */
|
|
210
|
+
mine?: boolean
|
|
195
211
|
}
|
|
196
212
|
|
|
197
213
|
/** A list inside a detail read's answer. */
|
|
@@ -248,6 +264,9 @@ export function detailReadOf(key: string | undefined): DetailRead | undefined {
|
|
|
248
264
|
return key ? details.get(key) : undefined
|
|
249
265
|
}
|
|
250
266
|
|
|
267
|
+
/** A month as a `month` condition holds it. */
|
|
268
|
+
const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
269
|
+
|
|
251
270
|
/** The conditions the model set, read by their declared types; a value that does not fit is said, never guessed at. */
|
|
252
271
|
export function readCriteria(target: ReadTarget, args: any): { criteria: Record<string, unknown>; refused: { name: string; code: string }[] } {
|
|
253
272
|
const criteria: Record<string, unknown> = {}
|
|
@@ -257,9 +276,21 @@ export function readCriteria(target: ReadTarget, args: any): { criteria: Record<
|
|
|
257
276
|
if (target.search && typeof args?.text === 'string' && args.text.trim()) criteria.text = args.text.trim()
|
|
258
277
|
for (const criterion of target.criteria) {
|
|
259
278
|
const value = args?.[criterion.name]
|
|
260
|
-
if (value === undefined || value === null || value === '') continue
|
|
279
|
+
if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) continue
|
|
280
|
+
if (criterion.many && Array.isArray(value)) {
|
|
281
|
+
const each = value.map(one => readFieldValue(criterion as EntryField, one))
|
|
282
|
+
const wrong = each.find(one => one.read === undefined)
|
|
283
|
+
if (wrong) refused.push({ name: criterion.name, code: wrong.code ?? 'unreadable' })
|
|
284
|
+
else criteria[criterion.name] = [...new Set(each.map(one => one.read))]
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
if (Array.isArray(value)) {
|
|
288
|
+
refused.push({ name: criterion.name, code: 'one-value' })
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
261
291
|
const { read, code } = readFieldValue(criterion as EntryField, value)
|
|
262
292
|
if (read === undefined) refused.push({ name: criterion.name, code: code ?? 'unreadable' })
|
|
293
|
+
else if (criterion.format === 'month' && !MONTH.test(String(read))) refused.push({ name: criterion.name, code: 'not-a-month' })
|
|
263
294
|
else criteria[criterion.name] = read
|
|
264
295
|
}
|
|
265
296
|
return { criteria, refused }
|
|
@@ -48,9 +48,10 @@ const wordsOf = (context: any) => (key: string | undefined, fallback: string) =>
|
|
|
48
48
|
function criterionSchema(criterion: ReadCriterion): any {
|
|
49
49
|
/* One type per field - Gemini's function schema refuses a list of types (2026-10-01). */
|
|
50
50
|
const type = criterion.type === 'number' ? 'number' : criterion.type === 'integer' ? 'integer' : criterion.type === 'boolean' ? 'boolean' : 'string'
|
|
51
|
-
const said = [criterion.description, criterion.type === 'date' ? 'YYYY-MM-DD' : undefined, criterion.type === 'enum' ? `one of: ${criterion.values!.join(', ')}` : undefined]
|
|
51
|
+
const said = [criterion.description, criterion.type === 'date' ? 'YYYY-MM-DD' : criterion.format === 'month' ? 'YYYY-MM' : undefined, criterion.type === 'enum' ? `one of: ${criterion.values!.join(', ')}` : undefined]
|
|
52
52
|
.filter(Boolean)
|
|
53
53
|
.join('. ')
|
|
54
|
+
if (criterion.many) return { type: 'array', items: { type }, ...(said ? { description: `${said} - one or more` } : {}) }
|
|
54
55
|
return { type, ...(said ? { description: said } : {}) }
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -161,7 +161,9 @@ describe('what twin needed of the dock', () => {
|
|
|
161
161
|
it('a question with no conversation to go to is said now, never sent later; a draft waits', () => {
|
|
162
162
|
assert.match(dock, /if \(waiting\.ask\) notify\(/)
|
|
163
163
|
assert.match(dock, /else holdAssistantAsk\(waiting\)/)
|
|
164
|
-
|
|
164
|
+
/* Taken when the opening ends whatever it found - a stuck anchor is said by takeAsk at once. */
|
|
165
|
+
assert.match(dock, /this\.loading = false\s*if \(this\.anchor\) void this\.takeAsk\(\)/)
|
|
166
|
+
assert.match(dock, /if \(!isAnchor\(this\.anchor\)\) \{/)
|
|
165
167
|
})
|
|
166
168
|
|
|
167
169
|
it('a handler\'s note stays in the conversation after its outcome is reported', () => {
|
|
@@ -227,6 +229,15 @@ describe('a screen request', () => {
|
|
|
227
229
|
const take = dock.slice(dock.indexOf('private async takeScreenRequest()'), dock.indexOf('\n }\n', dock.indexOf('private async takeScreenRequest()')))
|
|
228
230
|
const send = chat.slice(chat.indexOf('async sendScreenRequest('), chat.indexOf('\n }\n', chat.indexOf('async sendScreenRequest(')))
|
|
229
231
|
|
|
232
|
+
it('★ a request that reopens this screen\'s conversation is taken once the opening is done - never while it loads', () => {
|
|
233
|
+
const opening = dock.slice(dock.indexOf('private async open(): Promise<void> {'), dock.indexOf('private target('))
|
|
234
|
+
const [body, rest] = opening.split('} finally {')
|
|
235
|
+
/* takeAsk passes while loading: called inside the opening, the request stayed waiting and was never sent. */
|
|
236
|
+
assert.doesNotMatch(body, /this\.takeAsk\(\)/)
|
|
237
|
+
assert.match(rest, /this\.loading = false\s*if \(this\.anchor\) void this\.takeAsk\(\)/)
|
|
238
|
+
assert.match(dock, /private async takeAsk\(\) \{\s*if \(this\.loading \|\| !this\.anchor\) return/)
|
|
239
|
+
})
|
|
240
|
+
|
|
230
241
|
it('goes its own way - a request carrying a selection is a screen request', () => {
|
|
231
242
|
assert.match(dock, /if \(peekAssistantAsk\(\)\?\.selection\) return this\.takeScreenRequest\(\)/)
|
|
232
243
|
})
|
|
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict'
|
|
|
9
9
|
import { readFileSync } from 'node:fs'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
|
|
12
|
-
import { sessionActions } from '../client/utils/assistant-session-actions.ts'
|
|
12
|
+
import { bulkActions, sessionActions } from '../client/utils/assistant-session-actions.ts'
|
|
13
13
|
|
|
14
14
|
const read = (file: string) => readFileSync(join(__dirname, file), 'utf8')
|
|
15
15
|
const manager = read('../client/components/assistant-session-manager.ts')
|
|
@@ -111,6 +111,58 @@ describe('opening and starting stay the host\'s', () => {
|
|
|
111
111
|
})
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
+
describe('many at once, suggestions, archive and bringing back (shared dock §2-7, 2026-10-25)', () => {
|
|
115
|
+
it('★ the picked rows are each done by their own hints: delete mine, leave the ones I am only in, hide any', () => {
|
|
116
|
+
const can = bulkActions([
|
|
117
|
+
{ id: 'mine', mine: true, owner: true, participant: true },
|
|
118
|
+
{ id: 'member', participant: true },
|
|
119
|
+
{ id: 'seen', participant: false },
|
|
120
|
+
{ id: 'hidden', participant: true, hidden: true }
|
|
121
|
+
])
|
|
122
|
+
assert.deepEqual(can.remove, ['mine'])
|
|
123
|
+
assert.deepEqual(can.leave, ['member', 'hidden'])
|
|
124
|
+
assert.deepEqual(can.hide, ['mine', 'member', 'seen'])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('the list, the deleted ones and bringing back go through the framework doors', () => {
|
|
128
|
+
for (const door of ['archivedSessionCount', 'cleanupSessionCount', 'aiSessionKeeping', 'deletedAISessions', 'restoreAISession', 'includeArchived', 'cleanupOnly']) {
|
|
129
|
+
assert.match(admin, new RegExp(door), door)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('★ deleting many asks once and says how long they can come back; hiding many does not ask', () => {
|
|
134
|
+
assert.match(manager, /private async askRemoveMany\(ids: string\[\]\) \{\s*const ok = await this\.ask\(/)
|
|
135
|
+
assert.match(manager, /confirm-remove-many[^\n]*\$\{this\.keptFor\(\)\}/)
|
|
136
|
+
assert.match(manager, /confirm-remove'[^\n]*\$\{this\.keptFor\(\)\}/)
|
|
137
|
+
assert.match(manager, /private hideMany\(ids: string\[\]\) \{\s*void this\.runMany\(/)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('★ each success is told to the host like a row\'s own change, each refusal said by name', () => {
|
|
141
|
+
assert.match(manager, /if \(await run\(id\)\) \{\s*count\+\+\s*this\.emit\('assistant-session-changed', \{ sessionId: id, change \}\)/)
|
|
142
|
+
assert.match(manager, /some-refused/)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('★ the suggestions arrive picked; the archived wait for their view', () => {
|
|
146
|
+
assert.match(manager, /this\.pickAllOnRead = view === 'cleanup'/)
|
|
147
|
+
assert.match(manager, /if \(this\.pickAllOnRead\) \{\s*this\.pickAllOnRead = false\s*this\.picked = listed/)
|
|
148
|
+
assert.match(manager, /includeArchived: this\.view === 'archived',\s*cleanupOnly: this\.view === 'cleanup'/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('the box picks from the space around it too, so a press beside it does not open the conversation', () => {
|
|
152
|
+
assert.equal((manager.match(/<label class="pick-area"><input type="checkbox" class="pick"/g) ?? []).length, 2)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('the deleted view shows only what applies to it, and its search narrows by name', () => {
|
|
156
|
+
assert.match(manager, /\$\{this\.view === 'deleted' \? nothing : html`<select aria-label=\$\{t\('filter-whose'/)
|
|
157
|
+
assert.match(manager, /\.filter\(one => !word \|\| \(one\.name \?\? ''\)\.toLowerCase\(\)\.includes\(word\)\)/)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('the deleted ones come back from their own view, by the creator\'s door', () => {
|
|
161
|
+
assert.match(manager, /this\.admin\.deleted\(this\.anchorType, this\.anchorId\)/)
|
|
162
|
+
assert.match(manager, /id => this\.admin\.restore\(id\)/)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
114
166
|
describe('every word is in every locale', () => {
|
|
115
167
|
const keys = [...new Set([...manager.matchAll(/\bt\('([a-z-]+)'/g)].map(match => `ai-assistant.session.${match[1]}`))]
|
|
116
168
|
|
package/test/open-doors.test.ts
CHANGED
|
@@ -150,11 +150,13 @@ describe('what the walk itself has to be true for', () => {
|
|
|
150
150
|
* agents and their usage, design/ai/agent-settings.md), declared.
|
|
151
151
|
* 2026-10-24: 41 — mayInviteToAISession · inviteToAISession · removeFromAISession · leaveAISession · sayTyping declared, chatTyping open (a
|
|
152
152
|
* group conversation and its typing line, ADR-0073 decision 3).
|
|
153
|
+
* 2026-10-25: 46 — archivedSessionCount · cleanupSessionCount · aiSessionKeeping · deletedAISessions · restoreAISession
|
|
154
|
+
* (archive, suggestions to clear and bringing a deleted one back, shared dock §2-7), declared.
|
|
153
155
|
*/
|
|
154
156
|
const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
|
|
155
157
|
const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
|
|
156
158
|
|
|
157
|
-
expect(all).toBe(
|
|
159
|
+
expect(all).toBe(46)
|
|
158
160
|
expect(open).toBe(3)
|
|
159
161
|
})
|
|
160
162
|
})
|
package/translations/en.json
CHANGED
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
"ai-assistant.session.unhide": "Show in my list",
|
|
147
147
|
"ai-assistant.session.remove": "Delete",
|
|
148
148
|
"ai-assistant.session.cancel": "Cancel",
|
|
149
|
-
"ai-assistant.session.confirm-remove": "Delete \"{name}\"? It disappears for every participant
|
|
149
|
+
"ai-assistant.session.confirm-remove": "Delete \"{name}\"? It disappears for every participant.",
|
|
150
150
|
"ai-assistant.session.confirm-open": "Make \"{name}\" public? Everyone in this domain will be able to read what is already written.",
|
|
151
151
|
"ai-assistant.session.rename-refused": "this conversation could not be renamed.",
|
|
152
152
|
"ai-assistant.session.hide-refused": "this conversation could not be hidden.",
|
|
@@ -249,7 +249,10 @@
|
|
|
249
249
|
"ai-assistant.read.flow": "in or out",
|
|
250
250
|
"ai-assistant.read.missing": "missing",
|
|
251
251
|
"ai-assistant.read.period": "month",
|
|
252
|
+
"ai-assistant.read.period-from": "first month",
|
|
253
|
+
"ai-assistant.read.period-to": "last month",
|
|
252
254
|
"ai-assistant.read.status": "status",
|
|
255
|
+
"ai-assistant.read.status-not": "status left out",
|
|
253
256
|
"ai-assistant.read.min-amount": "minimum amount",
|
|
254
257
|
"ai-assistant.read.max-amount": "maximum amount",
|
|
255
258
|
"ai-assistant.read.text": "search words",
|
|
@@ -420,6 +423,39 @@
|
|
|
420
423
|
"ai-assistant.session.confirm-leave": "Leave \"{name}\"? You will not read it again unless its owner brings you back.",
|
|
421
424
|
"ai-assistant.session.leave-refused": "you could not leave this conversation.",
|
|
422
425
|
"ai-assistant.session.audience-group": "Invited people",
|
|
426
|
+
"ai-assistant.session.archived": "Archived",
|
|
427
|
+
"ai-assistant.session.archived-count": "{count} archived - no activity for {days} days",
|
|
428
|
+
"ai-assistant.session.cleanup-empty": "Nothing of yours to clear here.",
|
|
429
|
+
"ai-assistant.session.cleanup-suggest": "{count} of yours look ready to clear - see them",
|
|
430
|
+
"ai-assistant.session.confirm-leave-many": "Leave {count} conversations? They stop coming to your list, and a group or private one is read again only when its owner brings you back.",
|
|
431
|
+
"ai-assistant.session.confirm-remove-many": "Delete {count} conversations? They disappear for every participant.",
|
|
432
|
+
"ai-assistant.session.deleted-empty": "Nothing you deleted here is kept.",
|
|
433
|
+
"ai-assistant.session.deleted-on": "Deleted {date}",
|
|
434
|
+
"ai-assistant.session.filter-view": "Show",
|
|
435
|
+
"ai-assistant.session.hidden-many": "{count} hidden from your list.",
|
|
436
|
+
"ai-assistant.session.hide-many": "Hide {count}",
|
|
437
|
+
"ai-assistant.session.kept": "You can bring it back from Deleted.",
|
|
438
|
+
"ai-assistant.session.kept-for": "You can bring it back from Deleted for {days} days.",
|
|
439
|
+
"ai-assistant.session.leave-many": "Leave {count}",
|
|
440
|
+
"ai-assistant.session.left-many": "Left {count}.",
|
|
441
|
+
"ai-assistant.session.pick": "Pick {name}",
|
|
442
|
+
"ai-assistant.session.pick-all": "Pick all",
|
|
443
|
+
"ai-assistant.session.picked": "{count} picked",
|
|
444
|
+
"ai-assistant.session.purges-on": "emptied {date}",
|
|
445
|
+
"ai-assistant.session.reason-empty": "Never asked anything",
|
|
446
|
+
"ai-assistant.session.reason-inactive": "No activity for {days} days",
|
|
447
|
+
"ai-assistant.session.remove-many": "Delete {count}",
|
|
448
|
+
"ai-assistant.session.removed-many": "{count} deleted.",
|
|
449
|
+
"ai-assistant.session.restore": "Bring back",
|
|
450
|
+
"ai-assistant.session.restore-many": "Bring back {count}",
|
|
451
|
+
"ai-assistant.session.restored": "\"{name}\" is back.",
|
|
452
|
+
"ai-assistant.session.restored-many": "{count} brought back.",
|
|
453
|
+
"ai-assistant.session.some-refused": "Not done for {count}: {names}",
|
|
454
|
+
"ai-assistant.session.unpick": "Clear the pick",
|
|
455
|
+
"ai-assistant.session.view-archived": "Including archived",
|
|
456
|
+
"ai-assistant.session.view-cleanup": "Suggested to clear",
|
|
457
|
+
"ai-assistant.session.view-deleted": "Deleted",
|
|
458
|
+
"ai-assistant.session.view-list": "Active",
|
|
423
459
|
"ai-assistant.text.invite-title": "Conversation invite",
|
|
424
460
|
"ai-assistant.text.invite-body-named": "{who} brought you into the conversation \"{name}\".",
|
|
425
461
|
"ai-assistant.text.invite-body": "{who} brought you into a conversation.",
|