@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.
@@ -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
- /* public decouple - AGENT_THREAD_UPDATED rides AgentThreadEventPayload (packages/common
10
- agent-thread-events.ts): the event's `thread` field is exactly the list/GET item shape,
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
- for (const live of liveWhileWalkingRef.current?.values() ?? []) {
269
- items = applyAgentThreadUpdate(items, live, { ...liveUpdateOptions, hasMore: cursor !== null });
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 raw = data?.thread;
363
- let incoming;
364
- if (isAgentThreadItem(raw)) {
365
- incoming = raw;
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
- else {
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: a failed fallback fetch skips this event — the next event or a
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}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocksdiy/react-common",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "type": "module",
5
5
  "description": "React common",
6
6
  "keywords": [],