@memberjunction/core-entities 5.21.0 → 5.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engines/conversations.d.ts +439 -0
- package/dist/engines/conversations.d.ts.map +1 -0
- package/dist/engines/conversations.js +1102 -0
- package/dist/engines/conversations.js.map +1 -0
- package/dist/engines/knowledgeHubMetadata.d.ts +54 -0
- package/dist/engines/knowledgeHubMetadata.d.ts.map +1 -0
- package/dist/engines/knowledgeHubMetadata.js +152 -0
- package/dist/engines/knowledgeHubMetadata.js.map +1 -0
- package/dist/generated/entity_subclasses.d.ts +594 -51
- package/dist/generated/entity_subclasses.d.ts.map +1 -1
- package/dist/generated/entity_subclasses.js +868 -78
- package/dist/generated/entity_subclasses.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/readme.md +73 -1
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
import { BaseEngine, Metadata, RunQuery, RunView, TransformSimpleObjectToEntityObject } from "@memberjunction/core";
|
|
2
|
+
import { NormalizeUUID, UUIDsEqual } from "@memberjunction/global";
|
|
3
|
+
import { BehaviorSubject } from "rxjs";
|
|
4
|
+
import { ArtifactMetadataEngine } from "./artifacts.js";
|
|
5
|
+
/**
|
|
6
|
+
* Helper: parse a raw ConversationDetailComplete row into typed arrays.
|
|
7
|
+
*/
|
|
8
|
+
export function parseConversationDetailComplete(queryResult) {
|
|
9
|
+
return {
|
|
10
|
+
...queryResult,
|
|
11
|
+
agentRuns: queryResult.AgentRunsJSON
|
|
12
|
+
? JSON.parse(queryResult.AgentRunsJSON)
|
|
13
|
+
: [],
|
|
14
|
+
artifacts: queryResult.ArtifactsJSON
|
|
15
|
+
? JSON.parse(queryResult.ArtifactsJSON)
|
|
16
|
+
: [],
|
|
17
|
+
ratings: queryResult.RatingsJSON
|
|
18
|
+
? JSON.parse(queryResult.RatingsJSON)
|
|
19
|
+
: []
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* ConversationEngine provides centralized, reactive caching for conversations,
|
|
24
|
+
* conversation details (messages), and peripheral data (agent runs, artifacts).
|
|
25
|
+
*
|
|
26
|
+
* This engine is the single source of truth for conversation data across all UI
|
|
27
|
+
* consumers (chat area, sidebar, overlay, etc.). It replaces per-component caching
|
|
28
|
+
* that previously lived in conversation-chat-area component,
|
|
29
|
+
* and other scattered locations.
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* ```typescript
|
|
33
|
+
* // Initialize (call once at app startup after metadata is loaded)
|
|
34
|
+
* await ConversationEngine.Instance.Config(false, contextUser);
|
|
35
|
+
*
|
|
36
|
+
* // Load conversations for the current user
|
|
37
|
+
* await ConversationEngine.Instance.LoadConversations('env-id', contextUser);
|
|
38
|
+
*
|
|
39
|
+
* // Subscribe to conversation list changes
|
|
40
|
+
* ConversationEngine.Instance.Conversations$.subscribe(conversations => {
|
|
41
|
+
* // React to changes
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* // Load details for a specific conversation
|
|
45
|
+
* const details = await ConversationEngine.Instance.LoadConversationDetails('conv-id', contextUser);
|
|
46
|
+
*
|
|
47
|
+
* // Get cached details (instant, no DB round-trip)
|
|
48
|
+
* const cached = ConversationEngine.Instance.GetCachedDetails('conv-id');
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export class ConversationEngine extends BaseEngine {
|
|
52
|
+
constructor() {
|
|
53
|
+
super(...arguments);
|
|
54
|
+
// ========================================================================
|
|
55
|
+
// REACTIVE STATE
|
|
56
|
+
// ========================================================================
|
|
57
|
+
this._conversations$ = new BehaviorSubject([]);
|
|
58
|
+
// ========================================================================
|
|
59
|
+
// INTERNAL STATE
|
|
60
|
+
// ========================================================================
|
|
61
|
+
/** Detail cache keyed by normalized conversation ID */
|
|
62
|
+
this._detailCache = new Map();
|
|
63
|
+
/** Track the environment ID used for the last load */
|
|
64
|
+
this._lastEnvironmentId = null;
|
|
65
|
+
/**
|
|
66
|
+
* Guard flag: set true while the engine itself is performing a mutation.
|
|
67
|
+
* Prevents the entity event handler from re-processing our own saves/deletes,
|
|
68
|
+
* which would cause redundant cache updates or infinite loops.
|
|
69
|
+
*/
|
|
70
|
+
this._selfMutating = false;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Returns the global instance of the class. This is a singleton class, so there is only
|
|
74
|
+
* one instance of it in the application. Do not directly create new instances of it,
|
|
75
|
+
* always use this method to get the instance.
|
|
76
|
+
*/
|
|
77
|
+
static get Instance() {
|
|
78
|
+
return super.getInstance();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Observable stream of the current user's conversation list.
|
|
82
|
+
* Emits whenever conversations are loaded, created, deleted, archived, or pinned.
|
|
83
|
+
*/
|
|
84
|
+
get Conversations$() {
|
|
85
|
+
return this._conversations$.asObservable();
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Current snapshot of conversations (non-reactive).
|
|
89
|
+
*/
|
|
90
|
+
get Conversations() {
|
|
91
|
+
return this._conversations$.value;
|
|
92
|
+
}
|
|
93
|
+
// ========================================================================
|
|
94
|
+
// ENGINE CONFIG (BaseEngine pattern)
|
|
95
|
+
// ========================================================================
|
|
96
|
+
/**
|
|
97
|
+
* Configures the engine. Unlike other engines that bulk-load entity tables via BaseEngine.Load(),
|
|
98
|
+
* ConversationEngine manages its own caching because conversations are user-scoped and filtered
|
|
99
|
+
* by environment, which doesn't fit the standard "load all rows" pattern.
|
|
100
|
+
*
|
|
101
|
+
* Call this once at startup to initialize the engine. Conversation data is loaded
|
|
102
|
+
* separately via LoadConversations().
|
|
103
|
+
*/
|
|
104
|
+
async Config(forceRefresh, contextUser, provider) {
|
|
105
|
+
// Ensure ArtifactMetadataEngine is loaded — conversation details reference artifacts
|
|
106
|
+
// and we need artifact types available before processing. Passing false means
|
|
107
|
+
// this is a no-op if it's already loaded.
|
|
108
|
+
await ArtifactMetadataEngine.Instance.Config(forceRefresh, contextUser, provider);
|
|
109
|
+
// We don't use BaseEngine.Load() because conversations are user-scoped.
|
|
110
|
+
// Mark the engine as "configured" so consumers can check .Loaded.
|
|
111
|
+
// We call Load with an empty config array just to set the Loaded flag and wire up
|
|
112
|
+
// the BaseEngine infrastructure (entity event listeners, etc.)
|
|
113
|
+
const configs = [];
|
|
114
|
+
await this.Load(configs, provider, forceRefresh, contextUser);
|
|
115
|
+
}
|
|
116
|
+
// ========================================================================
|
|
117
|
+
// CONVERSATION LIST OPERATIONS
|
|
118
|
+
// ========================================================================
|
|
119
|
+
/**
|
|
120
|
+
* Loads conversations from the database for the given user and environment.
|
|
121
|
+
* Results are cached and emitted via Conversations$.
|
|
122
|
+
*
|
|
123
|
+
* @param environmentId - The environment to filter conversations by
|
|
124
|
+
* @param contextUser - The current user context
|
|
125
|
+
* @param forceRefresh - If true, reloads even if data is already cached
|
|
126
|
+
*/
|
|
127
|
+
async LoadConversations(environmentId, contextUser, forceRefresh = false) {
|
|
128
|
+
// Skip if already loaded for this environment (unless forcing)
|
|
129
|
+
if (!forceRefresh && this._lastEnvironmentId === environmentId && this._conversations$.value.length > 0) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this._lastEnvironmentId = environmentId;
|
|
133
|
+
const rv = new RunView();
|
|
134
|
+
const filter = `EnvironmentID='${environmentId}' AND UserID='${contextUser.ID}' AND (IsArchived IS NULL OR IsArchived=0)`;
|
|
135
|
+
const result = await rv.RunView({
|
|
136
|
+
EntityName: 'MJ: Conversations',
|
|
137
|
+
ExtraFilter: filter,
|
|
138
|
+
OrderBy: 'IsPinned DESC, __mj_UpdatedAt DESC',
|
|
139
|
+
MaxRows: 1000,
|
|
140
|
+
ResultType: 'entity_object'
|
|
141
|
+
}, contextUser);
|
|
142
|
+
if (result.Success) {
|
|
143
|
+
this._conversations$.next(result.Results || []);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
console.error('[ConversationEngine] Failed to load conversations:', result.ErrorMessage);
|
|
147
|
+
this._conversations$.next([]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Creates a new conversation, saves it to the database, and adds it to the cached list.
|
|
152
|
+
*
|
|
153
|
+
* @param name - Display name for the conversation
|
|
154
|
+
* @param environmentId - The environment ID
|
|
155
|
+
* @param contextUser - The current user context
|
|
156
|
+
* @param description - Optional description
|
|
157
|
+
* @param projectId - Optional project ID
|
|
158
|
+
* @returns The newly created conversation entity
|
|
159
|
+
* @throws Error if save fails
|
|
160
|
+
*/
|
|
161
|
+
async CreateConversation(name, environmentId, contextUser, description, projectId) {
|
|
162
|
+
const md = new Metadata();
|
|
163
|
+
const conversation = await md.GetEntityObject('MJ: Conversations', contextUser);
|
|
164
|
+
conversation.Name = name;
|
|
165
|
+
conversation.EnvironmentID = environmentId;
|
|
166
|
+
conversation.UserID = contextUser.ID;
|
|
167
|
+
if (description)
|
|
168
|
+
conversation.Description = description;
|
|
169
|
+
if (projectId)
|
|
170
|
+
conversation.ProjectID = projectId;
|
|
171
|
+
const saved = await conversation.Save();
|
|
172
|
+
if (!saved) {
|
|
173
|
+
throw new Error(conversation.LatestResult?.Message || 'Failed to create conversation');
|
|
174
|
+
}
|
|
175
|
+
// Prepend to the list and emit
|
|
176
|
+
const updated = [conversation, ...this._conversations$.value];
|
|
177
|
+
this._conversations$.next(updated);
|
|
178
|
+
return conversation;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Deletes a conversation from the database and removes it from the cached list.
|
|
182
|
+
*
|
|
183
|
+
* @param id - The conversation ID to delete
|
|
184
|
+
* @param contextUser - The current user context
|
|
185
|
+
* @returns true if successful
|
|
186
|
+
* @throws Error if conversation not found or delete fails
|
|
187
|
+
*/
|
|
188
|
+
async DeleteConversation(id, contextUser) {
|
|
189
|
+
// Try to use the cached entity object first to avoid a DB round-trip
|
|
190
|
+
let conversation = this.GetConversation(id);
|
|
191
|
+
if (!conversation) {
|
|
192
|
+
// Not in cache — load from DB as fallback
|
|
193
|
+
const md = new Metadata();
|
|
194
|
+
conversation = await md.GetEntityObject('MJ: Conversations', contextUser);
|
|
195
|
+
const loaded = await conversation.Load(id);
|
|
196
|
+
if (!loaded) {
|
|
197
|
+
throw new Error('Conversation not found');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Remove from the list and cache BEFORE calling Delete(), because
|
|
201
|
+
// BaseEntity.Delete() calls NewRecord() which wipes the entity's fields.
|
|
202
|
+
// Since the cached entity is the same object reference in the list,
|
|
203
|
+
// wiping it causes a blank render frame before removal.
|
|
204
|
+
this.removeFromList(id);
|
|
205
|
+
this.removeDetailCache(id);
|
|
206
|
+
this._selfMutating = true;
|
|
207
|
+
try {
|
|
208
|
+
const deleted = await conversation.Delete();
|
|
209
|
+
if (!deleted) {
|
|
210
|
+
// Delete failed — restore to list
|
|
211
|
+
const current = this._conversations$.value;
|
|
212
|
+
this._conversations$.next([conversation, ...current]);
|
|
213
|
+
throw new Error(conversation.LatestResult?.Message || 'Failed to delete conversation');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
this._selfMutating = false;
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Archives a conversation (sets IsArchived = true) and removes it from the active list.
|
|
223
|
+
*
|
|
224
|
+
* @param id - The conversation ID to archive
|
|
225
|
+
* @param contextUser - The current user context
|
|
226
|
+
* @returns true if successful
|
|
227
|
+
*/
|
|
228
|
+
async ArchiveConversation(id, contextUser) {
|
|
229
|
+
const result = await this.saveConversationField(id, { IsArchived: true }, contextUser);
|
|
230
|
+
if (result) {
|
|
231
|
+
// Archived conversations disappear from the active list
|
|
232
|
+
this.removeFromList(id);
|
|
233
|
+
this.removeDetailCache(id);
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Toggles or sets the pinned status of a conversation.
|
|
239
|
+
*
|
|
240
|
+
* @param id - The conversation ID
|
|
241
|
+
* @param isPinned - Whether the conversation should be pinned
|
|
242
|
+
* @param contextUser - The current user context
|
|
243
|
+
* @returns true if successful
|
|
244
|
+
*/
|
|
245
|
+
async PinConversation(id, isPinned, contextUser) {
|
|
246
|
+
const result = await this.saveConversationField(id, { IsPinned: isPinned }, contextUser);
|
|
247
|
+
if (result) {
|
|
248
|
+
// Update the in-memory entity and re-emit
|
|
249
|
+
const conversation = this.GetConversation(id);
|
|
250
|
+
if (conversation) {
|
|
251
|
+
conversation.IsPinned = isPinned;
|
|
252
|
+
// Re-sort: pinned first, then by updated date
|
|
253
|
+
const sorted = this.sortConversations(this._conversations$.value);
|
|
254
|
+
this._conversations$.next(sorted);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Finds a conversation by ID in the cached list.
|
|
261
|
+
*
|
|
262
|
+
* @param id - The conversation ID to find
|
|
263
|
+
* @returns The conversation entity, or undefined if not in cache
|
|
264
|
+
*/
|
|
265
|
+
GetConversation(id) {
|
|
266
|
+
return this._conversations$.value.find(c => UUIDsEqual(c.ID, id));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Saves partial updates to a conversation (Name, Description, or any writable field).
|
|
270
|
+
* Loads the entity from DB, applies updates, saves, and updates the in-memory list.
|
|
271
|
+
*
|
|
272
|
+
* @param id - The conversation ID to update
|
|
273
|
+
* @param updates - Partial fields to update
|
|
274
|
+
* @param contextUser - The current user context
|
|
275
|
+
* @returns true if saved successfully
|
|
276
|
+
* @throws Error if conversation not found or save fails
|
|
277
|
+
*/
|
|
278
|
+
async SaveConversation(id, updates, contextUser) {
|
|
279
|
+
// Try to use the cached entity to avoid a DB round-trip
|
|
280
|
+
let conversation = this.GetConversation(id);
|
|
281
|
+
if (!conversation) {
|
|
282
|
+
// Not in cache — load from DB as fallback
|
|
283
|
+
const md = new Metadata();
|
|
284
|
+
conversation = await md.GetEntityObject('MJ: Conversations', contextUser);
|
|
285
|
+
const loaded = await conversation.Load(id);
|
|
286
|
+
if (!loaded) {
|
|
287
|
+
throw new Error('Conversation not found');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
this.mergeDataOntoRecord(conversation, updates);
|
|
291
|
+
this._selfMutating = true;
|
|
292
|
+
try {
|
|
293
|
+
const saved = await conversation.Save();
|
|
294
|
+
if (!saved) {
|
|
295
|
+
throw new Error(conversation.LatestResult?.Message || 'Failed to update conversation');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
this._selfMutating = false;
|
|
300
|
+
}
|
|
301
|
+
// Re-emit the list so subscribers see the update
|
|
302
|
+
this._conversations$.next([...this._conversations$.value]);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Deletes multiple conversations in a batch operation with per-item error tracking.
|
|
307
|
+
*
|
|
308
|
+
* @param ids - Array of conversation IDs to delete
|
|
309
|
+
* @param contextUser - The current user context
|
|
310
|
+
* @returns Object with successful and failed deletions
|
|
311
|
+
*/
|
|
312
|
+
async DeleteMultipleConversations(ids, contextUser) {
|
|
313
|
+
if (ids.length === 0) {
|
|
314
|
+
return { Successful: [], Failed: [] };
|
|
315
|
+
}
|
|
316
|
+
const md = new Metadata();
|
|
317
|
+
const successful = [];
|
|
318
|
+
const failed = [];
|
|
319
|
+
const entitiesToDelete = [];
|
|
320
|
+
// Phase 1: Gather entities — use cached objects when available to avoid DB round-trips
|
|
321
|
+
for (const id of ids) {
|
|
322
|
+
const cached = this.GetConversation(id);
|
|
323
|
+
if (cached) {
|
|
324
|
+
entitiesToDelete.push(cached);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
// Not in cache — try loading from DB
|
|
328
|
+
try {
|
|
329
|
+
const entity = await md.GetEntityObject('MJ: Conversations', contextUser);
|
|
330
|
+
const loaded = await entity.Load(id);
|
|
331
|
+
if (!loaded) {
|
|
332
|
+
failed.push({ ID: id, Name: 'Unknown', Error: 'Conversation not found' });
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
entitiesToDelete.push(entity);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
failed.push({ ID: id, Name: 'Unknown', Error: error instanceof Error ? error.message : 'Unknown error' });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (entitiesToDelete.length === 0) {
|
|
344
|
+
return { Successful: successful, Failed: failed };
|
|
345
|
+
}
|
|
346
|
+
// Phase 2: Delete each conversation individually.
|
|
347
|
+
// We use individual deletes rather than TransactionGroup because cached entities
|
|
348
|
+
// from RunView may have stale state on the server (e.g., already deleted records
|
|
349
|
+
// cause InnerLoad failures inside TransactionGroup, failing the entire batch).
|
|
350
|
+
// Individual deletes let us handle partial success gracefully.
|
|
351
|
+
this._selfMutating = true;
|
|
352
|
+
try {
|
|
353
|
+
for (const entity of entitiesToDelete) {
|
|
354
|
+
const name = entity.Name || 'Unknown';
|
|
355
|
+
try {
|
|
356
|
+
// Use DeleteConversation which handles Load + Delete properly
|
|
357
|
+
// and removes from list/cache on success
|
|
358
|
+
await this.DeleteConversation(entity.ID, contextUser);
|
|
359
|
+
successful.push(entity.ID);
|
|
360
|
+
}
|
|
361
|
+
catch (deleteError) {
|
|
362
|
+
const errMsg = deleteError instanceof Error ? deleteError.message : 'Delete failed';
|
|
363
|
+
// If the record wasn't found (already deleted), treat as success for UI purposes
|
|
364
|
+
if (errMsg.includes('not found') || errMsg.includes('hasn\'t yet been saved')) {
|
|
365
|
+
successful.push(entity.ID);
|
|
366
|
+
this.removeDetailCache(entity.ID);
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
failed.push({ ID: entity.ID, Name: name, Error: errMsg });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
this._selfMutating = false;
|
|
376
|
+
}
|
|
377
|
+
// Phase 3: Ensure all successful IDs are removed from the list.
|
|
378
|
+
// DeleteConversation handles its own removal, but "already deleted" items
|
|
379
|
+
// (counted as successful above) may still be in the stale cache.
|
|
380
|
+
this.removeMultipleFromList(successful);
|
|
381
|
+
return { Successful: successful, Failed: failed };
|
|
382
|
+
}
|
|
383
|
+
// ========================================================================
|
|
384
|
+
// CONVERSATION DETAILS (Messages)
|
|
385
|
+
// ========================================================================
|
|
386
|
+
/**
|
|
387
|
+
* Loads conversation details (messages) for a specific conversation.
|
|
388
|
+
* Results are cached for instant retrieval on subsequent calls.
|
|
389
|
+
*
|
|
390
|
+
* @param conversationId - The conversation to load details for
|
|
391
|
+
* @param contextUser - The current user context
|
|
392
|
+
* @param forceRefresh - If true, reloads even if cached
|
|
393
|
+
* @returns Array of conversation detail entities
|
|
394
|
+
*/
|
|
395
|
+
/**
|
|
396
|
+
* Loads conversation details using the efficient GetConversationComplete query
|
|
397
|
+
* which returns messages, agent runs, artifacts, ratings, and user avatars in one round-trip.
|
|
398
|
+
* Results are cached for instant retrieval on subsequent calls.
|
|
399
|
+
*
|
|
400
|
+
* @param conversationId - The conversation to load details for
|
|
401
|
+
* @param contextUser - The current user context
|
|
402
|
+
* @param forceRefresh - If true, reloads even if cached
|
|
403
|
+
* @returns The full cache entry with all peripheral data
|
|
404
|
+
*/
|
|
405
|
+
async LoadConversationDetails(conversationId, contextUser, forceRefresh = false) {
|
|
406
|
+
const key = NormalizeUUID(conversationId);
|
|
407
|
+
// Return cached if available and not forcing
|
|
408
|
+
if (!forceRefresh) {
|
|
409
|
+
const cached = this._detailCache.get(key);
|
|
410
|
+
if (cached) {
|
|
411
|
+
return cached;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Use GetConversationComplete for one-round-trip loading of all data
|
|
415
|
+
const rq = new RunQuery();
|
|
416
|
+
const result = await rq.RunQuery({
|
|
417
|
+
QueryName: 'GetConversationComplete',
|
|
418
|
+
CategoryPath: 'MJ/Conversations',
|
|
419
|
+
Parameters: { ConversationID: conversationId }
|
|
420
|
+
}, contextUser);
|
|
421
|
+
if (!result.Success || !result.Results) {
|
|
422
|
+
console.error('[ConversationEngine] Failed to load conversation details:', result.ErrorMessage);
|
|
423
|
+
// Return empty cache entry
|
|
424
|
+
const empty = {
|
|
425
|
+
Details: [],
|
|
426
|
+
RawData: [],
|
|
427
|
+
AgentRunsByDetailId: new Map(),
|
|
428
|
+
UserAvatars: new Map(),
|
|
429
|
+
RatingsByDetailId: new Map(),
|
|
430
|
+
ArtifactsByDetailId: new Map(),
|
|
431
|
+
LoadedAt: new Date(),
|
|
432
|
+
PeripheralDataStale: false
|
|
433
|
+
};
|
|
434
|
+
return empty;
|
|
435
|
+
}
|
|
436
|
+
const rawData = result.Results;
|
|
437
|
+
// Build the cache entry from the rich query result
|
|
438
|
+
const cacheEntry = await this.buildDetailCacheFromRawData(rawData, contextUser);
|
|
439
|
+
this._detailCache.set(key, cacheEntry);
|
|
440
|
+
return cacheEntry;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Builds a full ConversationDetailCache from raw GetConversationComplete query results.
|
|
444
|
+
* Hydrates entity objects and parses peripheral JSON data in one pass.
|
|
445
|
+
*/
|
|
446
|
+
async buildDetailCacheFromRawData(rawData, contextUser) {
|
|
447
|
+
const md = new Metadata();
|
|
448
|
+
// Hydrate raw rows into MJConversationDetailEntity objects
|
|
449
|
+
const validRows = rawData.filter(row => !!row.ID);
|
|
450
|
+
const details = await TransformSimpleObjectToEntityObject(Metadata.Provider, 'MJ: Conversation Details', validRows, contextUser);
|
|
451
|
+
// Build peripheral data maps from parsed JSON in one pass
|
|
452
|
+
const agentRuns = new Map();
|
|
453
|
+
const userAvatars = new Map();
|
|
454
|
+
const ratingsByDetailId = new Map();
|
|
455
|
+
const artifactsByDetailId = new Map();
|
|
456
|
+
for (const row of rawData) {
|
|
457
|
+
if (!row.ID)
|
|
458
|
+
continue;
|
|
459
|
+
const parsed = parseConversationDetailComplete(row);
|
|
460
|
+
// Agent runs
|
|
461
|
+
if (parsed.agentRuns.length > 0) {
|
|
462
|
+
const agentRunData = parsed.agentRuns[0];
|
|
463
|
+
const agentRun = await md.GetEntityObject('MJ: AI Agent Runs', contextUser);
|
|
464
|
+
agentRun.LoadFromData({
|
|
465
|
+
ID: agentRunData.ID,
|
|
466
|
+
AgentID: agentRunData.AgentID,
|
|
467
|
+
Agent: agentRunData.Agent,
|
|
468
|
+
Status: agentRunData.Status,
|
|
469
|
+
__mj_CreatedAt: agentRunData.__mj_CreatedAt,
|
|
470
|
+
__mj_UpdatedAt: agentRunData.__mj_UpdatedAt,
|
|
471
|
+
TotalPromptTokensUsed: agentRunData.TotalPromptTokensUsed,
|
|
472
|
+
TotalCompletionTokensUsed: agentRunData.TotalCompletionTokensUsed,
|
|
473
|
+
TotalCost: agentRunData.TotalCost,
|
|
474
|
+
ConversationDetailID: agentRunData.ConversationDetailID
|
|
475
|
+
});
|
|
476
|
+
agentRuns.set(row.ID, agentRun);
|
|
477
|
+
}
|
|
478
|
+
// Artifacts
|
|
479
|
+
if (parsed.artifacts.length > 0) {
|
|
480
|
+
artifactsByDetailId.set(row.ID, parsed.artifacts);
|
|
481
|
+
}
|
|
482
|
+
// Ratings
|
|
483
|
+
if (parsed.ratings.length > 0) {
|
|
484
|
+
ratingsByDetailId.set(row.ID, parsed.ratings);
|
|
485
|
+
}
|
|
486
|
+
// User avatars (deduplicate by UserID)
|
|
487
|
+
if (row.Role?.toLowerCase() === 'user' && row.UserID && !userAvatars.has(row.UserID)) {
|
|
488
|
+
userAvatars.set(row.UserID, {
|
|
489
|
+
ImageURL: row.UserImageURL || null,
|
|
490
|
+
IconClass: row.UserImageIconClass || null
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
Details: details,
|
|
496
|
+
RawData: rawData,
|
|
497
|
+
AgentRunsByDetailId: agentRuns,
|
|
498
|
+
UserAvatars: userAvatars,
|
|
499
|
+
RatingsByDetailId: ratingsByDetailId,
|
|
500
|
+
ArtifactsByDetailId: artifactsByDetailId,
|
|
501
|
+
LoadedAt: new Date(),
|
|
502
|
+
PeripheralDataStale: false
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Refreshes conversation details by re-running the GetConversationComplete query
|
|
507
|
+
* and surgically merging results into the existing cache. Existing objects that
|
|
508
|
+
* haven't changed keep their references (minimizing Angular re-renders).
|
|
509
|
+
*
|
|
510
|
+
* - New messages: appended to Details array
|
|
511
|
+
* - Existing messages: fields updated in-place on the same object
|
|
512
|
+
* - Agent runs: updated in-place or added
|
|
513
|
+
* - Artifacts/ratings: replaced per-detail (cheap — plain data, not entity objects)
|
|
514
|
+
* - User avatars: merged (new users added)
|
|
515
|
+
*
|
|
516
|
+
* If no cache exists yet, falls back to a full load.
|
|
517
|
+
*
|
|
518
|
+
* @param conversationId - The conversation to refresh
|
|
519
|
+
* @param contextUser - The current user context
|
|
520
|
+
* @returns The updated cache entry
|
|
521
|
+
*/
|
|
522
|
+
async RefreshConversationDetails(conversationId, contextUser) {
|
|
523
|
+
const key = NormalizeUUID(conversationId);
|
|
524
|
+
const existing = this._detailCache.get(key);
|
|
525
|
+
// No cache yet — do a full load
|
|
526
|
+
if (!existing) {
|
|
527
|
+
return this.LoadConversationDetails(conversationId, contextUser);
|
|
528
|
+
}
|
|
529
|
+
// Run the query
|
|
530
|
+
const rq = new RunQuery();
|
|
531
|
+
const result = await rq.RunQuery({
|
|
532
|
+
QueryName: 'GetConversationComplete',
|
|
533
|
+
CategoryPath: 'MJ/Conversations',
|
|
534
|
+
Parameters: { ConversationID: conversationId }
|
|
535
|
+
}, contextUser);
|
|
536
|
+
if (!result.Success || !result.Results) {
|
|
537
|
+
console.error('[ConversationEngine] Failed to refresh conversation details:', result.ErrorMessage);
|
|
538
|
+
return existing;
|
|
539
|
+
}
|
|
540
|
+
const freshRows = result.Results;
|
|
541
|
+
const md = new Metadata();
|
|
542
|
+
// Build a lookup of existing details by ID for fast comparison
|
|
543
|
+
const existingDetailsMap = new Map();
|
|
544
|
+
for (const detail of existing.Details) {
|
|
545
|
+
existingDetailsMap.set(detail.ID, detail);
|
|
546
|
+
}
|
|
547
|
+
// Process each row in parallel — sync mutations happen immediately,
|
|
548
|
+
// async hydrations (new entities) run concurrently
|
|
549
|
+
await Promise.all(freshRows.map(async (row) => {
|
|
550
|
+
if (!row.ID)
|
|
551
|
+
return;
|
|
552
|
+
const existingDetail = existingDetailsMap.get(row.ID);
|
|
553
|
+
if (existingDetail) {
|
|
554
|
+
// Update fields in-place on the existing entity object (preserves reference)
|
|
555
|
+
existingDetail.LoadFromData(row);
|
|
556
|
+
existingDetailsMap.delete(row.ID);
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
// New message — hydrate and append
|
|
560
|
+
const newDetail = await md.GetEntityObject('MJ: Conversation Details', contextUser);
|
|
561
|
+
newDetail.LoadFromData(row);
|
|
562
|
+
existing.Details.push(newDetail);
|
|
563
|
+
}
|
|
564
|
+
const parsed = parseConversationDetailComplete(row);
|
|
565
|
+
// Merge agent runs: update in-place or add
|
|
566
|
+
if (parsed.agentRuns.length > 0) {
|
|
567
|
+
const agentRunData = parsed.agentRuns[0];
|
|
568
|
+
const existingRun = existing.AgentRunsByDetailId.get(row.ID);
|
|
569
|
+
if (existingRun) {
|
|
570
|
+
existingRun.LoadFromData({
|
|
571
|
+
ID: agentRunData.ID,
|
|
572
|
+
AgentID: agentRunData.AgentID,
|
|
573
|
+
Agent: agentRunData.Agent,
|
|
574
|
+
Status: agentRunData.Status,
|
|
575
|
+
__mj_CreatedAt: agentRunData.__mj_CreatedAt,
|
|
576
|
+
__mj_UpdatedAt: agentRunData.__mj_UpdatedAt,
|
|
577
|
+
TotalPromptTokensUsed: agentRunData.TotalPromptTokensUsed,
|
|
578
|
+
TotalCompletionTokensUsed: agentRunData.TotalCompletionTokensUsed,
|
|
579
|
+
TotalCost: agentRunData.TotalCost,
|
|
580
|
+
ConversationDetailID: agentRunData.ConversationDetailID
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
const newRun = await md.GetEntityObject('MJ: AI Agent Runs', contextUser);
|
|
585
|
+
newRun.LoadFromData(agentRunData);
|
|
586
|
+
existing.AgentRunsByDetailId.set(row.ID, newRun);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Replace artifacts per-detail (plain data, cheap to replace)
|
|
590
|
+
if (parsed.artifacts.length > 0) {
|
|
591
|
+
existing.ArtifactsByDetailId.set(row.ID, parsed.artifacts);
|
|
592
|
+
}
|
|
593
|
+
// Replace ratings per-detail
|
|
594
|
+
if (parsed.ratings.length > 0) {
|
|
595
|
+
existing.RatingsByDetailId.set(row.ID, parsed.ratings);
|
|
596
|
+
}
|
|
597
|
+
// Merge user avatars
|
|
598
|
+
if (row.Role?.toLowerCase() === 'user' && row.UserID && !existing.UserAvatars.has(row.UserID)) {
|
|
599
|
+
existing.UserAvatars.set(row.UserID, {
|
|
600
|
+
ImageURL: row.UserImageURL || null,
|
|
601
|
+
IconClass: row.UserImageIconClass || null
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}));
|
|
605
|
+
// Update raw data and timestamp
|
|
606
|
+
existing.RawData = freshRows;
|
|
607
|
+
existing.LoadedAt = new Date();
|
|
608
|
+
existing.PeripheralDataStale = false;
|
|
609
|
+
return existing;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Returns cached conversation details (messages only) without hitting the database.
|
|
613
|
+
* Returns undefined if no cache entry exists for this conversation.
|
|
614
|
+
*
|
|
615
|
+
* @param conversationId - The conversation ID
|
|
616
|
+
* @returns Cached message entities, or undefined if not cached
|
|
617
|
+
*/
|
|
618
|
+
GetCachedDetails(conversationId) {
|
|
619
|
+
const key = NormalizeUUID(conversationId);
|
|
620
|
+
return this._detailCache.get(key)?.Details;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Returns the full cache entry for a conversation, including all peripheral data
|
|
624
|
+
* (agent runs, artifacts, ratings, user avatars). Returns undefined if not cached.
|
|
625
|
+
*
|
|
626
|
+
* This is the primary read method for UI components — returns instant cached data
|
|
627
|
+
* without any database round-trip.
|
|
628
|
+
*
|
|
629
|
+
* @param conversationId - The conversation ID
|
|
630
|
+
* @returns The full cache entry, or undefined
|
|
631
|
+
*/
|
|
632
|
+
GetCachedDetailEntry(conversationId) {
|
|
633
|
+
const key = NormalizeUUID(conversationId);
|
|
634
|
+
return this._detailCache.get(key);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Returns true if conversation details are cached for the given conversation.
|
|
638
|
+
*/
|
|
639
|
+
HasCachedDetails(conversationId) {
|
|
640
|
+
const key = NormalizeUUID(conversationId);
|
|
641
|
+
return this._detailCache.has(key);
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Adds a detail (message) to the cached list for a conversation.
|
|
645
|
+
* If no cache entry exists, this is a no-op (caller should LoadConversationDetails first).
|
|
646
|
+
*
|
|
647
|
+
* @param conversationId - The conversation this detail belongs to
|
|
648
|
+
* @param detail - The detail entity to add
|
|
649
|
+
*/
|
|
650
|
+
AddDetailToCache(conversationId, detail) {
|
|
651
|
+
const key = NormalizeUUID(conversationId);
|
|
652
|
+
const cached = this._detailCache.get(key);
|
|
653
|
+
if (cached) {
|
|
654
|
+
cached.Details.push(detail);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Updates a detail entity in the cache. Finds by ID and replaces.
|
|
659
|
+
* If the detail is not in cache, this is a no-op.
|
|
660
|
+
*
|
|
661
|
+
* @param conversationId - The conversation this detail belongs to
|
|
662
|
+
* @param detail - The updated detail entity
|
|
663
|
+
*/
|
|
664
|
+
UpdateDetailInCache(conversationId, detail) {
|
|
665
|
+
const key = NormalizeUUID(conversationId);
|
|
666
|
+
const cached = this._detailCache.get(key);
|
|
667
|
+
if (cached) {
|
|
668
|
+
const idx = cached.Details.findIndex(d => UUIDsEqual(d.ID, detail.ID));
|
|
669
|
+
if (idx >= 0) {
|
|
670
|
+
cached.Details[idx] = detail;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// ========================================================================
|
|
675
|
+
// PERIPHERAL DATA (Agent Runs)
|
|
676
|
+
// ========================================================================
|
|
677
|
+
/**
|
|
678
|
+
* Gets the cached agent run for a specific conversation detail.
|
|
679
|
+
*
|
|
680
|
+
* @param conversationId - The conversation ID
|
|
681
|
+
* @param detailId - The conversation detail ID
|
|
682
|
+
* @returns The agent run entity, or undefined if not cached
|
|
683
|
+
*/
|
|
684
|
+
GetAgentRunForDetail(conversationId, detailId) {
|
|
685
|
+
const key = NormalizeUUID(conversationId);
|
|
686
|
+
const cached = this._detailCache.get(key);
|
|
687
|
+
if (!cached)
|
|
688
|
+
return undefined;
|
|
689
|
+
// Search by detail ID (agent runs are keyed by the detail they belong to)
|
|
690
|
+
for (const [cachedDetailId, agentRun] of cached.AgentRunsByDetailId) {
|
|
691
|
+
if (UUIDsEqual(cachedDetailId, detailId)) {
|
|
692
|
+
return agentRun;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Gets all cached agent runs for a conversation, keyed by detail ID.
|
|
699
|
+
*
|
|
700
|
+
* @param conversationId - The conversation ID
|
|
701
|
+
* @returns Map of detail ID to agent run, or empty map if not cached
|
|
702
|
+
*/
|
|
703
|
+
GetAgentRunsMap(conversationId) {
|
|
704
|
+
const key = NormalizeUUID(conversationId);
|
|
705
|
+
return this._detailCache.get(key)?.AgentRunsByDetailId ?? new Map();
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Adds or updates an agent run in the cache for a specific detail.
|
|
709
|
+
*
|
|
710
|
+
* @param conversationId - The conversation ID
|
|
711
|
+
* @param detailId - The detail ID the agent run is associated with
|
|
712
|
+
* @param agentRun - The agent run entity
|
|
713
|
+
*/
|
|
714
|
+
SetAgentRunForDetail(conversationId, detailId, agentRun) {
|
|
715
|
+
const key = NormalizeUUID(conversationId);
|
|
716
|
+
const cached = this._detailCache.get(key);
|
|
717
|
+
if (cached) {
|
|
718
|
+
cached.AgentRunsByDetailId.set(detailId, agentRun);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
// ========================================================================
|
|
722
|
+
// CACHE MANAGEMENT
|
|
723
|
+
// ========================================================================
|
|
724
|
+
/**
|
|
725
|
+
* Invalidates (removes) the cached details for a specific conversation.
|
|
726
|
+
* The next call to LoadConversationDetails will fetch fresh data.
|
|
727
|
+
*
|
|
728
|
+
* @param conversationId - The conversation ID to invalidate
|
|
729
|
+
*/
|
|
730
|
+
InvalidateConversation(conversationId) {
|
|
731
|
+
this.removeDetailCache(conversationId);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Clears all cached data: conversations, details, and peripheral data.
|
|
735
|
+
* Typically called on logout or environment switch.
|
|
736
|
+
*/
|
|
737
|
+
ClearCache() {
|
|
738
|
+
this._conversations$.next([]);
|
|
739
|
+
this._detailCache.clear();
|
|
740
|
+
this._lastEnvironmentId = null;
|
|
741
|
+
}
|
|
742
|
+
// ========================================================================
|
|
743
|
+
// CONVERSATION DETAIL CRUD
|
|
744
|
+
// ========================================================================
|
|
745
|
+
/**
|
|
746
|
+
* Creates a new conversation detail (message), saves it, and adds it to the cache.
|
|
747
|
+
*
|
|
748
|
+
* @param conversationId - The conversation this detail belongs to
|
|
749
|
+
* @param role - The message role ('User', 'AI', 'System')
|
|
750
|
+
* @param message - The message content
|
|
751
|
+
* @param contextUser - The current user context
|
|
752
|
+
* @param additionalFields - Optional extra fields to set on the entity
|
|
753
|
+
* @returns The saved conversation detail entity
|
|
754
|
+
*/
|
|
755
|
+
async CreateConversationDetail(conversationId, role, message, contextUser, additionalFields) {
|
|
756
|
+
const md = new Metadata();
|
|
757
|
+
const detail = await md.GetEntityObject('MJ: Conversation Details', contextUser);
|
|
758
|
+
detail.ConversationID = conversationId;
|
|
759
|
+
detail.Role = role;
|
|
760
|
+
detail.Message = message;
|
|
761
|
+
if (additionalFields) {
|
|
762
|
+
this.mergeDataOntoRecord(detail, additionalFields);
|
|
763
|
+
}
|
|
764
|
+
this._selfMutating = true;
|
|
765
|
+
try {
|
|
766
|
+
const saved = await detail.Save();
|
|
767
|
+
if (!saved) {
|
|
768
|
+
throw new Error(detail.LatestResult?.Message || 'Failed to create conversation detail');
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
finally {
|
|
772
|
+
this._selfMutating = false;
|
|
773
|
+
}
|
|
774
|
+
// Add to cache if this conversation's details are cached
|
|
775
|
+
this.AddDetailToCache(conversationId, detail);
|
|
776
|
+
return detail;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Saves an existing conversation detail entity and updates the cache.
|
|
780
|
+
* Use this instead of calling detail.Save() directly to keep the engine cache in sync.
|
|
781
|
+
*
|
|
782
|
+
* @param detail - The conversation detail entity to save (must already be loaded)
|
|
783
|
+
* @returns true if saved successfully
|
|
784
|
+
*/
|
|
785
|
+
async SaveConversationDetail(detail) {
|
|
786
|
+
this._selfMutating = true;
|
|
787
|
+
try {
|
|
788
|
+
const saved = await detail.Save();
|
|
789
|
+
if (!saved) {
|
|
790
|
+
throw new Error(detail.LatestResult?.Message || 'Failed to save conversation detail');
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
finally {
|
|
794
|
+
this._selfMutating = false;
|
|
795
|
+
}
|
|
796
|
+
// Update in cache
|
|
797
|
+
this.UpdateDetailInCache(detail.ConversationID, detail);
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Deletes a conversation detail and removes it from the cache.
|
|
802
|
+
*
|
|
803
|
+
* @param conversationId - The conversation this detail belongs to
|
|
804
|
+
* @param detailId - The detail ID to delete
|
|
805
|
+
* @param contextUser - The current user context
|
|
806
|
+
* @returns true if deleted successfully
|
|
807
|
+
*/
|
|
808
|
+
async DeleteConversationDetail(conversationId, detailId, contextUser) {
|
|
809
|
+
// Try to use the cached detail entity to avoid a DB round-trip
|
|
810
|
+
const key = NormalizeUUID(conversationId);
|
|
811
|
+
const cachedEntry = this._detailCache.get(key);
|
|
812
|
+
let detail = cachedEntry?.Details.find(d => UUIDsEqual(d.ID, detailId));
|
|
813
|
+
if (!detail) {
|
|
814
|
+
// Not in cache — load from DB as fallback
|
|
815
|
+
const md = new Metadata();
|
|
816
|
+
detail = await md.GetEntityObject('MJ: Conversation Details', contextUser);
|
|
817
|
+
const loaded = await detail.Load(detailId);
|
|
818
|
+
if (!loaded) {
|
|
819
|
+
throw new Error('Conversation detail not found');
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
this._selfMutating = true;
|
|
823
|
+
try {
|
|
824
|
+
const deleted = await detail.Delete();
|
|
825
|
+
if (!deleted) {
|
|
826
|
+
throw new Error(detail.LatestResult?.Message || 'Failed to delete conversation detail');
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
finally {
|
|
830
|
+
this._selfMutating = false;
|
|
831
|
+
}
|
|
832
|
+
// Remove from cache
|
|
833
|
+
if (cachedEntry) {
|
|
834
|
+
cachedEntry.Details = cachedEntry.Details.filter(d => !UUIDsEqual(d.ID, detailId));
|
|
835
|
+
cachedEntry.AgentRunsByDetailId.delete(detailId);
|
|
836
|
+
}
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
// ========================================================================
|
|
840
|
+
// ENTITY EVENT HANDLING (External Mutation Sync)
|
|
841
|
+
// ========================================================================
|
|
842
|
+
/**
|
|
843
|
+
* Overrides BaseEngine's entity event handler to watch for external mutations
|
|
844
|
+
* to Conversations and Conversation Details. When another piece of code (outside
|
|
845
|
+
* this engine) saves or deletes these entities, we sync our cache.
|
|
846
|
+
*
|
|
847
|
+
* The _selfMutating guard prevents processing events from our own mutations.
|
|
848
|
+
*/
|
|
849
|
+
async HandleIndividualBaseEntityEvent(event) {
|
|
850
|
+
// Skip events from our own mutations to avoid redundant cache updates
|
|
851
|
+
if (this._selfMutating) {
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
// Entity name comes from baseEntity for local events, or event.entityName for remote-invalidate
|
|
855
|
+
const entityName = event.baseEntity?.EntityInfo?.Name || event.entityName;
|
|
856
|
+
if (!entityName) {
|
|
857
|
+
return await super.HandleIndividualBaseEntityEvent(event);
|
|
858
|
+
}
|
|
859
|
+
const normalizedName = entityName.toLowerCase().trim();
|
|
860
|
+
// For remote-invalidate events, resolve the action to save/delete for our handlers
|
|
861
|
+
const effectiveType = event.type === 'remote-invalidate'
|
|
862
|
+
? event.payload?.action || 'save'
|
|
863
|
+
: event.type;
|
|
864
|
+
if (normalizedName === 'mj: conversations') {
|
|
865
|
+
return this.handleConversationEntityEvent(event, effectiveType);
|
|
866
|
+
}
|
|
867
|
+
if (normalizedName === 'mj: conversation details') {
|
|
868
|
+
return this.handleConversationDetailEntityEvent(event, effectiveType);
|
|
869
|
+
}
|
|
870
|
+
if (normalizedName === 'mj: ai agent runs') {
|
|
871
|
+
return this.handleAgentRunEntityEvent(event, effectiveType);
|
|
872
|
+
}
|
|
873
|
+
if (normalizedName === 'mj: conversation detail artifacts' || normalizedName === 'mj: conversation detail ratings') {
|
|
874
|
+
return this.handlePeripheralJunctionEntityEvent(event);
|
|
875
|
+
}
|
|
876
|
+
// Not a conversation entity — let BaseEngine handle it
|
|
877
|
+
return await super.HandleIndividualBaseEntityEvent(event);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Extracts record data from a BaseEntityEvent.
|
|
881
|
+
* For local events: uses baseEntity directly.
|
|
882
|
+
* For remote-invalidate events: parses recordData JSON from the payload.
|
|
883
|
+
* Returns null if no data is available.
|
|
884
|
+
*/
|
|
885
|
+
extractRecordData(event) {
|
|
886
|
+
// Local event — entity is available directly
|
|
887
|
+
if (event.baseEntity) {
|
|
888
|
+
return event.baseEntity.GetAll();
|
|
889
|
+
}
|
|
890
|
+
// Remote event — parse from payload
|
|
891
|
+
const payload = event.payload;
|
|
892
|
+
if (payload?.recordData) {
|
|
893
|
+
try {
|
|
894
|
+
return JSON.parse(payload.recordData);
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Safely merges data onto a target object that may or may not be a BaseEntity.
|
|
904
|
+
* If the target has SetMany (i.e., it's a BaseEntity), uses that to properly handle
|
|
905
|
+
* read-only fields like __mj_CreatedAt. Otherwise falls back to Object.assign.
|
|
906
|
+
*/
|
|
907
|
+
mergeDataOntoRecord(target, data) {
|
|
908
|
+
const t = target;
|
|
909
|
+
if (typeof t.SetMany === 'function') {
|
|
910
|
+
t.SetMany(data, true);
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
Object.assign(target, data);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Handles save/delete events on Conversation entities from local or remote code.
|
|
918
|
+
*/
|
|
919
|
+
handleConversationEntityEvent(event, action) {
|
|
920
|
+
const data = this.extractRecordData(event);
|
|
921
|
+
const id = data?.['ID'];
|
|
922
|
+
if (!id)
|
|
923
|
+
return true;
|
|
924
|
+
if (action === 'save') {
|
|
925
|
+
const existing = this.GetConversation(id);
|
|
926
|
+
if (existing) {
|
|
927
|
+
this.mergeDataOntoRecord(existing, data);
|
|
928
|
+
this._conversations$.next([...this._conversations$.value]);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
else if (action === 'delete') {
|
|
932
|
+
const existing = this.GetConversation(id);
|
|
933
|
+
if (existing) {
|
|
934
|
+
this.removeFromList(id);
|
|
935
|
+
this.removeDetailCache(id);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return true;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Handles save/delete events on ConversationDetail entities from local or remote code.
|
|
942
|
+
*/
|
|
943
|
+
handleConversationDetailEntityEvent(event, action) {
|
|
944
|
+
const entity = event.baseEntity;
|
|
945
|
+
const data = this.extractRecordData(event);
|
|
946
|
+
const id = data?.['ID'];
|
|
947
|
+
const conversationId = data?.['ConversationID'];
|
|
948
|
+
if (!id || !conversationId)
|
|
949
|
+
return true;
|
|
950
|
+
const key = NormalizeUUID(conversationId);
|
|
951
|
+
const cached = this._detailCache.get(key);
|
|
952
|
+
if (!cached)
|
|
953
|
+
return true;
|
|
954
|
+
if (action === 'save') {
|
|
955
|
+
const existingIdx = cached.Details.findIndex(d => UUIDsEqual(d.ID, id));
|
|
956
|
+
if (existingIdx >= 0) {
|
|
957
|
+
// For local events, use the entity directly; for remote, update fields in place
|
|
958
|
+
if (entity) {
|
|
959
|
+
cached.Details[existingIdx] = entity;
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
this.mergeDataOntoRecord(cached.Details[existingIdx], data);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
else if (entity) {
|
|
966
|
+
// New detail from local event — append the entity
|
|
967
|
+
cached.Details.push(entity);
|
|
968
|
+
}
|
|
969
|
+
// For new details from remote events, we can't construct a full entity here
|
|
970
|
+
// — the next loadMessages will pick it up from the engine cache
|
|
971
|
+
}
|
|
972
|
+
else if (action === 'delete') {
|
|
973
|
+
cached.Details = cached.Details.filter(d => !UUIDsEqual(d.ID, id));
|
|
974
|
+
cached.AgentRunsByDetailId.delete(id);
|
|
975
|
+
}
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Handles save/delete events on AI Agent Run entities from local or remote code.
|
|
980
|
+
* Updates the AgentRunsByDetailId map so timers and status reflect reality.
|
|
981
|
+
*/
|
|
982
|
+
handleAgentRunEntityEvent(event, action) {
|
|
983
|
+
const data = this.extractRecordData(event);
|
|
984
|
+
const id = data?.['ID'];
|
|
985
|
+
const detailId = data?.['ConversationDetailID'];
|
|
986
|
+
if (!id || !detailId)
|
|
987
|
+
return true;
|
|
988
|
+
// Find which conversation cache entry contains this detail ID
|
|
989
|
+
for (const [_key, cached] of this._detailCache) {
|
|
990
|
+
const detail = cached.Details.find(d => UUIDsEqual(d.ID, detailId));
|
|
991
|
+
if (detail) {
|
|
992
|
+
if (action === 'save') {
|
|
993
|
+
// For local events, use the entity directly
|
|
994
|
+
if (event.baseEntity) {
|
|
995
|
+
cached.AgentRunsByDetailId.set(detailId, event.baseEntity);
|
|
996
|
+
}
|
|
997
|
+
else {
|
|
998
|
+
// Remote event — update existing agent run in place, or skip if not cached
|
|
999
|
+
const existing = cached.AgentRunsByDetailId.get(detailId);
|
|
1000
|
+
if (existing) {
|
|
1001
|
+
this.mergeDataOntoRecord(existing, data);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
else if (action === 'delete') {
|
|
1006
|
+
cached.AgentRunsByDetailId.delete(detailId);
|
|
1007
|
+
}
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return true;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Handles save/delete events on junction entities (Conversation Detail Artifacts,
|
|
1015
|
+
* Conversation Detail Ratings) from local or remote code.
|
|
1016
|
+
*
|
|
1017
|
+
* These entities have joined fields (ArtifactName, UserName, etc.) that can't be
|
|
1018
|
+
* reconstructed from the entity event alone, so we flag the cache as stale.
|
|
1019
|
+
* The UI component checks PeripheralDataStale and force-refreshes when needed.
|
|
1020
|
+
*/
|
|
1021
|
+
handlePeripheralJunctionEntityEvent(event) {
|
|
1022
|
+
const data = this.extractRecordData(event);
|
|
1023
|
+
const detailId = data?.['ConversationDetailID'];
|
|
1024
|
+
if (!detailId)
|
|
1025
|
+
return true;
|
|
1026
|
+
for (const [_key, cached] of this._detailCache) {
|
|
1027
|
+
const detail = cached.Details.find(d => UUIDsEqual(d.ID, detailId));
|
|
1028
|
+
if (detail) {
|
|
1029
|
+
cached.PeripheralDataStale = true;
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
return true;
|
|
1034
|
+
}
|
|
1035
|
+
// ========================================================================
|
|
1036
|
+
// PRIVATE HELPERS
|
|
1037
|
+
// ========================================================================
|
|
1038
|
+
/**
|
|
1039
|
+
* Saves a partial update to a conversation entity.
|
|
1040
|
+
*/
|
|
1041
|
+
async saveConversationField(id, updates, contextUser) {
|
|
1042
|
+
const md = new Metadata();
|
|
1043
|
+
const conversation = await md.GetEntityObject('MJ: Conversations', contextUser);
|
|
1044
|
+
const loaded = await conversation.Load(id);
|
|
1045
|
+
if (!loaded) {
|
|
1046
|
+
throw new Error('Conversation not found');
|
|
1047
|
+
}
|
|
1048
|
+
if (updates.IsArchived !== undefined)
|
|
1049
|
+
conversation.IsArchived = updates.IsArchived;
|
|
1050
|
+
if (updates.IsPinned !== undefined)
|
|
1051
|
+
conversation.IsPinned = updates.IsPinned;
|
|
1052
|
+
if (updates.Name !== undefined)
|
|
1053
|
+
conversation.Name = updates.Name;
|
|
1054
|
+
if (updates.Description !== undefined)
|
|
1055
|
+
conversation.Description = updates.Description;
|
|
1056
|
+
const saved = await conversation.Save();
|
|
1057
|
+
if (!saved) {
|
|
1058
|
+
throw new Error(conversation.LatestResult?.Message || 'Failed to update conversation');
|
|
1059
|
+
}
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Removes a conversation from the in-memory list and emits the updated list.
|
|
1064
|
+
*/
|
|
1065
|
+
removeFromList(id) {
|
|
1066
|
+
const filtered = this._conversations$.value.filter(c => !UUIDsEqual(c.ID, id));
|
|
1067
|
+
this._conversations$.next(filtered);
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Removes multiple IDs from the cached list in a single emission.
|
|
1071
|
+
* Used by DeleteMultipleConversations for a smooth batch UI update.
|
|
1072
|
+
*/
|
|
1073
|
+
removeMultipleFromList(ids) {
|
|
1074
|
+
const idSet = new Set(ids.map(id => NormalizeUUID(id)));
|
|
1075
|
+
const filtered = this._conversations$.value.filter(c => !idSet.has(NormalizeUUID(c.ID)));
|
|
1076
|
+
this._conversations$.next(filtered);
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Removes the detail cache entry for a conversation.
|
|
1080
|
+
*/
|
|
1081
|
+
removeDetailCache(conversationId) {
|
|
1082
|
+
const key = NormalizeUUID(conversationId);
|
|
1083
|
+
this._detailCache.delete(key);
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Sorts conversations: pinned first, then by updated date descending.
|
|
1087
|
+
*/
|
|
1088
|
+
sortConversations(conversations) {
|
|
1089
|
+
return [...conversations].sort((a, b) => {
|
|
1090
|
+
// Pinned conversations first
|
|
1091
|
+
if (a.IsPinned && !b.IsPinned)
|
|
1092
|
+
return -1;
|
|
1093
|
+
if (!a.IsPinned && b.IsPinned)
|
|
1094
|
+
return 1;
|
|
1095
|
+
// Then by updated date descending
|
|
1096
|
+
const aTime = a.__mj_UpdatedAt?.getTime() ?? 0;
|
|
1097
|
+
const bTime = b.__mj_UpdatedAt?.getTime() ?? 0;
|
|
1098
|
+
return bTime - aTime;
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
//# sourceMappingURL=conversations.js.map
|