@memberjunction/core-entities 5.22.0 → 5.24.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.
@@ -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