@x12i/ai 1.0.0 → 1.1.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/README.md CHANGED
@@ -1,40 +1,531 @@
1
1
  # @x12i/ai
2
2
 
3
- Neutral message shape and store contracts for the Unified UI Kit AI chat set.
3
+ Accessible, provider-agnostic AI chat for React, built on the Unified UI Kit.
4
4
 
5
- > **Status: foundation.** This package ships the typed message parts and the
6
- > `ThreadStore` contract. The chat components (`ChatThread`, `PromptInput`,
7
- > `ToolCall`, `ApprovalPrompt`, ...) and the provider adapters are not built yet.
5
+ - A **neutral message shape** with typed parts (`text`, `reasoning`, `tool`, `source`, `file`, `data`). Components never import a provider SDK.
6
+ - A **headless core** (no React, no DOM): the thread reducer, stream events, adapters for AI SDK / OpenAI / Anthropic messages, and Markdown-safety helpers. It is safe in server code.
7
+ - **React components** for a whole chat: `ChatThread`, `PromptInput`, `Conversation`, `Message`, `Markdown`, `Reasoning`, `ToolCall`, `ApprovalPrompt`, `Sources` and citations, `Suggestions`, `MessageActions`, `Attachments`, `ErrorBanner` and more.
8
+ - A **`useThread` hook** that runs any async generator of stream events: deltas, stop, errors, regenerate, edit with branches, and human approval of tool calls.
9
+ - Tokens-only styling, logical CSS properties (RTL works), light/dark/density through the kit's themes, `prefers-reduced-motion` and `forced-colors` support.
8
10
 
