@memberjunction/server 2.94.0 → 2.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +5 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/resolvers/AskSkipResolver.d.ts +5 -0
- package/dist/resolvers/AskSkipResolver.d.ts.map +1 -1
- package/dist/resolvers/AskSkipResolver.js +234 -18
- package/dist/resolvers/AskSkipResolver.js.map +1 -1
- package/package.json +39 -39
- package/src/config.ts +2 -0
- package/src/resolvers/AskSkipResolver.ts +289 -18
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
UserNotificationEntity,
|
|
55
55
|
AIAgentEntityExtended
|
|
56
56
|
} from '@memberjunction/core-entities';
|
|
57
|
-
import { apiKey, baseUrl, configInfo, graphqlPort, mj_core_schema } from '../config.js';
|
|
57
|
+
import { apiKey, baseUrl, publicUrl, configInfo, graphqlPort, graphqlRootPath, mj_core_schema } from '../config.js';
|
|
58
58
|
import mssql from 'mssql';
|
|
59
59
|
|
|
60
60
|
import { registerEnumType } from 'type-graphql';
|
|
@@ -66,6 +66,133 @@ import { AIEngine } from '@memberjunction/aiengine';
|
|
|
66
66
|
import { deleteAccessToken, GetDataAccessToken, registerAccessToken, tokenExists } from './GetDataResolver.js';
|
|
67
67
|
import e from 'express';
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Store for active conversation streams
|
|
71
|
+
* Maps conversationID to the last status message received
|
|
72
|
+
*/
|
|
73
|
+
class ActiveConversationStreams {
|
|
74
|
+
private static instance: ActiveConversationStreams;
|
|
75
|
+
private streams: Map<string, {
|
|
76
|
+
lastStatus: string,
|
|
77
|
+
lastUpdate: Date,
|
|
78
|
+
startTime: Date, // When processing actually started
|
|
79
|
+
sessionIds: Set<string> // Track which sessions are listening
|
|
80
|
+
}> = new Map();
|
|
81
|
+
|
|
82
|
+
private constructor() {}
|
|
83
|
+
|
|
84
|
+
static getInstance(): ActiveConversationStreams {
|
|
85
|
+
if (!ActiveConversationStreams.instance) {
|
|
86
|
+
ActiveConversationStreams.instance = new ActiveConversationStreams();
|
|
87
|
+
}
|
|
88
|
+
return ActiveConversationStreams.instance;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
updateStatus(conversationId: string, status: string, sessionId?: string) {
|
|
92
|
+
const existing = this.streams.get(conversationId);
|
|
93
|
+
if (existing) {
|
|
94
|
+
existing.lastStatus = status;
|
|
95
|
+
existing.lastUpdate = new Date();
|
|
96
|
+
if (sessionId) {
|
|
97
|
+
existing.sessionIds.add(sessionId);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
const now = new Date();
|
|
101
|
+
this.streams.set(conversationId, {
|
|
102
|
+
lastStatus: status,
|
|
103
|
+
lastUpdate: now,
|
|
104
|
+
startTime: now, // Track when processing started
|
|
105
|
+
sessionIds: sessionId ? new Set([sessionId]) : new Set()
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getStatus(conversationId: string): string | null {
|
|
111
|
+
const stream = this.streams.get(conversationId);
|
|
112
|
+
return stream ? stream.lastStatus : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getStartTime(conversationId: string): Date | null {
|
|
116
|
+
const stream = this.streams.get(conversationId);
|
|
117
|
+
return stream ? stream.startTime : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
addSession(conversationId: string, sessionId: string) {
|
|
121
|
+
const stream = this.streams.get(conversationId);
|
|
122
|
+
if (stream) {
|
|
123
|
+
stream.sessionIds.add(sessionId);
|
|
124
|
+
} else {
|
|
125
|
+
// If no stream exists yet, create one with default status
|
|
126
|
+
const now = new Date();
|
|
127
|
+
this.streams.set(conversationId, {
|
|
128
|
+
lastStatus: 'Processing...',
|
|
129
|
+
lastUpdate: now,
|
|
130
|
+
startTime: now, // Track when processing started
|
|
131
|
+
sessionIds: new Set([sessionId])
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
removeConversation(conversationId: string) {
|
|
137
|
+
this.streams.delete(conversationId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
isActive(conversationId: string): boolean {
|
|
141
|
+
const stream = this.streams.get(conversationId);
|
|
142
|
+
if (!stream) return false;
|
|
143
|
+
|
|
144
|
+
// Consider a stream inactive if no update in last 5 minutes
|
|
145
|
+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
146
|
+
return stream.lastUpdate > fiveMinutesAgo;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
getSessionIds(conversationId: string): string[] {
|
|
150
|
+
const stream = this.streams.get(conversationId);
|
|
151
|
+
return stream ? Array.from(stream.sessionIds) : [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Clean up stale streams that haven't been updated in a while
|
|
156
|
+
* This prevents memory leaks from abandoned conversations
|
|
157
|
+
*/
|
|
158
|
+
cleanupStaleStreams() {
|
|
159
|
+
const now = new Date();
|
|
160
|
+
const staleThreshold = new Date(now.getTime() - 30 * 60 * 1000); // 30 minutes
|
|
161
|
+
|
|
162
|
+
const staleConversations: string[] = [];
|
|
163
|
+
this.streams.forEach((stream, conversationId) => {
|
|
164
|
+
if (stream.lastUpdate < staleThreshold) {
|
|
165
|
+
staleConversations.push(conversationId);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
staleConversations.forEach(conversationId => {
|
|
170
|
+
this.streams.delete(conversationId);
|
|
171
|
+
LogStatus(`Cleaned up stale stream for conversation ${conversationId}`);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (staleConversations.length > 0) {
|
|
175
|
+
LogStatus(`Cleaned up ${staleConversations.length} stale conversation streams`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const activeStreams = ActiveConversationStreams.getInstance();
|
|
181
|
+
|
|
182
|
+
// Set up periodic cleanup of stale streams (every 10 minutes)
|
|
183
|
+
setInterval(() => {
|
|
184
|
+
activeStreams.cleanupStaleStreams();
|
|
185
|
+
}, 10 * 60 * 1000);
|
|
186
|
+
|
|
187
|
+
@ObjectType()
|
|
188
|
+
class ReattachConversationResponse {
|
|
189
|
+
@Field(() => String, { nullable: true })
|
|
190
|
+
lastStatusMessage?: string;
|
|
191
|
+
|
|
192
|
+
@Field(() => Date, { nullable: true })
|
|
193
|
+
startTime?: Date;
|
|
194
|
+
}
|
|
195
|
+
|
|
69
196
|
/**
|
|
70
197
|
* Enumeration representing the different phases of a Skip response
|
|
71
198
|
* Corresponds to the lifecycle of a Skip AI interaction
|
|
@@ -932,7 +1059,9 @@ cycle.`);
|
|
|
932
1059
|
organizationID: skipConfigInfo.orgID,
|
|
933
1060
|
organizationInfo: configInfo?.askSkip?.organizationInfo,
|
|
934
1061
|
apiKeys: this.buildSkipAPIKeys(),
|
|
935
|
-
|
|
1062
|
+
// Favors public URL for conciseness or when behind a proxy for local development
|
|
1063
|
+
// otherwise uses base URL and GraphQL port/path from configuration
|
|
1064
|
+
callingServerURL: accessToken ? (publicUrl || `${baseUrl}:${graphqlPort}${graphqlRootPath}`) : undefined,
|
|
936
1065
|
callingServerAPIKey: accessToken ? apiKey : undefined,
|
|
937
1066
|
callingServerAccessToken: accessToken ? accessToken.Token : undefined
|
|
938
1067
|
};
|
|
@@ -1331,6 +1460,110 @@ cycle.`);
|
|
|
1331
1460
|
];
|
|
1332
1461
|
}
|
|
1333
1462
|
|
|
1463
|
+
/**
|
|
1464
|
+
* Re-attaches the current session to receive status updates for a processing conversation
|
|
1465
|
+
* This is needed after page reloads to resume receiving push notifications
|
|
1466
|
+
*/
|
|
1467
|
+
@Query(() => ReattachConversationResponse)
|
|
1468
|
+
async ReattachToProcessingConversation(
|
|
1469
|
+
@Arg('ConversationId', () => String) ConversationId: string,
|
|
1470
|
+
@Ctx() { userPayload }: AppContext,
|
|
1471
|
+
@PubSub() pubSub: PubSubEngine
|
|
1472
|
+
): Promise<ReattachConversationResponse | null> {
|
|
1473
|
+
try {
|
|
1474
|
+
const md = new Metadata();
|
|
1475
|
+
const user = UserCache.Instance.Users.find((u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase());
|
|
1476
|
+
if (!user) {
|
|
1477
|
+
LogError(`User ${userPayload.email} not found in UserCache`);
|
|
1478
|
+
return null;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Load the conversation
|
|
1482
|
+
const convoEntity = await md.GetEntityObject<ConversationEntity>('Conversations', user);
|
|
1483
|
+
const loadResult = await convoEntity.Load(ConversationId);
|
|
1484
|
+
|
|
1485
|
+
if (!loadResult) {
|
|
1486
|
+
LogError(`Could not load conversation ${ConversationId} for re-attachment`);
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// Check if the conversation belongs to this user
|
|
1491
|
+
if (convoEntity.UserID !== user.ID) {
|
|
1492
|
+
LogError(`Conversation ${ConversationId} does not belong to user ${user.Email}`);
|
|
1493
|
+
return null;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// If the conversation is processing, reattach the session to receive updates
|
|
1497
|
+
if (convoEntity.Status === 'Processing') {
|
|
1498
|
+
// Add this session to the active streams for this conversation
|
|
1499
|
+
activeStreams.addSession(ConversationId, userPayload.sessionId);
|
|
1500
|
+
|
|
1501
|
+
// Get the last known status message and start time from our cache
|
|
1502
|
+
const lastStatusMessage = activeStreams.getStatus(ConversationId) || 'Processing...';
|
|
1503
|
+
const startTime = activeStreams.getStartTime(ConversationId);
|
|
1504
|
+
|
|
1505
|
+
// Check if the stream is still active
|
|
1506
|
+
const isStreamActive = activeStreams.isActive(ConversationId);
|
|
1507
|
+
|
|
1508
|
+
if (isStreamActive) {
|
|
1509
|
+
// Send the last known status to the frontend
|
|
1510
|
+
const statusMessage = {
|
|
1511
|
+
type: 'AskSkip',
|
|
1512
|
+
status: 'OK',
|
|
1513
|
+
ResponsePhase: 'Processing',
|
|
1514
|
+
conversationID: convoEntity.ID,
|
|
1515
|
+
message: lastStatusMessage,
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1518
|
+
pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
|
|
1519
|
+
pushStatusUpdates: {
|
|
1520
|
+
message: JSON.stringify(statusMessage),
|
|
1521
|
+
sessionId: userPayload.sessionId
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
LogStatus(`Re-attached session ${userPayload.sessionId} to active stream for conversation ${ConversationId}, last status: ${lastStatusMessage}`);
|
|
1526
|
+
|
|
1527
|
+
// Return the status and start time
|
|
1528
|
+
return {
|
|
1529
|
+
lastStatusMessage,
|
|
1530
|
+
startTime: startTime || convoEntity.__mj_UpdatedAt
|
|
1531
|
+
};
|
|
1532
|
+
} else {
|
|
1533
|
+
// Stream is inactive or doesn't exist, just send default status
|
|
1534
|
+
const statusMessage = {
|
|
1535
|
+
type: 'AskSkip',
|
|
1536
|
+
status: 'OK',
|
|
1537
|
+
ResponsePhase: 'Processing',
|
|
1538
|
+
conversationID: convoEntity.ID,
|
|
1539
|
+
message: 'Processing...',
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
|
|
1543
|
+
pushStatusUpdates: {
|
|
1544
|
+
message: JSON.stringify(statusMessage),
|
|
1545
|
+
sessionId: userPayload.sessionId
|
|
1546
|
+
}
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
LogStatus(`Re-attached session ${userPayload.sessionId} to conversation ${ConversationId}, but stream is inactive`);
|
|
1550
|
+
|
|
1551
|
+
// Return default start time since stream is inactive
|
|
1552
|
+
return {
|
|
1553
|
+
lastStatusMessage: 'Processing...',
|
|
1554
|
+
startTime: convoEntity.__mj_UpdatedAt
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
} else {
|
|
1558
|
+
LogStatus(`Conversation ${ConversationId} is not processing (Status: ${convoEntity.Status})`);
|
|
1559
|
+
return null;
|
|
1560
|
+
}
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
LogError(`Error re-attaching to conversation: ${error}`);
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1334
1567
|
/**
|
|
1335
1568
|
* Executes an analysis query with Skip
|
|
1336
1569
|
* This is the primary entry point for general Skip conversations
|
|
@@ -1372,7 +1605,7 @@ cycle.`);
|
|
|
1372
1605
|
);
|
|
1373
1606
|
|
|
1374
1607
|
// Set the conversation status to 'Processing' when a request is initiated
|
|
1375
|
-
await this.setConversationStatus(convoEntity, 'Processing', userPayload);
|
|
1608
|
+
await this.setConversationStatus(convoEntity, 'Processing', userPayload, pubSub);
|
|
1376
1609
|
|
|
1377
1610
|
// now load up the messages. We will load up ALL of the messages for this conversation, and then pass them to the Skip API
|
|
1378
1611
|
const messages: SkipMessage[] = await this.LoadConversationDetailsIntoSkipMessages(
|
|
@@ -2196,7 +2429,7 @@ cycle.`);
|
|
|
2196
2429
|
|
|
2197
2430
|
if (conversationDetailCount > 10) {
|
|
2198
2431
|
// Set status of conversation to Available since we still want to allow the user to ask questions
|
|
2199
|
-
await this.setConversationStatus(convoEntity, 'Available', userPayload);
|
|
2432
|
+
await this.setConversationStatus(convoEntity, 'Available', userPayload, pubSub);
|
|
2200
2433
|
|
|
2201
2434
|
// At this point it is likely that we are stuck in a loop, so we stop here
|
|
2202
2435
|
pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
|
|
@@ -2241,22 +2474,31 @@ cycle.`);
|
|
|
2241
2474
|
}) => {
|
|
2242
2475
|
LogStatus(JSON.stringify(message, null, 4));
|
|
2243
2476
|
if (message.type === 'status_update') {
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2477
|
+
const statusContent = message.value.messages[0].content;
|
|
2478
|
+
|
|
2479
|
+
// Store the status in our active streams cache
|
|
2480
|
+
activeStreams.updateStatus(ConversationId, statusContent, userPayload.sessionId);
|
|
2481
|
+
|
|
2482
|
+
// Publish to all sessions listening to this conversation
|
|
2483
|
+
const sessionIds = activeStreams.getSessionIds(ConversationId);
|
|
2484
|
+
for (const sessionId of sessionIds) {
|
|
2485
|
+
pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
|
|
2486
|
+
message: JSON.stringify({
|
|
2487
|
+
type: 'AskSkip',
|
|
2488
|
+
status: 'OK',
|
|
2489
|
+
conversationID: ConversationId,
|
|
2490
|
+
ResponsePhase: message.value.responsePhase,
|
|
2491
|
+
message: statusContent,
|
|
2492
|
+
}),
|
|
2493
|
+
sessionId: sessionId,
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2254
2496
|
}
|
|
2255
2497
|
}
|
|
2256
2498
|
);
|
|
2257
2499
|
} catch (error) {
|
|
2258
2500
|
// Set conversation status to Available on error so user can try again
|
|
2259
|
-
await this.setConversationStatus(convoEntity, 'Available', userPayload);
|
|
2501
|
+
await this.setConversationStatus(convoEntity, 'Available', userPayload, pubSub);
|
|
2260
2502
|
|
|
2261
2503
|
// Log the error for debugging
|
|
2262
2504
|
LogError(`Error in HandleSkipChatRequest sendPostRequest: ${error}`);
|
|
@@ -2339,7 +2581,7 @@ cycle.`);
|
|
|
2339
2581
|
}
|
|
2340
2582
|
} else {
|
|
2341
2583
|
// Set status of conversation to Available since we still want to allow the user to ask questions
|
|
2342
|
-
await this.setConversationStatus(convoEntity, 'Available', userPayload);
|
|
2584
|
+
await this.setConversationStatus(convoEntity, 'Available', userPayload, pubSub);
|
|
2343
2585
|
|
|
2344
2586
|
pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
|
|
2345
2587
|
message: JSON.stringify({
|
|
@@ -2511,7 +2753,7 @@ cycle.`);
|
|
|
2511
2753
|
convoDetailEntityAI.CompletionTime = endTime.getTime() - startTime.getTime();
|
|
2512
2754
|
|
|
2513
2755
|
// Set conversation status back to Available since we need user input for the clarifying question
|
|
2514
|
-
await this.setConversationStatus(convoEntity, 'Available', userPayload);
|
|
2756
|
+
await this.setConversationStatus(convoEntity, 'Available', userPayload, pubSub);
|
|
2515
2757
|
|
|
2516
2758
|
if (await convoDetailEntityAI.Save()) {
|
|
2517
2759
|
return {
|
|
@@ -2908,13 +3150,42 @@ cycle.`);
|
|
|
2908
3150
|
};
|
|
2909
3151
|
}
|
|
2910
3152
|
|
|
2911
|
-
private async setConversationStatus(convoEntity: ConversationEntity, status: 'Processing' | 'Available', userPayload: UserPayload): Promise<boolean> {
|
|
3153
|
+
private async setConversationStatus(convoEntity: ConversationEntity, status: 'Processing' | 'Available', userPayload: UserPayload, pubSub?: PubSubEngine): Promise<boolean> {
|
|
2912
3154
|
if (convoEntity.Status !== status) {
|
|
2913
3155
|
convoEntity.Status = status;
|
|
2914
3156
|
|
|
2915
3157
|
const convoSaveResult = await convoEntity.Save();
|
|
2916
3158
|
if (!convoSaveResult) {
|
|
2917
3159
|
LogError(`Error updating conversation status to '${status}'`, undefined, convoEntity.LatestResult);
|
|
3160
|
+
} else {
|
|
3161
|
+
// If conversation is now Available (completed), remove it from active streams
|
|
3162
|
+
if (status === 'Available') {
|
|
3163
|
+
activeStreams.removeConversation(convoEntity.ID);
|
|
3164
|
+
LogStatus(`Removed conversation ${convoEntity.ID} from active streams (status changed to Available)`);
|
|
3165
|
+
} else if (status === 'Processing') {
|
|
3166
|
+
// If conversation is starting to process, add the session to active streams
|
|
3167
|
+
activeStreams.addSession(convoEntity.ID, userPayload.sessionId);
|
|
3168
|
+
LogStatus(`Added session ${userPayload.sessionId} to active streams for conversation ${convoEntity.ID}`);
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
if (pubSub) {
|
|
3172
|
+
// Publish status update to notify frontend of conversation status change
|
|
3173
|
+
const statusMessage = {
|
|
3174
|
+
type: 'ConversationStatusUpdate',
|
|
3175
|
+
conversationID: convoEntity.ID,
|
|
3176
|
+
status: status,
|
|
3177
|
+
timestamp: new Date().toISOString()
|
|
3178
|
+
};
|
|
3179
|
+
|
|
3180
|
+
pubSub.publish(PUSH_STATUS_UPDATES_TOPIC, {
|
|
3181
|
+
pushStatusUpdates: {
|
|
3182
|
+
message: JSON.stringify(statusMessage),
|
|
3183
|
+
sessionId: userPayload.sessionId
|
|
3184
|
+
}
|
|
3185
|
+
});
|
|
3186
|
+
|
|
3187
|
+
LogStatus(`Published conversation status update for ${convoEntity.ID}: ${status}`);
|
|
3188
|
+
}
|
|
2918
3189
|
}
|
|
2919
3190
|
return convoSaveResult;
|
|
2920
3191
|
}
|