@nocobase/app-plugin-agents 2.0.0-beta.0 → 2.0.0-beta.1
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/CHANGELOG.md +31 -0
- package/README.md +6 -0
- package/dist/client/chat/coalesced-invalidation.d.ts +3 -8
- package/dist/client/chat/coalesced-invalidation.d.ts.map +1 -1
- package/dist/client/chat/coalesced-invalidation.js +16 -21
- package/dist/client/chat/coalesced-invalidation.js.map +1 -1
- package/dist/client/chat/controller.d.ts +25 -3
- package/dist/client/chat/controller.d.ts.map +1 -1
- package/dist/client/chat/controller.js +443 -68
- package/dist/client/chat/controller.js.map +1 -1
- package/dist/client/chat/provider.d.ts.map +1 -1
- package/dist/client/chat/provider.js +7 -4
- package/dist/client/chat/provider.js.map +1 -1
- package/dist/client/chat/use-chat.d.ts +4 -2
- package/dist/client/chat/use-chat.d.ts.map +1 -1
- package/dist/client/chat/use-chat.js +82 -51
- package/dist/client/chat/use-chat.js.map +1 -1
- package/dist/client/hooks/use-realtime-topic.d.ts +1 -1
- package/dist/client/hooks/use-realtime-topic.d.ts.map +1 -1
- package/dist/client/hooks/use-realtime-topic.js +8 -4
- package/dist/client/hooks/use-realtime-topic.js.map +1 -1
- package/dist/client/kit.d.ts.map +1 -1
- package/dist/client/kit.js +30 -5
- package/dist/client/kit.js.map +1 -1
- package/dist/client/pages/agents/agent-dialog.js +4 -1
- package/dist/client/pages/agents/agent-dialog.js.map +1 -1
- package/dist/package.json +1 -1
- package/dist/server/core/conversations/conversation.service.d.ts.map +1 -1
- package/dist/server/core/conversations/conversation.service.js +14 -3
- package/dist/server/core/conversations/conversation.service.js.map +1 -1
- package/dist/server/core/conversations/links.d.ts +3 -0
- package/dist/server/core/conversations/links.d.ts.map +1 -1
- package/dist/server/core/conversations/links.js +33 -0
- package/dist/server/core/conversations/links.js.map +1 -1
- package/dist/server/core/runs/run.service.d.ts +1 -1
- package/dist/server/core/runs/run.service.d.ts.map +1 -1
- package/dist/server/core/runs/run.service.js +7 -1
- package/dist/server/core/runs/run.service.js.map +1 -1
- package/dist/server/kernel/events.d.ts +2 -0
- package/dist/server/kernel/events.d.ts.map +1 -1
- package/dist/server/kernel/events.js.map +1 -1
- package/dist/server/online/agent/runner/backend.d.ts.map +1 -1
- package/dist/server/online/agent/runner/backend.js +17 -8
- package/dist/server/online/agent/runner/backend.js.map +1 -1
- package/dist/server/providers/agents.d.ts.map +1 -1
- package/dist/server/providers/agents.js +3 -0
- package/dist/server/providers/agents.js.map +1 -1
- package/dist/server/routes/schemas.d.ts +1 -1
- package/dist/server/routes/schemas.d.ts.map +1 -1
- package/dist/server/routes/schemas.js +5 -2
- package/dist/server/routes/schemas.js.map +1 -1
- package/dist/shared/conversations.d.ts +4 -0
- package/dist/shared/conversations.d.ts.map +1 -1
- package/dist/shared/conversations.js.map +1 -1
- package/dist/shared/ui-message.d.ts +2 -2
- package/dist/shared/ui-message.d.ts.map +1 -1
- package/dist/shared/ui-message.js +1 -1
- package/dist/shared/ui-message.js.map +1 -1
- package/package.json +10 -10
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { ApiClientError } from '@nocobase/app-client';
|
|
3
|
-
import { CancelledError } from '@tanstack/react-query';
|
|
3
|
+
import { CancelledError, hashKey } from '@tanstack/react-query';
|
|
4
4
|
import { isToolUIPart } from 'ai';
|
|
5
5
|
import { Chat, useChat as useSDKChat } from '@ai-sdk/react';
|
|
6
6
|
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
|
|
7
|
-
import { useApiClient } from '@nocobase/app-client';
|
|
7
|
+
import { useApiClient, useRealtimeReadiness } from '@nocobase/app-client';
|
|
8
|
+
import { CONVERSATIONS_TOPIC, } from '../../shared/conversations.js';
|
|
8
9
|
import { ChatApi } from './api.js';
|
|
10
|
+
import { CHAT_INVALIDATION_MS, coalescedInvalidation, } from './coalesced-invalidation.js';
|
|
9
11
|
import { chatIsRunning } from './feedback.js';
|
|
10
12
|
import { ChatSubmissions } from './submissions.js';
|
|
11
13
|
import { agentsQueryClient } from '../query.js';
|
|
14
|
+
import { agentsKeys } from '../api/keys.js';
|
|
12
15
|
import { chatKeys } from './keys.js';
|
|
13
16
|
import { EMPTY_MESSAGES, messagesReducer, mergeUIMessages, } from './message-model.js';
|
|
14
17
|
function deferred() {
|
|
@@ -22,14 +25,81 @@ function deferred() {
|
|
|
22
25
|
/** Application coordination around one SDK Chat per conversation, shared by every surface. */
|
|
23
26
|
export class ChatController {
|
|
24
27
|
api;
|
|
28
|
+
invalidations;
|
|
29
|
+
invalidateQuery(key, exact = false) {
|
|
30
|
+
(this.invalidations ??= coalescedInvalidation(agentsQueryClient())).add(key, exact);
|
|
31
|
+
}
|
|
32
|
+
invalidateLists() {
|
|
33
|
+
for (const query of agentsQueryClient()
|
|
34
|
+
.getQueryCache()
|
|
35
|
+
.findAll({ queryKey: chatKeys.lists })) {
|
|
36
|
+
if (query.queryKey[3]?.archived !==
|
|
37
|
+
true)
|
|
38
|
+
this.invalidateQuery(query.queryKey, true);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Reuse a detail snapshot when replacing its row cannot change membership, order or a page cursor. */
|
|
42
|
+
syncLists(detail) {
|
|
43
|
+
const client = agentsQueryClient();
|
|
44
|
+
for (const query of client
|
|
45
|
+
.getQueryCache()
|
|
46
|
+
.findAll({ queryKey: chatKeys.lists })) {
|
|
47
|
+
const filter = query.queryKey[3];
|
|
48
|
+
if (filter?.archived === true)
|
|
49
|
+
continue;
|
|
50
|
+
const data = query.state.data;
|
|
51
|
+
const pageIndex = data?.pages?.findIndex((page) => page.items.some((item) => item.id === detail.id)) ?? -1;
|
|
52
|
+
const page = data?.pages?.[pageIndex];
|
|
53
|
+
const index = page?.items.findIndex((item) => item.id === detail.id) ?? -1;
|
|
54
|
+
const row = page?.items[index];
|
|
55
|
+
if (row &&
|
|
56
|
+
((row.lastSeq ?? 0) > (detail.lastSeq ?? 0) ||
|
|
57
|
+
row.updatedAt > detail.updatedAt))
|
|
58
|
+
continue;
|
|
59
|
+
const sameOrder = row &&
|
|
60
|
+
(row.lastMessageAt === detail.lastMessageAt ||
|
|
61
|
+
(pageIndex === 0 &&
|
|
62
|
+
index === 0 &&
|
|
63
|
+
detail.lastMessageAt >= row.lastMessageAt &&
|
|
64
|
+
(page.items.length > 1 || !page.nextCursor)));
|
|
65
|
+
if (!row ||
|
|
66
|
+
!sameOrder ||
|
|
67
|
+
query.state.fetchStatus !== 'idle' ||
|
|
68
|
+
row.archivedAt !== detail.archivedAt ||
|
|
69
|
+
row.showInChatList !== detail.showInChatList ||
|
|
70
|
+
filter?.q ||
|
|
71
|
+
filter?.agentId ||
|
|
72
|
+
filter?.mode ||
|
|
73
|
+
filter?.source ||
|
|
74
|
+
typeof filter === 'string') {
|
|
75
|
+
this.invalidateQuery(query.queryKey, true);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
client.setQueryData(query.queryKey, {
|
|
79
|
+
...data,
|
|
80
|
+
pages: data.pages.map((item, position) => position === pageIndex
|
|
81
|
+
? {
|
|
82
|
+
...item,
|
|
83
|
+
items: item.items.map((summary) => summary.id === detail.id ? { ...detail } : summary),
|
|
84
|
+
}
|
|
85
|
+
: item),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
25
89
|
historyEpochs = new Map();
|
|
26
90
|
replacements = new Map();
|
|
27
91
|
resendRequests = new Map();
|
|
92
|
+
streamFollowers = new Map();
|
|
93
|
+
detailReads = new Map();
|
|
94
|
+
refreshes = new Map();
|
|
28
95
|
reconciles = new Map();
|
|
29
96
|
submissions;
|
|
30
97
|
chats = new Map();
|
|
31
98
|
histories = new Map();
|
|
32
99
|
loads = new Map();
|
|
100
|
+
messageReads = new Map();
|
|
101
|
+
catchups = new Map();
|
|
102
|
+
reconnects = new Map();
|
|
33
103
|
refreshAgain = new Set();
|
|
34
104
|
recoveries = new Map();
|
|
35
105
|
admissions = new Map();
|
|
@@ -46,6 +116,7 @@ export class ChatController {
|
|
|
46
116
|
generation = 0;
|
|
47
117
|
detailVersions = new Map();
|
|
48
118
|
receiptVersions = new Map();
|
|
119
|
+
pendingReceipts = new Map();
|
|
49
120
|
commandVersions = new Map();
|
|
50
121
|
constructor(api) {
|
|
51
122
|
this.api = api;
|
|
@@ -408,37 +479,31 @@ export class ChatController {
|
|
|
408
479
|
const initial = this.chat(id).messages.filter((m) => m.metadata?.seq !== undefined);
|
|
409
480
|
const task = (async () => {
|
|
410
481
|
const found = [];
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
482
|
+
// Reconnect reads the current window once, covering insertions, revisions and removed replies together.
|
|
483
|
+
const first = await this.api.messages(id, {});
|
|
484
|
+
if (!active())
|
|
485
|
+
return;
|
|
486
|
+
found.push(...first.items);
|
|
487
|
+
let window = first;
|
|
488
|
+
const oldest = Math.min(...initial.map((m) => m.metadata.seq));
|
|
489
|
+
const seen = new Set();
|
|
490
|
+
while (window.nextCursor &&
|
|
491
|
+
window.items.length &&
|
|
492
|
+
window.items[0].metadata.seq > oldest) {
|
|
493
|
+
if (seen.has(window.nextCursor))
|
|
494
|
+
throw new Error('History pagination did not advance.');
|
|
495
|
+
seen.add(window.nextCursor);
|
|
496
|
+
window = await this.api.messages(id, { pageToken: window.nextCursor });
|
|
418
497
|
if (!active())
|
|
419
498
|
return;
|
|
420
|
-
found.push(...
|
|
499
|
+
found.push(...window.items);
|
|
421
500
|
}
|
|
422
501
|
const present = new Set(found.map((m) => m.id));
|
|
423
502
|
const missing = new Set(initial.filter((m) => !present.has(m.id)).map((m) => m.id));
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
do {
|
|
429
|
-
window = await this.api.messages(id, window?.nextCursor ? { pageToken: window.nextCursor } : {});
|
|
430
|
-
if (!active())
|
|
431
|
-
return;
|
|
432
|
-
found.push(...window.items);
|
|
433
|
-
if (!window.nextCursor ||
|
|
434
|
-
!window.items.length ||
|
|
435
|
-
window.items[0].metadata.seq <= oldest)
|
|
436
|
-
break;
|
|
437
|
-
if (seen.has(window.nextCursor))
|
|
438
|
-
throw new Error('History pagination did not advance.');
|
|
439
|
-
seen.add(window.nextCursor);
|
|
440
|
-
} while (window.nextCursor);
|
|
441
|
-
}
|
|
503
|
+
this.dispatch(id, { type: 'page', page: first });
|
|
504
|
+
this.submissions.observe(id, found);
|
|
505
|
+
this.historyMeta(id).loadedThrough = first.lastSeq;
|
|
506
|
+
this.historyMeta(id).error = undefined;
|
|
442
507
|
if (!active())
|
|
443
508
|
return;
|
|
444
509
|
const changed = found.find((m) => m.role === 'user' &&
|
|
@@ -469,6 +534,13 @@ export class ChatController {
|
|
|
469
534
|
? chat.messages.at(-1)?.id
|
|
470
535
|
: undefined;
|
|
471
536
|
chat.messages = mergeUIMessages(chat.messages.filter((m) => !missing.has(m.id) || m.id === activeId), found, activeId);
|
|
537
|
+
const deferred = this.deferredReads.get(id);
|
|
538
|
+
for (const message of found)
|
|
539
|
+
if ((message.metadata?.revision ?? 0) >=
|
|
540
|
+
(this.messageVersions.get(`${id}:${message.id}`) ?? 0))
|
|
541
|
+
deferred?.delete(message.id);
|
|
542
|
+
if (deferred?.size === 0)
|
|
543
|
+
this.deferredReads.delete(id);
|
|
472
544
|
if (window)
|
|
473
545
|
this.historyMeta(id).page = {
|
|
474
546
|
...this.historyMeta(id).page,
|
|
@@ -564,9 +636,29 @@ export class ChatController {
|
|
|
564
636
|
const found = [];
|
|
565
637
|
const active = this.historyLifetime(id);
|
|
566
638
|
for (let offset = 0; offset < ids.length; offset += 100) {
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
639
|
+
const selection = ids.slice(offset, offset + 100);
|
|
640
|
+
const key = JSON.stringify([id, [...selection].sort()]);
|
|
641
|
+
let pendingRead = this.messageReads.get(key);
|
|
642
|
+
const shared = Boolean(pendingRead);
|
|
643
|
+
if (!pendingRead) {
|
|
644
|
+
pendingRead = this.api.messages(id, { ids: selection.join(',') });
|
|
645
|
+
this.messageReads.set(key, pendingRead);
|
|
646
|
+
const clear = () => {
|
|
647
|
+
if (this.messageReads.get(key) === pendingRead)
|
|
648
|
+
this.messageReads.delete(key);
|
|
649
|
+
};
|
|
650
|
+
void pendingRead.then(clear, clear);
|
|
651
|
+
}
|
|
652
|
+
const page = await pendingRead;
|
|
653
|
+
if (shared &&
|
|
654
|
+
active() &&
|
|
655
|
+
page.items.some((message) => (message.metadata?.revision ?? 0) <
|
|
656
|
+
(this.messageVersions.get(`${id}:${message.id}`) ?? 0))) {
|
|
657
|
+
// A revision announced during the older read needs a causal trailing read, never a cancellation.
|
|
658
|
+
const saved = await this.reread(id, selection);
|
|
659
|
+
found.push(...saved);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
570
662
|
if (!active())
|
|
571
663
|
return [];
|
|
572
664
|
found.push(...page.items);
|
|
@@ -583,6 +675,42 @@ export class ChatController {
|
|
|
583
675
|
}
|
|
584
676
|
return found;
|
|
585
677
|
}
|
|
678
|
+
/** A cursor announcement shares the current read; only an actual gap needs a trailing page. */
|
|
679
|
+
messagesChanged(id, lastSeq) {
|
|
680
|
+
if (this.disposed || !this.watches.has(id))
|
|
681
|
+
return Promise.resolve();
|
|
682
|
+
const pending = this.catchups.get(id);
|
|
683
|
+
if (pending) {
|
|
684
|
+
pending.lastSeq = Math.max(pending.lastSeq, lastSeq);
|
|
685
|
+
return pending.task;
|
|
686
|
+
}
|
|
687
|
+
const active = this.historyLifetime(id);
|
|
688
|
+
const owner = { lastSeq, task: Promise.resolve() };
|
|
689
|
+
const run = async () => {
|
|
690
|
+
const reading = this.loads.get(id);
|
|
691
|
+
if (reading)
|
|
692
|
+
await reading;
|
|
693
|
+
while (active() &&
|
|
694
|
+
(!this.history(id).state.loaded ||
|
|
695
|
+
this.history(id).loadedThrough < owner.lastSeq)) {
|
|
696
|
+
const before = this.history(id).loadedThrough;
|
|
697
|
+
await this.loadHistory(id);
|
|
698
|
+
if (this.history(id).error)
|
|
699
|
+
throw this.history(id).error;
|
|
700
|
+
if (this.history(id).loadedThrough <= before)
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
owner.task = run().finally(() => {
|
|
705
|
+
if (this.catchups.get(id) === owner)
|
|
706
|
+
this.catchups.delete(id);
|
|
707
|
+
});
|
|
708
|
+
this.catchups.set(id, owner);
|
|
709
|
+
return owner.task;
|
|
710
|
+
}
|
|
711
|
+
isWatching(id) {
|
|
712
|
+
return this.watches.has(id);
|
|
713
|
+
}
|
|
586
714
|
watch(id) {
|
|
587
715
|
let watch = this.watches.get(id);
|
|
588
716
|
if (!watch) {
|
|
@@ -592,11 +720,15 @@ export class ChatController {
|
|
|
592
720
|
return;
|
|
593
721
|
const detail = agentsQueryClient().getQueryData(chatKeys.conversation(id));
|
|
594
722
|
const current = this.watches.get(id);
|
|
595
|
-
const
|
|
723
|
+
const status = this.chats.get(id)?.status;
|
|
724
|
+
const busy = chatIsRunning(detail ?? null) ||
|
|
725
|
+
status === 'submitted' ||
|
|
726
|
+
status === 'streaming';
|
|
727
|
+
const interval = busy ? 5000 : 30000;
|
|
596
728
|
if (Date.now() - current.last < interval)
|
|
597
729
|
return;
|
|
598
730
|
current.last = Date.now();
|
|
599
|
-
void this.refresh(id).catch(() => { });
|
|
731
|
+
void this.refresh(id, true, !busy, false).catch(() => { });
|
|
600
732
|
}, 5000);
|
|
601
733
|
watch = { refs: 0, timer, last: Date.now() };
|
|
602
734
|
this.watches.set(id, watch);
|
|
@@ -611,12 +743,24 @@ export class ChatController {
|
|
|
611
743
|
};
|
|
612
744
|
}
|
|
613
745
|
reconnect() {
|
|
614
|
-
|
|
615
|
-
|
|
746
|
+
this.invalidateLists();
|
|
747
|
+
for (const id of this.watches.keys()) {
|
|
748
|
+
if (this.reconnects.has(id))
|
|
749
|
+
continue;
|
|
750
|
+
const task = this.refresh(id)
|
|
616
751
|
.then(() => this.resume(id))
|
|
617
|
-
.catch(() => { })
|
|
752
|
+
.catch(() => { })
|
|
753
|
+
.finally(() => {
|
|
754
|
+
if (this.reconnects.get(id) === task)
|
|
755
|
+
this.reconnects.delete(id);
|
|
756
|
+
});
|
|
757
|
+
this.reconnects.set(id, task);
|
|
758
|
+
}
|
|
618
759
|
}
|
|
619
760
|
messageUpdated(id, messageId, revision) {
|
|
761
|
+
const saved = this.chat(id).messages.find((message) => message.id === messageId);
|
|
762
|
+
if ((saved?.metadata?.revision ?? -1) >= revision)
|
|
763
|
+
return;
|
|
620
764
|
const key = `${id}:${messageId}`;
|
|
621
765
|
if (((this.messageVersions.get(key) ?? -1) >= revision &&
|
|
622
766
|
!this.deferredReads.get(id)?.has(messageId)) ||
|
|
@@ -634,9 +778,17 @@ export class ChatController {
|
|
|
634
778
|
queue.task = (async () => {
|
|
635
779
|
const attempted = new Set();
|
|
636
780
|
try {
|
|
781
|
+
// Let a concurrent cursor read or SDK finish supply the same durable revision first.
|
|
782
|
+
await new Promise((resolve) => setTimeout(resolve, CHAT_INVALIDATION_MS));
|
|
783
|
+
const reading = this.loads.get(id);
|
|
784
|
+
if (reading)
|
|
785
|
+
await reading;
|
|
637
786
|
while (active() && queue.pending.size) {
|
|
638
|
-
const ids = [...queue.pending]
|
|
787
|
+
const ids = [...queue.pending].filter((messageId) => (this.chat(id).messages.find((message) => message.id === messageId)?.metadata?.revision ?? -1) <
|
|
788
|
+
(this.messageVersions.get(`${id}:${messageId}`) ?? 0));
|
|
639
789
|
queue.pending.clear();
|
|
790
|
+
if (!ids.length)
|
|
791
|
+
continue;
|
|
640
792
|
ids.forEach((id) => attempted.add(id));
|
|
641
793
|
await this.reread(id, ids);
|
|
642
794
|
}
|
|
@@ -774,6 +926,19 @@ export class ChatController {
|
|
|
774
926
|
?.id ??
|
|
775
927
|
'');
|
|
776
928
|
}
|
|
929
|
+
followStream(id) {
|
|
930
|
+
const count = (this.streamFollowers.get(id) ?? 0) + 1;
|
|
931
|
+
this.streamFollowers.set(id, count);
|
|
932
|
+
if (count === 1)
|
|
933
|
+
this.receive(id);
|
|
934
|
+
return () => {
|
|
935
|
+
const remaining = (this.streamFollowers.get(id) ?? 1) - 1;
|
|
936
|
+
if (remaining === 0)
|
|
937
|
+
this.streamFollowers.delete(id);
|
|
938
|
+
else
|
|
939
|
+
this.streamFollowers.set(id, remaining);
|
|
940
|
+
};
|
|
941
|
+
}
|
|
777
942
|
receive(id, known) {
|
|
778
943
|
if (this.disposed)
|
|
779
944
|
return;
|
|
@@ -793,7 +958,10 @@ export class ChatController {
|
|
|
793
958
|
return;
|
|
794
959
|
if (!chatIsRunning(detail) || detail.execution?.waitingApproval) {
|
|
795
960
|
const chat = this.chats.get(id);
|
|
796
|
-
if (chat?.status !== 'submitted' &&
|
|
961
|
+
if (chat?.status !== 'submitted' &&
|
|
962
|
+
chat?.status !== 'streaming' &&
|
|
963
|
+
(!this.history(id).state.loaded ||
|
|
964
|
+
this.history(id).loadedThrough < (detail.lastSeq ?? 0)))
|
|
797
965
|
await this.loadHistory(id);
|
|
798
966
|
return;
|
|
799
967
|
}
|
|
@@ -870,7 +1038,13 @@ export class ChatController {
|
|
|
870
1038
|
recovery.phase = 'interrupted';
|
|
871
1039
|
recovery.error = chat.error;
|
|
872
1040
|
}
|
|
873
|
-
|
|
1041
|
+
if (agentsQueryClient()
|
|
1042
|
+
.getQueryCache()
|
|
1043
|
+
.find({ queryKey: chatKeys.conversation(id), exact: true })
|
|
1044
|
+
?.isActive())
|
|
1045
|
+
this.scheduleRefresh(id);
|
|
1046
|
+
else
|
|
1047
|
+
await this.refresh(id);
|
|
874
1048
|
}
|
|
875
1049
|
}
|
|
876
1050
|
catch (error) {
|
|
@@ -947,9 +1121,63 @@ export class ChatController {
|
|
|
947
1121
|
if (active())
|
|
948
1122
|
await this.refresh(id);
|
|
949
1123
|
}
|
|
950
|
-
|
|
1124
|
+
/** One detail request shared by the query observer and message reconciliation. */
|
|
1125
|
+
readDetail(id, fresh = false) {
|
|
1126
|
+
const pending = this.detailReads.get(id);
|
|
1127
|
+
if (pending)
|
|
1128
|
+
return fresh
|
|
1129
|
+
? pending.catch(() => undefined).then(() => this.readDetail(id))
|
|
1130
|
+
: pending;
|
|
1131
|
+
const task = this.api.conversation(id);
|
|
1132
|
+
this.detailReads.set(id, task);
|
|
1133
|
+
const clear = () => {
|
|
1134
|
+
if (this.detailReads.get(id) === task)
|
|
1135
|
+
this.detailReads.delete(id);
|
|
1136
|
+
};
|
|
1137
|
+
void task.then(clear, clear);
|
|
1138
|
+
return task;
|
|
1139
|
+
}
|
|
1140
|
+
refresh(id, fresh = true, idleProbe = false, reconcileHistory = true) {
|
|
1141
|
+
if (this.disposed)
|
|
1142
|
+
return Promise.resolve();
|
|
1143
|
+
const pending = this.refreshes.get(id);
|
|
1144
|
+
if (pending) {
|
|
1145
|
+
if (fresh) {
|
|
1146
|
+
pending.trailing = true;
|
|
1147
|
+
this.nextDetailVersion(id);
|
|
1148
|
+
}
|
|
1149
|
+
return pending.task;
|
|
1150
|
+
}
|
|
1151
|
+
const lifetime = this.lifetime(), commandVersion = this.commandVersions.get(id), active = () => lifetime() && this.commandVersions.get(id) === commandVersion;
|
|
1152
|
+
const owner = { trailing: false, task: Promise.resolve() };
|
|
1153
|
+
const run = async () => {
|
|
1154
|
+
do {
|
|
1155
|
+
owner.trailing = false;
|
|
1156
|
+
await this.refreshOnce(id, fresh, idleProbe, undefined, reconcileHistory);
|
|
1157
|
+
fresh = true;
|
|
1158
|
+
idleProbe = false;
|
|
1159
|
+
} while (owner.trailing && active());
|
|
1160
|
+
};
|
|
1161
|
+
owner.task = run().finally(() => {
|
|
1162
|
+
if (this.refreshes.get(id) === owner)
|
|
1163
|
+
this.refreshes.delete(id);
|
|
1164
|
+
});
|
|
1165
|
+
this.refreshes.set(id, owner);
|
|
1166
|
+
return owner.task;
|
|
1167
|
+
}
|
|
1168
|
+
async refreshOnce(id, fresh, idleProbe = false, snapshot, reconcileHistory = true) {
|
|
951
1169
|
if (this.disposed)
|
|
952
1170
|
return;
|
|
1171
|
+
const waiting = this.lifetime();
|
|
1172
|
+
if ((!fresh || !reconcileHistory) && !snapshot) {
|
|
1173
|
+
const query = agentsQueryClient()
|
|
1174
|
+
.getQueryCache()
|
|
1175
|
+
.find({ queryKey: chatKeys.conversation(id), exact: true });
|
|
1176
|
+
if (query?.state.fetchStatus === 'fetching' && query.promise)
|
|
1177
|
+
snapshot = (await query.promise);
|
|
1178
|
+
if (!waiting())
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
953
1181
|
const lifetime = this.lifetime(), commandVersion = this.commandVersions.get(id), active = () => lifetime() && this.commandVersions.get(id) === commandVersion, ordered = this.detailRequest(id);
|
|
954
1182
|
const submittedOwner = this.chat(id);
|
|
955
1183
|
const submittedInput = submittedOwner.status === 'submitted' && !this.admissions.has(id)
|
|
@@ -957,16 +1185,62 @@ export class ChatController {
|
|
|
957
1185
|
.reverse()
|
|
958
1186
|
.find((message) => message.role === 'user' && message.metadata?.seq !== undefined)
|
|
959
1187
|
: undefined;
|
|
960
|
-
const
|
|
1188
|
+
const query = agentsQueryClient()
|
|
1189
|
+
.getQueryCache()
|
|
1190
|
+
.find({ queryKey: chatKeys.conversation(id), exact: true });
|
|
1191
|
+
const cached = !fresh && query?.isActive() && query.state.fetchStatus === 'idle'
|
|
1192
|
+
? query.state.data
|
|
1193
|
+
: undefined;
|
|
1194
|
+
const previous = query?.state.data;
|
|
1195
|
+
const detail = snapshot ?? cached ?? (await this.readDetail(id, fresh));
|
|
1196
|
+
if (!active())
|
|
1197
|
+
return;
|
|
1198
|
+
if (idleProbe &&
|
|
1199
|
+
previous &&
|
|
1200
|
+
!chatIsRunning(detail) &&
|
|
1201
|
+
detail.lastSeq !== undefined &&
|
|
1202
|
+
this.history(id).state.loaded &&
|
|
1203
|
+
!this.history(id).error &&
|
|
1204
|
+
this.history(id).loadedThrough >= detail.lastSeq &&
|
|
1205
|
+
hashKey([previous]) === hashKey([detail])) {
|
|
1206
|
+
// The durable cursor is current. Still reread revisions of the latest assistant response: a same-sequence
|
|
1207
|
+
// update may have lost its announcement, even though the conversation snapshot did not change.
|
|
1208
|
+
const latest = [...this.chat(id).messages]
|
|
1209
|
+
.reverse()
|
|
1210
|
+
.find((message) => message.role === 'assistant');
|
|
1211
|
+
const ids = [
|
|
1212
|
+
...new Set([
|
|
1213
|
+
...(this.deferredReads.get(id) ?? []),
|
|
1214
|
+
...(latest ? [latest.id] : []),
|
|
1215
|
+
]),
|
|
1216
|
+
];
|
|
1217
|
+
if (ids.length)
|
|
1218
|
+
await this.reread(id, ids);
|
|
1219
|
+
if (active() && ordered()) {
|
|
1220
|
+
this.remember(detail, false);
|
|
1221
|
+
this.publish();
|
|
1222
|
+
}
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
961
1225
|
if (!active())
|
|
962
1226
|
return;
|
|
963
|
-
|
|
1227
|
+
const reconciled = fresh && !snapshot && reconcileHistory && ordered();
|
|
1228
|
+
if (reconciled)
|
|
964
1229
|
await this.reconcile(id, detail);
|
|
965
1230
|
if (!active())
|
|
966
1231
|
return;
|
|
967
1232
|
// A newer detail query must not discard this refresh's message catch-up.
|
|
968
1233
|
// loadHistory coalesces overlapping reads independently of detail ordering.
|
|
969
|
-
|
|
1234
|
+
const beforeThrough = this.history(id).loadedThrough;
|
|
1235
|
+
if ((!snapshot && !reconciled && fresh && detail.lastSeq === undefined) ||
|
|
1236
|
+
(reconciled && !ordered()) ||
|
|
1237
|
+
!this.history(id).state.loaded ||
|
|
1238
|
+
this.history(id).loadedThrough < (detail.lastSeq ?? 0)) {
|
|
1239
|
+
if (!reconcileHistory && detail.lastSeq !== undefined)
|
|
1240
|
+
await this.messagesChanged(id, detail.lastSeq);
|
|
1241
|
+
else
|
|
1242
|
+
await this.loadHistory(id, false, fresh);
|
|
1243
|
+
}
|
|
970
1244
|
if (!active())
|
|
971
1245
|
return;
|
|
972
1246
|
const history = this.history(id);
|
|
@@ -981,14 +1255,23 @@ export class ChatController {
|
|
|
981
1255
|
const revisable = [
|
|
982
1256
|
...new Set([
|
|
983
1257
|
...(this.deferredReads.get(id) ?? []),
|
|
984
|
-
...(detail.execution?.messageId
|
|
1258
|
+
...(detail.execution?.messageId &&
|
|
1259
|
+
((!snapshot &&
|
|
1260
|
+
!reconciled &&
|
|
1261
|
+
this.history(id).loadedThrough === beforeThrough) ||
|
|
1262
|
+
(this.chat(id).messages.find((m) => m.id === detail.execution?.messageId)?.metadata?.revision ?? -1) < (detail.execution.revision ?? 0))
|
|
985
1263
|
? [detail.execution.messageId]
|
|
986
|
-
:
|
|
1264
|
+
: !snapshot &&
|
|
1265
|
+
!reconciled &&
|
|
1266
|
+
this.history(id).loadedThrough === beforeThrough &&
|
|
1267
|
+
latest
|
|
987
1268
|
? [latest.id]
|
|
988
1269
|
: []),
|
|
989
1270
|
]),
|
|
990
1271
|
];
|
|
991
|
-
const saved = revisable.length
|
|
1272
|
+
const saved = revisable.length
|
|
1273
|
+
? await this.reread(id, revisable)
|
|
1274
|
+
: [...this.chat(id).messages];
|
|
992
1275
|
if (!active() || !ordered())
|
|
993
1276
|
return;
|
|
994
1277
|
const owner = this.chat(id), receiving = owner.messages.at(-1);
|
|
@@ -1023,7 +1306,8 @@ export class ChatController {
|
|
|
1023
1306
|
}
|
|
1024
1307
|
if (!active() || !ordered())
|
|
1025
1308
|
return;
|
|
1026
|
-
|
|
1309
|
+
if (!snapshot)
|
|
1310
|
+
this.remember(detail, false);
|
|
1027
1311
|
const chat = this.chat(id);
|
|
1028
1312
|
if (detail.execution?.status === 'idle') {
|
|
1029
1313
|
this.recoveries.delete(id);
|
|
@@ -1033,6 +1317,13 @@ export class ChatController {
|
|
|
1033
1317
|
this.publish();
|
|
1034
1318
|
}
|
|
1035
1319
|
scheduleRefresh(id) {
|
|
1320
|
+
if (agentsQueryClient()
|
|
1321
|
+
.getQueryCache()
|
|
1322
|
+
.find({ queryKey: chatKeys.conversation(id), exact: true })
|
|
1323
|
+
?.isActive()) {
|
|
1324
|
+
this.invalidateQuery(chatKeys.conversation(id), true);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1036
1327
|
if (this.completionReads.has(id))
|
|
1037
1328
|
return;
|
|
1038
1329
|
this.completionReads.add(id);
|
|
@@ -1042,14 +1333,16 @@ export class ChatController {
|
|
|
1042
1333
|
void this.refresh(id).catch(() => { });
|
|
1043
1334
|
});
|
|
1044
1335
|
}
|
|
1045
|
-
observeDetail(detail, refresh = true) {
|
|
1336
|
+
observeDetail(detail, refresh = true, synchronizeLists = true) {
|
|
1337
|
+
if (synchronizeLists)
|
|
1338
|
+
this.syncLists(detail);
|
|
1046
1339
|
if (detail.execution?.status === 'running')
|
|
1047
1340
|
this.receive(detail.id, detail);
|
|
1048
1341
|
if (!chatIsRunning(detail)) {
|
|
1049
1342
|
if (refresh &&
|
|
1050
1343
|
(this.chats.get(detail.id)?.status === 'streaming' ||
|
|
1051
1344
|
this.chats.get(detail.id)?.status === 'submitted'))
|
|
1052
|
-
this.
|
|
1345
|
+
void this.refreshOnce(detail.id, false, false, detail).catch(() => { });
|
|
1053
1346
|
this.stopRequests.delete(detail.id);
|
|
1054
1347
|
this.recoveries.delete(detail.id);
|
|
1055
1348
|
this.publish();
|
|
@@ -1088,29 +1381,103 @@ export class ChatController {
|
|
|
1088
1381
|
}
|
|
1089
1382
|
remember(detail, refresh = true) {
|
|
1090
1383
|
this.nextDetailVersion(detail.id);
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1384
|
+
// A reconnect already owns a fresh list read; its later history response must not schedule another one.
|
|
1385
|
+
this.observeDetail(detail, refresh, !this.reconnects.has(detail.id));
|
|
1386
|
+
const client = agentsQueryClient();
|
|
1387
|
+
client.setQueryData(chatKeys.conversation(detail.id), detail);
|
|
1388
|
+
}
|
|
1389
|
+
/** The HTTP receipt supplies this tab's snapshot; retain a newer read position for a trailing read. */
|
|
1390
|
+
observeReceipt(id, seenSeq) {
|
|
1391
|
+
const update = (item) => {
|
|
1392
|
+
if (item.id !== id || (item.seenSeq ?? -1) >= seenSeq)
|
|
1393
|
+
return item;
|
|
1394
|
+
return {
|
|
1395
|
+
...item,
|
|
1396
|
+
seenSeq,
|
|
1397
|
+
read: seenSeq >= (item.lastSeq ?? 0) || item.read,
|
|
1398
|
+
needsYou: item.needsYou &&
|
|
1399
|
+
item.needsYou.reason !== 'confirm' &&
|
|
1400
|
+
item.needsYou.seq <= seenSeq
|
|
1401
|
+
? null
|
|
1402
|
+
: item.needsYou,
|
|
1403
|
+
};
|
|
1404
|
+
};
|
|
1405
|
+
agentsQueryClient().setQueriesData({ queryKey: chatKeys.lists }, (old) => !old
|
|
1406
|
+
? old
|
|
1407
|
+
: Array.isArray(old)
|
|
1408
|
+
? old.map(update)
|
|
1409
|
+
: {
|
|
1410
|
+
...old,
|
|
1411
|
+
pages: old.pages.map((page) => ({
|
|
1412
|
+
...page,
|
|
1413
|
+
items: page.items.map(update),
|
|
1414
|
+
})),
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
deferReadReceipt(id, seenSeq, refresh) {
|
|
1418
|
+
const pending = this.pendingReceipts.get(id);
|
|
1419
|
+
if (!pending)
|
|
1420
|
+
return false;
|
|
1421
|
+
pending.seenSeq = Math.max(pending.seenSeq, seenSeq);
|
|
1422
|
+
pending.refresh = refresh;
|
|
1423
|
+
return true;
|
|
1094
1424
|
}
|
|
1095
1425
|
async markRead(id) {
|
|
1426
|
+
const client = agentsQueryClient();
|
|
1427
|
+
const key = chatKeys.conversation(id);
|
|
1428
|
+
const before = client.getQueryData(key);
|
|
1096
1429
|
const active = this.lifetime(), commandVersion = this.commandVersions.get(id), receiptVersion = (this.receiptVersions.get(id) ?? 0) + 1;
|
|
1097
1430
|
// Detail reads must not suppress the read transition needed by a viewed conversation.
|
|
1098
1431
|
this.receiptVersions.set(id, receiptVersion);
|
|
1432
|
+
const pending = { seenSeq: -1 };
|
|
1433
|
+
this.pendingReceipts.set(id, pending);
|
|
1099
1434
|
this.nextDetailVersion(id);
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1435
|
+
try {
|
|
1436
|
+
const detail = await this.api.markRead(id);
|
|
1437
|
+
if (active() &&
|
|
1438
|
+
this.commandVersions.get(id) === commandVersion &&
|
|
1439
|
+
this.receiptVersions.get(id) === receiptVersion) {
|
|
1440
|
+
this.observeReceipt(id, detail.seenSeq ?? 0);
|
|
1441
|
+
// Read receipts do not change execution or invalidate pending history.
|
|
1442
|
+
this.nextDetailVersion(id);
|
|
1443
|
+
client.setQueryData(key, (current) => {
|
|
1444
|
+
if (!current ||
|
|
1445
|
+
current === before ||
|
|
1446
|
+
(current.lastSeq ?? 0) < (detail.lastSeq ?? 0) ||
|
|
1447
|
+
(current.lastSeq === detail.lastSeq &&
|
|
1448
|
+
current.updatedAt < detail.updatedAt))
|
|
1449
|
+
return detail;
|
|
1450
|
+
// A later snapshot may already contain the next reply or a metadata change.
|
|
1451
|
+
const seenSeq = Math.max(current.seenSeq ?? 0, detail.seenSeq ?? 0);
|
|
1452
|
+
return {
|
|
1453
|
+
...current,
|
|
1454
|
+
seenSeq,
|
|
1455
|
+
read: seenSeq >= (current.lastSeq ?? 0) || current.read,
|
|
1456
|
+
needsYou: current.needsYou &&
|
|
1457
|
+
current.needsYou.reason !== 'confirm' &&
|
|
1458
|
+
current.needsYou.seq <= seenSeq
|
|
1459
|
+
? null
|
|
1460
|
+
: current.needsYou,
|
|
1461
|
+
};
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
return detail;
|
|
1465
|
+
}
|
|
1466
|
+
finally {
|
|
1467
|
+
if (this.pendingReceipts.get(id) === pending) {
|
|
1468
|
+
this.pendingReceipts.delete(id);
|
|
1469
|
+
const current = agentsQueryClient().getQueryData(chatKeys.conversation(id));
|
|
1470
|
+
if (active() && pending.seenSeq > (current?.seenSeq ?? -1))
|
|
1471
|
+
pending.refresh?.();
|
|
1472
|
+
}
|
|
1108
1473
|
}
|
|
1109
|
-
return detail;
|
|
1110
1474
|
}
|
|
1111
1475
|
async command(id, request, detail) {
|
|
1112
1476
|
const active = this.lifetime(), version = (this.commandVersions.get(id) ?? 0) + 1;
|
|
1113
1477
|
this.commandVersions.set(id, version);
|
|
1478
|
+
// A command replaces the snapshot generation; its reads must not wait on obsolete HTTP.
|
|
1479
|
+
this.detailReads.delete(id);
|
|
1480
|
+
this.refreshes.delete(id);
|
|
1114
1481
|
this.nextDetailVersion(id);
|
|
1115
1482
|
this.invalidateHistoryReads(id);
|
|
1116
1483
|
const recovery = this.recoveries.get(id);
|
|
@@ -1143,6 +1510,8 @@ export class ChatController {
|
|
|
1143
1510
|
this.disposed = false;
|
|
1144
1511
|
}
|
|
1145
1512
|
invalidate() {
|
|
1513
|
+
this.invalidations?.stop();
|
|
1514
|
+
this.invalidations = undefined;
|
|
1146
1515
|
if (this.disposed)
|
|
1147
1516
|
return;
|
|
1148
1517
|
this.disposed = true;
|
|
@@ -1157,6 +1526,9 @@ export class ChatController {
|
|
|
1157
1526
|
this.historyEpochs.clear();
|
|
1158
1527
|
this.replacements.clear();
|
|
1159
1528
|
this.reconciles.clear();
|
|
1529
|
+
this.streamFollowers.clear();
|
|
1530
|
+
this.detailReads.clear();
|
|
1531
|
+
this.refreshes.clear();
|
|
1160
1532
|
for (const watch of this.watches.values())
|
|
1161
1533
|
clearInterval(watch.timer);
|
|
1162
1534
|
this.watches.clear();
|
|
@@ -1166,14 +1538,19 @@ export class ChatController {
|
|
|
1166
1538
|
this.completionReads.clear();
|
|
1167
1539
|
this.admissions.clear();
|
|
1168
1540
|
this.loads.clear();
|
|
1541
|
+
this.messageReads.clear();
|
|
1542
|
+
this.catchups.clear();
|
|
1543
|
+
this.reconnects.clear();
|
|
1169
1544
|
this.refreshAgain.clear();
|
|
1170
1545
|
this.commandVersions.clear();
|
|
1171
1546
|
this.receiptVersions.clear();
|
|
1547
|
+
this.pendingReceipts.clear();
|
|
1172
1548
|
this.stopRequests.clear();
|
|
1173
1549
|
this.approvalRequests.clear();
|
|
1174
1550
|
this.recoveries.clear();
|
|
1175
1551
|
this.submissions.invalidate();
|
|
1176
|
-
|
|
1552
|
+
// All plugin snapshots are viewer-scoped, including fresh agent options.
|
|
1553
|
+
agentsQueryClient().removeQueries({ queryKey: agentsKeys.all });
|
|
1177
1554
|
}
|
|
1178
1555
|
dispose() {
|
|
1179
1556
|
this.invalidate();
|
|
@@ -1192,10 +1569,7 @@ export function ChatClientProvider({ children, }) {
|
|
|
1192
1569
|
controller.activate();
|
|
1193
1570
|
const lifetimes = lifetimesRef.current;
|
|
1194
1571
|
lifetimes.set(controller, (lifetimes.get(controller) ?? 0) + 1);
|
|
1195
|
-
const reconnect = () => controller.reconnect();
|
|
1196
|
-
window.addEventListener('online', reconnect);
|
|
1197
1572
|
return () => {
|
|
1198
|
-
window.removeEventListener('online', reconnect);
|
|
1199
1573
|
controller.invalidate();
|
|
1200
1574
|
lifetimes.set(controller, (lifetimes.get(controller) ?? 1) - 1);
|
|
1201
1575
|
queueMicrotask(() => {
|
|
@@ -1216,12 +1590,13 @@ export function useChatController() {
|
|
|
1216
1590
|
}
|
|
1217
1591
|
export function useChatStream(id) {
|
|
1218
1592
|
const controller = useChatController();
|
|
1593
|
+
const ready = useRealtimeReadiness([CONVERSATIONS_TOPIC]);
|
|
1219
1594
|
useSDKChat({ chat: controller.chat(id ?? 'draft') });
|
|
1220
1595
|
useSyncExternalStore(controller.subscribe, controller.snapshot, controller.snapshot);
|
|
1221
1596
|
useEffect(() => {
|
|
1222
|
-
if (id)
|
|
1223
|
-
controller.
|
|
1224
|
-
}, [controller, id]);
|
|
1597
|
+
if (ready && id)
|
|
1598
|
+
return controller.followStream(id);
|
|
1599
|
+
}, [controller, id, ready]);
|
|
1225
1600
|
return id ? controller.state(id) : undefined;
|
|
1226
1601
|
}
|
|
1227
1602
|
export function useChatStreamState(id) {
|