@blocksdiy/react-common 1.48.0 → 1.49.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/components/new-agent-chat.d.ts +11 -28
- package/dist/components/new-agent-chat.d.ts.map +1 -1
- package/dist/components/new-agent-chat.js +79 -82
- package/dist/hooks/agent-conversation-context.d.ts +32 -0
- package/dist/hooks/agent-conversation-context.d.ts.map +1 -0
- package/dist/hooks/agent-conversation-context.js +39 -0
- package/dist/hooks/agent-thread-live-update.d.ts +35 -64
- package/dist/hooks/agent-thread-live-update.d.ts.map +1 -1
- package/dist/hooks/agent-thread-live-update.js +81 -64
- package/dist/hooks/use-agent-conversation.d.ts +105 -0
- package/dist/hooks/use-agent-conversation.d.ts.map +1 -0
- package/dist/hooks/use-agent-conversation.js +204 -0
- package/dist/hooks/use-agent-threads.d.ts +3 -50
- package/dist/hooks/use-agent-threads.d.ts.map +1 -1
- package/dist/hooks/use-agent-threads.js +53 -150
- package/dist/utils/composer-message.d.ts +57 -0
- package/dist/utils/composer-message.d.ts.map +1 -0
- package/dist/utils/composer-message.js +96 -0
- package/package.json +1 -1
|
@@ -1,36 +1,18 @@
|
|
|
1
1
|
import { getApiHost } from "@blocksdiy/blocks-client-api/envService";
|
|
2
2
|
import { websocketsService } from "@blocksdiy/blocks-client-api/websocketService";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import { agentThreadKey, applyAgentThreadUpdate, isAgentThreadItem, matchesAgentThreadFilters, optimisticThreadAfterSend, overlayOptimisticThreads, } from "./agent-thread-live-update.js";
|
|
5
|
-
// Wire types, the identity key, and the realtime merge logic live in
|
|
6
|
-
// ./agent-thread-live-update (dependency-free, unit-testable); re-exported so
|
|
7
|
-
// consumers keep importing everything from this module.
|
|
4
|
+
import { agentThreadKey, applyAgentThreadUpdate, isAgentThreadItem, matchesAgentThreadFilters, optimisticThreadAfterSend, overlayOptimisticThreads, reconcileWalkWithLive, removeAgentThread, retireOptimisticThreadsTakenOver, } from "./agent-thread-live-update.js";
|
|
8
5
|
export * from "./agent-thread-live-update.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
relayed verbatim from wf-actions-py to the app's Pusher channel. */
|
|
6
|
+
export { agentThreadStandIn, nextRunStaleAt, RUN_STALE_MS, useAgentConversation, useAgentConversationSelection, } from "./use-agent-conversation.js";
|
|
7
|
+
export { AgentConversationProvider, useAgentConversationContext, } from "./agent-conversation-context.js";
|
|
12
8
|
const AGENT_THREAD_UPDATED = "AGENT_THREAD_UPDATED";
|
|
13
|
-
/**
|
|
14
|
-
* The platform inbox's fixed chatId for an agent: one shared value per agent block, so
|
|
15
|
-
* index rows are recognizable as "direct chats with this agent on the platform" at a
|
|
16
|
-
* glance (and filterable via the endpoint's chatId param). Conversations under it are
|
|
17
|
-
* distinguished by their unique chatThreadId.
|
|
18
|
-
*/
|
|
19
9
|
export const AGENT_INBOX_CHAT_PREFIX = "agent-inbox-";
|
|
20
10
|
export const agentInboxChatId = (agentId) => `${AGENT_INBOX_CHAT_PREFIX}${agentId}`;
|
|
21
|
-
/**
|
|
22
|
-
* Short human label for a row with no generated title yet: the half of the identity
|
|
23
|
-
* pair that varies for the row. Inbox rows share one fixed chatId (above) and vary by
|
|
24
|
-
* thread id; rows from other surfaces (published-app chats) are the reverse. One-sided
|
|
25
|
-
* writers (agent calls) index a "_" placeholder for the missing half — fall back to
|
|
26
|
-
* the real half rather than labeling every such row "_".
|
|
27
|
-
*/
|
|
28
11
|
export const agentThreadShortLabel = (thread) => {
|
|
29
12
|
const varying = thread.chatId.startsWith(AGENT_INBOX_CHAT_PREFIX) ? thread.chatThreadId : thread.chatId;
|
|
30
13
|
const other = varying === thread.chatId ? thread.chatThreadId : thread.chatId;
|
|
31
14
|
return (varying === "_" ? other : varying).slice(0, 8);
|
|
32
15
|
};
|
|
33
|
-
/** How a row is labelled: its title, or the id-based short label while it has none. */
|
|
34
16
|
export const agentThreadDisplayTitle = (thread) => thread.title ?? `Chat ${agentThreadShortLabel(thread)}`;
|
|
35
17
|
const buildQuery = (filters, cursor) => {
|
|
36
18
|
const params = new URLSearchParams();
|
|
@@ -62,82 +44,45 @@ const appendPage = (existing, page) => {
|
|
|
62
44
|
const seen = new Set(existing.map(agentThreadKey));
|
|
63
45
|
return [...existing, ...page.filter((thread) => !seen.has(agentThreadKey(thread)))];
|
|
64
46
|
};
|
|
65
|
-
/**
|
|
66
|
-
* Headless agent-threads index: fetches an agent's conversation threads from the platform
|
|
67
|
-
* endpoint with keyset-cursor pagination, then keeps them live — every threads-table write
|
|
68
|
-
* publishes AGENT_THREAD_UPDATED on the app's Pusher channel and the hook upserts the row,
|
|
69
|
-
* falling back to the getOne endpoint when a payload lacks the full row. New conversations
|
|
70
|
-
* list optimistically the moment the first prompt is sent (wire notifyThreadActivity to the
|
|
71
|
-
* chat's send seam). The LIST only — which conversation is open is the consumer's (a
|
|
72
|
-
* conversation is addressed by its (chatId, chatThreadId) pair; a new one is a fresh UUID
|
|
73
|
-
* thread id under the surface's chatId — CopilotKit replaces non-UUID ids per load).
|
|
74
|
-
* Pair it with whatever list UI the host app uses. No per-host wiring: both hosts already
|
|
75
|
-
* run the shared websocketsService singleton and subscribe the app channel (the platform
|
|
76
|
-
* via its layouts' useWebsockets hooks, a boilerplate app via the client SDK's
|
|
77
|
-
* ClientProvider), and listening before the socket connects just parks the handler.
|
|
78
|
-
*/
|
|
47
|
+
/** Agent-threads index with pagination, realtime upserts, and optimistic send listing. */
|
|
79
48
|
export const useAgentThreads = (options) => {
|
|
80
49
|
const { agentId, status, source, chatId, limit, sort, order, baseUrl } = options;
|
|
50
|
+
const [optimisticThreads, setOptimisticThreads] = useState(new Map());
|
|
51
|
+
const optimisticThreadsRef = useRef(optimisticThreads);
|
|
52
|
+
const applyOptimisticThreads = useCallback((next) => {
|
|
53
|
+
optimisticThreadsRef.current = next;
|
|
54
|
+
setOptimisticThreads(next);
|
|
55
|
+
}, []);
|
|
81
56
|
const [threads, setThreads] = useState([]);
|
|
82
|
-
// Mirror of `threads` for reads outside render (a refresh needs the loaded count without
|
|
83
|
-
// re-closing over state). Every write goes through applyThreads so the two can't diverge;
|
|
84
|
-
// reads are race-free because writers are serialized (loadMore awaits any in-flight
|
|
85
|
-
// refresh, and every write re-checks the request sequence first).
|
|
86
57
|
const threadsRef = useRef([]);
|
|
87
58
|
const applyThreads = useCallback((items) => {
|
|
88
59
|
threadsRef.current = items;
|
|
89
60
|
setThreads(items);
|
|
90
|
-
|
|
61
|
+
const remaining = retireOptimisticThreadsTakenOver(optimisticThreadsRef.current, items);
|
|
62
|
+
if (remaining !== optimisticThreadsRef.current) {
|
|
63
|
+
applyOptimisticThreads(remaining);
|
|
64
|
+
}
|
|
65
|
+
}, [applyOptimisticThreads]);
|
|
91
66
|
const [nextCursor, setNextCursor] = useState(null);
|
|
92
|
-
// Cursor mirror, same contract as threadsRef: loadMore reads it after awaiting an
|
|
93
|
-
// in-flight refresh, when its render-time closure value is stale by definition.
|
|
94
67
|
const nextCursorRef = useRef(null);
|
|
95
68
|
const applyCursor = useCallback((cursor) => {
|
|
96
69
|
nextCursorRef.current = cursor;
|
|
97
70
|
setNextCursor(cursor);
|
|
98
71
|
}, []);
|
|
99
|
-
// Starts true (when a fetch is coming): the first-page effect only runs AFTER the mount
|
|
100
|
-
// paint, so initializing false would flash the "no threads" empty state for a frame.
|
|
101
72
|
const [isLoading, setIsLoading] = useState(Boolean(agentId));
|
|
102
73
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
103
74
|
const [error, setError] = useState(null);
|
|
104
|
-
// ChatGPT-style optimistic rows, keyed by thread identity: conversations listed from
|
|
105
|
-
// the moment a prompt is sent (see notifyThreadActivity), before/ahead of their server
|
|
106
|
-
// row. The display overlay hands each one over to its server row as events land.
|
|
107
|
-
// Ref-mirrored like threadsRef, for reads outside render (renameThread's snapshots).
|
|
108
|
-
const [optimisticThreads, setOptimisticThreads] = useState(new Map());
|
|
109
|
-
const optimisticThreadsRef = useRef(optimisticThreads);
|
|
110
|
-
const applyOptimisticThreads = useCallback((next) => {
|
|
111
|
-
optimisticThreadsRef.current = next;
|
|
112
|
-
setOptimisticThreads(next);
|
|
113
|
-
}, []);
|
|
114
|
-
// The realtime merge's list-shape inputs, shared by the display overlay, the live
|
|
115
|
-
// event handler, and the walk-result reconciliation in loadFirstPage.
|
|
116
75
|
const liveUpdateOptions = useMemo(() => ({ status, source, chatId, sort, order }), [status, source, chatId, sort, order]);
|
|
117
|
-
// What consumers see: the loaded pages with the optimistic rows overlaid.
|
|
118
76
|
const displayThreads = useMemo(() => overlayOptimisticThreads(threads, optimisticThreads, liveUpdateOptions), [threads, optimisticThreads, liveUpdateOptions]);
|
|
119
|
-
/**
|
|
120
|
-
* Report a user send on a conversation — ChatGPT-style optimistic listing. A
|
|
121
|
-
* conversation with no server row yet appears in `threads` immediately, titled by the
|
|
122
|
-
* truncated prompt, and every send bumps the row's recency. Server rows take over under
|
|
123
|
-
* the same identity as events land (turn end, title generation), the provisional title
|
|
124
|
-
* holding until the generated one arrives.
|
|
125
|
-
*/
|
|
126
77
|
const notifyThreadActivity = useCallback((thread, prompt) => {
|
|
127
78
|
const prev = optimisticThreadsRef.current;
|
|
128
79
|
const next = new Map(prev);
|
|
129
80
|
next.set(agentThreadKey(thread), optimisticThreadAfterSend(thread, prev.get(agentThreadKey(thread)), prompt, new Date().toISOString()));
|
|
130
81
|
applyOptimisticThreads(next);
|
|
131
82
|
}, [applyOptimisticThreads]);
|
|
132
|
-
// Auth material is read at request time via refs so an inline `headers` object literal
|
|
133
|
-
// doesn't churn the effect below into a refetch loop.
|
|
134
83
|
const authRef = useRef({ token: options.token, headers: options.headers });
|
|
135
84
|
authRef.current = { token: options.token, headers: options.headers };
|
|
136
|
-
// Every state-writing path checks it still owns the latest request before touching state,
|
|
137
|
-
// so a stale response (filter switched mid-flight, unmount) can never clobber a newer one.
|
|
138
85
|
const requestSeqRef = useRef(0);
|
|
139
|
-
// Authed request against the platform host, JSON-decoded — shared by the page and
|
|
140
|
-
// getOne fetchers below and by renameThread.
|
|
141
86
|
const fetchJson = useCallback(async (path, init) => {
|
|
142
87
|
const { token, headers } = authRef.current;
|
|
143
88
|
const response = await fetch(`${baseUrl ?? getApiHost()}${path}`, {
|
|
@@ -154,27 +99,13 @@ export const useAgentThreads = (options) => {
|
|
|
154
99
|
return (await response.json());
|
|
155
100
|
}, [baseUrl]);
|
|
156
101
|
const fetchPage = useCallback((cursor) => fetchJson(`/agent-threads/${encodeURIComponent(agentId)}${buildQuery({ status, source, chatId, limit, sort, order }, cursor)}`), [agentId, status, source, chatId, limit, sort, order, fetchJson]);
|
|
157
|
-
// getOne, mirroring the upstream GET /thread endpoint — the realtime fallback for
|
|
158
|
-
// events whose payload doesn't carry the full row.
|
|
159
102
|
const fetchThread = useCallback((threadChatId, threadChatThreadId) => fetchJson(`/agent-threads/${encodeURIComponent(agentId)}/thread?${new URLSearchParams({ chatId: threadChatId, chatThreadId: threadChatThreadId })}`), [agentId, fetchJson]);
|
|
160
|
-
/**
|
|
161
|
-
* Rename a conversation — optimistic with rollback, rejecting on failure so callers
|
|
162
|
-
* can surface it. The new title is applied to every local title source: the loaded
|
|
163
|
-
* row and any optimistic entry (whose snapshot the overlay may currently be showing).
|
|
164
|
-
* A conversation the list does not hold (a filter dropped its row) has no local title
|
|
165
|
-
* here — the consumer keeps its own copy of that row and applies the rename itself.
|
|
166
|
-
* Other tabs get the rename via the pusher event; this tab is excluded from the echo
|
|
167
|
-
* (x-socket-id), matching the optimistic apply.
|
|
168
|
-
*/
|
|
169
103
|
const renameThread = useCallback(async (thread, title) => {
|
|
170
104
|
const trimmed = title.trim();
|
|
171
105
|
if (!trimmed) {
|
|
172
106
|
return;
|
|
173
107
|
}
|
|
174
108
|
const key = agentThreadKey(thread);
|
|
175
|
-
// `undefined` means "that source held no row when we started" — leave whatever is
|
|
176
|
-
// there now alone. Collapsing it to null would let a rollback erase a title that
|
|
177
|
-
// landed from the server while the request was in flight.
|
|
178
109
|
const setLocalTitle = (rowTitle, entryTitle) => {
|
|
179
110
|
const row = threadsRef.current.find((item) => agentThreadKey(item) === key);
|
|
180
111
|
if (row && rowTitle !== undefined && row.title !== rowTitle) {
|
|
@@ -200,9 +131,6 @@ export const useAgentThreads = (options) => {
|
|
|
200
131
|
},
|
|
201
132
|
body: JSON.stringify({ chatId: thread.chatId, chatThreadId: thread.chatThreadId, title: trimmed }),
|
|
202
133
|
});
|
|
203
|
-
// `updated: false` means no such row upstream (e.g. renaming before the first
|
|
204
|
-
// turn indexes the conversation) — a failure like any other, or the optimistic
|
|
205
|
-
// title would stick locally and silently vanish on reload.
|
|
206
134
|
if (!updated) {
|
|
207
135
|
throw new Error("Thread rename was not applied");
|
|
208
136
|
}
|
|
@@ -212,19 +140,10 @@ export const useAgentThreads = (options) => {
|
|
|
212
140
|
throw error;
|
|
213
141
|
}
|
|
214
142
|
}, [agentId, fetchJson, applyThreads, applyOptimisticThreads]);
|
|
215
|
-
// Realtime rows that land while a refresh walk is in flight: the walk's fetches may
|
|
216
|
-
// predate those writes, so replacing the list with the raw walk result could silently
|
|
217
|
-
// revert or drop them — re-merging afterwards keeps whichever side is newer (the
|
|
218
|
-
// merge's staleness guard makes this idempotent).
|
|
219
143
|
const liveWhileWalkingRef = useRef(null);
|
|
220
|
-
// The in-flight refresh, if any, so loadMore can wait it out instead of dropping the
|
|
221
|
-
// gesture (a click on "Load more" often arrives right behind the window-focus refresh
|
|
222
|
-
// the same gesture triggered).
|
|
223
144
|
const refreshPromiseRef = useRef(null);
|
|
224
145
|
const loadFirstPage = useCallback(() => {
|
|
225
146
|
if (!agentId) {
|
|
226
|
-
// Nothing will fetch — make sure the optimistic initial `true` (or a fetch
|
|
227
|
-
// superseded by agentId turning falsy) can't leave the loading flag latched on.
|
|
228
147
|
setIsLoading(false);
|
|
229
148
|
return Promise.resolve();
|
|
230
149
|
}
|
|
@@ -234,15 +153,6 @@ export const useAgentThreads = (options) => {
|
|
|
234
153
|
setError(null);
|
|
235
154
|
const run = (async () => {
|
|
236
155
|
try {
|
|
237
|
-
// A refresh re-covers every page the user has already loaded — one request in the
|
|
238
|
-
// common one-page case (the query-change effect blanks the list first, so filter
|
|
239
|
-
// switches also fetch a single page). The alternatives both break pagination:
|
|
240
|
-
// replacing with page one collapses the list back to page one on every focus
|
|
241
|
-
// refresh, and splicing page one over the old tail either rewinds the cursor
|
|
242
|
-
// ("Load more" then refetches known rows and looks dead) or keeps the old cursor
|
|
243
|
-
// and permanently skips rows that arrived beyond page one while the user was away.
|
|
244
|
-
// Walking the cursor keeps the list contiguous from the top, in server order,
|
|
245
|
-
// with the cursor pointing past everything shown.
|
|
246
156
|
const loadedCount = threadsRef.current.length;
|
|
247
157
|
let items = [];
|
|
248
158
|
let cursor = null;
|
|
@@ -252,21 +162,21 @@ export const useAgentThreads = (options) => {
|
|
|
252
162
|
return;
|
|
253
163
|
}
|
|
254
164
|
if (page.items.length === 0) {
|
|
255
|
-
// Defensive: an empty page must terminate the walk even with a non-null cursor.
|
|
256
165
|
cursor = null;
|
|
257
166
|
break;
|
|
258
167
|
}
|
|
259
168
|
items = appendPage(items, page.items);
|
|
260
169
|
if (page.nextCursor === cursor) {
|
|
261
|
-
// Defensive: dedupe can hold items.length under loadedCount, so a
|
|
262
|
-
// non-advancing cursor must terminate too or the walk loops unbounded.
|
|
263
170
|
cursor = null;
|
|
264
171
|
break;
|
|
265
172
|
}
|
|
266
173
|
cursor = page.nextCursor;
|
|
267
174
|
} while (cursor !== null && items.length < loadedCount);
|
|
268
|
-
|
|
269
|
-
items =
|
|
175
|
+
if (liveWhileWalkingRef.current) {
|
|
176
|
+
items = reconcileWalkWithLive(items, liveWhileWalkingRef.current, {
|
|
177
|
+
...liveUpdateOptions,
|
|
178
|
+
hasMore: cursor !== null,
|
|
179
|
+
});
|
|
270
180
|
}
|
|
271
181
|
applyThreads(items);
|
|
272
182
|
applyCursor(cursor);
|
|
@@ -287,8 +197,6 @@ export const useAgentThreads = (options) => {
|
|
|
287
197
|
refreshPromiseRef.current = run;
|
|
288
198
|
return run;
|
|
289
199
|
}, [agentId, fetchPage, liveUpdateOptions, applyThreads, applyCursor]);
|
|
290
|
-
// Ref-guarded rather than state-guarded: after the awaited refresh below, the closure's
|
|
291
|
-
// state flags are stale by definition — and a ref also makes double-clicks race-free.
|
|
292
200
|
const loadMoreInFlightRef = useRef(false);
|
|
293
201
|
const loadMore = useCallback(async () => {
|
|
294
202
|
if (!agentId || loadMoreInFlightRef.current) {
|
|
@@ -296,14 +204,8 @@ export const useAgentThreads = (options) => {
|
|
|
296
204
|
}
|
|
297
205
|
loadMoreInFlightRef.current = true;
|
|
298
206
|
setIsLoadingMore(true);
|
|
299
|
-
// Captured BEFORE the wait below: this closure's fetchPage belongs to the render the
|
|
300
|
-
// click landed on, so anything that supersedes the list while we sit behind the
|
|
301
|
-
// refresh (a query change, a newer refresh) must invalidate this gesture too.
|
|
302
207
|
const requestSeq = requestSeqRef.current;
|
|
303
208
|
try {
|
|
304
|
-
// A refresh may be in flight — typically fired by the very window-focus that
|
|
305
|
-
// preceded this click. Wait it out instead of silently swallowing the gesture;
|
|
306
|
-
// the cursor ref then points past everything the refresh walk re-covered.
|
|
307
209
|
if (refreshPromiseRef.current) {
|
|
308
210
|
await refreshPromiseRef.current;
|
|
309
211
|
}
|
|
@@ -328,8 +230,6 @@ export const useAgentThreads = (options) => {
|
|
|
328
230
|
setError(fetchError instanceof Error ? fetchError : new Error(String(fetchError)));
|
|
329
231
|
}
|
|
330
232
|
finally {
|
|
331
|
-
// Unconditional: even when a refresh superseded this request the flags must clear
|
|
332
|
-
// or pagination locks up.
|
|
333
233
|
loadMoreInFlightRef.current = false;
|
|
334
234
|
setIsLoadingMore(false);
|
|
335
235
|
}
|
|
@@ -337,59 +237,51 @@ export const useAgentThreads = (options) => {
|
|
|
337
237
|
useEffect(() => {
|
|
338
238
|
applyThreads([]);
|
|
339
239
|
applyCursor(null);
|
|
340
|
-
// Server-side filters know nothing of the optimistic rows — drop them on a query
|
|
341
|
-
// change rather than risk re-listing a snapshot whose server truth the new filter
|
|
342
|
-
// excludes (see the retire-on-eviction note in the realtime handler below).
|
|
343
240
|
applyOptimisticThreads(new Map());
|
|
344
241
|
void loadFirstPage();
|
|
345
242
|
return () => {
|
|
346
|
-
// Invalidate in-flight requests on unmount / query change.
|
|
347
243
|
requestSeqRef.current += 1;
|
|
348
244
|
};
|
|
349
245
|
}, [loadFirstPage, applyThreads, applyCursor, applyOptimisticThreads]);
|
|
350
|
-
// Live sync: every threads-table write lands here as a serialized row, so the happy
|
|
351
|
-
// path is a pure client-side upsert; a payload without the full row falls back to the
|
|
352
|
-
// getOne endpoint. The service-level filter drops other agents' events.
|
|
353
246
|
useEffect(() => {
|
|
354
247
|
if (!agentId) {
|
|
355
248
|
return;
|
|
356
249
|
}
|
|
357
250
|
const onThreadUpdated = async (data) => {
|
|
358
|
-
// Same superseded-request contract as the fetch paths, guarding the async fallback
|
|
359
|
-
// (trivially true on the no-await happy path).
|
|
360
251
|
const requestSeq = requestSeqRef.current;
|
|
361
252
|
try {
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
else if (typeof raw?.chatId === "string" && typeof raw.chatThreadId === "string") {
|
|
368
|
-
incoming = await fetchThread(raw.chatId, raw.chatThreadId);
|
|
253
|
+
const event = data;
|
|
254
|
+
const raw = event?.thread;
|
|
255
|
+
if (typeof raw?.chatId !== "string" || typeof raw.chatThreadId !== "string") {
|
|
256
|
+
return;
|
|
369
257
|
}
|
|
370
|
-
|
|
258
|
+
const identity = { chatId: raw.chatId, chatThreadId: raw.chatThreadId };
|
|
259
|
+
const key = agentThreadKey(identity);
|
|
260
|
+
if (event?.removed) {
|
|
261
|
+
const removed = { ...identity, runningRunId: raw.runningRunId };
|
|
262
|
+
const removal = removeAgentThread(threadsRef.current, optimisticThreadsRef.current, removed);
|
|
263
|
+
if (!removal) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
liveWhileWalkingRef.current?.set(key, { removed });
|
|
267
|
+
if (removal.threads !== threadsRef.current) {
|
|
268
|
+
applyThreads(removal.threads);
|
|
269
|
+
}
|
|
270
|
+
if (removal.optimistic !== optimisticThreadsRef.current) {
|
|
271
|
+
applyOptimisticThreads(removal.optimistic);
|
|
272
|
+
}
|
|
371
273
|
return;
|
|
372
274
|
}
|
|
275
|
+
const incoming = isAgentThreadItem(raw) ? raw : await fetchThread(identity.chatId, identity.chatThreadId);
|
|
373
276
|
if (requestSeqRef.current !== requestSeq) {
|
|
374
277
|
return;
|
|
375
278
|
}
|
|
376
|
-
const key = agentThreadKey(incoming);
|
|
377
|
-
// An authoritative row the active filters reject must retire the thread's
|
|
378
|
-
// optimistic entry too: the merge below evicts the server row, and with no
|
|
379
|
-
// listed row there is no staleness guard to stop the overlay from re-adding
|
|
380
|
-
// the entry's stale snapshot — which no later event or refresh walk could
|
|
381
|
-
// correct, since the server filters the row out as well. Gated on turnCount
|
|
382
|
-
// (server-monotonic, like the overlay's handover) rather than the entry's
|
|
383
|
-
// CLIENT-stamped lastActivityAt, which a fast browser clock could hold ahead
|
|
384
|
-
// of every server timestamp.
|
|
385
279
|
const entry = optimisticThreadsRef.current.get(key);
|
|
386
280
|
if (entry && !matchesAgentThreadFilters(incoming, liveUpdateOptions) && incoming.turnCount >= entry.turnCount) {
|
|
387
281
|
const next = new Map(optimisticThreadsRef.current);
|
|
388
282
|
next.delete(key);
|
|
389
283
|
applyOptimisticThreads(next);
|
|
390
284
|
}
|
|
391
|
-
// Park the row for the in-flight refresh walk (if any) — its fetches may
|
|
392
|
-
// predate this write — then merge it into the current list.
|
|
393
285
|
liveWhileWalkingRef.current?.set(key, incoming);
|
|
394
286
|
applyThreads(applyAgentThreadUpdate(threadsRef.current, incoming, {
|
|
395
287
|
...liveUpdateOptions,
|
|
@@ -397,8 +289,7 @@ export const useAgentThreads = (options) => {
|
|
|
397
289
|
}));
|
|
398
290
|
}
|
|
399
291
|
catch {
|
|
400
|
-
// Best-effort:
|
|
401
|
-
// recovery refresh re-converges. Must not reject; the websocket service doesn't catch.
|
|
292
|
+
// Best-effort: websocket handler must not reject.
|
|
402
293
|
}
|
|
403
294
|
};
|
|
404
295
|
websocketsService.listen(AGENT_THREAD_UPDATED, onThreadUpdated, { filter: { agentId } });
|
|
@@ -406,6 +297,18 @@ export const useAgentThreads = (options) => {
|
|
|
406
297
|
websocketsService.unlisten(AGENT_THREAD_UPDATED, onThreadUpdated);
|
|
407
298
|
};
|
|
408
299
|
}, [agentId, liveUpdateOptions, fetchThread, applyThreads, applyOptimisticThreads]);
|
|
300
|
+
// Realtime has no replay — reconnect re-walks; last socket id lives in a ref so a filter switch while offline does not look like a first connect.
|
|
301
|
+
const lastSocketIdRef = useRef(undefined);
|
|
302
|
+
useEffect(() => websocketsService.onSocketIdChange((socketId) => {
|
|
303
|
+
if (!socketId) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const reconnected = lastSocketIdRef.current !== undefined && socketId !== lastSocketIdRef.current;
|
|
307
|
+
lastSocketIdRef.current = socketId;
|
|
308
|
+
if (reconnected) {
|
|
309
|
+
void loadFirstPage();
|
|
310
|
+
}
|
|
311
|
+
}), [loadFirstPage]);
|
|
409
312
|
return {
|
|
410
313
|
threads: displayThreads,
|
|
411
314
|
notifyThreadActivity,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface TextPart {
|
|
2
|
+
type: "text";
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
/** Arms the next send; cleared by the send and by a conversation switch. */
|
|
6
|
+
export interface ComposerMessageMeta {
|
|
7
|
+
name?: string;
|
|
8
|
+
leadingParts?: TextPart[];
|
|
9
|
+
}
|
|
10
|
+
export declare const isFeedbackTag: (text: string) => boolean;
|
|
11
|
+
export declare const buildArmedMessageContent: <Part extends {
|
|
12
|
+
type: string;
|
|
13
|
+
}>(meta: ComposerMessageMeta, composed: string | Part[]) => (TextPart | Part)[];
|
|
14
|
+
/** What the user wrote: the last text part that is not the feedback marker, else empty. */
|
|
15
|
+
export declare const userTextOfContent: (content: string | readonly {
|
|
16
|
+
type: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
}[]) => string;
|
|
19
|
+
/** User message `name` that marks a feedback send; the marker carries the rating. */
|
|
20
|
+
export declare const FEEDBACK_MESSAGE_NAME = "feedback";
|
|
21
|
+
export type FeedbackRate = 1 | -1 | 0;
|
|
22
|
+
/** What a feedback send is about: the rating, and the rated message (absent: the whole conversation). */
|
|
23
|
+
export interface FeedbackDraft {
|
|
24
|
+
rate: FeedbackRate;
|
|
25
|
+
messageId?: string;
|
|
26
|
+
}
|
|
27
|
+
export declare const isFeedbackMessage: (message: {
|
|
28
|
+
role: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
}) => boolean;
|
|
31
|
+
/**
|
|
32
|
+
* A rating sent as it is, no comment: the name, and the marker as the whole message. The server
|
|
33
|
+
* records it on the conversation and opens no thread (a thread's prompt is what the user typed).
|
|
34
|
+
*/
|
|
35
|
+
export declare const feedbackMessage: (draft: FeedbackDraft) => {
|
|
36
|
+
name: string;
|
|
37
|
+
content: TextPart[];
|
|
38
|
+
};
|
|
39
|
+
/** Arms the composer for a feedback send: the name, and the marker as the one leading part. */
|
|
40
|
+
export declare const feedbackComposerMeta: (draft: FeedbackDraft) => ComposerMessageMeta;
|
|
41
|
+
/** The marker's rating and subject, read the way the server reads them; null when `text` is not a marker. */
|
|
42
|
+
export declare const parseFeedbackTag: (text: string | undefined) => FeedbackDraft | null;
|
|
43
|
+
/** The feedback draft a composer is armed with, or null when it is not armed for feedback. */
|
|
44
|
+
export declare const feedbackDraftOf: (meta: ComposerMessageMeta | null | undefined) => FeedbackDraft | null;
|
|
45
|
+
export interface ParsedFeedback extends FeedbackDraft {
|
|
46
|
+
/** What the user typed, the marker stripped. */
|
|
47
|
+
body: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A sent feedback message as the server reads it (`parse_feedback_message`): the marker is the
|
|
51
|
+
* FIRST text part only — later parts are the user's text whatever they look like — blank parts
|
|
52
|
+
* are skipped, and an unreadable marker reads as unrated.
|
|
53
|
+
*/
|
|
54
|
+
export declare const parseFeedbackContent: (content: unknown) => ParsedFeedback;
|
|
55
|
+
/** The thread a feedback on one message opens — the server's key, so repeats land on the same thread. */
|
|
56
|
+
export declare const feedbackThreadId: (messageId: string) => string;
|
|
57
|
+
//# sourceMappingURL=composer-message.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"composer-message.d.ts","sourceRoot":"","sources":["../../src/utils/composer-message.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,4EAA4E;AAC5E,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,QAAQ,EAAE,CAAC;CAC3B;AAID,eAAO,MAAM,aAAa,GAAI,MAAM,MAAM,KAAG,OAA0C,CAAC;AAExF,eAAO,MAAM,wBAAwB,GAAI,IAAI,SAAS;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,EACpE,MAAM,mBAAmB,EACzB,UAAU,MAAM,GAAG,IAAI,EAAE,KACxB,CAAC,QAAQ,GAAG,IAAI,CAAC,EAGnB,CAAC;AAgBF,2FAA2F;AAC3F,eAAO,MAAM,iBAAiB,GAAI,SAAS,MAAM,GAAG,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,KAAG,MAQhG,CAAC;AASF,qFAAqF;AACrF,eAAO,MAAM,qBAAqB,aAAa,CAAC;AAEhD,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC;AAEtC,yGAAyG;AACzG,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,YAAY,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,eAAO,MAAM,iBAAiB,GAAI,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,KAAG,OACV,CAAC;AAYpE;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,aAAa,KAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,QAAQ,EAAE,CAAA;CAGxF,CAAC;AAEH,+FAA+F;AAC/F,eAAO,MAAM,oBAAoB,GAAI,OAAO,aAAa,KAAG,mBAG3D,CAAC;AASF,6GAA6G;AAC7G,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,GAAG,SAAS,KAAG,aAAa,GAAG,IAS3E,CAAC;AAEF,8FAA8F;AAC9F,eAAO,MAAM,eAAe,GAAI,MAAM,mBAAmB,GAAG,IAAI,GAAG,SAAS,KAAG,aAAa,GAAG,IACD,CAAC;AAE/F,MAAM,WAAW,cAAe,SAAQ,aAAa;IACnD,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;CACd;AAID;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,SAAS,OAAO,KAAG,cAUvD,CAAC;AAEF,yGAAyG;AACzG,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,KAAG,MAA2B,CAAC"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Composer send shape and read-back helpers; dependency-free for tests.
|
|
2
|
+
const FEEDBACK_TAG_PATTERN = /^\s*<feedback\b[^>]*\/?>\s*$/i;
|
|
3
|
+
export const isFeedbackTag = (text) => FEEDBACK_TAG_PATTERN.test(text);
|
|
4
|
+
export const buildArmedMessageContent = (meta, composed) => [
|
|
5
|
+
...(meta.leadingParts ?? []),
|
|
6
|
+
...(typeof composed === "string" ? [{ type: "text", text: composed }] : composed),
|
|
7
|
+
];
|
|
8
|
+
/** The message's text, part by part; non-text parts skipped (the server's `message_text_parts`). */
|
|
9
|
+
const textPartsOf = (content) => {
|
|
10
|
+
if (typeof content === "string") {
|
|
11
|
+
return [content];
|
|
12
|
+
}
|
|
13
|
+
if (!Array.isArray(content)) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
return content.flatMap((part) => {
|
|
17
|
+
const text = part?.type === "text" ? part.text : undefined;
|
|
18
|
+
return typeof text === "string" ? [text] : [];
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
/** What the user wrote: the last text part that is not the feedback marker, else empty. */
|
|
22
|
+
export const userTextOfContent = (content) => {
|
|
23
|
+
const parts = textPartsOf(content);
|
|
24
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
25
|
+
if (!isFeedbackTag(parts[index])) {
|
|
26
|
+
return parts[index];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
};
|
|
31
|
+
/* ---- Feedback wire protocol ------------------------------------------------------------
|
|
32
|
+
* A feedback send is a user message named FEEDBACK_MESSAGE_NAME whose FIRST text part is the
|
|
33
|
+
* marker `<feedback rate="…" messageId="…" />` (no messageId for the conversation as a whole).
|
|
34
|
+
* The server reads the marker from that part only and files the agent's reply under
|
|
35
|
+
* `fb-<messageId>`. Mirrors wf-actions-py agent_feedback_action.
|
|
36
|
+
* -------------------------------------------------------------------------------------- */
|
|
37
|
+
/** User message `name` that marks a feedback send; the marker carries the rating. */
|
|
38
|
+
export const FEEDBACK_MESSAGE_NAME = "feedback";
|
|
39
|
+
export const isFeedbackMessage = (message) => message.role === "user" && message.name === FEEDBACK_MESSAGE_NAME;
|
|
40
|
+
// The server reads an attribute as `"([^"]*)"` inside `<feedback [^>]*>`: a quote or an angle
|
|
41
|
+
// bracket in the id would end the attribute or the tag early.
|
|
42
|
+
const MARKER_SAFE_ID_PATTERN = /^[^"<>\s]+$/;
|
|
43
|
+
/** The marker text; `messageId` rides only when the id can sit inside a quoted attribute. */
|
|
44
|
+
const feedbackMarker = ({ rate, messageId }) => messageId && MARKER_SAFE_ID_PATTERN.test(messageId)
|
|
45
|
+
? `<feedback rate="${rate}" messageId="${messageId}" />`
|
|
46
|
+
: `<feedback rate="${rate}" />`;
|
|
47
|
+
/**
|
|
48
|
+
* A rating sent as it is, no comment: the name, and the marker as the whole message. The server
|
|
49
|
+
* records it on the conversation and opens no thread (a thread's prompt is what the user typed).
|
|
50
|
+
*/
|
|
51
|
+
export const feedbackMessage = (draft) => ({
|
|
52
|
+
name: FEEDBACK_MESSAGE_NAME,
|
|
53
|
+
content: [{ type: "text", text: feedbackMarker(draft) }],
|
|
54
|
+
});
|
|
55
|
+
/** Arms the composer for a feedback send: the name, and the marker as the one leading part. */
|
|
56
|
+
export const feedbackComposerMeta = (draft) => {
|
|
57
|
+
const { name, content } = feedbackMessage(draft);
|
|
58
|
+
return { name, leadingParts: content };
|
|
59
|
+
};
|
|
60
|
+
const ATTRIBUTE_PATTERN = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"/g;
|
|
61
|
+
const toRate = (value) => {
|
|
62
|
+
const rate = Number(value);
|
|
63
|
+
return rate > 0 ? 1 : rate < 0 ? -1 : 0;
|
|
64
|
+
};
|
|
65
|
+
/** The marker's rating and subject, read the way the server reads them; null when `text` is not a marker. */
|
|
66
|
+
export const parseFeedbackTag = (text) => {
|
|
67
|
+
if (text === undefined || !isFeedbackTag(text)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const attributes = {};
|
|
71
|
+
for (const [, key, value] of text.matchAll(ATTRIBUTE_PATTERN)) {
|
|
72
|
+
attributes[key.toLowerCase()] = value;
|
|
73
|
+
}
|
|
74
|
+
return { rate: toRate(attributes.rate), messageId: attributes.messageid?.trim() || undefined };
|
|
75
|
+
};
|
|
76
|
+
/** The feedback draft a composer is armed with, or null when it is not armed for feedback. */
|
|
77
|
+
export const feedbackDraftOf = (meta) => meta?.name === FEEDBACK_MESSAGE_NAME ? parseFeedbackTag(meta.leadingParts?.[0]?.text) : null;
|
|
78
|
+
const UNRATED = { rate: 0 };
|
|
79
|
+
/**
|
|
80
|
+
* A sent feedback message as the server reads it (`parse_feedback_message`): the marker is the
|
|
81
|
+
* FIRST text part only — later parts are the user's text whatever they look like — blank parts
|
|
82
|
+
* are skipped, and an unreadable marker reads as unrated.
|
|
83
|
+
*/
|
|
84
|
+
export const parseFeedbackContent = (content) => {
|
|
85
|
+
const parts = textPartsOf(content);
|
|
86
|
+
const marker = parseFeedbackTag(parts[0]);
|
|
87
|
+
return {
|
|
88
|
+
...(marker ?? UNRATED),
|
|
89
|
+
body: (marker ? parts.slice(1) : parts)
|
|
90
|
+
.filter((text) => text.trim())
|
|
91
|
+
.join("\n")
|
|
92
|
+
.trim(),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/** The thread a feedback on one message opens — the server's key, so repeats land on the same thread. */
|
|
96
|
+
export const feedbackThreadId = (messageId) => `fb-${messageId}`;
|