9
- Components read a neutral message with typed parts (`text`, `reasoning`, `tool`,
10
- `source`, `file`, `data`), so they never import a provider SDK. Adapters convert
11
- provider formats, such as Vercel AI SDK message parts, into this shape.
11
+ > This package has no AI SDK dependency. You bring the model; you give the hook an async generator that yields `StreamEvent` objects.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pnpm add @x12i/ai @x12i/ui @x12i/themes react react-dom
17
+ ```
18
+
19
+ `@x12i/ui` and `react` are peer dependencies. `react-markdown` and `remark-gfm` come with the package.
20
+
21
+ Load the stylesheets once, after the theme:
22
+
23
+ ```css
24
+ @import "@x12i/themes/themes.css";
25
+ @import "@x12i/ui/styles.css";
26
+ @import "@x12i/ai/styles.css";
27
+ ```
28
+
29
+ Wrap your app in the kit's `ThemeProvider` (and optionally `DensityProvider`) from `@x12i/ui`.
30
+
31
+ ## Two entry points
32
+
33
+ | Import | Contains | Runs where |
34
+ | --- | --- | --- |
35
+ | `@x12i/ai` | Types, `threadReducer`, stream events, adapters, helpers, Markdown URL safety | Anywhere: server, worker, tests. No React, no DOM. |
36
+ | `@x12i/ai/react` | `useThread` and all components | Browser (or a Next.js client boundary). |
37
+ | `@x12i/ai/styles.css` | One stylesheet for every component | Import once. |
38
+
39
+ `dist/react.js` starts with `"use client"`, so importing it from a React Server Component file works in a framework that honours the directive: the components render as client components. Server components can still import `@x12i/ai` (for example to run an adapter or call `threadReducer`).
40
+
41
+ ### Server rendering
42
+
43
+ Every component renders on the server without touching `window` or `document`. The only things that differ between server and client are time-dependent: with `showTimestamps`, a message time is formatted with `Intl` in the viewer's time zone, so the server and the browser can disagree. The `<time>` element has `suppressHydrationWarning`; leave `showTimestamps` off, or pass `formatTimestamp` to `Message` if you need stable output.
44
+
45
+ ## Quick start
46
+
47
+ `useThread` keeps the conversation, runs your provider, and hands `ChatThread` everything it needs through `chatProps`.
48
+
49
+ ```tsx
50
+ import "@x12i/ai/styles.css";
51
+ import { ChatThread, useThread } from "@x12i/ai/react";
52
+
53
+ export function Chat() {
54
+ const thread = useThread({
55
+ // Return (or be) an async generator of stream events.
56
+ onSend: async ({ messages, signal }) => {
57
+ const response = await fetch("/api/chat", {
58
+ method: "POST",
59
+ headers: { "content-type": "application/json" },
60
+ body: JSON.stringify({ messages }),
61
+ signal, // aborted when the user presses Stop
62
+ });
63
+ if (!response.ok) throw Object.assign(new Error(response.statusText), { status: response.status });
64
+ return readEvents(response); // see "Writing a provider adapter"
65
+ },
66
+ });
67
+
68
+ return (
69
+ <div style={{ blockSize: "40rem" }}>
70
+ <ChatThread {...thread.chatProps} suggestions={["What can you do?"]} />
71
+ </div>
72
+ );
73
+ }
74
+ ```
75
+
76
+ `ChatThread` fills its parent's height (`block-size: 100%`), so give the parent a height. The message list scrolls inside it, and the prompt stays at the bottom.
77
+
78
+ ### Ready-to-paste example with a fake streaming provider
79
+
80
+ This runs with no backend. It streams reasoning, a tool call, Markdown (a table, a list, code and a Hebrew sentence), then sources with `[1]` citations. Type or pick "Send the summary by mail" to see an approval request; type anything with "fail" or "error" to see the error banner.
81
+
82
+ ```tsx
83
+ import "@x12i/ai/styles.css";
84
+ import type { StreamEvent } from "@x12i/ai";
85
+ import { ChatThread, useThread, type ThreadRequest, type ToolDecisionRequest } from "@x12i/ai/react";
86
+
87
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
88
+
89
+ /** Streams text in small pieces, the way a model streams tokens. */
90
+ async function* typeOut(text: string, delay: number): AsyncGenerator<StreamEvent> {
91
+ for (let index = 0; index < text.length; index += 6) {
92
+ yield { type: "text-delta", delta: text.slice(index, index + 6) };
93
+ await wait(delay);
94
+ }
95
+ }
96
+
97
+ const REPLY = [
98
+ "## Quarter summary\n\n",
99
+ "Revenue grew **12%** [1] while costs stayed flat [2].\n\n",
100
+ "| Region | Revenue | Change |\n| --- | ---: | ---: |\n| North | 120 | +14% |\n| South | 95 | +9% |\n\n",
101
+ "Next steps:\n\n- Review the regional gaps\n- Share the summary with finance\n\n",
102
+ "```ts\nconst total = north + south; // 215\n```\n\n",
103
+ "שלום! זה סיכום קצר בעברית: ההכנסות עלו בכל האזורים.",
104
+ ].join("");
105
+
106
+ async function* fakeProvider({ text }: ThreadRequest): AsyncGenerator<StreamEvent> {
107
+ yield { type: "start" };
108
+
109
+ yield { type: "reasoning-delta", delta: "The user wants a summary. " };
110
+ await wait(150);
111
+ yield { type: "reasoning-delta", delta: "I will check the sources first." };
112
+ yield { type: "reasoning-end" };
113
+
114
+ if (/fail|error/i.test(text)) {
115
+ await wait(200);
116
+ yield { type: "error", error: { message: "The fake provider was asked to fail.", kind: "network" } };
117
+ return;
118
+ }
119
+
120
+ if (/\b(mail|send)/i.test(text)) {
121
+ yield* typeOut("I can send that summary. I need your approval first.", 20);
122
+ yield {
123
+ type: "tool-call",
124
+ callId: "send-1",
125
+ name: "send_email",
126
+ input: { to: "dana@example.com", subject: "Quarter summary" },
127
+ requiresApproval: true,
128
+ approvalPrompt: "Send the summary to dana@example.com?",
129
+ };
130
+ // The stream ends here. The thread waits for the person to decide.
131
+ yield { type: "finish", finishReason: "tool-calls" };
132
+ return;
133
+ }
134
+
135
+ yield { type: "tool-call", callId: "lookup-1", name: "query_sales", input: { quarter: "Q3" } };
136
+ await wait(400);
137
+ yield { type: "tool-result", callId: "lookup-1", output: { rows: 2 }, durationMs: 400 };
138
+
139
+ yield* typeOut(REPLY, 15);
140
+ yield { type: "source", id: "s1", title: "Q3 revenue report", url: "https://example.com/q3", snippet: "Revenue by region." };
141
+ yield { type: "source", id: "s2", title: "Cost memo", url: "https://example.com/costs" };
142
+ yield { type: "finish", finishReason: "stop", usage: { inputTokens: 42, outputTokens: 180 } };
143
+ }
144
+
145
+ /** Called after Approve or Deny. What it streams is written into the same reply. */
146
+ async function* afterDecision({ decision, reason }: ToolDecisionRequest): AsyncGenerator<StreamEvent> {
147
+ if (decision === "approve") {
148
+ yield { type: "tool-result", callId: "send-1", output: { delivered: true }, durationMs: 300 };
149
+ yield* typeOut(" Done: the summary was sent.", 20);
150
+ } else {
151
+ yield* typeOut(` Understood, I will not send it${reason ? ` (${reason})` : ""}.`, 20);
152
+ }
153
+ yield { type: "finish" };
154
+ }
155
+
156
+ export function FakeChat() {
157
+ const thread = useThread({ onSend: fakeProvider, onToolDecision: afterDecision });
158
+
159
+ return (
160
+ <div style={{ blockSize: "min(40rem, 85vh)" }}>
161
+ <ChatThread
162
+ {...thread.chatProps}
163
+ suggestions={["Summarize the quarter", "Send the summary by mail", "Simulate a failure"]}
164
+ followUps={["Compare to last year"]}
165
+ onFeedback={() => {}}
166
+ />
167
+ </div>
168
+ );
169
+ }
170
+ ```
171
+
172
+ The same file is kept in `test/fake-chat.example.tsx` and exercised by the test suite, so it stays correct.
173
+
174
+ ## Writing a provider adapter
175
+
176
+ A provider adapter is an async generator that yields `StreamEvent`s. The UI only ever sees those events. A minimal adapter for a server that streams newline-delimited JSON events:
177
+
178
+ ```ts
179
+ import type { StreamEvent } from "@x12i/ai";
180
+
181
+ export async function* readEvents(response: Response): AsyncGenerator<StreamEvent> {
182
+ if (!response.body) return;
183
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
184
+ let buffer = "";
185
+ for (;;) {
186
+ const { value, done } = await reader.read();
187
+ if (done) break;
188
+ buffer += value;
189
+ let newline = buffer.indexOf("\n");
190
+ while (newline >= 0) {
191
+ const line = buffer.slice(0, newline).trim();
192
+ buffer = buffer.slice(newline + 1);
193
+ if (line !== "") yield JSON.parse(line) as StreamEvent;
194
+ newline = buffer.indexOf("\n");
195
+ }
196
+ }
197
+ }
198
+ ```
199
+
200
+ If your stream already uses another protocol, map each chunk to one or more events (`text-delta`, `tool-call`, ...) in the same way. Unknown event types are ignored, so you can pass extra events through.
201
+
202
+ Errors you throw (or the stream throws) become `status: "error"` with a typed `kind` (`network`, `rate-limit`, `moderation`, `auth`, `unknown`), worked out from `status` / `statusCode`, `code`, and the message. `toThreadError` is exported if you want the same mapping elsewhere.
203
+
204
+ ## `useThread`
205
+
206
+ ```ts
207
+ const thread = useThread({
208
+ initialMessages, // read once, for example loaded from your store
209
+ onSend, // (request) => AsyncIterable<StreamEvent> | void
210
+ onRegenerate, // optional; defaults to onSend with trigger "regenerate"
211
+ onToolDecision, // (request) => AsyncIterable<StreamEvent> | void, after Approve or Deny
212
+ onStop, onFinish, onError, // notifications
213
+ batch: true, // one render per animation frame; false renders every event (tests)
214
+ generateId, now, // for tests
215
+ });
216
+ ```
217
+
218
+ | Result | Meaning |
219
+ | --- | --- |
220
+ | `messages`, `status`, `error`, `state` | The thread. `status` is `idle`, `submitted`, `streaming` or `error`. `state` also holds the branches. |
221
+ | `send(text, attachments?)` | Adds a user message and asks `onSend` for the reply. Ignored while a reply runs, and when there is neither text nor an attachment. |
222
+ | `stop()` | Aborts the `signal` your handler received, ends the stream, and keeps the text written so far (`message.status === "stopped"`). Tool calls still running are marked `failed` with `errorCode: "aborted"`. |
223
+ | `regenerate()` | Drops the last reply and asks again (also the retry after an error). |
224
+ | `approve(callId, reason?)`, `deny(callId, reason?)`, `decide(...)` | Decides a tool call that needs approval. |
225
+ | `edit(messageId, text, { mode })` | Edits a user message and asks again. `"truncate"` (default) drops what followed; `"branch"` keeps the old version reachable. |
226
+ | `switchBranch(parentId, index)`, `dismissError()`, `clear()`, `setMessages(list)` | State controls. |
227
+ | `dispatch(action)`, `applyEvent(event)` | Escape hatches: apply a reducer action, or one stream event (for example from a WebSocket). |
228
+ | `chatProps` | Spread into `ChatThread`. Stable between unrelated renders. |
229
+
230
+ The request object your handlers receive:
12
231
 
13
232
  ```ts
