@icanbwell/baileyai-chat-ui 1.0.15 → 1.0.17

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,147 @@ 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
+ - Every call has a timeout (`requestTimeoutMs`, default 30s; the stream uses an idle timeout,
185
+ `idleTimeoutMs`). A failed best-effort cleanup is reported through `onCleanupError` and never thrown.
186
+ - **Nothing is logged** by the client or the hooks, and request/response bodies are never placed in
187
+ errors or callbacks.
188
+ - **Conversation id.** `useBaileyChat` returns a read-only `conversationId`. It comes from the
189
+ `conversationId` option (a seed), from `storage`, or from the `X-Conversation-Id` response header
190
+ the transport reports via `onConversationId`. `newChat()` clears it. The library does not create
191
+ ids. Once an id is held it is sent as the `x-chat-id` header, and **only the newest message** goes in
192
+ `input` because the server prepends its stored history and does not deduplicate. With no id held
193
+ (for example the first turn, before the server reports one) the full transcript is sent, exactly
194
+ as before. Pass `sendFullTranscript: true` to always send the full transcript **and never the id**
195
+ (stateless requests; escape hatch, e.g. where the server does not persist; the library cannot
196
+ detect that itself). The id is withheld because with one the server would prepend its stored
197
+ history to the full transcript and duplicate it in the model context.
198
+ - **`conversationVia` (opt-in, default `'header'`).** `createBaileyClient({ conversationVia: 'body' })`
199
+ sends the held id as the `conversation` field of the request body instead of `x-chat-id` (never
200
+ both). See the `conversationVia` JSDoc for when it is safe to switch. Independently of that option,
201
+ `useBaileyChat` also adopts `response.conversation.id` from `response.completed` / `response.created`
202
+ events when present (non-empty string only), ahead of the `X-Conversation-Id` header value.
203
+ - **The id is persisted in sessionStorage by default (per tab).** A reload resumes the conversation
204
+ (the messages themselves are loaded with `loadConversation`). Pass `storage: false` to disable
205
+ persistence, or your own `BaileyConversationStorage` to store it elsewhere. A storage that throws
206
+ (Safari private mode, blocked storage) never reaches your render: chat continues with the id in
207
+ memory, `persistence` becomes `'degraded'` (`'ok'` normally, `'off'` for `storage: false`) and
208
+ `onStorageError(error, operation)` is called. A `sub` that is provided but blank is a host error
209
+ reported the same way (operation `'key'`); the id then stays in memory and the shared key is not used.
210
+ Append your own surface name to `storageKey` so surfaces do not share a conversation. Selecting an
211
+ old conversation (`loadConversation`) always replaces the stored id. Hosts should clear the stored
212
+ id on logout through the same `storage`; the library has no logout hook.
213
+ - **`sub` keys and validates the stored id (optional).** Pass the signed-in user's `sub` and the stored
214
+ id lives under `${storageKey}:${sub}` and is rejected (and removed) on read unless it starts with
215
+ `<sub>:`, so a different user in the same browser session never inherits the previous user's id. A
216
+ seed and any id reported by the transport are validated the same way. `sub` is used for nothing
217
+ else: **the library never creates a conversation id**, it only reads the one the server reports
218
+ (`X-Conversation-Id`) or one you seed. Without `sub` the stored id is held unvalidated; after a
219
+ shared-browser user switch the server rejects the foreign id and the library adopts the
220
+ server-reported one, so the worst case is a lost first-turn context.
221
+ - **Lost server context is surfaced.** When a turn that sent a held id comes back with no id, or a
222
+ different one, the server did not continue that conversation (persistence off, an id it does not
223
+ know, or history it could not load). The hook drops the held id and its stored value, sets
224
+ `contextLost: true` until the next turn, and calls `onContextLoss({ sentId, reportedId })`; the next
225
+ turn sends the full transcript. A turn that fails outright (4xx/5xx) is reported as an error as
226
+ before. A history load that fails while the server still reports the same id is not detectable
227
+ yet (it needs a server signal).
228
+ - **Persistence off on the server.** Stable conversation ids only help where the server persists
229
+ conversations (the conversation routes return 503 when persistence is off). The library does not
230
+ probe for this: enable stable ids (pass a `client` and keep the default storage) only in environments
231
+ where persistence is on, and otherwise set `sendFullTranscript: true` so every request carries the
232
+ full transcript and no conversation id.
233
+ - Existing hosts that pass none of these options behave as before until a transport reports a
234
+ conversation id: a custom `BaileyChatTransport` keeps working (`conversationId` and
235
+ `onConversationId` are optional transport params, and `conversationId` is only passed once an id is
236
+ held), and none of the hosts' transports report one today. Only a transport that does (such as this
237
+ client) makes the hook hold, persist and send an id.
238
+ - **Loading a conversation.** Pass `client` to `useBaileyChat` to get `loadConversation({ conversationId })`
239
+ (newest 100 messages), `loadEarlier()` / `hasEarlier` (older pages) and `historyStatus`. Hidden rows
240
+ are dropped, each assistant message gets `serverMessageId`, and a stored rating is mapped to
241
+ `message.feedback`. Loading holds the id, so the next turn sends only the newest message and the
242
+ loaded history is not re-sent. Failures reject with `BaileyApiError`, leave the messages intact and
243
+ set `historyStatus` to `'error'`; a response that arrives after `newChat()` or another load is
244
+ discarded. `mapTranscript` and `mergeEarlierPage` are exported for hosts that fetch transcripts
245
+ themselves. `loadMessages` still never changes the held id.
246
+ - **Feedback.** `createBaileyFeedbackHandlers({ client, getConversationId })` returns `onSubmit` / `onClear`
247
+ for `BaileyFeedbackButtons`:
248
+
249
+ ```tsx
250
+ const handlers = createBaileyFeedbackHandlers({ client, getConversationId: () => chat.conversationId });
251
+ // inside renderMessageActions:
252
+ <BaileyFeedbackButtons messageId={m.serverMessageId} initialRating={m.feedback?.rating} {...handlers} />
253
+ ```
254
+
255
+ Any rejection (including a 404, or no conversation id held) is a failure the buttons show; nothing is
256
+ retried automatically. The comment goes only into the request body.
257
+ - Not included yet: automatic resume on mount. A restored id does not load the transcript; call
258
+ `loadConversation` yourself (the history drawer below does this when the user picks a row).
259
+
260
+ ## Conversation history drawer
261
+
262
+ An opt-in, built-in history UI: pass a Bailey client to the panel and the "Chat history" item in the
263
+ "..." menu opens a drawer inside the panel (no dialog dependency, nothing fetched until it opens).
264
+
265
+ ```tsx
266
+ const client = createBaileyClient({ baseUrl, getHeaders });
267
+ const chat = useBaileyChat({ transport: client, client, model: 'bailey-default', instructions: '...' });
268
+
269
+ <BaileyChatPanel chat={chat} history={{ client }} locale="en" />
270
+ ```
271
+
272
+ - Rows show the title (or "Untitled conversation"), a relative time, and the message count (hidden in
273
+ narrow panels); the active conversation is marked with a tint, an accent bar and the word "Current".
274
+ Selecting a row calls `chat.loadConversation` and closes the drawer. Delete asks for an inline
275
+ confirmation (no undo window); deleting the active conversation resets the chat. "Load more" follows
276
+ the server's cursor.
277
+ - Errors show fixed copy (a separate "session expired" message for a 401/403 on the list call, the
278
+ connection message otherwise) with Retry; error text and titles never reach logs or `title` attributes.
279
+ - `onHistoryClick` is the host override: when both `history` and `onHistoryClick` are given, your callback
280
+ runs and the drawer never mounts. A host that passes neither renders exactly as before.
281
+ - Every string is a `labels` key (`historyTitle`, `historyEmptyTitle`, `historySessionExpiredTitle`, and so
282
+ on, all with English defaults). The session-expired wording is a default for copy review. Relative times
283
+ use `Intl.RelativeTimeFormat` with `locale` (default `navigator.language`): a future time shows "now",
284
+ and a time without an offset is read as UTC.
285
+
145
286
  ## Resuming a conversation
