@livelayer/react 0.2.6 → 0.4.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 +346 -26
- package/dist/index.d.ts +256 -0
- package/dist/index.js +2 -1
- package/dist/index.mjs +1834 -1040
- package/dist/styles.css +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,58 +1,378 @@
|
|
|
1
1
|
# @livelayer/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Drop-in voice/video AI agent widget for React apps. The full-fidelity widget that powers [app.livelayer.studio](https://app.livelayer.studio), packaged for direct mount in your app's DOM (no iframe).
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quickstart (5 minutes)
|
|
6
|
+
|
|
7
|
+
Three files, one published agent, working voice nav.
|
|
8
|
+
|
|
9
|
+
**1. Install**
|
|
6
10
|
|
|
7
11
|
```bash
|
|
8
12
|
npm install @livelayer/react
|
|
13
|
+
# or pnpm add @livelayer/react / yarn add @livelayer/react
|
|
9
14
|
```
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
**2. Get an agent ID** — go to [app.livelayer.studio](https://app.livelayer.studio), publish an agent, copy its ID (looks like `cmobfeluv000bju04ct1cqdb0`).
|
|
17
|
+
|
|
18
|
+
**3. Mount the widget** (Next.js App Router shown — works the same way in any React app):
|
|
12
19
|
|
|
13
20
|
```tsx
|
|
14
|
-
|
|
21
|
+
"use client";
|
|
22
|
+
|
|
23
|
+
import { AvatarWidget } from "@livelayer/react";
|
|
24
|
+
import "@livelayer/react/styles.css";
|
|
25
|
+
import { useRouter, usePathname } from "next/navigation";
|
|
26
|
+
|
|
27
|
+
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
28
|
+
const router = useRouter();
|
|
29
|
+
const pathname = usePathname();
|
|
15
30
|
|
|
16
|
-
function App() {
|
|
17
31
|
return (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
<>
|
|
33
|
+
{children}
|
|
34
|
+
<AvatarWidget
|
|
35
|
+
agentId="cmobfeluv000bju04ct1cqdb0"
|
|
36
|
+
pathname={pathname}
|
|
37
|
+
onNavigate={(href) => router.push(href)}
|
|
38
|
+
hideOn={["/privacy", "/terms", "/legal/*"]}
|
|
39
|
+
/>
|
|
40
|
+
</>
|
|
22
41
|
);
|
|
23
42
|
}
|
|
24
43
|
```
|
|
25
44
|
|
|
26
|
-
|
|
45
|
+
That's it. The widget docks bottom-right, the agent can navigate users to other pages by voice, and it stays out of the way on legal pages. The LiveKit session survives every SPA route change.
|
|
46
|
+
|
|
47
|
+
> **Common gotcha:** if the widget renders unstyled, check that you imported `@livelayer/react/styles.css`. It's a separate import to give consumers the option to scope styles.
|
|
48
|
+
|
|
49
|
+
---
|
|
27
50
|
|
|
28
|
-
|
|
29
|
-
| --------------- | --------------------------------------- | -------- | --------------------------------------------------- |
|
|
30
|
-
| `agentId` | `string` | Yes | The published agent ID to connect to |
|
|
31
|
-
| `mode` | `"WIDGET" \| "EMBEDDED"` | No | Override the experience mode from the agent config |
|
|
32
|
-
| `onAgentEvent` | `(event: AgentEventDetail) => void` | No | Callback fired when the agent emits a data channel event |
|
|
33
|
-
| `className` | `string` | No | CSS class name on the wrapper div |
|
|
34
|
-
| `style` | `React.CSSProperties` | No | Inline styles on the wrapper div |
|
|
51
|
+
## Recipes
|
|
35
52
|
|
|
36
|
-
###
|
|
53
|
+
### 1. Voice navigation in Next.js / React Router
|
|
37
54
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
Pass your router into `onNavigate`. When the agent emits a `navigate` command, the widget calls your callback. The session never reloads.
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
// Next.js App Router
|
|
59
|
+
import { useRouter } from "next/navigation";
|
|
60
|
+
const router = useRouter();
|
|
61
|
+
<AvatarWidget agentId="..." onNavigate={(href) => router.push(href)} />
|
|
62
|
+
|
|
63
|
+
// React Router v6
|
|
64
|
+
import { useNavigate } from "react-router-dom";
|
|
65
|
+
const navigate = useNavigate();
|
|
66
|
+
<AvatarWidget agentId="..." onNavigate={navigate} />
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If you don't pass `onNavigate`, the widget falls back to (1) clicking a matching `<a href="...">` in the DOM (Next.js `<Link>` and React Router `<Link>` both intercept these), then (2) `history.pushState` for plain HTML pages. **It never uses `window.location` — that's a hard reload that would kill the call.**
|
|
70
|
+
|
|
71
|
+
You also need to register a `navigate` tool on your agent so it can emit the command. In your agent's tool schema:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"name": "navigate",
|
|
76
|
+
"description": "Take the user to a different page on this site.",
|
|
77
|
+
"parameters": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": { "href": { "type": "string" } },
|
|
80
|
+
"required": ["href"]
|
|
81
|
+
}
|
|
42
82
|
}
|
|
43
83
|
```
|
|
44
84
|
|
|
45
|
-
|
|
85
|
+
When the LLM calls `navigate({ href: "/pricing" })`, your agent server publishes `{ type: "navigate", href: "/pricing" }` on the data channel. The widget handles the rest.
|
|
86
|
+
|
|
87
|
+
### 2. Hide on sensitive routes
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
<AvatarWidget
|
|
91
|
+
agentId="..."
|
|
92
|
+
pathname={usePathname()}
|
|
93
|
+
hideOn={["/privacy", "/terms", "/cookies", "/legal/**"]}
|
|
94
|
+
/>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Glob rules:
|
|
98
|
+
- `*` matches one path segment: `/admin/*` → `/admin/users` but not `/admin/users/edit`
|
|
99
|
+
- `**` matches any depth: `/admin/**` → `/admin`, `/admin/users`, `/admin/users/edit`
|
|
100
|
+
- A `RegExp` or function works too: `hideOn={[/^\/blog\/draft-.+$/, (p) => p.startsWith("/internal")]}`
|
|
101
|
+
|
|
102
|
+
The LiveKit session **stays alive** while hidden. When the user navigates back to an allowed route, the call resumes seamlessly.
|
|
103
|
+
|
|
104
|
+
`showOn` is the inverse — restrict to a whitelist. `hideOn` wins on collisions.
|
|
105
|
+
|
|
106
|
+
### 3. Let the agent see the page
|
|
107
|
+
|
|
108
|
+
When the agent asks "what's the user looking at?", the widget walks the DOM and sends back a structured snapshot. You don't need to do anything for this to work, but you can guide it with `<LiveLayerRegion>`:
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
import { LiveLayerRegion } from "@livelayer/react";
|
|
112
|
+
|
|
113
|
+
<LiveLayerRegion id="pricing" intent="show pricing tiers">
|
|
114
|
+
<PricingTable />
|
|
115
|
+
</LiveLayerRegion>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This renders a `<div data-ll-region="pricing" data-ll-intent="show pricing tiers">` that the page-context extractor surfaces with priority. The `intent` is author-language for the agent.
|
|
46
119
|
|
|
47
|
-
|
|
120
|
+
To register the agent-side tool:
|
|
48
121
|
|
|
49
122
|
```json
|
|
50
123
|
{
|
|
51
|
-
"
|
|
52
|
-
"
|
|
124
|
+
"name": "getPageContext",
|
|
125
|
+
"description": "Snapshot of what the user is currently looking at — useful when they ask 'what is this' or 'show me the X'.",
|
|
126
|
+
"parameters": { "type": "object", "properties": {} }
|
|
53
127
|
}
|
|
54
128
|
```
|
|
55
129
|
|
|
130
|
+
When the LLM calls it, your agent publishes `{ type: "request_page_context" }` and waits for the widget's `{ type: "page_context", context: {...} }` response (typically <100ms).
|
|
131
|
+
|
|
132
|
+
You can override the default extractor entirely:
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
<AvatarWidget
|
|
136
|
+
getPageContext={() => ({
|
|
137
|
+
url: window.location.href,
|
|
138
|
+
pathname: window.location.pathname,
|
|
139
|
+
title: document.title,
|
|
140
|
+
regions: [{ id: "cart", text: cartSummary }],
|
|
141
|
+
visibleText: "",
|
|
142
|
+
visibleLinks: [],
|
|
143
|
+
visibleFields: [],
|
|
144
|
+
})}
|
|
145
|
+
/>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Or attach extra app state without replacing the walker:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
<AvatarWidget
|
|
152
|
+
pageContextExtras={{ userId: user.id, cartItemCount: items.length }}
|
|
153
|
+
/>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 4. Let the agent click + scroll + fill forms (0.4.0)
|
|
157
|
+
|
|
158
|
+
**Click anything the agent should be able to trigger**: tag interactive elements with `data-ll-action` (or any selector you want — `button[aria-label="..."]` works too).
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<button data-ll-action="open-pricing-modal" onClick={openPricing}>
|
|
162
|
+
See pricing
|
|
163
|
+
</button>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The agent emits `{ type: "click", selector: "[data-ll-action='open-pricing-modal']" }` and the widget triggers a click. **Use `onNavigate` for nav-shaped clicks** — `click` is for buttons, dialog toggles, expand/collapse, etc.
|
|
167
|
+
|
|
168
|
+
**Page scrolling**: the agent can call `scroll_page` with `direction: "up" | "down" | "top" | "bottom"`. Default behavior scrolls the window by ±1 viewport height. Override with `onScrollPage` for custom scroll containers.
|
|
169
|
+
|
|
170
|
+
**Forms** — declarative wrappers. Tag the form and the fields the agent is allowed to fill:
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
import { LiveLayerForm, LiveLayerField } from "@livelayer/react";
|
|
174
|
+
|
|
175
|
+
<LiveLayerForm id="contact" intent="contact us — send a message">
|
|
176
|
+
<LiveLayerField name="name" label="Your name" />
|
|
177
|
+
<LiveLayerField name="email" label="Email" type="email" />
|
|
178
|
+
<LiveLayerField name="message" as="textarea" label="Message" />
|
|
179
|
+
<button type="submit">Send</button>
|
|
180
|
+
</LiveLayerForm>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Or use raw HTML with `data-ll-form` / `data-ll-field`:
|
|
184
|
+
|
|
185
|
+
```html
|
|
186
|
+
<form data-ll-form="contact" data-ll-intent="contact us">
|
|
187
|
+
<input data-ll-field="name" name="name" />
|
|
188
|
+
<input data-ll-field="email" name="email" type="email" />
|
|
189
|
+
<textarea data-ll-field="message" name="message"></textarea>
|
|
190
|
+
<button type="submit">Send</button>
|
|
191
|
+
</form>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The agent sees these in `PageContext.forms` and calls:
|
|
195
|
+
- `fill_form` — sets values via the React-controlled-input pattern (your `onChange` listeners fire correctly)
|
|
196
|
+
- `focus_field` — moves focus to a specific field
|
|
197
|
+
- `submit_form` — calls `form.requestSubmit()`. The widget publishes `{ type: "form_submitted", formId }` on success or `{ type: "form_submit_blocked", formId, reason: "validation" }` on HTML5 validation failure
|
|
198
|
+
|
|
199
|
+
**Privacy is enforced regardless of tagging**: `type="password"`, `autocomplete="cc-*"`, and `[data-ll-private="true"]` fields are NEVER agent-fillable. Card fields belong in Stripe Elements; we will not be the rail.
|
|
200
|
+
|
|
201
|
+
**Routes**: the agent can call `request_routes` to get up to 200 deduped `<a href>` entries from the page (internal flagged separately from external). Useful for "where can I go?" prompts.
|
|
202
|
+
|
|
203
|
+
### 5. Restrict what the agent can do (0.4.0)
|
|
204
|
+
|
|
205
|
+
Compliance / safety knob: pass an allowlist.
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
<AvatarWidget
|
|
209
|
+
agentId="..."
|
|
210
|
+
capabilities={["read_page", "navigate", "scroll", "fill_forms"]}
|
|
211
|
+
// not in list: "click", "submit_forms"
|
|
212
|
+
/>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
| Capability | Commands gated |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `navigate` | `navigate` |
|
|
218
|
+
| `scroll` | `scroll_to`, `scroll_page` |
|
|
219
|
+
| `click` | `click` |
|
|
220
|
+
| `fill_forms` | `fill_form`, `focus_field` |
|
|
221
|
+
| `submit_forms` | `submit_form` |
|
|
222
|
+
| `read_page` | `request_page_context`, `request_routes` |
|
|
223
|
+
|
|
224
|
+
Default (`capabilities` undefined) = all enabled. **Recommended starter**: omit `submit_forms` for the first few weeks of production. Filling is reversible, submitting isn't.
|
|
225
|
+
|
|
226
|
+
### 6. Persist the session across pages (multi-page apps)
|
|
227
|
+
|
|
228
|
+
For SPAs (Next.js, Remix, React Router), mount the widget at the app root and the session survives route changes automatically. For multi-page apps where the entire React tree unmounts, use `controlledSession` to own the LiveKit Room yourself and keep it alive across reloads. See [the `ControlledSession` interface](src/AvatarWidget.tsx) for the contract.
|
|
229
|
+
|
|
230
|
+
### 7. Custom branding
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
<AvatarWidget
|
|
234
|
+
branding={{
|
|
235
|
+
primaryColor: "#0ea5e9",
|
|
236
|
+
accentColor: "#f59e0b",
|
|
237
|
+
productName: "Acme Concierge",
|
|
238
|
+
logoUrl: "/logo.png",
|
|
239
|
+
}}
|
|
240
|
+
/>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## API reference
|
|
246
|
+
|
|
247
|
+
### `<AvatarWidget>` (primary)
|
|
248
|
+
|
|
249
|
+
All props are optional except `agentId`.
|
|
250
|
+
|
|
251
|
+
| Prop | Type | Description |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `agentId` | `string` | **Required.** The published agent ID. |
|
|
254
|
+
| `apiKey` | `string` | API key for cross-origin auth. Required if your agent isn't public. |
|
|
255
|
+
| `baseUrl` | `string` | Base URL of the LiveLayer API. Defaults to `https://app.livelayer.studio`. |
|
|
256
|
+
| `pathname` | `string` | Current pathname. **Required for Next.js App Router and React Router v6+.** Pass `usePathname()` / `useLocation().pathname`. |
|
|
257
|
+
| `showOn` | `RoutePattern[]` | Render only on matching paths. |
|
|
258
|
+
| `hideOn` | `RoutePattern[]` | Never render on matching paths. Wins over `showOn`. |
|
|
259
|
+
| `onNavigate` | `(href: string) => void` | Called on agent `navigate` command. Wire to your router. |
|
|
260
|
+
| `onScrollToSelector` | `(sel, behavior?) => void` | Called on agent `scroll_to` command. Default: `scrollIntoView({ behavior: "smooth" })`. |
|
|
261
|
+
| `onScrollPage` | `(direction, behavior?) => void` | Called on agent `scroll_page` command. Default: `window.scrollBy` / `scrollTo`. |
|
|
262
|
+
| `onClick` | `(selector: string) => void` | Called on agent `click` command. Default: `document.querySelector(selector)?.click()`. |
|
|
263
|
+
| `getPageContext` | `() => PageContext \| Promise<PageContext>` | Override the default DOM walker. |
|
|
264
|
+
| `pageContextExtras` | `Record<string, unknown>` | Extra app state attached to every page context snapshot. |
|
|
265
|
+
| `capabilities` | `AgentCapability[]` | Allowlist gating which commands the agent can run. |
|
|
266
|
+
| `position` | `"top-left" \| "top-right" \| "bottom-left" \| "bottom-right" \| "custom"` | Where the widget docks. Defaults to `"bottom-right"`. |
|
|
267
|
+
| `defaultDisplayMode` | `"hidden" \| "minimized" \| "expanded"` | Initial display mode. |
|
|
268
|
+
| `branding` | `BrandingConfig` | Colors, product name, logo. |
|
|
269
|
+
| `teamMembers` | `TeamMember[]` | Multi-agent picker. |
|
|
270
|
+
| `controlledSession` | `ControlledSession` | Bring-your-own LiveKit Room. |
|
|
271
|
+
| `onAgentCommand` | `(cmd) => void` | Receive non-universal data-channel commands. |
|
|
272
|
+
| `onAgentEvent` | `(e) => void` | Receive ALL data-channel events (including the universal ones). |
|
|
273
|
+
|
|
274
|
+
### `<LiveLayerRegion>` (page-context primitive)
|
|
275
|
+
|
|
276
|
+
```tsx
|
|
277
|
+
<LiveLayerRegion id="pricing" intent="show pricing tiers" as="section">
|
|
278
|
+
...
|
|
279
|
+
</LiveLayerRegion>
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Renders a wrapper element with `data-ll-region` + `data-ll-intent` that the page-context extractor prioritizes.
|
|
283
|
+
|
|
284
|
+
### `<LiveLayerForm>` + `<LiveLayerField>` (form primitives, 0.4.0)
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
<LiveLayerForm id="signup" intent="create account" onSubmit={handleSubmit}>
|
|
288
|
+
<LiveLayerField name="email" label="Email" type="email" />
|
|
289
|
+
<LiveLayerField name="bio" as="textarea" label="Bio" />
|
|
290
|
+
<LiveLayerField name="role" as="select" label="Role">
|
|
291
|
+
<option value="dev">Developer</option>
|
|
292
|
+
<option value="pm">PM</option>
|
|
293
|
+
</LiveLayerField>
|
|
294
|
+
<button type="submit">Sign up</button>
|
|
295
|
+
</LiveLayerForm>
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Equivalent to raw HTML with `data-ll-form` + `data-ll-field` attributes. Untagged forms remain invisible to the agent.
|
|
299
|
+
|
|
300
|
+
### Hooks (power users)
|
|
301
|
+
|
|
302
|
+
`useLiveKitSession`, `useDisplayMode`, `useAgentInfo`, `usePathname`, `useRouteMatch`, `useAudioLevel`, `useMicrophoneState`, `useCameraState`, `useScreenShareState`, `useMediaDevices`, `useTranscript`. All exported from the package root.
|
|
303
|
+
|
|
304
|
+
### Types
|
|
305
|
+
|
|
306
|
+
`AvatarWidgetProps`, `RoutePattern`, `PageContext`, `AgentCommand`, `AgentEventDetail`, `TeamMember`, `BrandingConfig`, `WidgetPosition`, `DisplayMode`. All exported from the package root.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
## Privacy
|
|
311
|
+
|
|
312
|
+
The default page-context walker **never** extracts:
|
|
313
|
+
|
|
314
|
+
- Form values (only labels and field types)
|
|
315
|
+
- Inputs with `type="password"`
|
|
316
|
+
- Inputs with `autocomplete="cc-*"` or `autocomplete="off"`
|
|
317
|
+
- Elements (and their subtrees) with `data-ll-private="true"`
|
|
318
|
+
- The widget itself (`.ll-widget`)
|
|
319
|
+
|
|
320
|
+
To redact additional content:
|
|
321
|
+
|
|
322
|
+
```tsx
|
|
323
|
+
<div data-ll-private="true">
|
|
324
|
+
<UserBankAccount />
|
|
325
|
+
</div>
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Or override `getPageContext` entirely to control exactly what reaches the agent.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## Migrating from 0.2.x
|
|
333
|
+
|
|
334
|
+
0.3.0 is **additive**. All existing 0.2.x code continues to work without changes.
|
|
335
|
+
|
|
336
|
+
**Soft breaking — observability only:** the data-channel commands `navigate`, `scroll_to`, and `request_page_context` are now handled internally by the widget and no longer reach `onAgentCommand`. If you previously observed them via that callback (unlikely — they were never emitted in 0.2.x), switch to `onAgentEvent`, which still fires for every message.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## Errors and warnings
|
|
341
|
+
|
|
342
|
+
Every console message from this package starts with `[LiveLayer]` and includes a doc URL. Examples:
|
|
343
|
+
|
|
344
|
+
```
|
|
345
|
+
[LiveLayer] Agent emitted "navigate" without href. Skipping.
|
|
346
|
+
Check your agent's tool schema.
|
|
347
|
+
See https://livelayer.studio/docs/errors/navigate-missing-href
|
|
348
|
+
|
|
349
|
+
[LiveLayer] scroll_to: no element matched "#pricing-table".
|
|
350
|
+
The user may be on a different page.
|
|
351
|
+
See https://livelayer.studio/docs/errors/scroll-no-match
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
If you see one of these in production, the doc URL has the explanation and remediation.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Legacy: `<LiveLayerWidget>`
|
|
359
|
+
|
|
360
|
+
The thin web-component wrapper from 0.1.x is still exported for backwards compatibility. New apps should use `<AvatarWidget>`.
|
|
361
|
+
|
|
362
|
+
```tsx
|
|
363
|
+
import { LiveLayerWidget } from "@livelayer/react";
|
|
364
|
+
<LiveLayerWidget agentId="..." />
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Peer dependencies
|
|
370
|
+
|
|
371
|
+
- `react` >= 18.0.0
|
|
372
|
+
- `react-dom` >= 18.0.0
|
|
373
|
+
|
|
374
|
+
No router peer dependency. Works with Next.js App Router, Next.js Pages Router, React Router (any version), Remix, TanStack Router, or no router at all.
|
|
375
|
+
|
|
56
376
|
## License
|
|
57
377
|
|
|
58
378
|
MIT
|