14
- import { messageText, type Message } from "@x12i/ai";
233
+ interface ThreadRequest {
234
+ trigger: "send" | "regenerate" | "edit";
235
+ message: Message | undefined; // the user message being answered
236
+ text: string; // its text
237
+ attachments: FilePart[];
238
+ messages: readonly Message[]; // a snapshot of the whole thread
239
+ signal: AbortSignal; // aborted by stop(); pass it to fetch
240
+ }
241
+
242
+ interface ToolDecisionRequest {
243
+ callId: string; toolName: string;
244
+ decision: "approve" | "deny";
245
+ reason: string | undefined;
246
+ pending: number; // calls still waiting for a decision after this one
247
+ messages: readonly Message[];
248
+ signal: AbortSignal;
249
+ }
250
+ ```
251
+
252
+ Behavior worth knowing:
253
+
254
+ - Events are applied to the state as they arrive and rendered once per animation frame, so a fast stream does not render once per token.
255
+ - If the stream ends without a `finish` event, the message is finished for you.
256
+ - A stop that races a late event is safe: events after `stop()` are ignored.
257
+ - **Approval is never automatic.** A tool call that needs approval stays in `needs-approval` until `approve` or `deny` is called. Decisions run one after another. `pending` is counted when the decision is made, so with two calls waiting you get `1` and then `0`; resume the model only when it is `0`. What `onToolDecision` returns is written into the same assistant message. If the original stream is still open (waiting for the decision), it is left running instead of being aborted.
258
+
259
+ ## Components
260
+
261
+ All components take `ref`, `className`, and the props of their root element, and a `messages` prop to override their words (see [Internationalization](#internationalization)).
262
+
263
+ ### High level
264
+
265
+ | Component | Purpose | Key props |
266
+ | --- | --- | --- |
267
+ | `ChatThread` | The whole chat: message list, prompt, errors, suggestions, live announcements. Owns no conversation state. | `messages`, `status`, `error`, `onSend`, `onStop`, `onRegenerate`, `onToolDecision`, `onEdit`, `branches`, `onSwitchBranch`, `onFeedback`, `suggestions`, `followUps`, `renderPart`, `emptyState`, `header`, `disclaimer`, `avatars`, `names`, `showTimestamps`, `showUsage`, `images`, `announce`, `disabled`, `promptProps`, `locale`, `strings`, `conversationRef` |
268
+ | `useThread` | State for `ChatThread` (see above). | |
269
+
270
+ ### Building blocks
15
271
 
16
- const message: Message = {
17
- id: "m1",
18
- role: "assistant",
19
- parts: [{ type: "text", text: "Hello" }],
272
+ | Component | Purpose | Key props |
273
+ | --- | --- | --- |
274
+ | `Conversation` | The scrolling `role="log"`. Follows new content, lets go when you scroll up, shows "Jump to latest". | `stickThreshold`, `onAtBottomChange`, `label`, `handleRef` |
275
+ | `useStickToBottom` | The hook behind it, for your own scroller. | `threshold`, `onAtBottomChange` |
276
+ | `Message` (`ChatMessage`) | One message and its parts. | `message`, `streaming`, `avatar`, `name`, `showTimestamp`, `showUsage`, `renderPart`, `onToolDecision`, `onRetry`, `editing`, `onEditSubmit`, `onEditCancel`, `actions`, `images` |
277
+ | `MessageActions` | Copy, regenerate, edit, thumbs, version navigation. | `text`, `onCopied`, `onRegenerate`, `onEdit`, `feedback`, `onFeedback`, `branch` (`{ index, count, onSelect }`), `disabled`, `children` (extra buttons) |
278
+ | `Markdown` | Safe GFM rendering. | `children`, `streaming`, `headingOffset`, `images`, `allowDataImages`, `transformImageUrl`, `citations`, `sources`, `onLinkClick`, `onCopyCode` |
279
+ | `CodeBlock` | Plain code with a language label and copy button. Always left-to-right. | `code`, `language`, `onCopied` |
280
+ | `Reasoning` | Collapsible reasoning. Open while streaming, folds when done ("Thought for 4s"). | `text`, `streaming`, `durationMs`, `open` / `defaultOpen` / `onOpenChange` |
281
+ | `ToolCall` | Name, state badge, duration, JSON input/output. | `part`, `onDecision`, `renderInput`, `renderOutput` |
282
+ | `ApprovalPrompt` | Allow or refuse an action. Non-modal. | `toolName`, `input`, `prompt`, `onApprove`, `onDeny`, `autoFocus`, `returnFocusTo`, `allowReason`, `renderInput` |
283
+ | `Sources`, `SourceCard`, `Citation`, `SourcesProvider` | The collapsible source list, one source, an inline `[n]` chip, and the provider that connects chips to the list. | `Sources`: `sources`, `open` / `defaultOpen` / `onOpenChange`. `Citation`: `index`, `source`. `SourcesProvider`: `sources`. |
284
+ | `Attachments` | Files of a message: image thumbnails and download chips. | `files` |
285
+ | `PromptInput` | The composer. | `onSubmit`, `value` / `defaultValue` / `onValueChange`, `status`, `onStop`, `disabled`, `maxLength`, `minRows`, `maxRows`, `submitOnEnter`, `stopOnEscape`, `attachments`, `onRemoveAttachment`, `onFiles`, `allowFiles`, `accept`, `maxFiles`, `slashCommands`, `onCommandSelect`, `footer`, `textareaRef` |
286
+ | `Suggestions` | A row of starter or follow-up prompts. | `suggestions`, `onSelect`, `disabled` |
287
+ | `StreamingIndicator` | Three dots (still under reduced motion). | `label` |
288
+ | `ErrorBanner` | Explains a failure by kind, with retry. | `error`, `onRetry`, `onDismiss`, `onEdit`, `showDetail` |
289
+ | `EmptyThread` | Title, line of help, starter suggestions as children. | `title`, `description`, `icon` |
290
+ | `AiMessagesProvider`, `useAiMessages`, `defaultMessages`, `mergeMessages` | Words. | |
291
+
292
+ Internal controls (the copy button, JSON viewer, disclosure) are private, built from native elements and the kit's `Button`, `IconButton`, `Badge`, `Stack`, `Inline`, `Divider`, `Spinner`, `VisuallyHidden`.
293
+
294
+ ### Custom part renderers
295
+
296
+ `ChatThread` and `Message` take `renderPart` to change how any part draws. Return `undefined` to use the built-in view, `null` to show nothing, or any node. `data:<kind>` targets one kind of data part and beats `data`. The context carries a `defaultRender()` so you can wrap the default.
297
+
298
+ ```tsx
299
+ import { ChatThread, type PartRenderers } from "@x12i/ai/react";
300
+
301
+ // Define it outside the component (or memoize it) so message rows are not re-rendered.
302
+ const renderPart: PartRenderers = {
303
+ "data:chart": (part) => <BarChart aria-label="Revenue by region" values={(part.data as { values: number[] }).values} />,
304
+ tool: (part, { defaultRender }) =>
305
+ part.name === "query_sales" ? <SalesTable output={part.output} /> : defaultRender(),
306
+ reasoning: () => null, // hide reasoning entirely
20
307
  };