146
287
 
147
288
  `useBaileyChat` also returns `loadMessages(messages)`, for restoring a transcript a host fetched
@@ -167,7 +308,8 @@ be mid-stream.
167
308
 
168
309
  `loadMessages` only replaces `useBaileyChat`'s own state — a host still owns fetching the
169
310
  transcript and its own loading/error UI, and owns conversation identity (which id is active,
170
- where it's persisted) entirely outside this package.
311
+ where it's persisted) outside this package, unless it opts in to the id handling described in
312
+ [Using the Bailey client](#using-the-bailey-client).
171
313
 
172
314
  ### Known limitations
173
315
 
@@ -236,6 +378,43 @@ Pass `privacyNote` to render a short compliance/privacy disclosure as a footer b
236
378
 
237
379
  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
380
 
381
+ ## Collecting feedback
382
+
383
+ `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.
384
+
385
+ ```tsx
386
+ <BaileyChatPanel
387
+ chat={chat}
388
+ renderMessageActions={(message) =>
389
+ message.serverMessageId ? (
390
+ <BaileyFeedbackButtons
391
+ messageId={message.serverMessageId}
392
+ onSubmit={({ messageId, rating, reasonCodes, comment }) =>
393
+ submitFeedback({ messageId, rating, reasonCodes, comment })
394
+ }
395
+ onClear={({ messageId }) => clearFeedback({ messageId })}
396
+ />
397
+ ) : null
398
+ }
399
+ />
400
+ ```
401
+
402
+ Pass a stable function for `renderMessageActions`. The control renders nothing when `messageId` is empty.
403
+
404
+ **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.
405
+
406
+ **Behavior.**
407
+
408
+ - 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 })`.
409
+ - At most one request is in flight per `messageId`; edits made meanwhile are coalesced to the latest state and sent afterwards.
410
+ - A rejected promise means failure: the optimistic selection reverts, `labels.feedbackErrorText` is shown and the draft is kept.
411
+ - 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`.
412
+ - `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.
413
+ - 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.
414
+ - Strings are overridable through the `labels` prop (`BaileyFeedbackLabels`), merged shallowly with English defaults, like `BaileyChatPanel`'s labels.
415
+
416
+ 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.
417
+
239
418
  ## Localization
240
419
 
241
420
  Every visible string in `BaileyChatPanel` defaults to English. Pass a `labels` prop with