@cubos/agent-sdk-react-dom 0.0.1136563
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 +265 -0
- package/dist/agent-markdown.d.ts +53 -0
- package/dist/colors.d.ts +24 -0
- package/dist/currency.d.ts +9 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +678 -0
- package/dist/index.js.map +16 -0
- package/dist/recording-wave.d.ts +26 -0
- package/dist/styles.css +496 -0
- package/dist/use-recorder.d.ts +56 -0
- package/dist/use-transcript-anchor.d.ts +67 -0
- package/dist/voice-message.d.ts +53 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# @cubos/agent-sdk-react-dom
|
|
2
|
+
|
|
3
|
+
Ready-made React components for a Cubos Agent chat. Everything here renders DOM —
|
|
4
|
+
that is the whole difference from [`@cubos/agent-sdk-react`](../sdk-react), which
|
|
5
|
+
stays platform-neutral so its hooks also run in React Native.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @cubos/agent-sdk-react-dom # or npm / pnpm / yarn
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Its dependencies are the markdown chain and `lucide-react` for the handful of
|
|
12
|
+
icons a player needs. React is a peer.
|
|
13
|
+
|
|
14
|
+
## Agent markdown
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { AgentMarkdown } from "@cubos/agent-sdk-react-dom";
|
|
18
|
+
|
|
19
|
+
<AgentMarkdown>{message.content}</AgentMarkdown>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That is the whole setup. GFM tables, fenced code with syntax highlighting, TeX,
|
|
23
|
+
and a look that already works — **no stylesheet to import**.
|
|
24
|
+
|
|
25
|
+
### The CSS takes care of itself
|
|
26
|
+
|
|
27
|
+
The components import their own stylesheet. Your bundler resolves that the way
|
|
28
|
+
it resolves any other import — Vite, Next, Rspack, webpack, Bun — and there is
|
|
29
|
+
nothing for you to remember, no order to get right, no `styles.css` to hunt for
|
|
30
|
+
in a README.
|
|
31
|
+
|
|
32
|
+
Two properties make that safe to do in someone else's app:
|
|
33
|
+
|
|
34
|
+
- **Nothing escapes.** Every selector is scoped to a class this package puts on
|
|
35
|
+
its own wrapper: `.cubos-agent-sdk-react-dom` for what every component here
|
|
36
|
+
shares, `.cubos-agent-sdk-react-dom--markdown` for this one's rules. The sheet
|
|
37
|
+
cannot touch anything it didn't draw.
|
|
38
|
+
- **Nothing wins an argument.** The whole sheet sits in the `cubos-agent-sdk-react-dom`
|
|
39
|
+
cascade layer, and every selector is wrapped in `:where()`, which contributes
|
|
40
|
+
no specificity. A layer loses to unlayered CSS outright, and a bare `p {}`
|
|
41
|
+
beats a zero-specificity rule — so these are defaults, and yours are
|
|
42
|
+
decisions.
|
|
43
|
+
|
|
44
|
+
**If your app puts its own CSS in layers** (Tailwind v4 does), name ours once so
|
|
45
|
+
the order is explicit rather than accidental:
|
|
46
|
+
|
|
47
|
+
```css
|
|
48
|
+
/* Above your `@import`, since that is where a layer's position gets fixed. */
|
|
49
|
+
@layer theme, base, cubos-agent-sdk-react-dom, components, utilities;
|
|
50
|
+
|
|
51
|
+
@import "tailwindcss";
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**After your reset, before your own classes.** A reset clears borders and
|
|
55
|
+
margins on `*`; below it, these defaults would be erased along with the
|
|
56
|
+
browser's. Above your components and utilities, and your app would have to fight
|
|
57
|
+
this package to restyle anything.
|
|
58
|
+
|
|
59
|
+
Theming is `--cubos-agent-sdk-react-dom-*`, on the package class — one block, and
|
|
60
|
+
every component in here follows it:
|
|
61
|
+
|
|
62
|
+
```css
|
|
63
|
+
.cubos-agent-sdk-react-dom {
|
|
64
|
+
--cubos-agent-sdk-react-dom-border: var(--my-border);
|
|
65
|
+
--cubos-agent-sdk-react-dom-surface: var(--my-code-bg);
|
|
66
|
+
--cubos-agent-sdk-react-dom-keyword: var(--my-pink);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Most of it you never touch: borders, muted text and code surfaces are mixed from
|
|
71
|
+
`currentColor`, so the same sheet reads correctly on a white page, a dark one, or
|
|
72
|
+
inside a coloured bubble. Only the six syntax colours are absolute, and they
|
|
73
|
+
switch on `prefers-color-scheme` — if your app toggles dark mode itself, override
|
|
74
|
+
those under whatever selector you use.
|
|
75
|
+
|
|
76
|
+
The same tokens are a prop, for an app that themes in JavaScript or wants one
|
|
77
|
+
instance to differ from the rest:
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
<VoiceMessage colors={{ surface: "#0b57d0", border: "#8ab4f8" }} … />
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
They land as inline custom properties, which is the app speaking directly — it
|
|
84
|
+
outranks every stylesheet, this package's included, without an `!important`
|
|
85
|
+
anywhere.
|
|
86
|
+
|
|
87
|
+
### Making it yours
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
// Your design system, element by element. Anything you style here outranks the
|
|
91
|
+
// packaged default for that element; anything you leave alone keeps it.
|
|
92
|
+
<AgentMarkdown components={{ p: (props) => <p className="my-2" {...props} /> }}>
|
|
93
|
+
{text}
|
|
94
|
+
</AgentMarkdown>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`components` is `react-markdown`'s map, merged over the defaults — so overriding
|
|
98
|
+
`a` leaves the table scroller in place. `gfm`, `math` and `highlight` each turn
|
|
99
|
+
their plugin off. That is the whole API: there is no `unstyled` flag, because
|
|
100
|
+
with a scoped layer at zero specificity there is nothing to opt out of.
|
|
101
|
+
|
|
102
|
+
### Two things worth knowing
|
|
103
|
+
|
|
104
|
+
**Raw HTML is never rendered, and there is no option to allow it.** Message
|
|
105
|
+
content is model output, which is untrusted input however friendly the agent.
|
|
106
|
+
`rehype-raw` is deliberately absent: markdown in, elements out.
|
|
107
|
+
|
|
108
|
+
**KaTeX's stylesheet is not bundled.** It ships fonts, and re-exporting a copy of
|
|
109
|
+
those from here would double them in your bundle. If your agent writes TeX, add
|
|
110
|
+
it once in your app:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import "katex/dist/katex.min.css";
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Without it formulas still render, just unstyled. `math={false}` if the agent
|
|
117
|
+
never writes any.
|
|
118
|
+
|
|
119
|
+
## Voice messages
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import { VoiceMessage } from "@cubos/agent-sdk-react-dom";
|
|
123
|
+
|
|
124
|
+
<VoiceMessage
|
|
125
|
+
loadBytes={(signal) => client.fetchAttachment(conversationId, message.id, attachment.id, signal)}
|
|
126
|
+
bytes={attachment.bytes}
|
|
127
|
+
transcript={message.content}
|
|
128
|
+
/>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
It **fills the width of whatever holds it** — how wide a voice message is, is
|
|
132
|
+
the bubble's decision, not the player's. All it insists on is a floor, so a
|
|
133
|
+
container with no width of its own doesn't collapse around a waveform that is
|
|
134
|
+
absolutely positioned.
|
|
135
|
+
|
|
136
|
+
Play/pause, a seek bar, the elapsed time, and the transcription folded away
|
|
137
|
+
under a disclosure — collapsed by default, because the player is the message and
|
|
138
|
+
the words are there when you want them. `defaultTranscriptOpen` starts it open.
|
|
139
|
+
|
|
140
|
+
Three things it does that are easy to miss:
|
|
141
|
+
|
|
142
|
+
- **The waveform is the clip's own.** It is read off the decoded samples — peak
|
|
143
|
+
amplitude per bucket, normalised — so a pause in the recording is a gap on
|
|
144
|
+
screen. Until the bytes are here there is nothing to read, and the track is a
|
|
145
|
+
plain progress bar rather than an invented pattern. A codec the browser can't
|
|
146
|
+
decode keeps that bar; it never breaks playback.
|
|
147
|
+
- **Nothing is fetched until you press play.** A transcript often answers the
|
|
148
|
+
question, and a conversation full of voice notes should not pull every clip
|
|
149
|
+
down to render one screen.
|
|
150
|
+
- **The seek control is a real `<input type="range">`,** for the keyboard and
|
|
151
|
+
the ARIA — painted by us, because the native thumb needs a vendor
|
|
152
|
+
pseudo-element and hiding it with `opacity` loses to Tailwind's preflight,
|
|
153
|
+
which sets `opacity: 1` on every `input` from a layer above this package's.
|
|
154
|
+
- **It measures a `MediaRecorder` clip.** Those carry no duration in their
|
|
155
|
+
header, so browsers report `Infinity` until the file has been seeked through;
|
|
156
|
+
the component forces that measurement and rewinds, which is why the seek bar
|
|
157
|
+
has a range at all.
|
|
158
|
+
|
|
159
|
+
`loadBytes` returns a `Blob` because the bytes usually sit behind a bearer
|
|
160
|
+
token, which an `<audio src>` cannot carry. Every string is in `labels`:
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
<VoiceMessage
|
|
164
|
+
loadBytes={…}
|
|
165
|
+
labels={{ play: "Tocar", pause: "Pausar", transcript: "Transcrição" }}
|
|
166
|
+
/>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Recording a voice message
|
|
170
|
+
|
|
171
|
+
`useRecorder` is the other half of `VoiceMessage`: capture, as a `Blob` ready
|
|
172
|
+
for `sendAudio`.
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
const rec = useRecorder();
|
|
176
|
+
|
|
177
|
+
{rec.state !== "unsupported" && (
|
|
178
|
+
<button onClick={() => (rec.state === "recording" ? finish() : rec.start())}>
|
|
179
|
+
{rec.state === "recording" ? `Gravando ${rec.seconds}s` : "Gravar"}
|
|
180
|
+
</button>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
async function finish() {
|
|
184
|
+
const clip = await rec.stop(); // null when nothing was captured
|
|
185
|
+
if (clip) await sendAudio(clip);
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Two things it owns, both silent when wrong:
|
|
190
|
+
|
|
191
|
+
- **The container is probed, not chosen.** Chrome and Firefox record WebM;
|
|
192
|
+
Safari only ever produces MP4/AAC and *fails silently* when asked for WebM —
|
|
193
|
+
a clip that is simply never assembled.
|
|
194
|
+
- **The stream is released on every exit path** — stop, cancel, unmount, a
|
|
195
|
+
failed permission. A live `MediaStream` keeps the browser's recording
|
|
196
|
+
indicator lit long after the UI has moved on, with nothing on screen to
|
|
197
|
+
explain it.
|
|
198
|
+
|
|
199
|
+
`state` is `unsupported` where there is no `MediaRecorder` or no secure context
|
|
200
|
+
(`getUserMedia` exists only in one), and it settles after mount rather than
|
|
201
|
+
during render, so it is safe to render on a server. `cancel()` drops the take
|
|
202
|
+
without assembling it; `error` carries a denied microphone, which is the failure
|
|
203
|
+
worth putting in front of someone.
|
|
204
|
+
|
|
205
|
+
## Keeping the transcript anchored
|
|
206
|
+
|
|
207
|
+
`useTranscriptAnchor` is the scroll behaviour of a bottom-anchored transcript
|
|
208
|
+
with none of its looks: it renders no element, sets no class, and hands you two
|
|
209
|
+
refs.
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
const { scrollRef, contentRef, onScroll, isAtBottom, scrollToEnd } =
|
|
213
|
+
useTranscriptAnchor({ tailKey: messages.at(-1)?.id, hasOlder, loadOlder });
|
|
214
|
+
|
|
215
|
+
<div ref={scrollRef} onScroll={onScroll} className="overflow-auto">
|
|
216
|
+
<div ref={contentRef}>{items}</div>
|
|
217
|
+
</div>;
|
|
218
|
+
{!isAtBottom && <button onClick={scrollToEnd}>Ver o mais recente</button>}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`tailKey` is anything that changes when the tail grows — the last event's `seq`,
|
|
222
|
+
the message count, whether a typing indicator is up. It is only a trigger; the
|
|
223
|
+
value is never read. `loadOlder` resolves with **how many items it prepended**,
|
|
224
|
+
which is what lets a page that came back empty release the anchor instead of
|
|
225
|
+
freezing the viewport.
|
|
226
|
+
|
|
227
|
+
Four behaviours, each of which was a bug in this repository before it was a
|
|
228
|
+
rule:
|
|
229
|
+
|
|
230
|
+
1. **Follow only if already following.** The intent is recorded when the reader
|
|
231
|
+
scrolls, never measured after an update — by then the content has grown, so
|
|
232
|
+
someone sitting exactly at the end is suddenly "far" from it by the height of
|
|
233
|
+
whatever just arrived.
|
|
234
|
+
2. **Follow when the content grows, not when React re-renders.** A transcript
|
|
235
|
+
grows for reasons no state changed: an image decodes, a font swaps, a
|
|
236
|
+
disclosure opens, a card lays out a frame late. That is what `contentRef`
|
|
237
|
+
watches, and skipping it leaves the last few hundred pixels — the ones the
|
|
238
|
+
answer is written in — below the fold.
|
|
239
|
+
3. **Hold the reader's place when paging backwards**, before paint, against a
|
|
240
|
+
height measured before the commit. The correction survives the renders that
|
|
241
|
+
happen *while* the page is in flight; consuming it on the first of them is
|
|
242
|
+
what drops the reader at the top of a page they never asked to jump to.
|
|
243
|
+
4. **Keep pulling while the content is shorter than the viewport**, or a short
|
|
244
|
+
first page leaves a box that cannot scroll, and no scroll event ever fires to
|
|
245
|
+
ask for the rest.
|
|
246
|
+
|
|
247
|
+
## What belongs in this package
|
|
248
|
+
|
|
249
|
+
The bar is deliberately high, because a components package that answers every
|
|
250
|
+
request becomes a chat UI nobody can restyle:
|
|
251
|
+
|
|
252
|
+
1. It solves a problem of the **protocol** — markdown the model wrote, bytes
|
|
253
|
+
behind a bearer token, a clip that needs a player — or it is a **mechanic
|
|
254
|
+
that is invisible when right and wrong in the same way everywhere**, like a
|
|
255
|
+
transcript that has to follow the answer being written into it. Never a
|
|
256
|
+
question of product taste.
|
|
257
|
+
2. There is one obviously right behaviour, or a security invariant worth keeping
|
|
258
|
+
in one place.
|
|
259
|
+
3. Styling is delegated, not baked in.
|
|
260
|
+
4. It was already written twice.
|
|
261
|
+
|
|
262
|
+
Bubbles, composers and conversation lists fail (1): they are where products
|
|
263
|
+
differ most, and `packages/demo` in the repository is the worked example to copy
|
|
264
|
+
instead. Recording a voice note is the next candidate — the demo has it, and the
|
|
265
|
+
question is only whether a recorder belongs next to a player.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Components } from "react-markdown";
|
|
2
|
+
import { type AgentColors, type SyntaxColors } from "./colors.js";
|
|
3
|
+
import "./styles.css";
|
|
4
|
+
export interface AgentMarkdownProps {
|
|
5
|
+
/** The markdown, as the agent wrote it — a `Message.content`, or a
|
|
6
|
+
* `markdown` block's `text`. */
|
|
7
|
+
children: string;
|
|
8
|
+
/** GitHub-flavoured markdown: tables, strikethrough, task lists, autolinks.
|
|
9
|
+
* Defaults to on. */
|
|
10
|
+
gfm?: boolean;
|
|
11
|
+
/** TeX through KaTeX (`$x$` inline, `$$…$$` in display). Defaults to on.
|
|
12
|
+
* **KaTeX's own stylesheet is not bundled** — add `import
|
|
13
|
+
* "katex/dist/katex.min.css"` to your app, or formulas render unstyled.
|
|
14
|
+
*
|
|
15
|
+
* Amounts are safe with this on: a `$` that reads as currency is escaped
|
|
16
|
+
* before the parser sees it, so "de R$ 524 a R$ 632" stays a sentence instead
|
|
17
|
+
* of becoming one formula. See `currency.ts` for what counts. */
|
|
18
|
+
math?: boolean;
|
|
19
|
+
/** Syntax highlighting for fenced code. Defaults to on. */
|
|
20
|
+
highlight?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Element overrides, as `react-markdown` takes them. Merged over the
|
|
23
|
+
* defaults, so overriding `a` leaves the table scroller in place.
|
|
24
|
+
*
|
|
25
|
+
* This is the hook for an app with its own design system: pass
|
|
26
|
+
* `{ p: (props) => <p className="my-2" {...props} /> }` and style everything
|
|
27
|
+
* with your own classes.
|
|
28
|
+
*/
|
|
29
|
+
components?: Components;
|
|
30
|
+
/** Overrides the palette for this block, as inline custom properties — the
|
|
31
|
+
* same tokens `styles.css` declares, without writing CSS. */
|
|
32
|
+
colors?: AgentColors & SyntaxColors;
|
|
33
|
+
/** Put on the wrapper, alongside this package's own classes. */
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Markdown the way an agent writes it: GFM, fenced code with highlighting, and
|
|
38
|
+
* TeX — styled out of the box, with no stylesheet to import, and restyleable
|
|
39
|
+
* down to the element through `components`.
|
|
40
|
+
*
|
|
41
|
+
* ```tsx
|
|
42
|
+
* <AgentMarkdown>{message.content}</AgentMarkdown>
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* **Raw HTML is never rendered.** `rehype-raw` is deliberately absent and there
|
|
46
|
+
* is no option to add it: this text is model output, which is untrusted input
|
|
47
|
+
* however friendly the agent. Markdown in, elements out.
|
|
48
|
+
*
|
|
49
|
+
* Memoised on its input — a live conversation re-renders the whole transcript
|
|
50
|
+
* whenever a message arrives, and re-parsing every bubble each time is work
|
|
51
|
+
* nobody sees.
|
|
52
|
+
*/
|
|
53
|
+
export declare const AgentMarkdown: import("react").MemoExoticComponent<({ children, gfm, math, highlight, components, colors, className, }: AgentMarkdownProps) => import("react").JSX.Element>;
|
package/dist/colors.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
/** The colours every component here reads. Each is a `--cubos-agent-sdk-react-dom-*`
|
|
3
|
+
* custom property, so a stylesheet can set the same things — the prop exists so
|
|
4
|
+
* an app that themes in JS doesn't have to write CSS to do it. */
|
|
5
|
+
export interface AgentColors {
|
|
6
|
+
/** Hairlines: table cells, code borders, the player's ring. */
|
|
7
|
+
border?: string;
|
|
8
|
+
/** Secondary text: quotes, the clock, "transcribing…". */
|
|
9
|
+
muted?: string;
|
|
10
|
+
/** Filled surfaces: code blocks, table headers, the play button. */
|
|
11
|
+
surface?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Syntax colours, on top of the shared three. */
|
|
14
|
+
export interface SyntaxColors {
|
|
15
|
+
comment?: string;
|
|
16
|
+
keyword?: string;
|
|
17
|
+
string?: string;
|
|
18
|
+
number?: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
type?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Inline custom properties — the app speaking directly, which outranks every
|
|
23
|
+
* stylesheet without anyone reaching for `!important`. */
|
|
24
|
+
export declare function colorStyle(colors: Partial<AgentColors & SyntaxColors> | undefined): CSSProperties | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escapes every `$` that is currency, leaving TeX delimiters alone.
|
|
3
|
+
*
|
|
4
|
+
* Walks the text rather than running a regex over it, because the decision
|
|
5
|
+
* depends on context a regex cannot see: fenced blocks, inline code and `$$…$$`
|
|
6
|
+
* display maths all have to pass through untouched, or this would corrupt a
|
|
7
|
+
* code sample or break a formula the agent meant.
|
|
8
|
+
*/
|
|
9
|
+
export declare function escapeCurrency(markdown: string): string;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type { AgentMarkdownProps } from "./agent-markdown.js";
|
|
2
|
+
export { AgentMarkdown } from "./agent-markdown.js";
|
|
3
|
+
export type { AgentColors, SyntaxColors } from "./colors.js";
|
|
4
|
+
export type { RecordingWaveProps } from "./recording-wave.js";
|
|
5
|
+
export { RecordingWave } from "./recording-wave.js";
|
|
6
|
+
export type { RecorderState, UseRecorderResult } from "./use-recorder.js";
|
|
7
|
+
export { useRecorder } from "./use-recorder.js";
|
|
8
|
+
export type { UseTranscriptAnchorOptions, UseTranscriptAnchorResult, } from "./use-transcript-anchor.js";
|
|
9
|
+
export { useTranscriptAnchor } from "./use-transcript-anchor.js";
|
|
10
|
+
export type { VoiceMessageLabels, VoiceMessageProps } from "./voice-message.js";
|
|
11
|
+
export { VoiceMessage } from "./voice-message.js";
|