@memberjunction/core-entities 6.2.0-edge.3 → 6.2.0-edge.5
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/.tsbuildinfo +1 -1
- package/dist/custom/ConversationForkAccess.d.ts +15 -0
- package/dist/custom/ConversationForkAccess.d.ts.map +1 -0
- package/dist/custom/ConversationForkAccess.js +28 -0
- package/dist/custom/ConversationForkAccess.js.map +1 -0
- package/dist/custom/ConversationWriteAccess.d.ts +21 -0
- package/dist/custom/ConversationWriteAccess.d.ts.map +1 -0
- package/dist/custom/ConversationWriteAccess.js +34 -0
- package/dist/custom/ConversationWriteAccess.js.map +1 -0
- package/dist/custom/MJConversationBranchEntityExtended.d.ts +30 -0
- package/dist/custom/MJConversationBranchEntityExtended.d.ts.map +1 -0
- package/dist/custom/MJConversationBranchEntityExtended.js +120 -0
- package/dist/custom/MJConversationBranchEntityExtended.js.map +1 -0
- package/dist/custom/MJConversationDetailEntityExtended.d.ts +46 -4
- package/dist/custom/MJConversationDetailEntityExtended.d.ts.map +1 -1
- package/dist/custom/MJConversationDetailEntityExtended.js +194 -47
- package/dist/custom/MJConversationDetailEntityExtended.js.map +1 -1
- package/dist/custom/MJDashboardEntityExtended.d.ts.map +1 -1
- package/dist/custom/MJDashboardEntityExtended.js +3 -0
- package/dist/custom/MJDashboardEntityExtended.js.map +1 -1
- package/dist/custom/MJThemeEntityExtended.d.ts +13 -0
- package/dist/custom/MJThemeEntityExtended.d.ts.map +1 -0
- package/dist/custom/MJThemeEntityExtended.js +37 -0
- package/dist/custom/MJThemeEntityExtended.js.map +1 -0
- package/dist/custom/ResourcePermissions/ResourcePermissionEngine.d.ts.map +1 -1
- package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js +14 -13
- package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js.map +1 -1
- package/dist/engines/FileStorageEngine.d.ts +40 -0
- package/dist/engines/FileStorageEngine.d.ts.map +1 -1
- package/dist/engines/FileStorageEngine.js +49 -0
- package/dist/engines/FileStorageEngine.js.map +1 -1
- package/dist/engines/InstanceConfigEngine.d.ts.map +1 -1
- package/dist/engines/InstanceConfigEngine.js +13 -3
- package/dist/engines/InstanceConfigEngine.js.map +1 -1
- package/dist/engines/PermissionEngine.d.ts +5 -4
- package/dist/engines/PermissionEngine.d.ts.map +1 -1
- package/dist/engines/PermissionEngine.js +7 -5
- package/dist/engines/PermissionEngine.js.map +1 -1
- package/dist/engines/QueryEngine.d.ts +81 -6
- package/dist/engines/QueryEngine.d.ts.map +1 -1
- package/dist/engines/QueryEngine.js +283 -4
- package/dist/engines/QueryEngine.js.map +1 -1
- package/dist/engines/UserInfoEngine.d.ts +64 -12
- package/dist/engines/UserInfoEngine.d.ts.map +1 -1
- package/dist/engines/UserInfoEngine.js +100 -33
- package/dist/engines/UserInfoEngine.js.map +1 -1
- package/dist/engines/artifacts.d.ts.map +1 -1
- package/dist/engines/artifacts.js +1 -0
- package/dist/engines/artifacts.js.map +1 -1
- package/dist/engines/conversations.d.ts +378 -35
- package/dist/engines/conversations.d.ts.map +1 -1
- package/dist/engines/conversations.js +736 -79
- package/dist/engines/conversations.js.map +1 -1
- package/dist/generated/entities/__mj.d.ts +3488 -230
- package/dist/generated/entities/__mj.d.ts.map +1 -1
- package/dist/generated/entities/__mj.js +2927 -18
- package/dist/generated/entities/__mj.js.map +1 -1
- package/dist/generated/remote_operations.d.ts +17 -5
- package/dist/generated/remote_operations.d.ts.map +1 -1
- package/dist/generated/remote_operations.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- package/readme.md +1 -1
|
@@ -14,19 +14,50 @@ const CONVERSATIONS_RESOURCE_TYPE_ID = '81D4BC3D-9FEB-EF11-B01A-286B35C04427';
|
|
|
14
14
|
* {@link ConversationWindowSourceRow} — single-sourced so server callers cannot drift
|
|
15
15
|
* from the assembler's requirements.
|
|
16
16
|
*/
|
|
17
|
-
export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation', '__mj_CreatedAt'];
|
|
17
|
+
export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation', '__mj_CreatedAt', 'ReplacedAt'];
|
|
18
|
+
/** The fork kinds, keyed by {@link ForkKind} so a value CodeGen adds to the union fails to compile until it is listed. */
|
|
19
|
+
const FORK_KIND_KEYS = { Fork: true, Edit: true, Regenerate: true };
|
|
20
|
+
/** True when `value` is a key of {@link FORK_KIND_KEYS}. */
|
|
21
|
+
function isForkKind(value) {
|
|
22
|
+
return Object.prototype.hasOwnProperty.call(FORK_KIND_KEYS, value);
|
|
23
|
+
}
|
|
24
|
+
/** Every fork kind. */
|
|
25
|
+
export const FORK_KINDS = Object.keys(FORK_KIND_KEYS).filter(isForkKind);
|
|
26
|
+
/** Longest branch chain the path predicate will build. */
|
|
27
|
+
export const MAX_BRANCH_DEPTH = 32;
|
|
28
|
+
/** The Fields a fresh load of fork rows selects. */
|
|
29
|
+
export const ConversationBranchFields = ['ID', 'ConversationID', 'ParentBranchID', 'ForkFromSequence', 'Name', 'Kind', 'SourceDetailID', 'UserID', 'User', '__mj_CreatedAt'];
|
|
30
|
+
/** The Fields the fork-summary reads select. */
|
|
31
|
+
export const ForkSummaryRowFields = ['ID', 'BranchID', 'Sequence', 'Role', 'Message', 'UserID', 'AgentID', 'User', 'Agent', 'HiddenToUser', '__mj_CreatedAt'];
|
|
32
|
+
/** Most participants a fork summary lists. */
|
|
33
|
+
export const MAX_FORK_PARTICIPANTS = 3;
|
|
34
|
+
const FORK_NAME_MAX_WORDS = 6;
|
|
35
|
+
const FORK_NAME_MAX_CHARS = 40;
|
|
36
|
+
const FORK_PREVIEW_MAX_CHARS = 120;
|
|
37
|
+
function sameId(a, b) {
|
|
38
|
+
if (a == null || b == null) {
|
|
39
|
+
return a == null && b == null;
|
|
40
|
+
}
|
|
41
|
+
return NormalizeUUID(a) === NormalizeUUID(b);
|
|
42
|
+
}
|
|
18
43
|
/**
|
|
19
44
|
* Helper: parse a raw ConversationDetailComplete row into typed arrays.
|
|
20
45
|
*/
|
|
21
46
|
export function ParseConversationDetailComplete(queryResult) {
|
|
47
|
+
const rawArtifacts = queryResult.ArtifactsJSON
|
|
48
|
+
? JSON.parse(queryResult.ArtifactsJSON)
|
|
49
|
+
: [];
|
|
50
|
+
const artifacts = rawArtifacts.slice().sort((a, b) => {
|
|
51
|
+
const aTime = ToEpochMs(a.VersionCreatedAt) ?? ToEpochMs(a.__mj_CreatedAt) ?? 0;
|
|
52
|
+
const bTime = ToEpochMs(b.VersionCreatedAt) ?? ToEpochMs(b.__mj_CreatedAt) ?? 0;
|
|
53
|
+
return aTime - bTime;
|
|
54
|
+
});
|
|
22
55
|
return {
|
|
23
56
|
...queryResult,
|
|
24
57
|
agentRuns: queryResult.AgentRunsJSON
|
|
25
58
|
? JSON.parse(queryResult.AgentRunsJSON)
|
|
26
59
|
: [],
|
|
27
|
-
artifacts
|
|
28
|
-
? JSON.parse(queryResult.ArtifactsJSON)
|
|
29
|
-
: [],
|
|
60
|
+
artifacts,
|
|
30
61
|
ratings: queryResult.RatingsJSON
|
|
31
62
|
? JSON.parse(queryResult.RatingsJSON)
|
|
32
63
|
: []
|
|
@@ -88,12 +119,63 @@ function detailIdInFilter(detailIds) {
|
|
|
88
119
|
function idInFilter(ids) {
|
|
89
120
|
return `ID IN (${quoteIdList(ids)})`;
|
|
90
121
|
}
|
|
91
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Priority tier for choosing which `AIAgentRun` represents a `ConversationDetail` in the UI
|
|
124
|
+
* (gear icon -> "View Agent Run", token/cost stats, duration):
|
|
125
|
+
* - Tier 0: Root primary orchestrating run (`ParentRunID == null` and `ContinuationDepth == 0`)
|
|
126
|
+
* - Tier 1: Root post-workflow continuation synthesis run (`ParentRunID == null` and `ContinuationDepth > 0`)
|
|
127
|
+
* - Tier 2: Child / sub-agent run (`ParentRunID != null`)
|
|
128
|
+
*/
|
|
129
|
+
function agentRunPriorityTier(run) {
|
|
130
|
+
if (run.ParentRunID) {
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
return (run.ContinuationDepth ?? 0) > 0 ? 1 : 0;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Returns true when `candidate` should replace `existing` as the representative agent run
|
|
137
|
+
* for a single `ConversationDetailID`. Lower tier wins (primary root run beats continuation
|
|
138
|
+
* synthesis run, which beats child sub-agent run); within the same tier, a strictly newer run
|
|
139
|
+
* wins (so user retries of a message replace the earlier run while DESC-ordered queries keep
|
|
140
|
+
* the first row when timestamps are tied or omitted in test fixtures).
|
|
141
|
+
*/
|
|
142
|
+
export function ShouldReplaceDetailAgentRun(existing, candidate) {
|
|
143
|
+
if (!existing) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (existing.ID && candidate.ID && UUIDsEqual(existing.ID, candidate.ID)) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
const existingTier = agentRunPriorityTier(existing);
|
|
150
|
+
const candidateTier = agentRunPriorityTier(candidate);
|
|
151
|
+
if (candidateTier !== existingTier) {
|
|
152
|
+
return candidateTier < existingTier;
|
|
153
|
+
}
|
|
154
|
+
const existingTime = ToEpochMs(existing.__mj_CreatedAt ?? existing.StartedAt) ?? 0;
|
|
155
|
+
const candidateTime = ToEpochMs(candidate.__mj_CreatedAt ?? candidate.StartedAt) ?? 0;
|
|
156
|
+
if (candidateTime !== existingTime) {
|
|
157
|
+
return candidateTime > existingTime;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
function selectPreferredDetailAgentRun(runs) {
|
|
162
|
+
let best;
|
|
163
|
+
for (const run of runs) {
|
|
164
|
+
if (ShouldReplaceDetailAgentRun(best, run)) {
|
|
165
|
+
best = run;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return best;
|
|
169
|
+
}
|
|
170
|
+
/** Keys agent runs by the detail they belong to, preferring the primary root orchestrating run. */
|
|
92
171
|
function groupAgentRunsByDetailId(runs) {
|
|
93
172
|
const byDetailId = new Map();
|
|
94
173
|
for (const run of runs) {
|
|
95
174
|
if (run.ConversationDetailID) {
|
|
96
|
-
byDetailId.
|
|
175
|
+
const existing = byDetailId.get(run.ConversationDetailID);
|
|
176
|
+
if (ShouldReplaceDetailAgentRun(existing, run)) {
|
|
177
|
+
byDetailId.set(run.ConversationDetailID, run);
|
|
178
|
+
}
|
|
97
179
|
}
|
|
98
180
|
}
|
|
99
181
|
return byDetailId;
|
|
@@ -592,6 +674,29 @@ export class ConversationEngine extends BaseEngine {
|
|
|
592
674
|
async MoveConversationToProject(conversationId, projectId, contextUser) {
|
|
593
675
|
return this.SaveConversation(conversationId, { ProjectID: projectId }, contextUser);
|
|
594
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* Creates a fork (an `MJ: Conversation Branches` row) started by `contextUser`. A fork before the
|
|
679
|
+
* first message shares nothing, so its parent is always Main. Never reads or writes the
|
|
680
|
+
* conversation row. Write access is the branch entity's rule (owner, or an Edit/Owner share).
|
|
681
|
+
* The server refuses it unless the person holds the `Conversations: Fork` authorization.
|
|
682
|
+
* @throws when the fork cannot be saved; the message carries the save result
|
|
683
|
+
*/
|
|
684
|
+
async CreateFork(input, contextUser) {
|
|
685
|
+
const branch = await this.ProviderToUse.GetEntityObject('MJ: Conversation Branches', contextUser);
|
|
686
|
+
branch.NewRecord();
|
|
687
|
+
branch.ConversationID = input.ConversationID;
|
|
688
|
+
branch.Kind = input.Kind;
|
|
689
|
+
branch.ForkFromSequence = input.ForkFromSequence;
|
|
690
|
+
branch.ParentBranchID = input.ForkFromSequence == null ? null : input.ParentBranchID;
|
|
691
|
+
branch.SourceDetailID = input.SourceDetailID ?? null;
|
|
692
|
+
branch.UserID = contextUser.ID;
|
|
693
|
+
const name = input.Name?.trim();
|
|
694
|
+
branch.Name = name ? name : null;
|
|
695
|
+
if (!(await branch.Save())) {
|
|
696
|
+
throw new Error(`Failed to create the fork: ${branch.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
697
|
+
}
|
|
698
|
+
return branch;
|
|
699
|
+
}
|
|
595
700
|
/**
|
|
596
701
|
* Reparents a folder (project) under another folder, or to the top level when
|
|
597
702
|
* parentId is null. Callers are responsible for preventing cycles (don't pass a
|
|
@@ -988,14 +1093,29 @@ export class ConversationEngine extends BaseEngine {
|
|
|
988
1093
|
throw new Error('Conversation not found');
|
|
989
1094
|
}
|
|
990
1095
|
}
|
|
1096
|
+
const previousValues = {};
|
|
1097
|
+
for (const key of Object.keys(updates)) {
|
|
1098
|
+
previousValues[key] = conversation[key];
|
|
1099
|
+
}
|
|
991
1100
|
this.mergeDataOntoRecord(conversation, updates);
|
|
1101
|
+
// Emit immediately after mutating the shared cached entity so Angular subscribers
|
|
1102
|
+
// (e.g. ConversationListComponent) run change detection synchronously rather than
|
|
1103
|
+
// seeing an uncommitted mutation during the async Save() round-trip (NG0100).
|
|
1104
|
+
this._conversations$.next([...this._conversations$.value]);
|
|
992
1105
|
this._selfMutating = true;
|
|
993
1106
|
try {
|
|
994
1107
|
const saved = await conversation.Save();
|
|
995
1108
|
if (!saved) {
|
|
1109
|
+
this.mergeDataOntoRecord(conversation, previousValues);
|
|
1110
|
+
this._conversations$.next([...this._conversations$.value]);
|
|
996
1111
|
throw new Error(conversation.LatestResult?.Message || 'Failed to update conversation');
|
|
997
1112
|
}
|
|
998
1113
|
}
|
|
1114
|
+
catch (err) {
|
|
1115
|
+
this.mergeDataOntoRecord(conversation, previousValues);
|
|
1116
|
+
this._conversations$.next([...this._conversations$.value]);
|
|
1117
|
+
throw err;
|
|
1118
|
+
}
|
|
999
1119
|
finally {
|
|
1000
1120
|
this._selfMutating = false;
|
|
1001
1121
|
}
|
|
@@ -1184,36 +1304,33 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1184
1304
|
// ========================================================================
|
|
1185
1305
|
// CONVERSATION DETAILS (Messages)
|
|
1186
1306
|
// ========================================================================
|
|
1187
|
-
/**
|
|
1188
|
-
* Loads conversation details (messages) for a specific conversation.
|
|
1189
|
-
* Results are cached for instant retrieval on subsequent calls.
|
|
1190
|
-
*
|
|
1191
|
-
* @param conversationId - The conversation to load details for
|
|
1192
|
-
* @param contextUser - The current user context
|
|
1193
|
-
* @param forceRefresh - If true, reloads even if cached
|
|
1194
|
-
* @returns Array of conversation detail entities
|
|
1195
|
-
*/
|
|
1196
1307
|
/**
|
|
1197
1308
|
* Loads conversation details using the efficient GetConversationComplete query
|
|
1198
1309
|
* which returns messages, agent runs, artifacts, ratings, and user avatars in one round-trip.
|
|
1199
|
-
*
|
|
1310
|
+
* The cache holds every row of the conversation, on every branch path; a scope limits
|
|
1311
|
+
* only what this call returns.
|
|
1200
1312
|
*
|
|
1201
1313
|
* @param conversationId - The conversation to load details for
|
|
1202
1314
|
* @param contextUser - The current user context
|
|
1203
1315
|
* @param forceRefresh - If true, reloads even if cached
|
|
1204
|
-
* @
|
|
1316
|
+
* @param scope - When given, the returned entry holds only the rows in this scope
|
|
1317
|
+
* (see {@link scopedEntry}); when omitted, every row of the conversation
|
|
1318
|
+
* @returns The cache entry with all peripheral data, limited to the scope when one is given
|
|
1319
|
+
* @throws when the scope belongs to another conversation
|
|
1205
1320
|
*/
|
|
1206
|
-
async LoadConversationDetails(conversationId, contextUser, forceRefresh = false) {
|
|
1321
|
+
async LoadConversationDetails(conversationId, contextUser, forceRefresh = false, scope) {
|
|
1322
|
+
ConversationEngine.assertScopeOf(conversationId, scope);
|
|
1207
1323
|
const key = NormalizeUUID(conversationId);
|
|
1208
1324
|
// Return cached if available and not forcing
|
|
1209
1325
|
if (!forceRefresh) {
|
|
1210
1326
|
const cached = this._detailCache.get(key);
|
|
1211
1327
|
if (cached) {
|
|
1212
|
-
return cached;
|
|
1328
|
+
return ConversationEngine.scopedEntry(cached, scope);
|
|
1213
1329
|
}
|
|
1214
1330
|
}
|
|
1215
1331
|
// Use GetConversationComplete for one-round-trip loading of all data
|
|
1216
1332
|
const rq = new RunQuery();
|
|
1333
|
+
// conversation-scope: whole-conversation cache; every consumer filters to its scope
|
|
1217
1334
|
const result = await rq.RunQuery({
|
|
1218
1335
|
QueryName: 'GetConversationComplete',
|
|
1219
1336
|
CategoryPath: 'MJ/Conversations',
|
|
@@ -1232,13 +1349,41 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1232
1349
|
LoadedAt: new Date(),
|
|
1233
1350
|
PeripheralDataStale: false
|
|
1234
1351
|
};
|
|
1235
|
-
return empty;
|
|
1352
|
+
return ConversationEngine.scopedEntry(empty, scope);
|
|
1236
1353
|
}
|
|
1237
1354
|
const rawData = result.Results;
|
|
1238
1355
|
// Build the cache entry from the rich query result
|
|
1239
1356
|
const cacheEntry = await this.buildDetailCacheFromRawData(rawData, contextUser);
|
|
1240
1357
|
this._detailCache.set(key, cacheEntry);
|
|
1241
|
-
return cacheEntry;
|
|
1358
|
+
return ConversationEngine.scopedEntry(cacheEntry, scope);
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Throws when a scope is given and belongs to another conversation.
|
|
1362
|
+
* @throws when the scope belongs to another conversation
|
|
1363
|
+
*/
|
|
1364
|
+
static assertScopeOf(conversationId, scope) {
|
|
1365
|
+
if (scope && !sameId(scope.ConversationID, conversationId)) {
|
|
1366
|
+
throw new Error(`Conversation ${conversationId} is not the scope's conversation ${scope.ConversationID}`);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* A cache entry limited to a scope: a copy whose `Details` hold only the rows in the scope,
|
|
1371
|
+
* and whose `RawData` holds the raw rows of those details, both in their cached order. The
|
|
1372
|
+
* peripheral maps are the cached ones, shared. The entry itself when no scope is given. The
|
|
1373
|
+
* cached entry is never changed. Callers check the scope's conversation first
|
|
1374
|
+
* ({@link assertScopeOf}).
|
|
1375
|
+
*/
|
|
1376
|
+
static scopedEntry(entry, scope) {
|
|
1377
|
+
if (!scope) {
|
|
1378
|
+
return entry;
|
|
1379
|
+
}
|
|
1380
|
+
const details = ConversationEngine.FilterToScope(scope, entry.Details);
|
|
1381
|
+
const detailIds = new Set(details.map(d => NormalizeUUID(d.ID)));
|
|
1382
|
+
return {
|
|
1383
|
+
...entry,
|
|
1384
|
+
Details: details,
|
|
1385
|
+
RawData: entry.RawData.filter(r => !!r.ID && detailIds.has(NormalizeUUID(r.ID))),
|
|
1386
|
+
};
|
|
1242
1387
|
}
|
|
1243
1388
|
/**
|
|
1244
1389
|
* Loads ONE page of a conversation's transcript — the chat area's windowed history read.
|
|
@@ -1254,19 +1399,25 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1254
1399
|
* a partial entry there would silently starve the agent of everything before the summary
|
|
1255
1400
|
* boundary, with no error. Use a separate partial cache if incremental caching is needed.
|
|
1256
1401
|
*
|
|
1257
|
-
*
|
|
1258
|
-
*
|
|
1259
|
-
*
|
|
1402
|
+
* Every detail read is limited to the path of `BranchID` (the trunk when omitted).
|
|
1403
|
+
* Replaced rows (`ReplacedAt` set) are never read.
|
|
1404
|
+
*
|
|
1405
|
+
* Does not throw for a read that FAILS — a failed branch-path read, like a failed page
|
|
1406
|
+
* read, returns an empty window flagged {@link DetailWindowLoadResult.Failed} so the
|
|
1407
|
+
* caller can leave its paging state intact rather than mistaking the failure for the
|
|
1408
|
+
* start of the conversation. A failed branch-path read never falls back to the trunk.
|
|
1260
1409
|
*
|
|
1261
|
-
* It is not, however, exception-proof:
|
|
1262
|
-
* REJECTS rather than returning `Success: false` propagates out to the
|
|
1263
|
-
* is left deliberately — it degrades better than the handled one,
|
|
1264
|
-
* skips the cursor write entirely and the caller's paging state
|
|
1410
|
+
* It is not, however, exception-proof: the detail reads have no `try/catch`, so a
|
|
1411
|
+
* provider that REJECTS rather than returning `Success: false` propagates out to the
|
|
1412
|
+
* caller. That path is left deliberately — it degrades better than the handled one,
|
|
1413
|
+
* because an exception skips the cursor write entirely and the caller's paging state
|
|
1414
|
+
* survives untouched.
|
|
1265
1415
|
*
|
|
1266
1416
|
* ### Round-trip profile — the counterweight to the payload win
|
|
1267
1417
|
*
|
|
1268
1418
|
* This trades ONE fat query for several thin ones. Per page:
|
|
1269
1419
|
*
|
|
1420
|
+
* 0. {@link BranchPathFilterFresh} — one branch read, only when `BranchID` is set
|
|
1270
1421
|
* 1. {@link fetchDetailRowsBySequence} — always
|
|
1271
1422
|
* 2. {@link expandOldestSession} — only when the oldest row is session-stamped
|
|
1272
1423
|
* 3. {@link hasOlderDetails} — always, in parallel with (4)
|
|
@@ -1288,13 +1439,18 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1288
1439
|
* over more content — and unlike first paint, a reader who has scrolled up has already
|
|
1289
1440
|
* committed to reading back. If paging up ever feels slow, that is the number to raise.
|
|
1290
1441
|
*
|
|
1291
|
-
* @param params - Conversation, optional `Sequence` bound, and page sizing
|
|
1442
|
+
* @param params - Conversation, optional branch, optional `Sequence` bound, and page sizing
|
|
1292
1443
|
* @param contextUser - The requesting user (entity RLS applies)
|
|
1293
1444
|
*/
|
|
1294
1445
|
async LoadDetailWindow(params, contextUser) {
|
|
1295
1446
|
const pageSize = params.PageSize ?? DEFAULT_WINDOW_PAGE_SIZE;
|
|
1296
1447
|
const overread = params.RawOverread ?? pageSize * DETAIL_WINDOW_OVERREAD_FACTOR;
|
|
1297
|
-
const
|
|
1448
|
+
const pathFilter = await this.resolveWindowPathFilter(params, contextUser);
|
|
1449
|
+
if (pathFilter === null) {
|
|
1450
|
+
return emptyDetailWindowResult(true);
|
|
1451
|
+
}
|
|
1452
|
+
const liveFilter = ConversationEngine.LiveRowsFilter(pathFilter);
|
|
1453
|
+
const page = await this.fetchDetailRowsBySequence(liveFilter, params.BeforeSequence, overread, contextUser);
|
|
1298
1454
|
// Deliberately two branches, not one. `null` is a FAILED read; an empty array is a
|
|
1299
1455
|
// range that genuinely holds no rows. Collapsing them is what lets a transport blip
|
|
1300
1456
|
// read downstream as "you have reached the start of the conversation".
|
|
@@ -1304,7 +1460,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1304
1460
|
if (page.length === 0) {
|
|
1305
1461
|
return emptyDetailWindowResult(false);
|
|
1306
1462
|
}
|
|
1307
|
-
const details = await this.expandOldestSession(
|
|
1463
|
+
const details = await this.expandOldestSession(liveFilter, page, contextUser);
|
|
1308
1464
|
const oldestSequence = details[0].Sequence;
|
|
1309
1465
|
const newestSequence = details[details.length - 1].Sequence;
|
|
1310
1466
|
// Concurrent, not sequential: the probe needs only `oldestSequence` and the peripherals
|
|
@@ -1312,7 +1468,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1312
1468
|
// to an empty/false result on a failed read — so `Promise.all` cannot introduce a
|
|
1313
1469
|
// rejection path this method did not already have.
|
|
1314
1470
|
const [olderProbe, peripherals] = await Promise.all([
|
|
1315
|
-
this.hasOlderDetails(
|
|
1471
|
+
this.hasOlderDetails(liveFilter, oldestSequence, contextUser),
|
|
1316
1472
|
this.buildWindowPeripherals(details, contextUser)
|
|
1317
1473
|
]);
|
|
1318
1474
|
return {
|
|
@@ -1327,6 +1483,22 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1327
1483
|
Failed: olderProbe.Failed
|
|
1328
1484
|
};
|
|
1329
1485
|
}
|
|
1486
|
+
/**
|
|
1487
|
+
* The `ExtraFilter` predicate for the window's branch path.
|
|
1488
|
+
*
|
|
1489
|
+
* @returns The predicate, or null when the branch rows could not be read or the branch
|
|
1490
|
+
* path could not be built.
|
|
1491
|
+
*/
|
|
1492
|
+
async resolveWindowPathFilter(params, contextUser) {
|
|
1493
|
+
try {
|
|
1494
|
+
return await ConversationEngine.BranchPathFilterFresh(params.ConversationID, params.BranchID, contextUser, this.ProviderToUse);
|
|
1495
|
+
}
|
|
1496
|
+
catch (error) {
|
|
1497
|
+
console.error(`[ConversationEngine] Failed to build the branch path for conversation ${params.ConversationID}, `
|
|
1498
|
+
+ `branch ${params.BranchID}:`, error);
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1330
1502
|
/**
|
|
1331
1503
|
* Reads the newest `maxRows` detail rows below an optional `Sequence` bound and returns
|
|
1332
1504
|
* them in CHRONOLOGICAL order.
|
|
@@ -1334,13 +1506,14 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1334
1506
|
* The query is `Sequence DESC` because the interesting end of a transcript is the tail;
|
|
1335
1507
|
* the reversal happens here so every caller downstream sees oldest-to-newest.
|
|
1336
1508
|
*
|
|
1509
|
+
* @param pathFilter - The branch path predicate from {@link BuildBranchPathFilter}
|
|
1337
1510
|
* @returns The page in ascending `Sequence` order, or null when the read failed.
|
|
1338
1511
|
*/
|
|
1339
|
-
async fetchDetailRowsBySequence(
|
|
1512
|
+
async fetchDetailRowsBySequence(pathFilter, beforeSequence, maxRows, contextUser) {
|
|
1340
1513
|
const rv = RunView.FromMetadataProvider(this.ProviderToUse);
|
|
1341
1514
|
const filter = beforeSequence == null
|
|
1342
|
-
?
|
|
1343
|
-
:
|
|
1515
|
+
? pathFilter
|
|
1516
|
+
: `${pathFilter} AND Sequence < ${beforeSequence}`;
|
|
1344
1517
|
const result = await rv.RunView({
|
|
1345
1518
|
EntityName: 'MJ: Conversation Details',
|
|
1346
1519
|
ExtraFilter: filter,
|
|
@@ -1362,9 +1535,10 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1362
1535
|
* boundary — without this read the card renders from a partial row set AND the same
|
|
1363
1536
|
* session reappears on the next older page.
|
|
1364
1537
|
*
|
|
1538
|
+
* @param pathFilter - The branch path predicate from {@link BuildBranchPathFilter}
|
|
1365
1539
|
* @returns The page with any missing session rows prepended, still chronological.
|
|
1366
1540
|
*/
|
|
1367
|
-
async expandOldestSession(
|
|
1541
|
+
async expandOldestSession(pathFilter, details, contextUser) {
|
|
1368
1542
|
const sessionId = details[0].AgentSessionID?.trim();
|
|
1369
1543
|
if (!sessionId) {
|
|
1370
1544
|
return details;
|
|
@@ -1372,7 +1546,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1372
1546
|
const rv = RunView.FromMetadataProvider(this.ProviderToUse);
|
|
1373
1547
|
const result = await rv.RunView({
|
|
1374
1548
|
EntityName: 'MJ: Conversation Details',
|
|
1375
|
-
ExtraFilter:
|
|
1549
|
+
ExtraFilter: `${pathFilter} AND AgentSessionID='${sessionId}' `
|
|
1376
1550
|
+ `AND Sequence < ${details[0].Sequence}`,
|
|
1377
1551
|
OrderBy: 'Sequence DESC',
|
|
1378
1552
|
MaxRows: MAX_SESSION_EXPANSION_ROWS,
|
|
@@ -1392,12 +1566,14 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1392
1566
|
*
|
|
1393
1567
|
* Returns a TRI-STATE rather than a boolean. "No older rows" and "could not find out"
|
|
1394
1568
|
* are both `HasOlder: false`, and only the second must leave the caller's cursor alone.
|
|
1569
|
+
*
|
|
1570
|
+
* @param pathFilter - The branch path predicate from {@link BuildBranchPathFilter}
|
|
1395
1571
|
*/
|
|
1396
|
-
async hasOlderDetails(
|
|
1572
|
+
async hasOlderDetails(pathFilter, oldestSequence, contextUser) {
|
|
1397
1573
|
const rv = RunView.FromMetadataProvider(this.ProviderToUse);
|
|
1398
1574
|
const probe = await rv.RunView({
|
|
1399
1575
|
EntityName: 'MJ: Conversation Details',
|
|
1400
|
-
ExtraFilter:
|
|
1576
|
+
ExtraFilter: `${pathFilter} AND Sequence < ${oldestSequence}`,
|
|
1401
1577
|
OrderBy: 'Sequence DESC',
|
|
1402
1578
|
MaxRows: 1,
|
|
1403
1579
|
Fields: ['ID'],
|
|
@@ -1427,14 +1603,16 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1427
1603
|
if (!row.ID)
|
|
1428
1604
|
continue;
|
|
1429
1605
|
const parsed = ParseConversationDetailComplete(row);
|
|
1430
|
-
// Agent runs
|
|
1431
|
-
|
|
1432
|
-
|
|
1606
|
+
// Agent runs (prefer primary root orchestrating run over continuation synthesis or child runs)
|
|
1607
|
+
const agentRunData = selectPreferredDetailAgentRun(parsed.agentRuns);
|
|
1608
|
+
if (agentRunData) {
|
|
1433
1609
|
const agentRun = await md.GetEntityObject('MJ: AI Agent Runs', contextUser);
|
|
1434
1610
|
agentRun.LoadFromData({
|
|
1435
1611
|
ID: agentRunData.ID,
|
|
1436
1612
|
AgentID: agentRunData.AgentID,
|
|
1437
1613
|
Agent: agentRunData.Agent,
|
|
1614
|
+
ParentRunID: agentRunData.ParentRunID ?? null,
|
|
1615
|
+
ContinuationDepth: agentRunData.ContinuationDepth ?? 0,
|
|
1438
1616
|
Status: agentRunData.Status,
|
|
1439
1617
|
__mj_CreatedAt: agentRunData.__mj_CreatedAt,
|
|
1440
1618
|
__mj_UpdatedAt: agentRunData.__mj_UpdatedAt,
|
|
@@ -1496,6 +1674,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1496
1674
|
{
|
|
1497
1675
|
EntityName: 'MJ: Conversation Detail Artifacts',
|
|
1498
1676
|
ExtraFilter: `${byDetail} AND Direction='Output'`,
|
|
1677
|
+
OrderBy: '__mj_CreatedAt ASC',
|
|
1499
1678
|
ResultType: 'simple'
|
|
1500
1679
|
}
|
|
1501
1680
|
], contextUser);
|
|
@@ -1566,6 +1745,14 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1566
1745
|
list.push(mergeArtifactJSON(junction, version, artifact));
|
|
1567
1746
|
byDetailId.set(junction.ConversationDetailID, list);
|
|
1568
1747
|
}
|
|
1748
|
+
for (const [detailId, list] of byDetailId) {
|
|
1749
|
+
list.sort((a, b) => {
|
|
1750
|
+
const aTime = ToEpochMs(a.VersionCreatedAt) ?? ToEpochMs(a.__mj_CreatedAt) ?? 0;
|
|
1751
|
+
const bTime = ToEpochMs(b.VersionCreatedAt) ?? ToEpochMs(b.__mj_CreatedAt) ?? 0;
|
|
1752
|
+
return aTime - bTime;
|
|
1753
|
+
});
|
|
1754
|
+
byDetailId.set(detailId, list);
|
|
1755
|
+
}
|
|
1569
1756
|
return byDetailId;
|
|
1570
1757
|
}
|
|
1571
1758
|
/** Reads the artifact rows behind a window's artifact versions. */
|
|
@@ -1589,21 +1776,27 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1589
1776
|
* - Artifacts/ratings: replaced per-detail (cheap — plain data, not entity objects)
|
|
1590
1777
|
* - User avatars: merged (new users added)
|
|
1591
1778
|
*
|
|
1592
|
-
* If no cache exists yet, falls back to a full load.
|
|
1779
|
+
* If no cache exists yet, falls back to a full load. The cache holds every row of the
|
|
1780
|
+
* conversation; a scope limits only what this call returns.
|
|
1593
1781
|
*
|
|
1594
1782
|
* @param conversationId - The conversation to refresh
|
|
1595
1783
|
* @param contextUser - The current user context
|
|
1596
|
-
* @
|
|
1784
|
+
* @param scope - When given, the returned entry holds only the rows in this scope
|
|
1785
|
+
* (see {@link scopedEntry}); when omitted, every row of the conversation
|
|
1786
|
+
* @returns The updated cache entry, limited to the scope when one is given
|
|
1787
|
+
* @throws when the scope belongs to another conversation
|
|
1597
1788
|
*/
|
|
1598
|
-
async RefreshConversationDetails(conversationId, contextUser) {
|
|
1789
|
+
async RefreshConversationDetails(conversationId, contextUser, scope) {
|
|
1790
|
+
ConversationEngine.assertScopeOf(conversationId, scope);
|
|
1599
1791
|
const key = NormalizeUUID(conversationId);
|
|
1600
1792
|
const existing = this._detailCache.get(key);
|
|
1601
1793
|
// No cache yet — do a full load
|
|
1602
1794
|
if (!existing) {
|
|
1603
|
-
return this.LoadConversationDetails(conversationId, contextUser);
|
|
1795
|
+
return this.LoadConversationDetails(conversationId, contextUser, false, scope);
|
|
1604
1796
|
}
|
|
1605
1797
|
// Run the query
|
|
1606
1798
|
const rq = new RunQuery();
|
|
1799
|
+
// conversation-scope: whole-conversation cache; every consumer filters to its scope
|
|
1607
1800
|
const result = await rq.RunQuery({
|
|
1608
1801
|
QueryName: 'GetConversationComplete',
|
|
1609
1802
|
CategoryPath: 'MJ/Conversations',
|
|
@@ -1611,7 +1804,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1611
1804
|
}, contextUser);
|
|
1612
1805
|
if (!result.Success || !result.Results) {
|
|
1613
1806
|
console.error('[ConversationEngine] Failed to refresh conversation details:', result.ErrorMessage);
|
|
1614
|
-
return existing;
|
|
1807
|
+
return ConversationEngine.scopedEntry(existing, scope);
|
|
1615
1808
|
}
|
|
1616
1809
|
const freshRows = result.Results;
|
|
1617
1810
|
const md = this.ProviderToUse;
|
|
@@ -1646,12 +1839,14 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1646
1839
|
// check for `LoadFromData` before calling it, and hydrate a real entity when
|
|
1647
1840
|
// we only have plain data. Mirrors the pattern in
|
|
1648
1841
|
// `conversation-chat-area.component.ts#onMessageComplete` (line ~909).
|
|
1649
|
-
|
|
1650
|
-
|
|
1842
|
+
const agentRunData = selectPreferredDetailAgentRun(parsed.agentRuns);
|
|
1843
|
+
if (agentRunData) {
|
|
1651
1844
|
const mergeFields = {
|
|
1652
1845
|
ID: agentRunData.ID,
|
|
1653
1846
|
AgentID: agentRunData.AgentID,
|
|
1654
1847
|
Agent: agentRunData.Agent,
|
|
1848
|
+
ParentRunID: agentRunData.ParentRunID ?? null,
|
|
1849
|
+
ContinuationDepth: agentRunData.ContinuationDepth ?? 0,
|
|
1655
1850
|
Status: agentRunData.Status,
|
|
1656
1851
|
__mj_CreatedAt: agentRunData.__mj_CreatedAt,
|
|
1657
1852
|
__mj_UpdatedAt: agentRunData.__mj_UpdatedAt,
|
|
@@ -1661,15 +1856,17 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1661
1856
|
ConversationDetailID: agentRunData.ConversationDetailID
|
|
1662
1857
|
};
|
|
1663
1858
|
const existingRun = existing.AgentRunsByDetailId.get(row.ID);
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1859
|
+
if (ShouldReplaceDetailAgentRun(existingRun, agentRunData)) {
|
|
1860
|
+
const existingRunIsEntity = !!existingRun && typeof existingRun.LoadFromData === 'function';
|
|
1861
|
+
if (existingRunIsEntity) {
|
|
1862
|
+
existingRun.LoadFromData(mergeFields);
|
|
1863
|
+
}
|
|
1864
|
+
else {
|
|
1865
|
+
// Either no existing run, or existing value is plain JSON — hydrate fresh.
|
|
1866
|
+
const newRun = await md.GetEntityObject('MJ: AI Agent Runs', contextUser);
|
|
1867
|
+
newRun.LoadFromData(existingRun ? { ...existingRun, ...mergeFields } : agentRunData);
|
|
1868
|
+
existing.AgentRunsByDetailId.set(row.ID, newRun);
|
|
1869
|
+
}
|
|
1673
1870
|
}
|
|
1674
1871
|
}
|
|
1675
1872
|
// Replace artifacts per-detail (plain data, cheap to replace)
|
|
@@ -1692,7 +1889,7 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1692
1889
|
existing.RawData = freshRows;
|
|
1693
1890
|
existing.LoadedAt = new Date();
|
|
1694
1891
|
existing.PeripheralDataStale = false;
|
|
1695
|
-
return existing;
|
|
1892
|
+
return ConversationEngine.scopedEntry(existing, scope);
|
|
1696
1893
|
}
|
|
1697
1894
|
/**
|
|
1698
1895
|
* Returns cached conversation details (messages only) without hitting the database.
|
|
@@ -1752,22 +1949,55 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1752
1949
|
* covers everything before it, and cutting into the post-boundary tail would create
|
|
1753
1950
|
* a coverage gap.
|
|
1754
1951
|
* @param options.historyFrom - A history floor: see {@link AssembleContextWindow}.
|
|
1952
|
+
* @param scope - When given, the window is built from the rows in this scope only; when
|
|
1953
|
+
* omitted, from every cached row of the conversation
|
|
1755
1954
|
* @returns Messages in chronological order, each stamped with
|
|
1756
1955
|
* {@link ConversationContextMetadata}
|
|
1956
|
+
* @throws when the scope belongs to another conversation
|
|
1757
1957
|
*/
|
|
1758
|
-
async GetAgentContextWindow(conversationId, contextUser, options) {
|
|
1958
|
+
async GetAgentContextWindow(conversationId, contextUser, options, scope) {
|
|
1759
1959
|
await this.Config(false, contextUser);
|
|
1760
|
-
const cache = await this.LoadConversationDetails(conversationId, contextUser);
|
|
1960
|
+
const cache = await this.LoadConversationDetails(conversationId, contextUser, false, scope);
|
|
1761
1961
|
return ConversationEngine.AssembleContextWindow(cache.Details, options);
|
|
1762
1962
|
}
|
|
1763
1963
|
/**
|
|
1764
|
-
* Loads a conversation's
|
|
1964
|
+
* Loads a conversation's branch rows FRESH through the given provider (entity RLS applies).
|
|
1965
|
+
* Ordered by creation so alternatives at a fork point list in the order they were made.
|
|
1966
|
+
* @throws when the RunView reports failure
|
|
1967
|
+
*/
|
|
1968
|
+
static async LoadBranchesFresh(conversationId, contextUser, provider) {
|
|
1969
|
+
const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
|
|
1970
|
+
const rows = await rv.RunView({
|
|
1971
|
+
EntityName: 'MJ: Conversation Branches',
|
|
1972
|
+
ExtraFilter: `ConversationID='${EscapeSQLString(conversationId)}'`,
|
|
1973
|
+
OrderBy: '__mj_CreatedAt ASC',
|
|
1974
|
+
Fields: [...ConversationBranchFields],
|
|
1975
|
+
ResultType: 'simple',
|
|
1976
|
+
}, contextUser);
|
|
1977
|
+
if (!rows.Success) {
|
|
1978
|
+
throw new Error(`Failed to load branches for conversation ${conversationId}: ${rows.ErrorMessage}`);
|
|
1979
|
+
}
|
|
1980
|
+
return rows.Results || [];
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* {@link ScopeFilter} of the scope from {@link LoadScope}. The trunk (an empty, whitespace-only
|
|
1984
|
+
* or missing branch id) needs no load and costs no query.
|
|
1985
|
+
* @throws When the branch read fails ({@link LoadBranchesFresh}), when `branchId` is not
|
|
1986
|
+
* among the conversation's branches, or when its chain is longer than {@link MAX_BRANCH_DEPTH}
|
|
1987
|
+
*/
|
|
1988
|
+
static async BranchPathFilterFresh(conversationId, branchId, contextUser, provider) {
|
|
1989
|
+
const scope = await ConversationEngine.LoadScope(conversationId, branchId, contextUser, provider);
|
|
1990
|
+
return ConversationEngine.ScopeFilter(scope);
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Loads the window source rows on a conversation's branch path FRESH — through the given
|
|
1765
1994
|
* provider with the contextUser (entity RLS applies), never touching this engine's
|
|
1766
|
-
* process-global detail cache. The
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1995
|
+
* process-global detail cache. The trunk costs one RunView; a branch costs two (its branch
|
|
1996
|
+
* rows via {@link BranchPathFilterFresh}, then the path's rows). The single source of the
|
|
1997
|
+
* fresh-load query shape (entity name, filter, order, fields) for every server-side
|
|
1998
|
+
* caller: the agent resolver's history loader and the cross-turn compaction pass both consume this,
|
|
1769
1999
|
* so the two can never drift apart. THROWS on load failure — servers must fail
|
|
1770
|
-
* loudly rather than proceed against an empty history.
|
|
2000
|
+
* loudly rather than proceed against an empty history. Replaced rows never leave the database.
|
|
1771
2001
|
*
|
|
1772
2002
|
* @param conversationId - The conversation whose detail rows to load
|
|
1773
2003
|
* @param contextUser - The requesting user (entity RLS is applied under this user)
|
|
@@ -1775,18 +2005,21 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1775
2005
|
* @param historyFrom - Optional history floor: only rows written at or after it are loaded,
|
|
1776
2006
|
* so nothing before it leaves the database. Pair it with the same `historyFrom` on
|
|
1777
2007
|
* {@link AssembleContextWindow}, which also drops the summary of earlier messages.
|
|
1778
|
-
* @
|
|
1779
|
-
* @
|
|
1780
|
-
*
|
|
1781
|
-
|
|
1782
|
-
|
|
2008
|
+
* @param branchId - The branch whose path to load; null or omitted reads the trunk.
|
|
2009
|
+
* @returns The rows on the branch's path (the trunk when no branch) in Sequence order,
|
|
2010
|
+
* shaped for {@link AssembleContextWindow}
|
|
2011
|
+
* @throws When a RunView reports failure (never returns a silent empty set), when
|
|
2012
|
+
* `historyFrom` is an invalid date, when `branchId` is not among the conversation's
|
|
2013
|
+
* branches, or when its chain is longer than {@link MAX_BRANCH_DEPTH}
|
|
2014
|
+
*/
|
|
2015
|
+
static async LoadWindowRowsFresh(conversationId, contextUser, provider, historyFrom, branchId) {
|
|
1783
2016
|
const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
|
|
1784
|
-
const
|
|
2017
|
+
const pathFilter = await ConversationEngine.BranchPathFilterFresh(conversationId, branchId, contextUser, provider);
|
|
1785
2018
|
const rows = await rv.RunView({
|
|
1786
2019
|
EntityName: 'MJ: Conversation Details',
|
|
1787
|
-
ExtraFilter: historyFrom
|
|
1788
|
-
? `${
|
|
1789
|
-
:
|
|
2020
|
+
ExtraFilter: ConversationEngine.LiveRowsFilter(historyFrom
|
|
2021
|
+
? `${pathFilter} AND ${ConversationEngine.HistoryFromFilter(historyFrom)}`
|
|
2022
|
+
: pathFilter),
|
|
1790
2023
|
OrderBy: 'Sequence ASC',
|
|
1791
2024
|
Fields: [...ConversationWindowFields],
|
|
1792
2025
|
ResultType: 'simple',
|
|
@@ -1814,12 +2047,14 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1814
2047
|
* floor none is used, and the window is the most recent `maxTailMessages` rows at or
|
|
1815
2048
|
* after the floor. A row whose `__mj_CreatedAt` is missing or unreadable can't be shown
|
|
1816
2049
|
* to be after the floor, so it is dropped too.
|
|
2050
|
+
*
|
|
2051
|
+
* Replaced rows (`ReplacedAt` set) are always left out.
|
|
1817
2052
|
*/
|
|
1818
2053
|
static AssembleContextWindow(details, options) {
|
|
1819
2054
|
const excluded = new Set((options?.excludeDetailIds || []).map(id => NormalizeUUID(id)));
|
|
1820
2055
|
const floor = options?.historyFrom ?? null;
|
|
1821
2056
|
const ordered = details
|
|
1822
|
-
.filter(d => !excluded.has(NormalizeUUID(d.ID)))
|
|
2057
|
+
.filter(d => !excluded.has(NormalizeUUID(d.ID)) && !ConversationEngine.IsReplacedRow(d))
|
|
1823
2058
|
.filter(d => !floor || ConversationEngine.isAtOrAfter(d.__mj_CreatedAt, floor))
|
|
1824
2059
|
.sort((a, b) => a.Sequence - b.Sequence);
|
|
1825
2060
|
const boundary = floor ? undefined : ConversationEngine.findSummaryBoundary(ordered);
|
|
@@ -1844,6 +2079,412 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1844
2079
|
static HistoryFromFilter(historyFrom, column = '__mj_CreatedAt') {
|
|
1845
2080
|
return `${column} >= '${historyFrom.toISOString()}'`;
|
|
1846
2081
|
}
|
|
2082
|
+
/**
|
|
2083
|
+
* `filter` limited to rows no rerun replaced (`ReplacedAt` is NULL). A replaced row is an answer a
|
|
2084
|
+
* rerun of its turn superseded: it is kept, but never shown and never sent to an agent. Every reader
|
|
2085
|
+
* of conversation rows for display or for agent context applies this.
|
|
2086
|
+
*/
|
|
2087
|
+
static LiveRowsFilter(filter) {
|
|
2088
|
+
return `${filter} AND [ReplacedAt] IS NULL`;
|
|
2089
|
+
}
|
|
2090
|
+
/** True when a rerun of its turn replaced the row (`ReplacedAt` is set). */
|
|
2091
|
+
static IsReplacedRow(row) {
|
|
2092
|
+
return row.ReplacedAt != null;
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* The branch and its ancestors, current branch first. Empty for the trunk.
|
|
2096
|
+
* @throws when the branch is not in `branches`, or the chain is longer than
|
|
2097
|
+
* {@link MAX_BRANCH_DEPTH} (which also catches a cycle).
|
|
2098
|
+
*/
|
|
2099
|
+
static BuildBranchChain(branches, branchId) {
|
|
2100
|
+
const chain = [];
|
|
2101
|
+
let current = branchId ?? null;
|
|
2102
|
+
while (current != null) {
|
|
2103
|
+
if (chain.length >= MAX_BRANCH_DEPTH) {
|
|
2104
|
+
throw new Error(`Branch chain for ${branchId} exceeds ${MAX_BRANCH_DEPTH} levels`);
|
|
2105
|
+
}
|
|
2106
|
+
const id = current;
|
|
2107
|
+
const branch = branches.find(b => sameId(b.ID, id));
|
|
2108
|
+
if (!branch) {
|
|
2109
|
+
throw new Error(`Branch ${id} not found`);
|
|
2110
|
+
}
|
|
2111
|
+
chain.push(branch);
|
|
2112
|
+
current = branch.ParentBranchID ?? null;
|
|
2113
|
+
}
|
|
2114
|
+
return chain;
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* The `ExtraFilter` predicate for a branch path: the branch's own rows, plus each
|
|
2118
|
+
* ancestor's rows up to that ancestor's fork sequence, plus the trunk up to the last
|
|
2119
|
+
* fork sequence. A null branch is the trunk alone.
|
|
2120
|
+
*
|
|
2121
|
+
* Column names are bracketed, so the predicate also works inside a subquery on
|
|
2122
|
+
* `vwConversationDetails` from another entity's filter: the PostgreSQL provider converts
|
|
2123
|
+
* bracketed names itself but quotes bare names only when they are fields of the outer entity.
|
|
2124
|
+
*/
|
|
2125
|
+
static BuildBranchPathFilter(conversationId, branchId, branches) {
|
|
2126
|
+
const conversationFilter = `[ConversationID]='${EscapeSQLString(conversationId)}'`;
|
|
2127
|
+
const chain = ConversationEngine.BuildBranchChain(branches, branchId);
|
|
2128
|
+
if (chain.length === 0) {
|
|
2129
|
+
return `${conversationFilter} AND [BranchID] IS NULL`;
|
|
2130
|
+
}
|
|
2131
|
+
const clauses = [];
|
|
2132
|
+
let cap = null;
|
|
2133
|
+
for (const branch of chain) {
|
|
2134
|
+
const id = EscapeSQLString(branch.ID);
|
|
2135
|
+
clauses.push(cap == null ? `[BranchID]='${id}'` : `([BranchID]='${id}' AND [Sequence] <= ${cap})`);
|
|
2136
|
+
cap = branch.ForkFromSequence;
|
|
2137
|
+
if (cap == null) {
|
|
2138
|
+
return `${conversationFilter} AND (${clauses.join(' OR ')})`;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
clauses.push(`([BranchID] IS NULL AND [Sequence] <= ${cap})`);
|
|
2142
|
+
return `${conversationFilter} AND (${clauses.join(' OR ')})`;
|
|
2143
|
+
}
|
|
2144
|
+
/** True when a row is on the path described by a chain from {@link BuildBranchChain}. */
|
|
2145
|
+
static IsRowOnBranchPath(row, chain) {
|
|
2146
|
+
let cap = null;
|
|
2147
|
+
for (const branch of chain) {
|
|
2148
|
+
if (sameId(row.BranchID, branch.ID)) {
|
|
2149
|
+
return cap == null || row.Sequence <= cap;
|
|
2150
|
+
}
|
|
2151
|
+
cap = branch.ForkFromSequence;
|
|
2152
|
+
if (cap == null) {
|
|
2153
|
+
return false;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
return row.BranchID == null && (cap == null || row.Sequence <= cap);
|
|
2157
|
+
}
|
|
2158
|
+
/** In-memory counterpart of {@link BuildBranchPathFilter} for rows already loaded. */
|
|
2159
|
+
static FilterRowsToBranchPath(rows, branchId, branches) {
|
|
2160
|
+
const chain = ConversationEngine.BuildBranchChain(branches, branchId);
|
|
2161
|
+
return rows.filter(r => ConversationEngine.IsRowOnBranchPath(r, chain));
|
|
2162
|
+
}
|
|
2163
|
+
/** A scope for the trunk: no branch rows are needed. */
|
|
2164
|
+
static TrunkScope(conversationId) {
|
|
2165
|
+
return { ConversationID: conversationId, BranchID: null, Branches: [] };
|
|
2166
|
+
}
|
|
2167
|
+
/**
|
|
2168
|
+
* Builds a scope for an explicit branch. An empty, whitespace-only or missing branch id is the
|
|
2169
|
+
* trunk. A branch id loads the conversation's branch rows once and must name one of them
|
|
2170
|
+
* (compared without regard to case); the scope's `BranchID` is that row's `ID`.
|
|
2171
|
+
* @throws when the branch rows cannot be read or the branch is not one of them.
|
|
2172
|
+
*/
|
|
2173
|
+
static async LoadScope(conversationId, branchId, contextUser, provider) {
|
|
2174
|
+
const normalized = branchId && branchId.trim().length > 0 ? branchId : null;
|
|
2175
|
+
if (normalized === null) {
|
|
2176
|
+
return ConversationEngine.TrunkScope(conversationId);
|
|
2177
|
+
}
|
|
2178
|
+
const branches = await ConversationEngine.LoadBranchesFresh(conversationId, contextUser, provider);
|
|
2179
|
+
const branch = branches.find(b => sameId(b.ID, normalized));
|
|
2180
|
+
if (!branch) {
|
|
2181
|
+
throw new Error(`Branch ${normalized} is not a branch of conversation ${conversationId}`);
|
|
2182
|
+
}
|
|
2183
|
+
return { ConversationID: conversationId, BranchID: branch.ID, Branches: branches };
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Predicate over MJ: Conversation Details for the rows in scope. Same text as
|
|
2187
|
+
* {@link BuildBranchPathFilter}.
|
|
2188
|
+
* @throws when the scope's `BranchID`, or the parent of a branch on its chain, is not in the
|
|
2189
|
+
* scope's `Branches`, or when the chain is longer than {@link MAX_BRANCH_DEPTH}.
|
|
2190
|
+
*/
|
|
2191
|
+
static ScopeFilter(scope) {
|
|
2192
|
+
return ConversationEngine.BuildBranchPathFilter(scope.ConversationID, scope.BranchID, scope.Branches);
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* `[column] IN (SELECT ID FROM [__mj].[vwConversationDetails] WHERE <ScopeFilter>)` for entities
|
|
2196
|
+
* anchored by a conversation detail id column.
|
|
2197
|
+
*/
|
|
2198
|
+
static ScopeSubquery(scope, column = 'ConversationDetailID') {
|
|
2199
|
+
return `[${column}] IN (SELECT ID FROM [__mj].[vwConversationDetails] WHERE ${ConversationEngine.ScopeFilter(scope)})`;
|
|
2200
|
+
}
|
|
2201
|
+
/** In-memory form of {@link ScopeFilter} for one row already loaded. */
|
|
2202
|
+
static IsInScope(scope, row) {
|
|
2203
|
+
const chain = ConversationEngine.BuildBranchChain(scope.Branches, scope.BranchID);
|
|
2204
|
+
return ConversationEngine.IsRowOnBranchPath(row, chain);
|
|
2205
|
+
}
|
|
2206
|
+
/** In-memory form of {@link ScopeFilter} for rows already loaded. Keeps their order. */
|
|
2207
|
+
static FilterToScope(scope, rows) {
|
|
2208
|
+
return ConversationEngine.FilterRowsToBranchPath(rows, scope.BranchID, scope.Branches);
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Filter over MJ: Artifact Versions for the versions of one artifact visible in scope: versions
|
|
2212
|
+
* linked (in any direction) from a message on the path, and versions with no conversation link
|
|
2213
|
+
* at all.
|
|
2214
|
+
*/
|
|
2215
|
+
static ArtifactVersionScopeFilter(scope, artifactId) {
|
|
2216
|
+
const path = ConversationEngine.ScopeFilter(scope);
|
|
2217
|
+
const id = EscapeSQLString(artifactId);
|
|
2218
|
+
return `[ArtifactID]='${id}' AND (` +
|
|
2219
|
+
`[ID] IN (SELECT ArtifactVersionID FROM [__mj].[vwConversationDetailArtifacts] WHERE ConversationDetailID IN (SELECT ID FROM [__mj].[vwConversationDetails] WHERE ${path}))` +
|
|
2220
|
+
` OR [ID] NOT IN (SELECT ArtifactVersionID FROM [__mj].[vwConversationDetailArtifacts] WHERE ArtifactVersionID IS NOT NULL)` +
|
|
2221
|
+
`)`;
|
|
2222
|
+
}
|
|
2223
|
+
/** The kind of a fork row; a missing or unknown value is 'Fork'. */
|
|
2224
|
+
static NormalizeForkKind(kind) {
|
|
2225
|
+
return FORK_KINDS.find(k => k === kind) ?? 'Fork';
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Plain text of a stored message: each `@{...}` mention becomes `@<name>`, or is dropped when it
|
|
2229
|
+
* has no name, and runs of whitespace become one space. Null when nothing is left.
|
|
2230
|
+
*/
|
|
2231
|
+
static ForkPlainText(message) {
|
|
2232
|
+
if (!message) {
|
|
2233
|
+
return null;
|
|
2234
|
+
}
|
|
2235
|
+
const plain = message
|
|
2236
|
+
.replace(/@\{[^{}]*\}/g, token => {
|
|
2237
|
+
try {
|
|
2238
|
+
const parsed = JSON.parse(token.slice(1));
|
|
2239
|
+
return typeof parsed.name === 'string' && parsed.name.trim().length > 0 ? `@${parsed.name.trim()}` : ' ';
|
|
2240
|
+
}
|
|
2241
|
+
catch {
|
|
2242
|
+
return ' ';
|
|
2243
|
+
}
|
|
2244
|
+
})
|
|
2245
|
+
.replace(/\s+/g, ' ')
|
|
2246
|
+
.trim();
|
|
2247
|
+
return plain.length > 0 ? plain : null;
|
|
2248
|
+
}
|
|
2249
|
+
/** The first words of a message, for a fork with no name: at most 6 words and 40 characters, '…' when cut. */
|
|
2250
|
+
static ForkFirstWords(message) {
|
|
2251
|
+
const plain = ConversationEngine.ForkPlainText(message);
|
|
2252
|
+
if (!plain) {
|
|
2253
|
+
return null;
|
|
2254
|
+
}
|
|
2255
|
+
const words = plain.split(' ');
|
|
2256
|
+
let text = words.slice(0, FORK_NAME_MAX_WORDS).join(' ');
|
|
2257
|
+
let cut = words.length > FORK_NAME_MAX_WORDS;
|
|
2258
|
+
if (text.length > FORK_NAME_MAX_CHARS) {
|
|
2259
|
+
text = text.slice(0, FORK_NAME_MAX_CHARS).trimEnd();
|
|
2260
|
+
cut = true;
|
|
2261
|
+
}
|
|
2262
|
+
return cut ? `${text}…` : text;
|
|
2263
|
+
}
|
|
2264
|
+
/** A one-line preview of a message: at most 120 characters, '…' when cut. */
|
|
2265
|
+
static ForkPreviewText(message) {
|
|
2266
|
+
const plain = ConversationEngine.ForkPlainText(message);
|
|
2267
|
+
if (!plain) {
|
|
2268
|
+
return null;
|
|
2269
|
+
}
|
|
2270
|
+
return plain.length > FORK_PREVIEW_MAX_CHARS ? `${plain.slice(0, FORK_PREVIEW_MAX_CHARS).trimEnd()}…` : plain;
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* The label of a fork: its name, else a default from its kind — Fork: the first words of its first
|
|
2274
|
+
* message, else "Fork"; Edit: "Edited version"; Regenerate: "Other answer".
|
|
2275
|
+
*/
|
|
2276
|
+
static ForkDisplayName(branch, firstOwnMessage) {
|
|
2277
|
+
const name = branch.Name?.trim();
|
|
2278
|
+
if (name) {
|
|
2279
|
+
return name;
|
|
2280
|
+
}
|
|
2281
|
+
switch (ConversationEngine.NormalizeForkKind(branch.Kind)) {
|
|
2282
|
+
case 'Edit':
|
|
2283
|
+
return 'Edited version';
|
|
2284
|
+
case 'Regenerate':
|
|
2285
|
+
return 'Other answer';
|
|
2286
|
+
default:
|
|
2287
|
+
return ConversationEngine.ForkFirstWords(firstOwnMessage) ?? 'Fork';
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
/** The fork point at a message: the new fork starts after the message itself, on its branch. */
|
|
2291
|
+
static ForkPointFrom(message) {
|
|
2292
|
+
return { ParentBranchID: message.BranchID ?? null, ForkFromSequence: message.Sequence };
|
|
2293
|
+
}
|
|
2294
|
+
/**
|
|
2295
|
+
* Edit of a message, or Fork from here on it (the fork replaces it): the new fork starts at the row
|
|
2296
|
+
* before it on its path (both null when it is the first message). Undefined when that row is not
|
|
2297
|
+
* known: the message is not in `pathRows`, or it is the first loaded row while older rows exist
|
|
2298
|
+
* (`hasMoreAbove`).
|
|
2299
|
+
*/
|
|
2300
|
+
static EditForkPoint(message, pathRows, hasMoreAbove = false) {
|
|
2301
|
+
const sorted = [...pathRows].sort((a, b) => a.Sequence - b.Sequence);
|
|
2302
|
+
const index = sorted.findIndex(r => sameId(r.ID, message.ID));
|
|
2303
|
+
if (index < 0) {
|
|
2304
|
+
return undefined;
|
|
2305
|
+
}
|
|
2306
|
+
if (index === 0) {
|
|
2307
|
+
return hasMoreAbove ? undefined : { ParentBranchID: null, ForkFromSequence: null };
|
|
2308
|
+
}
|
|
2309
|
+
return ConversationEngine.ForkPointFrom(sorted[index - 1]);
|
|
2310
|
+
}
|
|
2311
|
+
/** Regenerate of an answer: the new fork starts at the user message the answer replied to. */
|
|
2312
|
+
static RegenerateForkPoint(userMessage) {
|
|
2313
|
+
return ConversationEngine.ForkPointFrom(userMessage);
|
|
2314
|
+
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Predicate over MJ: Conversation Details for the anchor rows of the given forks (one clause per
|
|
2317
|
+
* distinct fork point); null when no fork has a fork point.
|
|
2318
|
+
*/
|
|
2319
|
+
static AnchorRowsFilter(conversationId, branches) {
|
|
2320
|
+
const clauses = [];
|
|
2321
|
+
for (const branch of branches) {
|
|
2322
|
+
if (branch.ForkFromSequence == null) {
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
const onParent = branch.ParentBranchID == null
|
|
2326
|
+
? '[BranchID] IS NULL'
|
|
2327
|
+
: `[BranchID]='${EscapeSQLString(branch.ParentBranchID)}'`;
|
|
2328
|
+
const clause = `(${onParent} AND [Sequence]=${Math.trunc(branch.ForkFromSequence)})`;
|
|
2329
|
+
if (!clauses.includes(clause)) {
|
|
2330
|
+
clauses.push(clause);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
if (clauses.length === 0) {
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
return `[ConversationID]='${EscapeSQLString(conversationId)}' AND (${clauses.join(' OR ')})`;
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Predicate over MJ: Conversation Details for the source rows of the given forks (each distinct
|
|
2340
|
+
* SourceDetailID once); null when no fork has a source.
|
|
2341
|
+
*/
|
|
2342
|
+
static SourceRowsFilter(conversationId, branches) {
|
|
2343
|
+
const ids = [];
|
|
2344
|
+
for (const branch of branches) {
|
|
2345
|
+
const id = branch.SourceDetailID;
|
|
2346
|
+
if (id && !ids.some(known => sameId(known, id))) {
|
|
2347
|
+
ids.push(id);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
if (ids.length === 0) {
|
|
2351
|
+
return null;
|
|
2352
|
+
}
|
|
2353
|
+
const list = ids.map(id => `'${EscapeSQLString(id)}'`).join(', ');
|
|
2354
|
+
return `[ConversationID]='${EscapeSQLString(conversationId)}' AND [ID] IN (${list})`;
|
|
2355
|
+
}
|
|
2356
|
+
/**
|
|
2357
|
+
* One summary per fork, in the order of `branches`. Pure: `ownRows` are the conversation's rows
|
|
2358
|
+
* with a BranchID, `anchorRows` the rows {@link AnchorRowsFilter} selects, `sourceRows` the rows
|
|
2359
|
+
* {@link SourceRowsFilter} selects.
|
|
2360
|
+
*/
|
|
2361
|
+
static BuildForkSummaries(branches, ownRows, anchorRows, sourceRows = []) {
|
|
2362
|
+
const ownByFork = new Map();
|
|
2363
|
+
for (const row of ownRows) {
|
|
2364
|
+
if (row.BranchID == null || row.HiddenToUser === true) {
|
|
2365
|
+
continue;
|
|
2366
|
+
}
|
|
2367
|
+
const key = NormalizeUUID(row.BranchID);
|
|
2368
|
+
const list = ownByFork.get(key);
|
|
2369
|
+
if (list) {
|
|
2370
|
+
list.push(row);
|
|
2371
|
+
}
|
|
2372
|
+
else {
|
|
2373
|
+
ownByFork.set(key, [row]);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
return branches.map(branch => {
|
|
2377
|
+
const own = [...(ownByFork.get(NormalizeUUID(branch.ID)) ?? [])].sort((a, b) => a.Sequence - b.Sequence);
|
|
2378
|
+
const anchor = branch.ForkFromSequence == null
|
|
2379
|
+
? undefined
|
|
2380
|
+
: anchorRows.find(r => r.Sequence === branch.ForkFromSequence && sameId(r.BranchID, branch.ParentBranchID ?? null));
|
|
2381
|
+
const sourceId = branch.SourceDetailID;
|
|
2382
|
+
const source = sourceId ? sourceRows.find(r => sameId(r.ID, sourceId)) : undefined;
|
|
2383
|
+
const placement = sourceId ? source : anchor;
|
|
2384
|
+
const first = own.find(r => ConversationEngine.ForkPlainText(r.Message) != null);
|
|
2385
|
+
const last = own.length > 0 ? own[own.length - 1] : undefined;
|
|
2386
|
+
const authors = [];
|
|
2387
|
+
for (const r of own) {
|
|
2388
|
+
if (r.Role === 'User' && r.UserID && !authors.some(a => sameId(a, r.UserID))) {
|
|
2389
|
+
authors.push(r.UserID);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
return {
|
|
2393
|
+
Branch: branch,
|
|
2394
|
+
Kind: ConversationEngine.NormalizeForkKind(branch.Kind),
|
|
2395
|
+
DisplayName: ConversationEngine.ForkDisplayName(branch, first?.Message ?? null),
|
|
2396
|
+
MessageCount: own.length,
|
|
2397
|
+
Participants: ConversationEngine.forkParticipants(own),
|
|
2398
|
+
AuthorUserIDs: authors,
|
|
2399
|
+
LastActivityAt: (last ? ConversationEngine.toForkDate(last.__mj_CreatedAt) : null)
|
|
2400
|
+
?? ConversationEngine.toForkDate(branch.__mj_CreatedAt)
|
|
2401
|
+
?? new Date(0),
|
|
2402
|
+
LastMessagePreview: last ? ConversationEngine.ForkPreviewText(last.Message) : null,
|
|
2403
|
+
LastMessageAuthorName: last ? ConversationEngine.rowAuthorName(last) : null,
|
|
2404
|
+
PlacementDetailID: branch.SourceDetailID ?? anchor?.ID ?? null,
|
|
2405
|
+
PlacementPosition: placement ? { BranchID: placement.BranchID ?? null, Sequence: placement.Sequence } : null,
|
|
2406
|
+
AnchorDetailID: anchor?.ID ?? null,
|
|
2407
|
+
AnchorAuthorName: anchor ? ConversationEngine.rowAuthorName(anchor) : null,
|
|
2408
|
+
AnchorAt: anchor ? ConversationEngine.toForkDate(anchor.__mj_CreatedAt) : null,
|
|
2409
|
+
SourceAuthorName: source ? ConversationEngine.rowAuthorName(source) : null,
|
|
2410
|
+
StartedByUserID: branch.UserID ?? null,
|
|
2411
|
+
StartedByName: branch.User ?? null,
|
|
2412
|
+
ParentBranchID: branch.ParentBranchID ?? null,
|
|
2413
|
+
};
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Loads the fork summaries of a conversation FRESH (entity RLS applies): the fork rows (unless
|
|
2418
|
+
* given), then the forks' own rows, their anchor rows and their source rows in one RunViews batch.
|
|
2419
|
+
* @throws when a read fails
|
|
2420
|
+
*/
|
|
2421
|
+
static async LoadForkSummaries(conversationId, contextUser, provider, branches) {
|
|
2422
|
+
const forks = branches ?? await ConversationEngine.LoadBranchesFresh(conversationId, contextUser, provider);
|
|
2423
|
+
if (forks.length === 0) {
|
|
2424
|
+
return [];
|
|
2425
|
+
}
|
|
2426
|
+
const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
|
|
2427
|
+
const params = [{
|
|
2428
|
+
EntityName: 'MJ: Conversation Details',
|
|
2429
|
+
ExtraFilter: `[ConversationID]='${EscapeSQLString(conversationId)}' AND [BranchID] IS NOT NULL`,
|
|
2430
|
+
OrderBy: 'Sequence ASC',
|
|
2431
|
+
Fields: [...ForkSummaryRowFields],
|
|
2432
|
+
ResultType: 'simple',
|
|
2433
|
+
}];
|
|
2434
|
+
const rowsParams = (filter) => ({
|
|
2435
|
+
EntityName: 'MJ: Conversation Details',
|
|
2436
|
+
ExtraFilter: filter,
|
|
2437
|
+
Fields: [...ForkSummaryRowFields],
|
|
2438
|
+
ResultType: 'simple',
|
|
2439
|
+
});
|
|
2440
|
+
const anchorFilter = ConversationEngine.AnchorRowsFilter(conversationId, forks);
|
|
2441
|
+
const anchorIndex = anchorFilter ? params.push(rowsParams(anchorFilter)) - 1 : -1;
|
|
2442
|
+
const sourceFilter = ConversationEngine.SourceRowsFilter(conversationId, forks);
|
|
2443
|
+
const sourceIndex = sourceFilter ? params.push(rowsParams(sourceFilter)) - 1 : -1;
|
|
2444
|
+
const results = await rv.RunViews(params, contextUser);
|
|
2445
|
+
const own = results[0];
|
|
2446
|
+
const anchors = anchorIndex >= 0 ? results[anchorIndex] : undefined;
|
|
2447
|
+
const sources = sourceIndex >= 0 ? results[sourceIndex] : undefined;
|
|
2448
|
+
if (!own?.Success) {
|
|
2449
|
+
throw new Error(`Failed to load the forks of conversation ${conversationId}: ${own?.ErrorMessage}`);
|
|
2450
|
+
}
|
|
2451
|
+
if (anchors && !anchors.Success) {
|
|
2452
|
+
throw new Error(`Failed to load the fork anchors of conversation ${conversationId}: ${anchors.ErrorMessage}`);
|
|
2453
|
+
}
|
|
2454
|
+
if (sources && !sources.Success) {
|
|
2455
|
+
throw new Error(`Failed to load the fork source messages of conversation ${conversationId}: ${sources.ErrorMessage}`);
|
|
2456
|
+
}
|
|
2457
|
+
return ConversationEngine.BuildForkSummaries(forks, own.Results ?? [], anchors?.Results ?? [], sources?.Results ?? []);
|
|
2458
|
+
}
|
|
2459
|
+
/** Distinct agents and people of rows in order, at most {@link MAX_FORK_PARTICIPANTS}. */
|
|
2460
|
+
static forkParticipants(rows) {
|
|
2461
|
+
const out = [];
|
|
2462
|
+
for (const r of rows) {
|
|
2463
|
+
const participant = r.AgentID
|
|
2464
|
+
? { Kind: 'Agent', ID: r.AgentID, Name: r.Agent ?? null }
|
|
2465
|
+
: r.UserID ? { Kind: 'User', ID: r.UserID, Name: r.User ?? null } : null;
|
|
2466
|
+
if (!participant || out.some(p => p.Kind === participant.Kind && sameId(p.ID, participant.ID))) {
|
|
2467
|
+
continue;
|
|
2468
|
+
}
|
|
2469
|
+
out.push(participant);
|
|
2470
|
+
if (out.length === MAX_FORK_PARTICIPANTS) {
|
|
2471
|
+
break;
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
return out;
|
|
2475
|
+
}
|
|
2476
|
+
/** The agent's name for an agent row, else the person's name; null when the row names neither. */
|
|
2477
|
+
static rowAuthorName(row) {
|
|
2478
|
+
return row.AgentID ? (row.Agent ?? null) : (row.User ?? null);
|
|
2479
|
+
}
|
|
2480
|
+
/** A Date from a stored timestamp; null when missing or unreadable. */
|
|
2481
|
+
static toForkDate(value) {
|
|
2482
|
+
if (value == null) {
|
|
2483
|
+
return null;
|
|
2484
|
+
}
|
|
2485
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
2486
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
2487
|
+
}
|
|
1847
2488
|
/** True when a row's timestamp is at or after the floor; false when it is missing or unreadable. */
|
|
1848
2489
|
static isAtOrAfter(createdAt, floor) {
|
|
1849
2490
|
if (createdAt == null) {
|
|
@@ -1969,7 +2610,9 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1969
2610
|
return this._detailCache.get(key)?.AgentRunsByDetailId ?? new Map();
|
|
1970
2611
|
}
|
|
1971
2612
|
/**
|
|
1972
|
-
* Adds or updates an agent run in the cache for a specific detail
|
|
2613
|
+
* Adds or updates an agent run in the cache for a specific detail, honoring
|
|
2614
|
+
* {@link ShouldReplaceDetailAgentRun} so a continuation/sub-agent run never overwrites
|
|
2615
|
+
* the primary root orchestrating run for that detail.
|
|
1973
2616
|
*
|
|
1974
2617
|
* @param conversationId - The conversation ID
|
|
1975
2618
|
* @param detailId - The detail ID the agent run is associated with
|
|
@@ -1978,8 +2621,22 @@ export class ConversationEngine extends BaseEngine {
|
|
|
1978
2621
|
SetAgentRunForDetail(conversationId, detailId, agentRun) {
|
|
1979
2622
|
const key = NormalizeUUID(conversationId);
|
|
1980
2623
|
const cached = this._detailCache.get(key);
|
|
1981
|
-
if (cached) {
|
|
1982
|
-
|
|
2624
|
+
if (!cached) {
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
let targetDetailKey = detailId;
|
|
2628
|
+
let existing = cached.AgentRunsByDetailId.get(detailId);
|
|
2629
|
+
if (!existing) {
|
|
2630
|
+
for (const [cachedDetailId, cachedRun] of cached.AgentRunsByDetailId) {
|
|
2631
|
+
if (UUIDsEqual(cachedDetailId, detailId)) {
|
|
2632
|
+
targetDetailKey = cachedDetailId;
|
|
2633
|
+
existing = cachedRun;
|
|
2634
|
+
break;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
if (ShouldReplaceDetailAgentRun(existing, agentRun)) {
|
|
2639
|
+
cached.AgentRunsByDetailId.set(targetDetailKey, agentRun);
|
|
1983
2640
|
}
|
|
1984
2641
|
}
|
|
1985
2642
|
// ========================================================================
|