21
308
 
22
- messageText(message); // "Hello"
309
+ <ChatThread {...thread.chatProps} renderPart={renderPart} />;
310
+ ```
311
+
312
+ Keys: `text`, `reasoning`, `tool`, `sources` (all source parts at once), `files` (all file parts at once), `data`, `data:<kind>`.
313
+
314
+ A model sends a chart as a data part: `{ type: "data", kind: "chart", data: { values: [3, 1, 4] } }`, or as the stream event `{ type: "data", kind: "chart", data: {...} }`. Data parts with no renderer show as copyable JSON.
315
+
316
+ ## Adapters
317
+
318
+ Pure functions from provider message arrays to the neutral `Message[]`. They do not import any SDK; they read documented shapes.
319
+
320
+ ```ts
321
+ import { fromAiSdkMessages, fromOpenAIChatMessages, fromAnthropicMessages } from "@x12i/ai";
322
+
323
+ const fromAiSdk = fromAiSdkMessages(uiMessages); // Vercel AI SDK UIMessage (v4 and v5 shapes)
324
+ const fromOpenAI = fromOpenAIChatMessages(chatMessages); // chat.completions messages, tool calls matched to tool results
325
+ const fromAnthropic = fromAnthropicMessages(messages, { system: "You are helpful." });
326
+ ```
327
+
328
+ They map text, reasoning (`reasoning_content`, thinking blocks), tool calls and results (with states), sources, files and usage, skip roles that have no UI (a bare `tool` message is merged into its call), and make ids for messages that have none (`idPrefix` option). `normalizeUsage` and `normalizeFinishReason` are exported for your own adapters.
329
+
330
+ ## Message and part schema
331
+
332
+ Plain JSON, so a thread can be stored, sent over the wire or restored.
333
+
334
+ ```ts
335
+ interface Message {
336
+ id: string;
337
+ role: "user" | "assistant" | "system" | "tool";
338
+ parts: MessagePart[];
339
+ status?: "streaming" | "complete" | "error" | "stopped";
340
+ createdAt?: string; // ISO 8601
341
+ parentId?: string;
342
+ metadata?: Record<string, unknown>; // metadata.edited === true shows an "Edited" mark
343
+ usage?: Usage; // inputTokens, outputTokens, totalTokens, reasoningTokens, cachedInputTokens
344
+ finishReason?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "other";
345
+ error?: ThreadError; // { message, kind?, code?, retryable? }
346
+ }
347
+
348
+ type MessagePart =
349
+ | { type: "text"; text: string }
350
+ | { type: "reasoning"; text: string; durationMs?: number; startedAt?: number }
351
+ | { type: "tool"; id: string; name: string; state: ToolState; input?: unknown; output?: unknown;
352
+ error?: string; errorCode?: string; durationMs?: number; startedAt?: number;
353
+ approval?: { prompt?: string; reason?: string } }
354
+ | { type: "source"; id: string; title: string; url: string; snippet?: string }
355
+ | { type: "file"; id?: string; name: string; mediaType: string; url: string; size?: number }
356
+ | { type: "data"; kind: string; data: unknown };
357
+
358
+ type ToolState = "queued" | "running" | "needs-approval" | "approved" | "denied" | "succeeded" | "failed";
359
+ ```
360
+
361
+ Assistant text is rendered as Markdown; user and system text is rendered as plain text. Source parts are shown together under the message, numbered in order; `[1]` in the text links to the first source. A `failed` tool with `errorCode: "aborted"` is shown as "Stopped before it finished", not as an error.
362
+
363
+ ## Stream event reference
364
+
365
+ The one shape every provider is adapted to. `messageId` on any event is optional.
366
+
367
+ | Event | Fields | Effect |
368
+ | --- | --- | --- |
369
+ | `start` | `messageId?`, `createdAt?`, `metadata?` | Begins the assistant message. Optional: content that arrives first starts it. |
370
+ | `text-delta` | `delta` | Appends to the text part. |
371
+ | `reasoning-delta` | `delta` | Appends to the reasoning part. |
372
+ | `reasoning-end` | `durationMs?` | Ends the reasoning block. Optional: the first text, tool call or finish ends it. |
373
+ | `tool-call` | `callId`, `name`, `input?`, `requiresApproval?`, `approvalPrompt?`, `state?` | Adds a tool part: `running`, or `needs-approval` when `requiresApproval`. |
374
+ | `tool-update` | `callId`, `state?`, `name?`, `input?`, `output?`, `error?`, `approval?` | Patches a tool part (for example streaming input). |
375
+ | `tool-result` | `callId`, `output?`, `error?`, `durationMs?`, `name?` | `succeeded`, or `failed` when `error` is set. |
376
+ | `source` | `id`, `title`, `url`, `snippet?` | Adds a source. |
377
+ | `file` | `id?`, `name`, `mediaType`, `url`, `size?` | Adds a file. |
378
+ | `data` | `kind`, `data` | Adds a data part. |
379
+ | `finish` | `usage?`, `finishReason?` | Completes the message and returns to `idle`. |
380
+ | `error` | `error` (string or `ThreadError`) | Marks the message and the thread as failed. |
381
+ | `abort` | | The user stopped the response. Content so far is kept. |
382
+
383
+ Headless helpers: `applyStreamEvent(state, event)`, `applyStreamEvents(state, events)`, `streamEventToAction`, `isTerminalStreamEvent`, `createAbortableStream(source, signal)` (stops quietly when the signal aborts and lets the source clean up), `errorEvent(error)`.
384
+
385
+ ### The reducer
386
+
387
+ `threadReducer(state, action)` is pure and immutable. State: `{ messages, status, error, activeMessageId, branches }`. Actions include `add-user-message`, `begin-assistant-message`, `append-text`, `append-reasoning`, `end-reasoning`, `start-tool-call`, `update-tool-call`, `finish-tool-call`, `tool-decision`, `add-source`, `add-part`, `attach-files`, `finish`, `error`, `abort`, `regenerate`, `edit-message`, `switch-branch`, `dismiss-error`, `clear`, `set-messages`, `add-message`, and `batch`. Use it without React for server-side thread handling or tests:
388
+
389
+ ```ts
390
+ import { applyStreamEvents, createThreadState, messageText } from "@x12i/ai";
391
+
392
+ const state = applyStreamEvents(createThreadState(), [
393
+ { type: "start", messageId: "a1" },
394
+ { type: "text-delta", delta: "Hello" },
395
+ { type: "finish" },
396
+ ]);
397
+ messageText(state.messages[0]!); // "Hello"
398
+ ```
399
+
400
+ ## Keyboard map
401
+
402
+ | Where | Key | Does |
403
+ | --- | --- | --- |
404
+ | Prompt | `Enter` | Sends (not during IME composition). |
405
+ | Prompt | `Shift+Enter` | New line. |
406
+ | Prompt (`submitOnEnter={false}`) | `Ctrl/Cmd+Enter` | Sends; `Enter` adds a line. |
407
+ | Prompt, while a reply runs | `Escape` | Stops the reply (`stopOnEscape`, default on). |
408
+ | Prompt, slash command list open | `ArrowDown` / `ArrowUp` | Move through commands (wraps). |
409
+ | Prompt, slash command list open | `Enter` or `Tab` | Insert the command. |
410
+ | Prompt, slash command list open | `Escape` | Close the list. |
411
+ | Send / Stop button | `Enter`, `Space` | One button: its name says "Send message" or "Stop generating". |
412
+ | Conversation | `Tab` | Focuses the log so the arrow keys, `PageUp`, `PageDown`, `Home`, `End` scroll it. Scrolling up stops auto-follow; "Jump to latest" resumes it. |
413
+ | Reasoning, tool call, sources, "Show more" | `Enter`, `Space` | Open or close (disclosure button with `aria-expanded`). |
414
+ | Citation `[1]` | `Enter` | Opens the source list and moves focus to that source. |
415
+ | Approval prompt | (appears) | Focus moves to the prompt itself, never to a button (so `Enter` cannot approve by accident). `Tab` reaches the reason field, Approve, then Deny. After a decision, focus returns to where it was, else to the prompt box. |
416
+ | Message editor | `Ctrl/Cmd+Enter` / `Escape` | Save and resend / cancel. Focus goes back to what had it before editing began. |
417
+ | Message actions | `Tab` | Each action is a button in the tab order. No roving focus, no trap. |
418
+ | Code block, table | `Tab` | The scroll region is focusable only when its content overflows. |
419
+
420
+ ## Accessibility notes
421
+
422
+ - **No focus traps, no modals.** The approval prompt is a non-modal `role="group"` with a label and description. Nothing steals focus, with one exception: an approval prompt takes focus once when it appears, so a keyboard or screen-reader user notices it. It skips this when focus is somewhere else on the page outside the chat (`autoFocus={false}` turns it off), and it never focuses a button. If you are typing in the prompt box at that moment, focus does move (see Limitations).
423
+ - **Live regions are quiet on purpose.** The message list is a `role="log"` with `aria-live="polite"` and `aria-relevant="additions"`. A streaming message is `aria-busy` and its content is `aria-live="off"`, so a screen reader is not read every token. A separate visually hidden `role="status"` in `ChatThread` speaks a reply **once, when it finishes** ("Assistant replied: ...", the plain-text reading of the Markdown, at most 600 characters). It also speaks a stop and a new approval request. `announce="sentences"` speaks each finished sentence while it streams; `announce="off"` speaks nothing (the log still holds everything). History that was already on screen is never spoken. Errors are spoken by the banner's `role="alert"`.
424
+ - **State is in words.** Tool states are badges with text ("Running", "Needs approval", "Done"), never colour alone. Buttons say what they do now ("Send message" / "Stop generating"); thumbs and the like are `aria-pressed` toggles.
425
+ - **Names.** Each message is an `article` named "Message from Assistant"; assistant messages carry an "AI-generated" label for assistive technology. Code blocks are named "Code, ts". External links say "(opens in a new tab)".
426
+ - **Focus rings** come from the kit's focus tokens. Forced-colors mode keeps borders and focus indicators. `prefers-reduced-motion` stops the streaming dots' pulse and makes "Jump to latest" instant.
427
+ - **Tested with axe** (zero violations) on a full `ChatThread` in idle, streaming, waiting, tool-running, approval, error, editing and right-to-left states, and on the playground example. axe in jsdom cannot check colour contrast; contrast comes from the themes.
428
+
429
+ ## Styling and theming
430
+
431
+ Everything is styled with the kit's `--kit-*` tokens, so a theme change (light, dark, high-contrast, density) restyles the chat. The stylesheet has no hard-coded colours, sizes or physical (`left`/`right`) properties, and **every selector is wrapped in `:where()`**, so any rule of yours wins regardless of order.
432
+
433
+ Hooks are `data-kit` values plus state attributes:
434
+
435
+ | Hook (`data-kit="…"`) | State attributes |
436
+ | --- | --- |
437
+ | `ai-chat-thread` | `data-status` (`idle`, `submitted`, `streaming`, `error`) |
438
+ | `ai-conversation`, `ai-conversation-scroller`, `ai-conversation-content`, `ai-message-list`, `ai-jump-to-latest`, `ai-pending` | |
439
+ | `ai-message` (`-main`, `-header`, `-author`, `-label`, `-time`, `-edited`, `-content`, `-avatar`, `-usage`, `-editor`) | `data-role`, `data-status`, `data-streaming` |
440
+ | `ai-message-part` | `data-part` (`text`, `reasoning`, `tool`, `data`, `files`) |
441
+ | `ai-message-status` | `data-status="stopped"` |
442
+ | `ai-markdown` | `data-streaming` |
443
+ | `ai-markdown-link`, `ai-markdown-image` (only when blocked) | `data-blocked` |
444
+ | `ai-markdown-table`, `ai-code-block` (`-header`, `-language`), `ai-json-view` | |
445
+ | `ai-reasoning` | `data-state` (`open` / `closed`), `data-streaming` |
446
+ | `ai-tool-call` | `data-state` (the tool's state: `running`, `needs-approval`, …), `data-open` |
447
+ | `ai-sources`, `ai-source`, `ai-citation` | `data-state` (`ai-sources`: `open` / `closed`); `data-inert` on `ai-citation` and `ai-source-link` when there is no safe link |
448
+ | `ai-approval-prompt` (`-title`, `-description`, `-action`, `-reason`, `-buttons`) | |
449
+ | `ai-prompt-input` (`-row`, `-textarea`, `-footer`, `-counter`, `-commands`, `-drop`) | `data-busy`, `data-disabled`, `data-dragging` |
450
+ | `ai-prompt-attachment` | `data-status` (`ready`, `uploading`, `error`) |
451
+ | `ai-attachments`, `ai-attachment` | `data-kind` (`image`, `file`) on `ai-attachment` |
452
+ | `ai-error-banner` | `data-kind` (`network`, `rate-limit`, `moderation`, `auth`, `unknown`) |
453
+ | `ai-copy` | `data-state` (copy button feedback) |
454
+ | `ai-chat-composer`, `ai-disclaimer`, `ai-message-actions`, `ai-branch-nav`, `ai-suggestions`, `ai-streaming-indicator`, `ai-empty-thread` | |
455
+
456
+ Reading width: the message column and the prompt are capped at a width of about 14 × the 56 size token. To change it, override `max-inline-size` on `[data-kit="ai-conversation-content"]` and `[data-kit="ai-chat-composer"]`. The prompt's auto-grow uses internal `--_ai-*` properties; set `minRows` and `maxRows` instead of touching them.
457
+
458
+ For right-to-left pages set `dir="rtl"` on any ancestor: bubbles align to the inline start and end, arrows and branch icons mirror, message text and the prompt use `dir="auto"` so a Hebrew message in an English page (and the reverse) aligns itself, and code blocks, tool names and JSON are always left-to-right.
459
+
460
+ ## Internationalization
461
+
462
+ No English is hard-coded in the components. Every string comes from a catalog, `defaultMessages` (English), which you can override in three places, from broadest to narrowest:
463
+
464
+ ```tsx
465
+ import { AiMessagesProvider, ChatThread, defaultMessages, mergeMessages } from "@x12i/ai/react";
466
+
467
+ // 1. Everything under a provider
468
+ <AiMessagesProvider messages={{ send: "Enviar", stop: "Detener" }}>...</AiMessagesProvider>;
469
+
470
+ // 2. One ChatThread (named `strings`, because `messages` is the list)
471
+ <ChatThread {...thread.chatProps} strings={{ promptLabel: "Mensaje", roles: { user: "Tú", assistant: "Asistente" } }} />;
472
+
473
+ // 3. One component
474
+ <PromptInput messages={{ send: "Enviar" }} onSubmit={...} />;
23
475
  ```
24
476
 
477
+ Nested records merge key by key, so you can translate one tool state (`toolStates: { running: "En curso" }`) or one error title (`errorKinds: { network: { title: "Sin conexión" } }`). Strings that include a value are functions, such as `thoughtFor: (duration) => ...`, `characterCount: (count, max) => ...`, `messageFrom: (role) => ...`, so word order can change. Durations and byte sizes use `Intl` with the `locale` prop. See `AiMessages` for the full list.
478
+
479
+ ## Security notes
480
+
481
+ - **Model output is untrusted.** `Markdown` skips raw HTML completely (no `<script>`, `<img onerror>`, `<iframe>`, no escaped echo). It never uses `dangerouslySetInnerHTML`.
482
+ - **Link allow-list.** Only `http:`, `https:`, `mailto:`, `tel:`, relative paths and `#fragments` become links. `javascript:`, `data:`, `vbscript:`, `file:` and anything else (including obfuscated forms such as mixed case or leading whitespace) render as plain text. External links open with `target="_blank" rel="noopener noreferrer"` and say so to assistive technology.
483
+ - **Images do not auto-load by default.** A remote image in a reply can leak the viewer's IP and reading time to a third party, so the default `images="link"` shows a link. `images="inline"` renders `http(s)` images with `referrerpolicy="no-referrer"` and lazy loading; `"none"` shows only alt text. SVG is never shown inline. `data:` images are blocked unless `allowDataImages` is set, and then only raster formats. `transformImageUrl` lets you route images through your own proxy; its result is checked again.
484
+ - **Footnote ids are scoped** per message, so two replies (or a reply that writes `id`-like text) cannot collide with or hijack each other's anchors.
485
+ - **Approvals are explicit.** The approval prompt shows the exact tool and arguments, never decides on its own, never focuses a button, and `useThread` never resolves a `needs-approval` call without a call to `approve` or `deny`.
486
+ - **Tool input and output are shown as text** (folded after 4,000 characters). Circular structures, `BigInt` and functions are handled without throwing.
487
+ - **No telemetry, no favicon fetches, no network access** from the package. Listing sources never makes a request to the source's site.
488
+ - What `renderPart` returns is your code; sanitize what you draw.
489
+
490
+ ## Design decisions
491
+
492
+ - **One neutral shape.** Components read typed parts; adapters and stream events translate. The existing message types are only extended (every addition is optional), because `@x12i/flowix-ui` and `@x12i/records` build on them.
493
+ - **Headless first.** The reducer and the stream layer are plain TypeScript, so the same thread logic runs on a server, in a worker or in a test, and the React layer is a thin view.
494
+ - **Streaming never re-parses finished text.** `Markdown` splits text into top-level blocks and memoizes finished ones; only the last block re-parses as tokens arrive. Text with footnotes is rendered as one block because footnotes depend on definitions elsewhere.
495
+ - **Reading comes before reacting.** Auto-follow stops the instant you scroll up and you get a "Jump to latest" button instead of being dragged down.
496
+ - **No syntax highlighting.** A highlighter is a large dependency with a licensing and bundle cost; code blocks are plain, with a language label and a copy button. Pass your own through `renderPart` or wrap `CodeBlock`.
497
+ - **Private controls.** Controls the kit's `@x12i/ui` does not have (copy button, disclosure, JSON viewer, textarea) are small internals built from native elements rather than new public API.
498
+ - **Edit and regenerate.** Regenerate replaces the last reply. Editing a user message replaces it and drops what followed (`truncate`), or keeps the old version as a branch you can step through with `Previous version` / `Next version`.
499
+
500
+ ## Limitations
501
+
502
+ Honest list of what is not here, or not verified:
503
+
504
+ - **No virtualization.** Every message renders. It is comfortable to a few hundred messages; for very long threads, window the list yourself and render rows with `Message`.
505
+ - **No ResponseStream animation, artifact panel, `@mentions`, model picker, voice input or text-to-speech, no Mermaid, no math, no syntax highlighting.** Math and diagrams can be added through `renderPart` for a `data` part.
506
+ - **Images in replies are links by default** (see Security notes).
507
+ - **Approvals have no timeout and no "remember my choice".** A call waits until a person decides.
508
+ - **An approval prompt can pull focus out of the prompt box** while the person is typing, because the chat is where it appears. Pass `autoFocus={false}` to `ApprovalPrompt` (when you render it yourself) if that is wrong for your product; the live announcement still tells screen-reader users.
509
+ - **Citation links go one way:** from `[n]` to the source. There is no link back from the source to the citation.
510
+ - **Footnotes make a message one parse block**, so very long replies with footnotes re-parse in full while streaming.
511
+ - **Half-written Markdown** (a table whose separator row has not arrived yet) shows as plain text until it is complete; an unclosed code fence shows as code to the end of the text.
512
+ - **One run per `useThread`.** A new `send` is ignored while a reply runs; use `stop()` first.
513
+ - **Verified with jsdom, Testing Library and axe**, not yet with real screen readers (NVDA, VoiceOver, TalkBack) or in real browsers across devices. Scrolling and auto-grow are tested against stubbed layout metrics.
514
+ - **Contrast** is delegated to the themes and not measured here.
515
+ - With `showTimestamps`, server and client may format the time differently (see Server rendering).
516
+
25
517
  ## Contracts
