@lenne.tech/nest-server 11.31.1 → 11.31.2
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/FRAMEWORK-API.md +1 -1
- package/dist/core/modules/ai/core-ai.controller.d.ts +1 -1
- package/dist/core/modules/ai/core-ai.controller.js +4 -5
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.resolver.d.ts +1 -1
- package/dist/core/modules/ai/core-ai.resolver.js +7 -6
- package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
- package/dist/core/modules/ai/models/core-ai-conversation.model.d.ts +6 -0
- package/dist/core/modules/ai/models/core-ai-conversation.model.js +13 -1
- package/dist/core/modules/ai/models/core-ai-conversation.model.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +8 -2
- package/dist/core/modules/ai/services/core-ai-conversation.service.js +53 -2
- package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.31.1-to-11.31.2.md +127 -0
- package/package.json +1 -1
- package/src/core/modules/ai/core-ai.controller.ts +12 -9
- package/src/core/modules/ai/core-ai.resolver.ts +14 -9
- package/src/core/modules/ai/models/core-ai-conversation.model.ts +18 -1
- package/src/core/modules/ai/services/core-ai-conversation.service.ts +110 -4
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Migration Guide: 11.31.1 → 11.31.2
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
| Category | Details |
|
|
6
|
+
|----------|---------|
|
|
7
|
+
| **Breaking Changes** | None (one behavior change for admins — see below) |
|
|
8
|
+
| **New Features** | Admin cross-user conversation listing (`?all=true`), creator attribution (`createdByUser`) |
|
|
9
|
+
| **Bugfixes** | Non-admins can list their own AI conversations again (was HTTP 403 / GraphQL "Access denied") |
|
|
10
|
+
| **Migration Effort** | None for most projects — no code changes required |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Quick Migration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Update package
|
|
18
|
+
pnpm update @lenne.tech/nest-server@11.31.2
|
|
19
|
+
|
|
20
|
+
# Verify
|
|
21
|
+
pnpm run build && pnpm test
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
No configuration changes are required. Everything below is automatic (the
|
|
25
|
+
bugfix) or opt-in (the two new features).
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## What's New in 11.31.2
|
|
30
|
+
|
|
31
|
+
### 1. Non-admins can list their own AI conversations again (bugfix)
|
|
32
|
+
|
|
33
|
+
`GET /ai/conversations` and the GraphQL `findAiConversations` query returned
|
|
34
|
+
`403` / "Access denied" for every non-admin user, so their own conversations
|
|
35
|
+
never loaded. The list endpoints passed the per-document roles
|
|
36
|
+
`[ADMIN, S_CREATOR, S_SELF]`, which a LIST operation can never satisfy (there is
|
|
37
|
+
no single object to check ownership against), so non-admins were rejected before
|
|
38
|
+
ownership scoping ran.
|
|
39
|
+
|
|
40
|
+
Both endpoints now authorize the list with `S_USER` and scope the result to the
|
|
41
|
+
caller via a server-side `createdBy` filter plus the model's `securityCheck`.
|
|
42
|
+
Cross-user isolation is unchanged — a non-admin still only ever sees their own
|
|
43
|
+
conversations. **No action required**; it simply works after the update.
|
|
44
|
+
|
|
45
|
+
### 2. Admins can opt in to a cross-user view (`?all=true`)
|
|
46
|
+
|
|
47
|
+
By default every user — **admins included** — now receives only their own
|
|
48
|
+
conversations. An admin can list every user's conversations on demand:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# REST
|
|
52
|
+
GET /ai/conversations?all=true
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```graphql
|
|
56
|
+
# GraphQL
|
|
57
|
+
query { findAiConversations(all: true) { id title createdBy createdByUser } }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The flag is ignored for non-admins, so it can never widen a regular user's
|
|
61
|
+
scope. Results are ordered newest-first.
|
|
62
|
+
|
|
63
|
+
### 3. Creator attribution in the admin cross-user view (`createdByUser`)
|
|
64
|
+
|
|
65
|
+
In the admin cross-user view (`all: true`) each conversation additionally
|
|
66
|
+
carries a resolved `createdByUser` object so the list is attributable to a named
|
|
67
|
+
user:
|
|
68
|
+
|
|
69
|
+
```jsonc
|
|
70
|
+
{
|
|
71
|
+
"id": "…",
|
|
72
|
+
"title": "…",
|
|
73
|
+
"createdBy": "665f…", // owner user id (always present)
|
|
74
|
+
"createdByUser": { // only in the admin all-view
|
|
75
|
+
"id": "665f…",
|
|
76
|
+
"email": "user@example.com",
|
|
77
|
+
"firstName": "…",
|
|
78
|
+
"lastName": "…",
|
|
79
|
+
"username": "…"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
On a normal own-only fetch `createdByUser` is left unset (the owner is the
|
|
85
|
+
caller, so no lookup is needed).
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Behavior Change (admins only)
|
|
90
|
+
|
|
91
|
+
Before 11.31.2, `GET /ai/conversations` returned **all** users' conversations
|
|
92
|
+
for an admin by default. It now returns the **admin's own** conversations by
|
|
93
|
+
default, matching every other role. If your admin UI relied on the unscoped
|
|
94
|
+
list, request the cross-user view explicitly with `?all=true` (REST) or
|
|
95
|
+
`all: true` (GraphQL). Non-admin behavior is unchanged.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Compatibility Notes
|
|
100
|
+
|
|
101
|
+
- `getConversation` / `deleteConversation` (single-object operations) are
|
|
102
|
+
unchanged — they still use `[ADMIN, S_CREATOR, S_SELF]`, which is correct
|
|
103
|
+
because a single loaded object exists to check ownership against.
|
|
104
|
+
- Projects that extend `CoreAiConversationService` and override its constructor
|
|
105
|
+
must forward the new `@InjectConnection()` parameter to `super(...)` (used to
|
|
106
|
+
resolve creators for the admin view). Projects that do not subclass the
|
|
107
|
+
service are unaffected.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Module Documentation
|
|
112
|
+
|
|
113
|
+
### AI Module
|
|
114
|
+
|
|
115
|
+
- **README:** [src/core/modules/ai/README.md](../src/core/modules/ai/README.md)
|
|
116
|
+
- **Integration Checklist:** [src/core/modules/ai/INTEGRATION-CHECKLIST.md](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
|
|
117
|
+
- **Reference Implementation:** `src/server/modules/ai/`
|
|
118
|
+
- **Key Files:**
|
|
119
|
+
- `core-ai.controller.ts` / `core-ai.resolver.ts` — list endpoints delegate to the shared service method
|
|
120
|
+
- `services/core-ai-conversation.service.ts` — `findForCurrentUser()` (shared owner-scoped list + admin all-scope + creator attribution)
|
|
121
|
+
- `models/core-ai-conversation.model.ts` — `createdByUser` field, ownership `securityCheck`
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## References
|
|
126
|
+
|
|
127
|
+
- [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.31.
|
|
3
|
+
"version": "11.31.2",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Body, Controller, Delete, Get, Param, Post, Put, Res } from '@nestjs/common';
|
|
1
|
+
import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res } from '@nestjs/common';
|
|
2
2
|
import { Response } from 'express';
|
|
3
3
|
|
|
4
4
|
import { RESTServiceOptions } from '../../common/decorators/rest-service-options.decorator';
|
|
@@ -266,7 +266,9 @@ export class CoreAiController {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
/**
|
|
269
|
-
* Find the current user's AI conversations (
|
|
269
|
+
* Find the current user's AI conversations (own only by default). An admin may
|
|
270
|
+
* pass `?all=true` to list every user's conversations; each result carries its
|
|
271
|
+
* `createdBy` owner id for attribution. The flag is ignored for non-admins.
|
|
270
272
|
*
|
|
271
273
|
* The `messages` subdocument array is excluded from the list result — clients
|
|
272
274
|
* fetching the conversation detail via `getConversation` get the full message
|
|
@@ -274,13 +276,14 @@ export class CoreAiController {
|
|
|
274
276
|
*/
|
|
275
277
|
@Get('conversations')
|
|
276
278
|
@Roles(RoleEnum.S_USER)
|
|
277
|
-
async findConversations(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
279
|
+
async findConversations(
|
|
280
|
+
@RESTServiceOptions() serviceOptions: ServiceOptions,
|
|
281
|
+
@Query('all') all?: string,
|
|
282
|
+
): Promise<CoreAiConversation[]> {
|
|
283
|
+
// Owner-scoped list shared with the GraphQL resolver — see
|
|
284
|
+
// CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
|
|
285
|
+
// Admins default to their own conversations and opt in to the cross-user view via ?all=true.
|
|
286
|
+
return this.conversationService.findForCurrentUser(serviceOptions, { all: all === 'true' });
|
|
284
287
|
}
|
|
285
288
|
|
|
286
289
|
/**
|
|
@@ -270,21 +270,26 @@ export class CoreAiResolver {
|
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
/**
|
|
273
|
-
* Find the current user's AI conversations (
|
|
273
|
+
* Find the current user's AI conversations (own only by default). An admin may
|
|
274
|
+
* pass `all: true` to list every user's conversations; each result carries its
|
|
275
|
+
* `createdBy` owner id for attribution. The argument is ignored for non-admins.
|
|
274
276
|
*
|
|
275
277
|
* The `messages` subdocument array is excluded from the list result — clients
|
|
276
278
|
* fetching the conversation detail via `getAiConversation` get the full message
|
|
277
279
|
* history. List payloads stay small even for users with many long conversations.
|
|
278
280
|
*/
|
|
279
|
-
@Query(() => [CoreAiConversation], {
|
|
281
|
+
@Query(() => [CoreAiConversation], {
|
|
282
|
+
description: "Find AI conversations of the current user (admins may pass all: true to see every user's)",
|
|
283
|
+
})
|
|
280
284
|
@Roles(RoleEnum.S_USER)
|
|
281
|
-
async findAiConversations(
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
285
|
+
async findAiConversations(
|
|
286
|
+
@GraphQLServiceOptions() serviceOptions: ServiceOptions,
|
|
287
|
+
@Args('all', { nullable: true, type: () => Boolean }) all?: boolean,
|
|
288
|
+
): Promise<CoreAiConversation[]> {
|
|
289
|
+
// Owner-scoped list shared with the REST controller — see
|
|
290
|
+
// CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
|
|
291
|
+
// Admins default to their own conversations and opt in to the cross-user view via all: true.
|
|
292
|
+
return this.conversationService.findForCurrentUser(serviceOptions, { all: all === true });
|
|
288
293
|
}
|
|
289
294
|
|
|
290
295
|
/**
|
|
@@ -6,6 +6,7 @@ import { Restricted } from '../../../common/decorators/restricted.decorator';
|
|
|
6
6
|
import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
|
|
7
7
|
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
8
8
|
import { CorePersistenceModel } from '../../../common/models/core-persistence.model';
|
|
9
|
+
import { JSON } from '../../../common/scalars/json.scalar';
|
|
9
10
|
import { CoreAiMessage } from './core-ai-message.model';
|
|
10
11
|
|
|
11
12
|
export type AiConversationDocument = CoreAiConversation & Document;
|
|
@@ -45,6 +46,22 @@ export class CoreAiConversation extends CorePersistenceModel {
|
|
|
45
46
|
})
|
|
46
47
|
createdBy?: string = undefined;
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Resolved creator identity (`{ id, email, firstName, lastName, username }`),
|
|
51
|
+
* attached at read time ONLY for an admin's cross-user list view (`all: true`)
|
|
52
|
+
* so each conversation is attributable to a named user. Not persisted and left
|
|
53
|
+
* unset on a normal own-only fetch, where the owner is the caller.
|
|
54
|
+
*/
|
|
55
|
+
@UnifiedField({
|
|
56
|
+
description:
|
|
57
|
+
'Resolved creator identity (id, email, name) — set only in the admin cross-user list view, not persisted',
|
|
58
|
+
gqlType: JSON,
|
|
59
|
+
isOptional: true,
|
|
60
|
+
roles: RoleEnum.S_USER,
|
|
61
|
+
type: () => Object,
|
|
62
|
+
})
|
|
63
|
+
createdByUser?: Record<string, any> = undefined;
|
|
64
|
+
|
|
48
65
|
/**
|
|
49
66
|
* Conversation messages (appended via $push).
|
|
50
67
|
*/
|
|
@@ -76,7 +93,7 @@ export class CoreAiConversation extends CorePersistenceModel {
|
|
|
76
93
|
if (force) {
|
|
77
94
|
return this;
|
|
78
95
|
}
|
|
79
|
-
if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === user.id))) {
|
|
96
|
+
if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === String(user.id)))) {
|
|
80
97
|
return this;
|
|
81
98
|
}
|
|
82
99
|
// Hide conversations owned by other users (filtered out of list responses).
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Inject, Injectable } from '@nestjs/common';
|
|
2
|
-
import { InjectModel } from '@nestjs/mongoose';
|
|
3
|
-
import { Model, Types } from 'mongoose';
|
|
2
|
+
import { InjectConnection, InjectModel } from '@nestjs/mongoose';
|
|
3
|
+
import { Connection, Model, Types } from 'mongoose';
|
|
4
4
|
|
|
5
|
+
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
6
|
+
import { ServiceOptions } from '../../../common/interfaces/service-options.interface';
|
|
5
7
|
import { CrudService } from '../../../common/services/crud.service';
|
|
6
8
|
import { CoreModelConstructor } from '../../../common/types/core-model-constructor.type';
|
|
7
9
|
import { CoreAiConversationCreateInput } from '../inputs/core-ai-conversation-create.input';
|
|
@@ -21,8 +23,15 @@ export { AI_CONVERSATION_CLASS, AI_CONVERSATION_MODEL } from '../core-ai.constan
|
|
|
21
23
|
* CRUD service for multi-turn {@link CoreAiConversation}s.
|
|
22
24
|
*
|
|
23
25
|
* `appendMessage()` adds a turn via `$push` (never round-trips the subdocument
|
|
24
|
-
* array through `update()`).
|
|
25
|
-
*
|
|
26
|
+
* array through `update()`). `findForCurrentUser()` is the shared owner-scoped
|
|
27
|
+
* list used by both the REST controller and the GraphQL resolver.
|
|
28
|
+
*
|
|
29
|
+
* Ownership is enforced differently per operation shape:
|
|
30
|
+
* - single-object `get`/`delete`: the controller/resolver pass
|
|
31
|
+
* `S_CREATOR`/`S_SELF`, evaluated against the loaded `dbObject`.
|
|
32
|
+
* - list (`findForCurrentUser`): a list has no single `dbObject`, so the gate
|
|
33
|
+
* uses `S_USER` and ownership is scoped by the server-computed `createdBy`
|
|
34
|
+
* filterQuery plus the model's `securityCheck`.
|
|
26
35
|
*/
|
|
27
36
|
@Injectable()
|
|
28
37
|
export class CoreAiConversationService extends CrudService<
|
|
@@ -37,10 +46,107 @@ export class CoreAiConversationService extends CrudService<
|
|
|
37
46
|
@InjectModel(AI_CONVERSATION_MODEL) protected override readonly mainDbModel: Model<AiConversationDocument>,
|
|
38
47
|
@Inject(AI_CONVERSATION_CLASS)
|
|
39
48
|
protected override readonly mainModelConstructor: CoreModelConstructor<CoreAiConversation>,
|
|
49
|
+
@InjectConnection() protected readonly connection: Connection,
|
|
40
50
|
) {
|
|
41
51
|
super();
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
/**
|
|
55
|
+
* List the AI conversations visible to the current user. By default — for
|
|
56
|
+
* every user, admins included — this returns only the caller's own
|
|
57
|
+
* conversations, newest first. An admin may opt in to the cross-user view
|
|
58
|
+
* (every user's conversations) by passing `{ all: true }`; the flag is
|
|
59
|
+
* ignored for non-admins, so it can never widen a regular user's scope.
|
|
60
|
+
*
|
|
61
|
+
* Each result carries its `createdBy` owner id. In the admin cross-user view
|
|
62
|
+
* (`all: true`) each result additionally carries a resolved `createdByUser`
|
|
63
|
+
* (`{ id, email, firstName, lastName, username }`) so the list is attributable
|
|
64
|
+
* to named users; on a normal own-only fetch that lookup is skipped, since the
|
|
65
|
+
* owner is the caller. The heavy `messages` subdocument array is excluded from
|
|
66
|
+
* the list payload — fetch a single conversation via `get()` for the full
|
|
67
|
+
* message history.
|
|
68
|
+
*
|
|
69
|
+
* Shared by the REST controller (`GET /ai/conversations`) and the GraphQL
|
|
70
|
+
* resolver (`findAiConversations`) so both API surfaces enforce ownership
|
|
71
|
+
* identically and cannot drift apart.
|
|
72
|
+
*
|
|
73
|
+
* A LIST operation has no single `dbObject`, so the per-document roles
|
|
74
|
+
* `S_CREATOR` / `S_SELF` can never be satisfied at the operation level —
|
|
75
|
+
* passing them rejected every non-admin with a 403 before ownership scoping
|
|
76
|
+
* ran. The operation gate therefore uses `[S_USER]`; the server-computed
|
|
77
|
+
* `createdBy` filterQuery and the model `securityCheck` scope the result to
|
|
78
|
+
* the owner.
|
|
79
|
+
*/
|
|
80
|
+
async findForCurrentUser(
|
|
81
|
+
serviceOptions?: ServiceOptions,
|
|
82
|
+
options?: { all?: boolean },
|
|
83
|
+
): Promise<CoreAiConversation[]> {
|
|
84
|
+
const currentUser = serviceOptions?.currentUser;
|
|
85
|
+
const isAdmin = !!currentUser?.roles?.includes(RoleEnum.ADMIN);
|
|
86
|
+
// Default is own-only for everyone; only an admin may opt in to all users' conversations.
|
|
87
|
+
const seeAll = isAdmin && options?.all === true;
|
|
88
|
+
const filterQuery = seeAll ? {} : { createdBy: currentUser?.id };
|
|
89
|
+
const conversations = await this.find(
|
|
90
|
+
{ filterQuery, queryOptions: { sort: { createdAt: -1 } } },
|
|
91
|
+
{ ...serviceOptions, roles: [RoleEnum.S_USER], select: '-messages' },
|
|
92
|
+
);
|
|
93
|
+
// Resolve creators to named users ONLY for the admin cross-user view — a normal
|
|
94
|
+
// fetch returns just the caller's own conversations, so the owner is already known.
|
|
95
|
+
if (seeAll && conversations.length) {
|
|
96
|
+
await this.attachCreators(conversations);
|
|
97
|
+
}
|
|
98
|
+
return conversations;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Attach a lightweight resolved creator (`{ id, email, firstName, lastName,
|
|
103
|
+
* username }`) as `createdByUser` to each conversation, so an admin's
|
|
104
|
+
* cross-user list is attributable to named users. Read-only lookup of
|
|
105
|
+
* non-sensitive identity fields via the shared `User` model on the same
|
|
106
|
+
* connection; fails soft (leaves `createdByUser` unset) when no `User` model is
|
|
107
|
+
* registered. Never called on a normal own-only fetch.
|
|
108
|
+
*/
|
|
109
|
+
protected async attachCreators(conversations: CoreAiConversation[]): Promise<void> {
|
|
110
|
+
const ids = [
|
|
111
|
+
...new Set(
|
|
112
|
+
conversations
|
|
113
|
+
.map((conversation) => conversation.createdBy)
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map(String),
|
|
116
|
+
),
|
|
117
|
+
];
|
|
118
|
+
if (!ids.length) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let userModel: Model<any>;
|
|
122
|
+
try {
|
|
123
|
+
userModel = this.connection.model('User');
|
|
124
|
+
} catch {
|
|
125
|
+
// No `User` model registered on this connection (exotic setup) — skip attribution.
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const users = await userModel
|
|
129
|
+
.find({ _id: { $in: ids } })
|
|
130
|
+
.select('email firstName lastName username')
|
|
131
|
+
.lean()
|
|
132
|
+
.exec();
|
|
133
|
+
const byId = new Map(
|
|
134
|
+
(users as any[]).map((user) => [
|
|
135
|
+
String(user._id),
|
|
136
|
+
{
|
|
137
|
+
email: user.email,
|
|
138
|
+
firstName: user.firstName,
|
|
139
|
+
id: String(user._id),
|
|
140
|
+
lastName: user.lastName,
|
|
141
|
+
username: user.username,
|
|
142
|
+
},
|
|
143
|
+
]),
|
|
144
|
+
);
|
|
145
|
+
for (const conversation of conversations) {
|
|
146
|
+
conversation.createdByUser = byId.get(String(conversation.createdBy));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
44
150
|
/**
|
|
45
151
|
* Append a message to a conversation via `$push` (system-internal; the user
|
|
46
152
|
* already authorized the prompt that produced it). The array is capped at
|