@lobehub/lobehub 2.0.0-next.77 → 2.0.0-next.79

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,451 @@
1
+ /* eslint-disable sort-keys-fix/sort-keys-fix */
2
+ import { nanoid } from '@lobechat/utils';
3
+ import { produce } from 'immer';
4
+ import { StateCreator } from 'zustand/vanilla';
5
+
6
+ import { ChatStore } from '@/store/chat/store';
7
+ import { messageMapKey } from '@/store/chat/utils/messageMapKey';
8
+ import { setNamespace } from '@/utils/storeDebug';
9
+
10
+ import type {
11
+ Operation,
12
+ OperationContext,
13
+ OperationFilter,
14
+ OperationMetadata,
15
+ OperationStatus,
16
+ OperationType,
17
+ } from './types';
18
+
19
+ const n = setNamespace('operation');
20
+
21
+ /**
22
+ * Operation Actions
23
+ */
24
+ export interface OperationActions {
25
+ /**
26
+ * Associate message with operation (for automatic context retrieval)
27
+ */
28
+ associateMessageWithOperation: (messageId: string, operationId: string) => void;
29
+
30
+ /**
31
+ * Cancel all operations
32
+ */
33
+ cancelAllOperations: (reason?: string) => void;
34
+
35
+ /**
36
+ * Cancel operation (recursively cancel all child operations)
37
+ */
38
+ cancelOperation: (operationId: string, reason?: string) => void;
39
+
40
+ /**
41
+ * Cancel operations by filter
42
+ */
43
+ cancelOperations: (filter: OperationFilter, reason?: string) => string[];
44
+
45
+ /**
46
+ * Cleanup completed operations (prevent memory leak)
47
+ */
48
+ cleanupCompletedOperations: (olderThan?: number) => void;
49
+
50
+ /**
51
+ * Complete operation
52
+ */
53
+ completeOperation: (operationId: string, metadata?: Partial<OperationMetadata>) => void;
54
+
55
+ /**
56
+ * Mark operation as failed
57
+ */
58
+ failOperation: (
59
+ operationId: string,
60
+ error: { code?: string; details?: any; message: string; type: string },
61
+ ) => void;
62
+
63
+ /**
64
+ * Start an operation (supports auto-inheriting context from parent operation)
65
+ */
66
+ startOperation: (params: {
67
+ context?: Partial<OperationContext>;
68
+ description?: string;
69
+ label?: string;
70
+ metadata?: Partial<OperationMetadata>;
71
+ parentOperationId?: string;
72
+ type: OperationType;
73
+ }) => { abortController: AbortController; operationId: string };
74
+
75
+ /**
76
+ * Update operation progress
77
+ */
78
+ updateOperationProgress: (operationId: string, current: number, total?: number) => void;
79
+
80
+ /**
81
+ * Update operation status
82
+ */
83
+ updateOperationStatus: (
84
+ operationId: string,
85
+ status: OperationStatus,
86
+ metadata?: Partial<OperationMetadata>,
87
+ ) => void;
88
+ }
89
+
90
+ export const operationActions: StateCreator<
91
+ ChatStore,
92
+ [['zustand/devtools', never]],
93
+ [],
94
+ OperationActions
95
+ > = (set, get) => ({
96
+ startOperation: (params) => {
97
+ const {
98
+ type,
99
+ context: partialContext,
100
+ parentOperationId,
101
+ label,
102
+ description,
103
+ metadata,
104
+ } = params;
105
+
106
+ const operationId = `op_${nanoid()}`;
107
+
108
+ // If parent operation exists and context is not fully provided, inherit from parent
109
+ let context: OperationContext = partialContext || {};
110
+
111
+ if (parentOperationId) {
112
+ const parentOp = get().operations[parentOperationId];
113
+ if (parentOp) {
114
+ // Inherit parent's context, allow partial override
115
+ context = { ...parentOp.context, ...partialContext };
116
+ }
117
+ }
118
+
119
+ const abortController = new AbortController();
120
+ const now = Date.now();
121
+
122
+ const operation: Operation = {
123
+ id: operationId,
124
+ type,
125
+ status: 'running',
126
+ context,
127
+ abortController,
128
+ metadata: {
129
+ startTime: now,
130
+ ...metadata,
131
+ },
132
+ parentOperationId,
133
+ childOperationIds: [],
134
+ label,
135
+ description,
136
+ };
137
+
138
+ set(
139
+ produce((state: ChatStore) => {
140
+ // Add to operations map
141
+ state.operations[operationId] = operation;
142
+
143
+ // Update type index
144
+ if (!state.operationsByType[type]) {
145
+ state.operationsByType[type] = [];
146
+ }
147
+ state.operationsByType[type].push(operationId);
148
+
149
+ // Update message index (if messageId exists)
150
+ if (context.messageId) {
151
+ if (!state.operationsByMessage[context.messageId]) {
152
+ state.operationsByMessage[context.messageId] = [];
153
+ }
154
+ state.operationsByMessage[context.messageId].push(operationId);
155
+ }
156
+
157
+ // Update context index (if sessionId exists)
158
+ if (context.sessionId) {
159
+ const contextKey = messageMapKey(
160
+ context.sessionId,
161
+ context.topicId !== undefined ? context.topicId : null,
162
+ );
163
+ if (!state.operationsByContext[contextKey]) {
164
+ state.operationsByContext[contextKey] = [];
165
+ }
166
+ state.operationsByContext[contextKey].push(operationId);
167
+ }
168
+
169
+ // Update parent's childOperationIds
170
+ if (parentOperationId && state.operations[parentOperationId]) {
171
+ if (!state.operations[parentOperationId].childOperationIds) {
172
+ state.operations[parentOperationId].childOperationIds = [];
173
+ }
174
+ state.operations[parentOperationId].childOperationIds!.push(operationId);
175
+ }
176
+ }),
177
+ false,
178
+ n(`startOperation/${type}/${operationId}`),
179
+ );
180
+
181
+ return { operationId, abortController };
182
+ },
183
+
184
+ updateOperationStatus: (operationId, status, metadata) => {
185
+ set(
186
+ produce((state: ChatStore) => {
187
+ const operation = state.operations[operationId];
188
+ if (!operation) return;
189
+
190
+ operation.status = status;
191
+
192
+ if (metadata) {
193
+ operation.metadata = {
194
+ ...operation.metadata,
195
+ ...metadata,
196
+ };
197
+ }
198
+ }),
199
+ false,
200
+ n(`updateOperationStatus/${operationId}/${status}`),
201
+ );
202
+ },
203
+
204
+ updateOperationProgress: (operationId, current, total) => {
205
+ set(
206
+ produce((state: ChatStore) => {
207
+ const operation = state.operations[operationId];
208
+ if (!operation) return;
209
+
210
+ operation.metadata.progress = {
211
+ current,
212
+ total: total ?? operation.metadata.progress?.total ?? current,
213
+ percentage: total ? Math.round((current / total) * 100) : undefined,
214
+ };
215
+ }),
216
+ false,
217
+ n(`updateOperationProgress/${operationId}`),
218
+ );
219
+ },
220
+
221
+ completeOperation: (operationId, metadata) => {
222
+ set(
223
+ produce((state: ChatStore) => {
224
+ const operation = state.operations[operationId];
225
+ if (!operation) return;
226
+
227
+ const now = Date.now();
228
+ operation.status = 'completed';
229
+ operation.metadata.endTime = now;
230
+ operation.metadata.duration = now - operation.metadata.startTime;
231
+
232
+ if (metadata) {
233
+ operation.metadata = {
234
+ ...operation.metadata,
235
+ ...metadata,
236
+ };
237
+ }
238
+ }),
239
+ false,
240
+ n(`completeOperation/${operationId}`),
241
+ );
242
+ },
243
+
244
+ cancelOperation: (operationId, reason = 'User cancelled') => {
245
+ const operation = get().operations[operationId];
246
+ if (!operation) return;
247
+
248
+ // Cancel all child operations recursively
249
+ if (operation.childOperationIds && operation.childOperationIds.length > 0) {
250
+ operation.childOperationIds.forEach((childId) => {
251
+ get().cancelOperation(childId, 'Parent operation cancelled');
252
+ });
253
+ }
254
+
255
+ // Abort the operation
256
+ try {
257
+ operation.abortController.abort(reason);
258
+ } catch {
259
+ // Ignore abort errors
260
+ }
261
+
262
+ // Update status
263
+ set(
264
+ produce((state: ChatStore) => {
265
+ const op = state.operations[operationId];
266
+ if (!op) return;
267
+
268
+ const now = Date.now();
269
+ op.status = 'cancelled';
270
+ op.metadata.endTime = now;
271
+ op.metadata.duration = now - op.metadata.startTime;
272
+ op.metadata.cancelReason = reason;
273
+ }),
274
+ false,
275
+ n(`cancelOperation/${operationId}`),
276
+ );
277
+ },
278
+
279
+ failOperation: (operationId, error) => {
280
+ set(
281
+ produce((state: ChatStore) => {
282
+ const operation = state.operations[operationId];
283
+ if (!operation) return;
284
+
285
+ const now = Date.now();
286
+ operation.status = 'failed';
287
+ operation.metadata.endTime = now;
288
+ operation.metadata.duration = now - operation.metadata.startTime;
289
+ operation.metadata.error = error;
290
+ }),
291
+ false,
292
+ n(`failOperation/${operationId}`),
293
+ );
294
+ },
295
+
296
+ cancelOperations: (filter, reason = 'Batch cancelled') => {
297
+ const operations = Object.values(get().operations);
298
+ const matchedIds: string[] = [];
299
+
300
+ operations.forEach((op) => {
301
+ if (op.status !== 'running') return;
302
+
303
+ let matches = true;
304
+
305
+ // Type filter
306
+ if (filter.type) {
307
+ const types = Array.isArray(filter.type) ? filter.type : [filter.type];
308
+ matches = matches && types.includes(op.type);
309
+ }
310
+
311
+ // Status filter
312
+ if (filter.status) {
313
+ const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
314
+ matches = matches && statuses.includes(op.status);
315
+ }
316
+
317
+ // Context filters
318
+ if (filter.sessionId !== undefined) {
319
+ matches = matches && op.context.sessionId === filter.sessionId;
320
+ }
321
+ if (filter.topicId !== undefined) {
322
+ matches = matches && op.context.topicId === filter.topicId;
323
+ }
324
+ if (filter.messageId !== undefined) {
325
+ matches = matches && op.context.messageId === filter.messageId;
326
+ }
327
+ if (filter.threadId !== undefined) {
328
+ matches = matches && op.context.threadId === filter.threadId;
329
+ }
330
+ if (filter.groupId !== undefined) {
331
+ matches = matches && op.context.groupId === filter.groupId;
332
+ }
333
+ if (filter.agentId !== undefined) {
334
+ matches = matches && op.context.agentId === filter.agentId;
335
+ }
336
+
337
+ if (matches) {
338
+ matchedIds.push(op.id);
339
+ }
340
+ });
341
+
342
+ // Cancel all matched operations
343
+ matchedIds.forEach((id) => {
344
+ get().cancelOperation(id, reason);
345
+ });
346
+
347
+ return matchedIds;
348
+ },
349
+
350
+ cancelAllOperations: (reason = 'Cancel all operations') => {
351
+ const operations = Object.values(get().operations);
352
+
353
+ operations.forEach((op) => {
354
+ if (op.status === 'running') {
355
+ get().cancelOperation(op.id, reason);
356
+ }
357
+ });
358
+ },
359
+
360
+ cleanupCompletedOperations: (olderThan = 60_000) => {
361
+ // Default: cleanup operations completed more than 1 minute ago
362
+ const now = Date.now();
363
+
364
+ // Collect operations to delete first
365
+ const operationsToDelete: string[] = [];
366
+ Object.values(get().operations).forEach((op) => {
367
+ const isCompleted =
368
+ op.status === 'completed' || op.status === 'cancelled' || op.status === 'failed';
369
+ const isOld = op.metadata.endTime && now - op.metadata.endTime > olderThan;
370
+
371
+ if (isCompleted && isOld) {
372
+ operationsToDelete.push(op.id);
373
+ }
374
+ });
375
+
376
+ if (operationsToDelete.length === 0) return;
377
+
378
+ set(
379
+ produce((state: ChatStore) => {
380
+ // Delete operations and update indexes
381
+ operationsToDelete.forEach((operationId) => {
382
+ const op = state.operations[operationId];
383
+ if (!op) return;
384
+
385
+ // Remove from operations map
386
+ delete state.operations[operationId];
387
+
388
+ // Remove from type index
389
+ const typeIndex = state.operationsByType[op.type];
390
+ if (typeIndex) {
391
+ state.operationsByType[op.type] = typeIndex.filter((id) => id !== operationId);
392
+ }
393
+
394
+ // Remove from message index
395
+ if (op.context.messageId) {
396
+ const msgIndex = state.operationsByMessage[op.context.messageId];
397
+ if (msgIndex) {
398
+ state.operationsByMessage[op.context.messageId] = msgIndex.filter(
399
+ (id) => id !== operationId,
400
+ );
401
+ }
402
+ }
403
+
404
+ // Remove from context index
405
+ if (op.context.sessionId) {
406
+ const contextKey = messageMapKey(
407
+ op.context.sessionId,
408
+ op.context.topicId !== undefined ? op.context.topicId : null,
409
+ );
410
+ const contextIndex = state.operationsByContext[contextKey];
411
+ if (contextIndex) {
412
+ state.operationsByContext[contextKey] = contextIndex.filter(
413
+ (id) => id !== operationId,
414
+ );
415
+ }
416
+ }
417
+
418
+ // Remove from parent's childOperationIds
419
+ if (op.parentOperationId && state.operations[op.parentOperationId]) {
420
+ const parent = state.operations[op.parentOperationId];
421
+ if (parent.childOperationIds) {
422
+ parent.childOperationIds = parent.childOperationIds.filter(
423
+ (id) => id !== operationId,
424
+ );
425
+ }
426
+ }
427
+
428
+ // Remove from messageOperationMap
429
+ const messageEntry = Object.entries(state.messageOperationMap).find(
430
+ ([, opId]) => opId === operationId,
431
+ );
432
+ if (messageEntry) {
433
+ delete state.messageOperationMap[messageEntry[0]];
434
+ }
435
+ });
436
+ }),
437
+ false,
438
+ n(`cleanupCompletedOperations/count=${operationsToDelete.length}`),
439
+ );
440
+ },
441
+
442
+ associateMessageWithOperation: (messageId, operationId) => {
443
+ set(
444
+ produce((state: ChatStore) => {
445
+ state.messageOperationMap[messageId] = operationId;
446
+ }),
447
+ false,
448
+ n(`associateMessageWithOperation/${messageId}/${operationId}`),
449
+ );
450
+ },
451
+ });
@@ -0,0 +1,4 @@
1
+ export * from './actions';
2
+ export * from './initialState';
3
+ export * from './selectors';
4
+ export * from './types';
@@ -0,0 +1,44 @@
1
+ import type { Operation, OperationType } from './types';
2
+
3
+ /**
4
+ * Chat Operation State
5
+ * Unified state for all async operations
6
+ */
7
+ export interface ChatOperationState {
8
+ /**
9
+ * Message to operation mapping (for automatic context retrieval)
10
+ * key: messageId, value: operationId
11
+ */
12
+ messageOperationMap: Record<string, string>;
13
+
14
+ /**
15
+ * All operations map, key is operationId
16
+ */
17
+ operations: Record<string, Operation>;
18
+
19
+ /**
20
+ * Operations indexed by session/topic
21
+ * key: messageMapKey(sessionId, topicId), value: operationId[]
22
+ */
23
+ operationsByContext: Record<string, string[]>;
24
+
25
+ /**
26
+ * Operations indexed by message
27
+ * key: messageId, value: operationId[]
28
+ */
29
+ operationsByMessage: Record<string, string[]>;
30
+
31
+ /**
32
+ * Operations indexed by type (for fast querying)
33
+ * key: OperationType, value: operationId[]
34
+ */
35
+ operationsByType: Record<OperationType, string[]>;
36
+ }
37
+
38
+ export const initialOperationState: ChatOperationState = {
39
+ messageOperationMap: {},
40
+ operations: {},
41
+ operationsByContext: {},
42
+ operationsByMessage: {},
43
+ operationsByType: {} as Record<OperationType, string[]>,
44
+ };