26
518
 
27
- - `MessageAdapter<Input>`: converts a provider message into a `Message`.
28
- - `ThreadStore`: the thread component is stateless, so storage and history belong
29
- to the app. `@x12i/flowix-ui` has one that keeps each thread as a Flowix fact
30
- (`createFlowixThreadStore`), so a conversation survives a reload and every save
31
- stays in Flowix's revision history.
32
- - `DataPart`: structured data a component can render. Tool input and output, and
33
- any JSON you attach, can be painted with `@x12i/records`' `RecordView`, which
34
- lays out undescribed JSON from its shape.
519
+ - `MessageAdapter<Input>`, `MessagesAdapter<Input>`: convert provider messages into `Message`.
520
+ - `ThreadStore`: the thread component is stateless, so storage and history belong to the app. `@x12i/flowix-ui` has one that keeps each thread as a Flowix fact (`createFlowixThreadStore`), so a conversation survives a reload and every save stays in Flowix's revision history. Load with `initialMessages` or `setMessages`.
521
+ - `DataPart`: structured data a component can render. Tool output, or any JSON you attach as a data part, can be drawn with a component from another package (for example `@x12i/records`' `RecordView`) through `renderPart` (`tool` or `data:<kind>`). This package does not import it.
522
+
523
+ ## Headless exports (`@x12i/ai`)
524
+
525
+ Types: `Message`, `MessagePart`, `TextPart`, `ReasoningPart`, `ToolPart`, `ToolState`, `ToolApproval`, `SourcePart`, `FilePart`, `DataPart`, `MessageRole`, `MessageStatus`, `Usage`, `FinishReason`, `ErrorKind`, `ThreadError`, `Thread`, `ThreadStore`, `MessageAdapter`, `MessagesAdapter`, `ThreadState`, `ThreadAction`, `ThreadStatus`, `BranchInfo`, `BranchPoint`, `StreamEvent` and each event type.
526
+
527
+ Functions: `threadReducer`, `createThreadState`, `getBranchInfo`, `getBranchInfoMap`, `isBusy`, `isTerminalToolState`, `lastUserMessage`; `applyStreamEvent`, `applyStreamEvents`, `streamEventToAction`, `isTerminalStreamEvent`, `createAbortableStream`, `errorEvent`; `fromAiSdkMessages`, `fromOpenAIChatMessages`, `fromAnthropicMessages`, `normalizeUsage`, `normalizeFinishReason`; `messageText`, `messageReasoning`, `messageToolCalls`, `messageSources`, `messageFiles`, `messageData`, `pendingApprovals`, `threadPendingApprovals`, `findToolCall`, `isStreaming`, `lastMessageWithRole`; `toThreadError`, `isAbortError`; `parseCitations`, `citedNumbers`; `formatBytes`, `formatDuration`, `formatTokenCount`, `formatUsage`, `mergeUsage`, `totalTokens`, `markdownToPlainText`, `splitCompletedSentences`; `sanitizeUrl`, `sanitizeImageUrl`, `isSafeUrl`, `isExternalUrl`, `getUrlHostname`, `splitMarkdownBlocks`, `SAFE_LINK_PROTOCOLS`, `SAFE_IMAGE_PROTOCOLS`.
35
528
 
36
529
  ## References
37
530
 
38
- The kit adopts structure and naming from Vercel AI Elements (Apache-2.0),
39
- assistant-ui (MIT) and prompt-kit (MIT), restyled through tokens, and does not
40
- depend on any of them at runtime.
531
+ The kit adopts structure and naming from Vercel AI Elements (Apache-2.0), assistant-ui (MIT) and prompt-kit (MIT), restyled through tokens, and does not depend on any of them at runtime.