@icanbwell/baileyai-chat-ui 1.0.16 → 1.0.18

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/README.md CHANGED
@@ -142,6 +142,166 @@ that case. If a host needs `privacyNote` to survive this (e.g. it's a persistent
142
142
  disclosure rather than a first-impression nicety), pass `privacyNote={{ ..., alwaysVisible: true }}`
143
143
  to render it in every state, not just the empty one.
144
144
 
145
+ ## Using the Bailey client
146
+
147
+ `createBaileyClient` is a framework-free client for the Bailey API. It implements
148
+ `BaileyChatTransport` (so it can be passed straight to `useBaileyChat`) and adds conversation
149
+ calls. The host supplies only `baseUrl` and `getHeaders`; the library never reads or stores tokens.
150
+
151
+ ```tsx
152
+ import { createBaileyClient, useBaileyChat, useBaileyConversations, createSessionConversationStorage } from '@icanbwell/baileyai-chat-ui';
153
+
154
+ const client = createBaileyClient({
155
+ baseUrl: 'https://api.example.com/bailey/v1', // the API root, including /bailey/v1
156
+ getHeaders: async () => ({ Authorization: `Bearer ${await getAuthToken()}` }),
157
+ onAuthError: (error) => signOut(error.status), // fires once on 401/403
158
+ });
159
+
160
+ const chat = useBaileyChat({
161
+ transport: client,
162
+ model: 'bailey-default',
163
+ instructions: 'You are Bailey...',
164
+ storage: createSessionConversationStorage(), // optional; see below
165
+ });
166
+ const history = useBaileyConversations({ client }); // conversations, loadMore, refresh, remove
167
+ ```
168
+
169
+ - Client methods: `listConversations`, `getConversation`, `deleteConversation`, `submitFeedback`,
170
+ `clearFeedback`, and `streamChat`. Failures reject with `BaileyApiError` (`status`, and `kind` of
171
+ `http`, `network`, `timeout`, `aborted` or `invalid-response`); its message is fixed per kind and never
172
+ contains a response body. For `network` and `timeout`, `error.cause` holds the original thrown error
173
+ (the failed fetch's error, or the abort reason); `http`, `invalid-response` and `aborted` have no cause.
174
+ Browsers do not expose whether a failed fetch was CORS, DNS or offline (all are a generic `TypeError`),
175
+ so `kind` plus `cause` is all a host can show or log. Do not show `cause.message` to users without
176
+ review, and do not log the request URL. `invalid-response` means a read (`listConversations`, `getConversation`) got a 2xx whose body was
177
+ not JSON (or a 204); writes (`submitFeedback`, `clearFeedback`, `deleteConversation`) still accept an
178
+ empty or non-JSON success body. `streamChat` keeps the `BaileyChatTransport` contract: it returns the
179
+ HTTP status and raw body for non-2xx responses instead of throwing. If the connection drops after the
180
+ response began it returns `interrupted: true`, and `useBaileyChat` then ends the turn in the error
181
+ state (the partial text stays visible, `onAssistantMessageComplete` does not fire). A custom
182
+ transport should set `interrupted: true` in that case, and must resolve `streamChat` only when the
183
+ response body has closed.
184
+ When no response arrives (`fetch` rejects, or `getHeaders` throws) `streamChat` still resolves with
185
+ `status: undefined` and the fixed `errorMessage: 'Request failed'`, and now also sets `errorKind: 'network'`
186
+ so a host can choose its own copy. `errorCause` carries the error `fetch` threw; it is not set when
187
+ `getHeaders` threw (it can carry auth detail). Both fields are optional and additive, and
188
+ `useBaileyChat`'s copy is unchanged. A caller abort and the idle timeout still reject with the abort
189
+ reason (`TimeoutError` for the idle timeout), and a mid-stream drop still resolves `interrupted: true`
190
+ without `errorKind`. Browsers do not distinguish CORS, DNS or offline, so do not show `errorCause.message`
191
+ to users without review and do not log the request URL.
192
+ A multi-byte character cut off at the end of the stream is flushed as a final `onChunk`.
193
+ - Every call has a timeout (`requestTimeoutMs`, default 30s; the stream uses an idle timeout,
194
+ `idleTimeoutMs`). A failed best-effort cleanup is reported through `onCleanupError` and never thrown.
195
+ - **Nothing is logged** by the client or the hooks, and request/response bodies are never placed in
196
+ errors or callbacks.
197
+ - **Conversation id.** `useBaileyChat` returns a read-only `conversationId`. It comes from the
198
+ `conversationId` option (a seed), from `storage`, or from the `X-Conversation-Id` response header
199
+ the transport reports via `onConversationId`. `newChat()` clears it. The library does not create
200
+ ids. Once an id is held it is sent as the `x-chat-id` header, and **only the newest message** goes in
201
+ `input` because the server prepends its stored history and does not deduplicate. With no id held
202
+ (for example the first turn, before the server reports one) the full transcript is sent, exactly
203
+ as before. Pass `sendFullTranscript: true` to always send the full transcript **and never the id**
204
+ (stateless requests; escape hatch, e.g. where the server does not persist; the library cannot
205
+ detect that itself). The id is withheld because with one the server would prepend its stored
206
+ history to the full transcript and duplicate it in the model context.
207
+ - **`conversationVia` (opt-in, default `'header'`).** `createBaileyClient({ conversationVia: 'body' })`
208
+ sends the held id as the `conversation` field of the request body instead of `x-chat-id` (never
209
+ both). See the `conversationVia` JSDoc for when it is safe to switch. Independently of that option,
210
+ `useBaileyChat` also adopts `response.conversation.id` from `response.completed` / `response.created`
211
+ events when present (non-empty string only), ahead of the `X-Conversation-Id` header value.
212
+ - **The id is persisted in sessionStorage by default (per tab).** A reload resumes the conversation
213
+ (the messages themselves are loaded with `loadConversation`). Pass `storage: false` to disable
214
+ persistence, or your own `BaileyConversationStorage` to store it elsewhere. A storage that throws
215
+ (Safari private mode, blocked storage) never reaches your render: chat continues with the id in
216
+ memory, `persistence` becomes `'degraded'` (`'ok'` normally, `'off'` for `storage: false`) and
217
+ `onStorageError(error, operation)` is called. A `sub` that is provided but blank is a host error
218
+ reported the same way (operation `'key'`); the id then stays in memory and the shared key is not used.
219
+ Append your own surface name to `storageKey` so surfaces do not share a conversation. Selecting an
220
+ old conversation (`loadConversation`) always replaces the stored id. Hosts should clear the stored
221
+ id on logout through the same `storage`; the library has no logout hook.
222
+ - **`sub` keys and validates the stored id (optional).** Pass the signed-in user's `sub` and the stored
223
+ id lives under `${storageKey}:${sub}` and is rejected (and removed) on read unless it starts with
224
+ `<sub>:`, so a different user in the same browser session never inherits the previous user's id. A
225
+ seed and any id reported by the transport are validated the same way. `sub` is used for nothing
226
+ else: **the library never creates a conversation id**, it only reads the one the server reports
227
+ (`X-Conversation-Id`) or one you seed. Without `sub` the stored id is held unvalidated; after a
228
+ shared-browser user switch the server rejects the foreign id and the library adopts the
229
+ server-reported one, so the worst case is a lost first-turn context.
230
+ - **Lost server context is surfaced.** A turn that sent a held id signals context loss when the
231
+ server did not continue that conversation, for one of two reasons. `reason: 'id'`: it came back
232
+ with no id, or a different one (persistence off, an id it does not know). `reason: 'history'`: it
233
+ reported the same id but `conversation.history_status` on `response.created` / `response.completed`
234
+ (the last one the turn carries wins) is `'failed'` or `'empty'`, meaning the stored history was not
235
+ loaded. In both cases the hook drops the held id and its stored value, clears the paging state, sets
236
+ `contextLost: true` until the next turn, and calls `onContextLoss({ sentId, reportedId, reason })`
237
+ (the library always sets `reason`, though it is optional in the type; `reportedId` is absent when the server reported none); the next turn sends
238
+ the full transcript. `'loaded'`, an absent field and any unrecognised value change nothing, and a
239
+ first turn (no id sent) never signals loss. A host-seeded id whose turn reports `'empty'` also
240
+ signals loss: a seeded id is only valid for resuming a stored conversation. The backend also sends
241
+ `history_loaded` (number of prior messages loaded); the library does not read it, it is for host
242
+ diagnostics. A turn that fails outright (4xx/5xx) is reported as an error as before. Older backends
243
+ do not send `history_status`, so for them only `reason: 'id'` can occur and a history load that
244
+ fails while the server reports the same id is still not detectable. Residual risk until the backend
245
+ change (baileyai PR #554, language-model-common PR #136) is deployed and released: the `'history'`
246
+ path is inert.
247
+ - **Persistence off on the server.** Stable conversation ids only help where the server persists
248
+ conversations (the conversation routes return 503 when persistence is off). The library does not
249
+ probe for this: enable stable ids (pass a `client` and keep the default storage) only in environments
250
+ where persistence is on, and otherwise set `sendFullTranscript: true` so every request carries the
251
+ full transcript and no conversation id.
252
+ - Existing hosts that pass none of these options behave as before until a transport reports a
253
+ conversation id: a custom `BaileyChatTransport` keeps working (`conversationId` and
254
+ `onConversationId` are optional transport params, and `conversationId` is only passed once an id is
255
+ held), and none of the hosts' transports report one today. Only a transport that does (such as this
256
+ client) makes the hook hold, persist and send an id.
257
+ - **Loading a conversation.** Pass `client` to `useBaileyChat` to get `loadConversation({ conversationId })`
258
+ (newest 100 messages), `loadEarlier()` / `hasEarlier` (older pages) and `historyStatus`. Hidden rows
259
+ are dropped, each assistant message gets `serverMessageId`, and a stored rating is mapped to
260
+ `message.feedback`. Loading holds the id, so the next turn sends only the newest message and the
261
+ loaded history is not re-sent. Failures reject with `BaileyApiError`, leave the messages intact and
262
+ set `historyStatus` to `'error'`; a response that arrives after `newChat()` or another load is
263
+ discarded. `mapTranscript` and `mergeEarlierPage` are exported for hosts that fetch transcripts
264
+ themselves. `loadMessages` still never changes the held id.
265
+ - **Feedback.** `createBaileyFeedbackHandlers({ client, getConversationId })` returns `onSubmit` / `onClear`
266
+ for `BaileyFeedbackButtons`:
267
+
268
+ ```tsx
269
+ const handlers = createBaileyFeedbackHandlers({ client, getConversationId: () => chat.conversationId });
270
+ // inside renderMessageActions:
271
+ <BaileyFeedbackButtons messageId={m.serverMessageId} initialRating={m.feedback?.rating} {...handlers} />
272
+ ```
273
+
274
+ Any rejection (including a 404, or no conversation id held) is a failure the buttons show; nothing is
275
+ retried automatically. The comment goes only into the request body.
276
+ - Not included yet: automatic resume on mount. A restored id does not load the transcript; call
277
+ `loadConversation` yourself (the history drawer below does this when the user picks a row).
278
+
279
+ ## Conversation history drawer
280
+
281
+ An opt-in, built-in history UI: pass a Bailey client to the panel and the "Chat history" item in the
282
+ "..." menu opens a drawer inside the panel (no dialog dependency, nothing fetched until it opens).
283
+
284
+ ```tsx
285
+ const client = createBaileyClient({ baseUrl, getHeaders });
286
+ const chat = useBaileyChat({ transport: client, client, model: 'bailey-default', instructions: '...' });
287
+
288
+ <BaileyChatPanel chat={chat} history={{ client }} locale="en" />
289
+ ```
290
+
291
+ - Rows show the title (or "Untitled conversation"), a relative time, and the message count (hidden in
292
+ narrow panels); the active conversation is marked with a tint, an accent bar and the word "Current".
293
+ Selecting a row calls `chat.loadConversation` and closes the drawer. Delete asks for an inline
294
+ confirmation (no undo window); deleting the active conversation resets the chat. "Load more" follows
295
+ the server's cursor.
296
+ - Errors show fixed copy (a separate "session expired" message for a 401/403 on the list call, the
297
+ connection message otherwise) with Retry; error text and titles never reach logs or `title` attributes.
298
+ - `onHistoryClick` is the host override: when both `history` and `onHistoryClick` are given, your callback
299
+ runs and the drawer never mounts. A host that passes neither renders exactly as before.
300
+ - Every string is a `labels` key (`historyTitle`, `historyEmptyTitle`, `historySessionExpiredTitle`, and so
301
+ on, all with English defaults). The session-expired wording is a default for copy review. Relative times
302
+ use `Intl.RelativeTimeFormat` with `locale` (default `navigator.language`): a future time shows "now",
303
+ and a time without an offset is read as UTC.
304
+
145
305
  ## Resuming a conversation
146
306
 
147
307
  `useBaileyChat` also returns `loadMessages(messages)`, for restoring a transcript a host fetched
@@ -167,7 +327,8 @@ be mid-stream.
167
327
 
168
328
  `loadMessages` only replaces `useBaileyChat`'s own state — a host still owns fetching the
169
329
  transcript and its own loading/error UI, and owns conversation identity (which id is active,
170
- where it's persisted) entirely outside this package.
330
+ where it's persisted) outside this package, unless it opts in to the id handling described in
331
+ [Using the Bailey client](#using-the-bailey-client).
171
332
 
172
333
  ### Known limitations
173
334
 
@@ -236,6 +397,43 @@ Pass `privacyNote` to render a short compliance/privacy disclosure as a footer b
236
397
 
237
398
  By default the note only renders in the empty state, before the first message. Pass `alwaysVisible: true` to keep it showing for the entire conversation instead — see [Sending a hidden turn](#sending-a-hidden-turn) above for why a host might need this.
238
399
 
400
+ ## Collecting feedback
401
+
402
+ `BaileyFeedbackButtons` renders thumbs up/down on an assistant message, with an inline form (reason chips and a comment) after a thumbs-down or an "Add a comment" click. It is opt-in and never calls an API itself: your app owns auth and networking and passes `onSubmit`/`onClear`. No extra install or subpath; it is part of the base entry.
403
+
404
+ ```tsx
405
+ <BaileyChatPanel
406
+ chat={chat}
407
+ renderMessageActions={(message) =>
408
+ message.serverMessageId ? (
409
+ <BaileyFeedbackButtons
410
+ messageId={message.serverMessageId}
411
+ onSubmit={({ messageId, rating, reasonCodes, comment }) =>
412
+ submitFeedback({ messageId, rating, reasonCodes, comment })
413
+ }
414
+ onClear={({ messageId }) => clearFeedback({ messageId })}
415
+ />
416
+ ) : null
417
+ }
418
+ />
419
+ ```
420
+
421
+ Pass a stable function for `renderMessageActions`. The control renders nothing when `messageId` is empty.
422
+
423
+ **Message ID.** `BaileyMessage.serverMessageId` is the backend's assistant message ID (the `id` field is minted client-side and is not usable for this). `useBaileyChat` reads it from the `message_id` field of the `response.completed` SSE event. A transport that reads it from a response header (`X-Message-Id`, non-streaming responses) can report it early through the optional `onMessageId(messageId)` callback of `streamChat`, parallel to `onRequestId`. `X-Message-Id` is only readable from a browser cross-origin if the backend exposes it through CORS (`Access-Control-Expose-Headers`), so browser hosts should prefer the `response.completed` event (or the `message_id` in the response body) over the header. A missing or non-string `message_id` is ignored, leaving `serverMessageId` undefined, so the control does not render against an older backend.
424
+
425
+ **Behavior.**
426
+
427
+ - Clicking a thumb calls `onSubmit({ messageId, rating })` immediately. The form's Submit calls `onSubmit` again with `{ messageId, rating, reasonCodes?, comment? }` (omitted when empty); the backend call is an upsert, so this replaces the earlier write. Clicking the selected thumb, or Undo, calls `onClear({ messageId })`.
428
+ - At most one request is in flight per `messageId`; edits made meanwhile are coalesced to the latest state and sent afterwards.
429
+ - A rejected promise means failure: the optimistic selection reverts, `labels.feedbackErrorText` is shown and the draft is kept.
430
+ - Any rejection, including a 404, is shown as an error and is never retried automatically; the user retries by hand. The backend emits `message_id` only after the assistant message is committed, so a message that carries a `serverMessageId` exists and a 404 on it is a real failure. Render the control only for messages that have a `serverMessageId`.
431
+ - `reasonOptions` selects a subset (and order) of the backend codes `inaccurate`, `unhelpful`, `unsafe`, `too_long`, `other`; an unknown code is treated as `other`. `maxCommentLength` defaults to 2000 and can only be lowered. `initialRating` shows a previously stored rating.
432
+ - The comment is PHI-capable. It is passed to `onSubmit` only, never to the console, the trace panel or any other callback. Do not log it in your handler either.
433
+ - Strings are overridable through the `labels` prop (`BaileyFeedbackLabels`), merged shallowly with English defaults, like `BaileyChatPanel`'s labels.
434
+
435
+ Minimum backend: the assistant `message_id` on `response.completed` (and `X-Message-Id` for non-streaming responses) and the feedback routes from BAI-1231 (baileyai, BAI-1234). On a backend without them the buttons simply do not appear. A `503` from the feedback API means feedback is disabled for that environment; hide the control from your side for the session.
436
+
239
437
  ## Localization
240
438
 
241
439
  Every visible string in `BaileyChatPanel` defaults to English. Pass a `labels` prop with