@elabs-ai/components-ai 4.0.0 → 4.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 +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- package/src/terminal.tsx +0 -244
package/src/message.test.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { math } from "@streamdown/math";
|
|
1
2
|
import { useState } from "react";
|
|
3
|
+
import type { ComponentProps } from "react";
|
|
2
4
|
import { describe, expect, it, vi } from "vitest";
|
|
3
5
|
import { render, screen } from "@testing-library/react";
|
|
4
6
|
import userEvent from "@testing-library/user-event";
|
|
@@ -183,6 +185,107 @@ describe("MessageResponse loading (#269, loading-states.md)", () => {
|
|
|
183
185
|
});
|
|
184
186
|
});
|
|
185
187
|
|
|
188
|
+
describe("MessageResponse plugins/components overrides (#10 — merge-not-replace semantics)", () => {
|
|
189
|
+
it("merges a real `plugins.cjk` override in (append), keeps sanitisation on, and keeps the untouched `plugins.math` default alive (#10)", () => {
|
|
190
|
+
// A real, discriminating lock — NOT `plugins={{}}` (that exercises zero
|
|
191
|
+
// slots and passes identically under merge, replace, or a no-op; #10
|
|
192
|
+
// review I3). This test supplies a genuine `cjk` plugin (one of the two
|
|
193
|
+
// slots MessageResponse actually reaches — `code`/`mermaid`/`renderers` are
|
|
194
|
+
// consulted only inside Streamdown's OWN default `code` renderer, which
|
|
195
|
+
// `MessageResponse` always shadows) and proves BOTH halves of the
|
|
196
|
+
// merge property:
|
|
197
|
+
// 1. Streamdown's default rehypePlugins (raw → sanitize → harden)
|
|
198
|
+
// still hold — a <script> is stripped.
|
|
199
|
+
// 2. The supplied `cjk` plugin APPENDS (its remark transformer runs)
|
|
200
|
+
// *and* the internal `math` default the consumer did NOT set
|
|
201
|
+
// SURVIVES alongside it (`$$x^2$$` renders as real KaTeX, not
|
|
202
|
+
// literal text). A REPLACE implementation
|
|
203
|
+
// (`plugins = pluginOverrides`) would drop `math` — along with
|
|
204
|
+
// `code`/`mermaid` — the moment a consumer sets `cjk`, and this
|
|
205
|
+
// assertion would fail.
|
|
206
|
+
const cjkRemarkSpy = vi.fn(() => (tree: unknown) => tree);
|
|
207
|
+
const customCjkPlugin: NonNullable<
|
|
208
|
+
NonNullable<ComponentProps<typeof MessageResponse>["plugins"]>["cjk"]
|
|
209
|
+
> = {
|
|
210
|
+
name: "cjk",
|
|
211
|
+
remarkPlugins: [],
|
|
212
|
+
remarkPluginsAfter: [cjkRemarkSpy],
|
|
213
|
+
remarkPluginsBefore: [],
|
|
214
|
+
type: "cjk",
|
|
215
|
+
};
|
|
216
|
+
const UNSAFE_DOC = `# Note
|
|
217
|
+
|
|
218
|
+
<script>window.__pwned = true;</script>
|
|
219
|
+
|
|
220
|
+
$$x^2$$
|
|
221
|
+
`;
|
|
222
|
+
render(<MessageResponse plugins={{ cjk: customCjkPlugin }}>{UNSAFE_DOC}</MessageResponse>);
|
|
223
|
+
|
|
224
|
+
// (1) sanitisation still holds.
|
|
225
|
+
expect(document.querySelector("script")).not.toBeInTheDocument();
|
|
226
|
+
expect(document.body.textContent).not.toMatch(/pwned/);
|
|
227
|
+
// (2a) the supplied `cjk` plugin appended (its transformer ran)…
|
|
228
|
+
expect(cjkRemarkSpy).toHaveBeenCalled();
|
|
229
|
+
// (2b) …and the internal `math` default the consumer did not set is
|
|
230
|
+
// still active — real KaTeX markup, not the literal `$$x^2$$` text.
|
|
231
|
+
expect(document.querySelector(".katex")).toBeInTheDocument();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("MessageResponse sanitiser is not overridable (#36)", () => {
|
|
236
|
+
it("ignores a caller-supplied rehypePlugins array (the sanitiser is not overridable)", () => {
|
|
237
|
+
// A rehype plugin that appends a <script> AFTER the pipeline. Under the bug the
|
|
238
|
+
// consumer array REPLACES [rehypeRaw, rehypeSanitize, harden], so nothing strips it.
|
|
239
|
+
const injectScript = () => (tree: { children: unknown[] }) => {
|
|
240
|
+
tree.children.push({
|
|
241
|
+
type: "element",
|
|
242
|
+
tagName: "script",
|
|
243
|
+
properties: {},
|
|
244
|
+
children: [{ type: "text", value: "globalThis.__pwned = true" }],
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
const { container } = render(
|
|
248
|
+
// `as any`: a JS consumer, an `any`, or a wider spread object still reaches
|
|
249
|
+
// this path even after the type-level `Omit`, so the assertion must exercise
|
|
250
|
+
// the RUNTIME strip, not the type. (`as never` doesn't typecheck as a JSX
|
|
251
|
+
// spread — TS2698, "Spread types may only be created from object types" —
|
|
252
|
+
// `any` is the cast the issue itself names as the bypass vector.)
|
|
253
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate: proves the runtime strip, not the type
|
|
254
|
+
<MessageResponse {...({ rehypePlugins: [injectScript] } as any)}>{"# hi"}</MessageResponse>,
|
|
255
|
+
);
|
|
256
|
+
expect(container.querySelector("script")).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("SUPPORTS a caller-supplied remarkPlugins array, and still sanitises what it injects", () => {
|
|
260
|
+
// PR #74 review, round 1: `remarkPlugins` is NOT a sanitiser override. It runs
|
|
261
|
+
// upstream of [rehypeRaw, rehypeSanitize, harden], which Streamdown derives
|
|
262
|
+
// without reading it — so the prop stays supported (no `as any` below: the TYPE
|
|
263
|
+
// must accept it) while its output is still sanitised. Both halves are asserted,
|
|
264
|
+
// so re-adding it to the runtime strip fails, and dropping the rehype chain fails.
|
|
265
|
+
const ran = vi.fn();
|
|
266
|
+
const injectHostile = () => (tree: { children: unknown[] }) => {
|
|
267
|
+
ran();
|
|
268
|
+
tree.children.push({
|
|
269
|
+
type: "html",
|
|
270
|
+
value: '<script>globalThis.__pwned = true</script><img src="x" onerror="void 0">',
|
|
271
|
+
});
|
|
272
|
+
tree.children.push({
|
|
273
|
+
type: "paragraph",
|
|
274
|
+
data: { hName: "script", hChildren: [{ type: "text", value: "globalThis.__x = 1" }] },
|
|
275
|
+
children: [],
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
const { container } = render(
|
|
279
|
+
<MessageResponse remarkPlugins={[injectHostile]}>{"# hi"}</MessageResponse>,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect(ran).toHaveBeenCalled(); // the prop really reached Streamdown
|
|
283
|
+
expect(container.querySelector("script")).toBeNull();
|
|
284
|
+
expect(container.querySelector("[onerror]")).toBeNull();
|
|
285
|
+
expect(document.body.textContent).not.toMatch(/pwned/);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
186
289
|
describe("Message data-slot / data-role contract", () => {
|
|
187
290
|
it("marks the root and its parts with kebab-case data-slots", () => {
|
|
188
291
|
const { container } = render(
|
|
@@ -506,3 +609,76 @@ describe("MessageActions hover-reveal pill", () => {
|
|
|
506
609
|
expect(screen.getByRole("button", { name: "Unpin" })).toHaveAttribute("aria-pressed", "true");
|
|
507
610
|
});
|
|
508
611
|
});
|
|
612
|
+
|
|
613
|
+
describe("MessageResponse trusted plugin slots run after the sanitiser (#76)", () => {
|
|
614
|
+
// `plugins.math.rehypePlugin` is appended to the END of Streamdown's rehype
|
|
615
|
+
// pipeline, i.e. AFTER `rehype-raw` → `rehype-sanitize` → `rehype-harden`, so
|
|
616
|
+
// whatever it emits is never re-sanitised. That is a deliberate, documented
|
|
617
|
+
// trusted-code seam (a consumer who can supply an executable `Pluggable` can
|
|
618
|
+
// already run code in their own bundle) — but until #76 it had no runtime
|
|
619
|
+
// half at all, unlike `rehypePlugins`. These three tests are the runtime half:
|
|
620
|
+
// it WARNS (1), it does not become noise (2), and it stays OPEN (3).
|
|
621
|
+
const injectScript = () => (tree: { children: unknown[] }) => {
|
|
622
|
+
tree.children.push({
|
|
623
|
+
type: "element",
|
|
624
|
+
tagName: "script",
|
|
625
|
+
properties: {},
|
|
626
|
+
children: [{ type: "text", value: "globalThis.__pwned76 = true" }],
|
|
627
|
+
});
|
|
628
|
+
};
|
|
629
|
+
const evilMath = { ...math, rehypePlugin: injectScript };
|
|
630
|
+
|
|
631
|
+
it("warns when a consumer replaces plugins.math.rehypePlugin", () => {
|
|
632
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
633
|
+
try {
|
|
634
|
+
render(<MessageResponse plugins={{ math: evilMath }}>{"# hi"}</MessageResponse>);
|
|
635
|
+
const messages = warn.mock.calls.flat().join("\n");
|
|
636
|
+
expect(messages).toMatch(/math\.rehypePlugin/);
|
|
637
|
+
expect(messages).toMatch(/after/i);
|
|
638
|
+
} finally {
|
|
639
|
+
warn.mockRestore();
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("does not warn for the default plugin set or a safe slot override", () => {
|
|
644
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
645
|
+
try {
|
|
646
|
+
const { unmount } = render(<MessageResponse>{"# hi"}</MessageResponse>);
|
|
647
|
+
unmount();
|
|
648
|
+
// A `cjk` override is remark-stage only — its output is re-sanitised
|
|
649
|
+
// downstream, so it is NOT a trust boundary and must stay silent. This is
|
|
650
|
+
// the arm that stops the warning degrading into noise consumers learn to
|
|
651
|
+
// ignore.
|
|
652
|
+
const customCjkPlugin: NonNullable<
|
|
653
|
+
NonNullable<ComponentProps<typeof MessageResponse>["plugins"]>["cjk"]
|
|
654
|
+
> = {
|
|
655
|
+
name: "cjk",
|
|
656
|
+
remarkPlugins: [],
|
|
657
|
+
remarkPluginsAfter: [],
|
|
658
|
+
remarkPluginsBefore: [],
|
|
659
|
+
type: "cjk",
|
|
660
|
+
};
|
|
661
|
+
render(<MessageResponse plugins={{ cjk: customCjkPlugin }}>{"# hi"}</MessageResponse>);
|
|
662
|
+
expect(warn).not.toHaveBeenCalled();
|
|
663
|
+
} finally {
|
|
664
|
+
warn.mockRestore();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("documents the boundary rather than closing it (the slot stays open)", () => {
|
|
669
|
+
// Counter-intuitive but load-bearing: this pins the deliberate trusted-code
|
|
670
|
+
// escape hatch OPEN. A future "hardening" that strips the slot would break a
|
|
671
|
+
// legitimate consumer (a real KaTeX/math plugin) silently — this fails loudly
|
|
672
|
+
// instead. The defence is the warning above plus the documented boundary,
|
|
673
|
+
// NOT removing the capability.
|
|
674
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
675
|
+
try {
|
|
676
|
+
const { container } = render(
|
|
677
|
+
<MessageResponse plugins={{ math: evilMath }}>{"# hi"}</MessageResponse>,
|
|
678
|
+
);
|
|
679
|
+
expect(container.querySelector("script")).not.toBeNull();
|
|
680
|
+
} finally {
|
|
681
|
+
warn.mockRestore();
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
});
|
package/src/message.tsx
CHANGED
|
@@ -6,7 +6,12 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@elabs
|
|
|
6
6
|
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
7
7
|
import { useLocale } from "@elabs-ai/components-ui";
|
|
8
8
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
9
|
-
import {
|
|
9
|
+
import { stripSanitizerOverrides, warnOnTrustedPluginSlots } from "./_streamdown-safety";
|
|
10
|
+
import {
|
|
11
|
+
useStreamdownMermaidOptions,
|
|
12
|
+
useStreamdownPlugins,
|
|
13
|
+
useStreamdownTranslations,
|
|
14
|
+
} from "./_streamdown-i18n";
|
|
10
15
|
import type { UIMessage } from "ai";
|
|
11
16
|
import { BotIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
|
12
17
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
|
@@ -540,7 +545,19 @@ export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProp
|
|
|
540
545
|
);
|
|
541
546
|
};
|
|
542
547
|
|
|
543
|
-
|
|
548
|
+
/**
|
|
549
|
+
* `rehypePlugins` is NOT exposed on `MessageResponse` (#36, fixed). This
|
|
550
|
+
* renders untrusted, streamed model output — the type-level `Omit` below plus
|
|
551
|
+
* a runtime `stripSanitizerOverrides()` call before the `{...props}` spread
|
|
552
|
+
* onto `<Streamdown>` keep Streamdown's default sanitiser chain (rehype-raw →
|
|
553
|
+
* rehype-sanitize → rehype-harden) non-overridable, even through a JS consumer
|
|
554
|
+
* or a force-cast.
|
|
555
|
+
*
|
|
556
|
+
* `remarkPlugins` IS supported: the remark stage runs upstream of that chain
|
|
557
|
+
* and cannot bypass it (PR #74 review, round 1). See `MarkdownView`'s TSDoc and
|
|
558
|
+
* `packages/ai/src/_streamdown-safety.ts` for the full security model.
|
|
559
|
+
*/
|
|
560
|
+
export type MessageResponseProps = Omit<ComponentProps<typeof Streamdown>, "rehypePlugins"> & {
|
|
544
561
|
/**
|
|
545
562
|
* No content has arrived yet (loading-states.md `loading`) — renders
|
|
546
563
|
* skeleton lines at the body line-height instead of `<Streamdown>`, so the
|
|
@@ -548,10 +565,58 @@ export type MessageResponseProps = ComponentProps<typeof Streamdown> & {
|
|
|
548
565
|
* @default false
|
|
549
566
|
*/
|
|
550
567
|
loading?: boolean;
|
|
568
|
+
/**
|
|
569
|
+
* Custom renderers for markdown elements. **Semantics differ from
|
|
570
|
+
* `MarkdownView`:** `components` here REPLACES (not merges) per key against
|
|
571
|
+
* Streamdown's own defaults, since `MessageResponse` has no internal
|
|
572
|
+
* component map to merge with. See `MarkdownView` for merge-over-defaults
|
|
573
|
+
* semantics and `#10` for details.
|
|
574
|
+
*/
|
|
575
|
+
components?: ComponentProps<typeof Streamdown>["components"];
|
|
576
|
+
/**
|
|
577
|
+
* Streamdown plugin-slot overrides (`cjk`/`code`/`math`/`mermaid`/
|
|
578
|
+
* `renderers`), **merged** per key over the internal defaults, so a
|
|
579
|
+
* consumer's entry wins for its key; every key the consumer does not set
|
|
580
|
+
* keeps the reactive, i18n-aware internal default. Same semantics as
|
|
581
|
+
* `MarkdownView` (#10 fix round).
|
|
582
|
+
*
|
|
583
|
+
* **This narrow `plugins` prop can only APPEND — it can never displace
|
|
584
|
+
* Streamdown's default `rehypePlugins` chain** (`rehype-raw` →
|
|
585
|
+
* `rehype-sanitize` → `rehype-harden`). `MessageResponse` never sets
|
|
586
|
+
* `rehypePlugins` itself, and nothing in `PluginConfig` removes a member of
|
|
587
|
+
* that pipeline. But two of the five slots are **not** upstream of it and
|
|
588
|
+
* must be treated as TRUSTED CODE (#76):
|
|
589
|
+
* - **`math.rehypePlugin` runs AFTER `rehype-sanitize`/`rehype-harden`** —
|
|
590
|
+
* it is appended to the end of the rehype pipeline (verified against
|
|
591
|
+
* `streamdown@2.5.0`'s `dist/chunk-BO2N2NFS.js`), so its output is never
|
|
592
|
+
* re-sanitised. Replacing it emits a dev-only `console.warn`; the plugin
|
|
593
|
+
* still runs (the seam is deliberate, not a hole to close). The sharp
|
|
594
|
+
* edge in practice is KaTeX's `trust` option — it disables KaTeX's own
|
|
595
|
+
* sanitisation, so leave it off for model-authored content.
|
|
596
|
+
* - **`mermaid` never enters the rehype/remark pipeline at all.** Its
|
|
597
|
+
* render output is written via `dangerouslySetInnerHTML` (streamdown's
|
|
598
|
+
* only such sink); brand-ui's default pins mermaid's strict security
|
|
599
|
+
* level, and a replacement must sanitise its own SVG. Also dev-warned.
|
|
600
|
+
* - `cjk` (remark-stage, re-sanitised downstream), `code` (feeds
|
|
601
|
+
* `shikiTheme` only) and `renderers` (ordinary React components) do not
|
|
602
|
+
* bypass sanitisation and are never warned about.
|
|
603
|
+
*
|
|
604
|
+
* See `docs/CSP-AND-NETWORK.md` §1 and
|
|
605
|
+
* `packages/ai/src/_streamdown-safety.ts`; `MarkdownView`'s TSDoc carries
|
|
606
|
+
* the same model for the sibling surface.
|
|
607
|
+
*/
|
|
608
|
+
plugins?: ComponentProps<typeof Streamdown>["plugins"];
|
|
551
609
|
};
|
|
552
610
|
|
|
553
611
|
export const MessageResponse = memo(
|
|
554
|
-
({ className, loading = false, ...props }: MessageResponseProps) => {
|
|
612
|
+
({ className, loading = false, plugins: pluginOverrides, ...props }: MessageResponseProps) => {
|
|
613
|
+
// Streamdown installs [rehypeRaw, rehypeSanitize, harden] as the DEFAULT VALUE of
|
|
614
|
+
// `rehypePlugins`; a supplied array REPLACES it. This component renders untrusted,
|
|
615
|
+
// streamed model output, so the chain is not overridable. Widen with `allowedTags` /
|
|
616
|
+
// `literalTagContent`, which merge into the sanitize schema. See issue #36.
|
|
617
|
+
// `remarkPlugins` is deliberately left alone — it runs upstream of the rehype
|
|
618
|
+
// chain and cannot bypass it (PR #74 review; see `_streamdown-safety.ts`).
|
|
619
|
+
stripSanitizerOverrides(props);
|
|
555
620
|
// Streamdown renders its own chrome (code copy, table menus, Mermaid
|
|
556
621
|
// toolbar); route its labels through the locale seam (#310). Spread AFTER
|
|
557
622
|
// so an explicit `translations` prop still wins (ADR 0017 override chain).
|
|
@@ -559,7 +624,27 @@ export const MessageResponse = memo(
|
|
|
559
624
|
const { t } = useLocale();
|
|
560
625
|
// Brand-token-derived `code` plugin, not the package's static github-*
|
|
561
626
|
// default (#315 follow-up) — re-derives when the active theme changes.
|
|
562
|
-
const
|
|
627
|
+
const internalPlugins = useStreamdownPlugins();
|
|
628
|
+
// MERGED per key over the internal defaults — the same semantics as
|
|
629
|
+
// `MarkdownView`'s `plugins` prop (#10 fix round, M4): a consumer entry
|
|
630
|
+
// wins for its key; every key the consumer does not set keeps the
|
|
631
|
+
// reactive, i18n-aware internal default. Previously this component
|
|
632
|
+
// REPLACED `plugins` wholesale (`{...props}` spread after `plugins=`),
|
|
633
|
+
// the opposite of `MarkdownView` for the same prop on a sibling
|
|
634
|
+
// Streamdown surface — aligned so both components mean the same thing by
|
|
635
|
+
// "override a plugin slot".
|
|
636
|
+
// `math.rehypePlugin`/`mermaid` are the two slots that land in the DOM
|
|
637
|
+
// after (or outside) the sanitiser chain, so replacing one is a
|
|
638
|
+
// TRUSTED-CODE decision. The runtime half of that documented boundary is a
|
|
639
|
+
// dev warning — the plugin still runs; stripping it would break the
|
|
640
|
+
// legitimate use (#76).
|
|
641
|
+
const plugins = useMemo(() => {
|
|
642
|
+
warnOnTrustedPluginSlots(pluginOverrides, internalPlugins);
|
|
643
|
+
return { ...internalPlugins, ...pluginOverrides };
|
|
644
|
+
}, [internalPlugins, pluginOverrides]);
|
|
645
|
+
// The TOP-LEVEL Streamdown `mermaid` prop — catches a failed diagram
|
|
646
|
+
// render, including a missing `mermaid` optional peer (issue #33).
|
|
647
|
+
const mermaidOptions = useStreamdownMermaidOptions();
|
|
563
648
|
|
|
564
649
|
if (loading) {
|
|
565
650
|
return (
|
|
@@ -594,6 +679,7 @@ export const MessageResponse = memo(
|
|
|
594
679
|
data-slot="message-response"
|
|
595
680
|
plugins={plugins}
|
|
596
681
|
translations={translations}
|
|
682
|
+
mermaid={mermaidOptions}
|
|
597
683
|
{...props}
|
|
598
684
|
/>
|
|
599
685
|
);
|
package/src/microcopy.test.tsx
CHANGED
|
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
3
|
import { LocaleProvider } from "@elabs-ai/components-ui";
|
|
4
4
|
|
|
5
|
+
import { Artifact, ArtifactClose } from "./artifact";
|
|
5
6
|
import { Composer } from "./composer";
|
|
6
7
|
import { InlineCitationCard, InlineCitationCarouselNext } from "./inline-citation";
|
|
7
8
|
import { Message, MessageBranch, MessageBranchNext } from "./message";
|
|
9
|
+
import { Plan, PlanTrigger } from "./plan";
|
|
8
10
|
import { PromptInput, PromptInputBody, PromptInputTextarea } from "./prompt-input";
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -19,7 +21,9 @@ const de = {
|
|
|
19
21
|
"ai.message.nextBranch": "Nächster Zweig",
|
|
20
22
|
"ai.promptInput.placeholder": "Was möchtest du wissen?",
|
|
21
23
|
"ai.promptInput.uploadFiles": "Dateien hochladen",
|
|
24
|
+
"ai.plan.togglePlan": "Plan umschalten",
|
|
22
25
|
next: "Weiter",
|
|
26
|
+
close: "Schließen",
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
describe("microcopy — English defaults are unchanged", () => {
|
|
@@ -48,6 +52,20 @@ describe("microcopy — English defaults are unchanged", () => {
|
|
|
48
52
|
);
|
|
49
53
|
expect(screen.getByRole("button", { name: "Next branch" })).toBeInTheDocument();
|
|
50
54
|
});
|
|
55
|
+
|
|
56
|
+
it("ArtifactClose keeps its sr-only 'Close' name (#18 — reuses the generic `close` key)", () => {
|
|
57
|
+
render(<ArtifactClose />);
|
|
58
|
+
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("PlanTrigger keeps its sr-only 'Toggle plan' name (#18)", () => {
|
|
62
|
+
render(
|
|
63
|
+
<Plan>
|
|
64
|
+
<PlanTrigger />
|
|
65
|
+
</Plan>,
|
|
66
|
+
);
|
|
67
|
+
expect(screen.getByRole("button", { name: "Toggle plan" })).toBeInTheDocument();
|
|
68
|
+
});
|
|
51
69
|
});
|
|
52
70
|
|
|
53
71
|
describe("microcopy — a LocaleProvider overrides it", () => {
|
|
@@ -106,6 +124,28 @@ describe("microcopy — a LocaleProvider overrides it", () => {
|
|
|
106
124
|
);
|
|
107
125
|
expect(screen.getByPlaceholderText("Explicit wins")).toBeInTheDocument();
|
|
108
126
|
});
|
|
127
|
+
|
|
128
|
+
it("translates ArtifactClose's sr-only name via the reused generic `close` key (#18)", () => {
|
|
129
|
+
render(
|
|
130
|
+
<LocaleProvider locale="de-DE" messages={de}>
|
|
131
|
+
<Artifact>
|
|
132
|
+
<ArtifactClose />
|
|
133
|
+
</Artifact>
|
|
134
|
+
</LocaleProvider>,
|
|
135
|
+
);
|
|
136
|
+
expect(screen.getByRole("button", { name: "Schließen" })).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("translates PlanTrigger's sr-only name via the new `ai.plan.togglePlan` key (#18)", () => {
|
|
140
|
+
render(
|
|
141
|
+
<LocaleProvider locale="de-DE" messages={de}>
|
|
142
|
+
<Plan>
|
|
143
|
+
<PlanTrigger />
|
|
144
|
+
</Plan>
|
|
145
|
+
</LocaleProvider>,
|
|
146
|
+
);
|
|
147
|
+
expect(screen.getByRole("button", { name: "Plan umschalten" })).toBeInTheDocument();
|
|
148
|
+
});
|
|
109
149
|
});
|
|
110
150
|
|
|
111
151
|
describe("microcopy — no provider is required", () => {
|
|
@@ -1,30 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* ModelProviderLogo — the mark of an AI model provider, sized for a list row.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* It fetches provider marks from a **remote origin**
|
|
5
5
|
* (`models.dev`) by default, so a restrictive `img-src` blocks them. Pass `src`
|
|
6
6
|
* to self-host, or `fallback` to control what a blocked load renders — it is
|
|
7
7
|
* never a broken-image glyph. See `docs/CSP-AND-NETWORK.md`.
|
|
8
8
|
*/
|
|
9
9
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { ModelProviderLogo } from "./model-provider-logo";
|
|
12
12
|
|
|
13
13
|
const meta = {
|
|
14
|
-
title: "AI/
|
|
15
|
-
component:
|
|
14
|
+
title: "AI/ModelProviderLogo",
|
|
15
|
+
component: ModelProviderLogo,
|
|
16
16
|
parameters: {
|
|
17
17
|
layout: "centered",
|
|
18
18
|
docs: {
|
|
19
19
|
description: {
|
|
20
20
|
component:
|
|
21
|
-
"
|
|
21
|
+
"The mark of an AI model provider, sized for a row in a model list. Defaults to " +
|
|
22
|
+
"fetching from models.dev; pass `src` for a self-hosted asset and `fallback` for the " +
|
|
23
|
+
"blocked/offline case. A failed load renders a neutral glyph, never a broken image.\n\n" +
|
|
24
|
+
"**`ModelProviderLogo` vs `ServiceLogo` (`@elabs-ai/components-icons`).** An AI " +
|
|
25
|
+
"provider mark with a zero-config remote default → this component. Any other " +
|
|
26
|
+
"third-party service mark, resolved from a registry the app supplies and never " +
|
|
27
|
+
"fetched → `ServiceLogo`.\n\n" +
|
|
28
|
+
"For the list this logo sits in: an inline pill in a composer footer → `ModelPicker` " +
|
|
29
|
+
"(`@elabs-ai/components-ui`); a full ⌘K palette → compose `CommandDialog` + " +
|
|
30
|
+
"`Command*` from `@elabs-ai/components-ui` directly.",
|
|
22
31
|
},
|
|
23
32
|
},
|
|
24
33
|
},
|
|
25
34
|
args: { provider: "anthropic" },
|
|
26
35
|
tags: ["autodocs"],
|
|
27
|
-
} satisfies Meta<typeof
|
|
36
|
+
} satisfies Meta<typeof ModelProviderLogo>;
|
|
28
37
|
|
|
29
38
|
export default meta;
|
|
30
39
|
type Story = StoryObj<typeof meta>;
|
|
@@ -64,7 +73,7 @@ export const LiveRemoteLogos: Story = {
|
|
|
64
73
|
<div className="flex items-center gap-4">
|
|
65
74
|
{(["anthropic", "openai", "google", "mistral"] as const).map((provider) => (
|
|
66
75
|
<figure className="flex flex-col items-center gap-2" key={provider}>
|
|
67
|
-
<
|
|
76
|
+
<ModelProviderLogo provider={provider} />
|
|
68
77
|
<figcaption className="text-meta text-muted-foreground">{provider}</figcaption>
|
|
69
78
|
</figure>
|
|
70
79
|
))}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { MODEL_PROVIDER_LOGO_BASE_URL, ModelProviderLogo } from "./model-provider-logo";
|
|
5
5
|
|
|
6
|
-
describe("
|
|
6
|
+
describe("ModelProviderLogo — remote origin escape hatches", () => {
|
|
7
7
|
it("defaults to the models.dev logo for the provider", () => {
|
|
8
|
-
render(<
|
|
8
|
+
render(<ModelProviderLogo provider="anthropic" />);
|
|
9
9
|
expect(screen.getByRole("img", { name: "anthropic logo" })).toHaveAttribute(
|
|
10
10
|
"src",
|
|
11
|
-
`${
|
|
11
|
+
`${MODEL_PROVIDER_LOGO_BASE_URL}/anthropic.svg`,
|
|
12
12
|
);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
it("lets a caller override src for a self-hosted asset", () => {
|
|
16
16
|
// The old props type did `Omit<…, "src">` AND spread props before `src=`, so
|
|
17
17
|
// a self-hosting consumer had no way to win. Both are fixed.
|
|
18
|
-
render(<
|
|
18
|
+
render(<ModelProviderLogo provider="anthropic" src="/local/anthropic.svg" />);
|
|
19
19
|
expect(screen.getByRole("img", { name: "anthropic logo" })).toHaveAttribute(
|
|
20
20
|
"src",
|
|
21
21
|
"/local/anthropic.svg",
|
|
@@ -23,7 +23,7 @@ describe("ModelSelectorLogo — remote origin escape hatches", () => {
|
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
it("renders a neutral glyph instead of a broken image when the load fails", () => {
|
|
26
|
-
render(<
|
|
26
|
+
render(<ModelProviderLogo provider="anthropic" />);
|
|
27
27
|
fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
|
|
28
28
|
|
|
29
29
|
const fallback = screen.getByRole("img", { name: "anthropic logo" });
|
|
@@ -32,24 +32,24 @@ describe("ModelSelectorLogo — remote origin escape hatches", () => {
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
it("renders a caller-supplied fallback when provided", () => {
|
|
35
|
-
render(<
|
|
35
|
+
render(<ModelProviderLogo provider="anthropic" fallback={<span data-testid="fb">AI</span>} />);
|
|
36
36
|
fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
|
|
37
37
|
expect(screen.getByTestId("fb")).toBeInTheDocument();
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it("still calls a caller's onError", () => {
|
|
41
41
|
const onError = vi.fn();
|
|
42
|
-
render(<
|
|
42
|
+
render(<ModelProviderLogo provider="anthropic" onError={onError} />);
|
|
43
43
|
fireEvent.error(screen.getByRole("img", { name: "anthropic logo" }));
|
|
44
44
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
it("keeps an accessible name in both states", () => {
|
|
48
|
-
const { rerender } = render(<
|
|
48
|
+
const { rerender } = render(<ModelProviderLogo provider="openai" />);
|
|
49
49
|
expect(screen.getByRole("img", { name: "openai logo" })).toBeInTheDocument();
|
|
50
50
|
|
|
51
51
|
fireEvent.error(screen.getByRole("img", { name: "openai logo" }));
|
|
52
|
-
rerender(<
|
|
52
|
+
rerender(<ModelProviderLogo provider="openai" />);
|
|
53
53
|
expect(screen.getByRole("img", { name: "openai logo" })).toBeInTheDocument();
|
|
54
54
|
});
|
|
55
55
|
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
2
|
+
import { BotIcon } from "lucide-react";
|
|
3
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where provider logos are fetched from by default.
|
|
8
|
+
*
|
|
9
|
+
* This is a REMOTE origin: a deployment with a restrictive `img-src` blocks it.
|
|
10
|
+
* Self-host the SVGs and pass `src` (or set a `fallback`) — see
|
|
11
|
+
* `docs/CSP-AND-NETWORK.md`.
|
|
12
|
+
*/
|
|
13
|
+
export const MODEL_PROVIDER_LOGO_BASE_URL = "https://models.dev/logos";
|
|
14
|
+
|
|
15
|
+
export type ModelProviderLogoProps = Omit<ComponentProps<"img">, "alt"> & {
|
|
16
|
+
/**
|
|
17
|
+
* Override the logo URL — point at a self-hosted or bundled asset when a CSP
|
|
18
|
+
* blocks `models.dev`. Defaults to `${MODEL_PROVIDER_LOGO_BASE_URL}/<provider>.svg`.
|
|
19
|
+
*/
|
|
20
|
+
src?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Rendered when the logo fails to load (blocked, offline, or unknown
|
|
23
|
+
* provider). Defaults to a neutral Lucide glyph, so the row never collapses
|
|
24
|
+
* to a broken image.
|
|
25
|
+
*/
|
|
26
|
+
fallback?: ReactNode;
|
|
27
|
+
provider:
|
|
28
|
+
| "moonshotai-cn"
|
|
29
|
+
| "lucidquery"
|
|
30
|
+
| "moonshotai"
|
|
31
|
+
| "zai-coding-plan"
|
|
32
|
+
| "alibaba"
|
|
33
|
+
| "xai"
|
|
34
|
+
| "vultr"
|
|
35
|
+
| "nvidia"
|
|
36
|
+
| "upstage"
|
|
37
|
+
| "groq"
|
|
38
|
+
| "github-copilot"
|
|
39
|
+
| "mistral"
|
|
40
|
+
| "vercel"
|
|
41
|
+
| "nebius"
|
|
42
|
+
| "deepseek"
|
|
43
|
+
| "alibaba-cn"
|
|
44
|
+
| "google-vertex-anthropic"
|
|
45
|
+
| "venice"
|
|
46
|
+
| "chutes"
|
|
47
|
+
| "cortecs"
|
|
48
|
+
| "github-models"
|
|
49
|
+
| "togetherai"
|
|
50
|
+
| "azure"
|
|
51
|
+
| "baseten"
|
|
52
|
+
| "huggingface"
|
|
53
|
+
| "opencode"
|
|
54
|
+
| "fastrouter"
|
|
55
|
+
| "google"
|
|
56
|
+
| "google-vertex"
|
|
57
|
+
| "cloudflare-workers-ai"
|
|
58
|
+
| "inception"
|
|
59
|
+
| "wandb"
|
|
60
|
+
| "openai"
|
|
61
|
+
| "zhipuai-coding-plan"
|
|
62
|
+
| "perplexity"
|
|
63
|
+
| "openrouter"
|
|
64
|
+
| "zenmux"
|
|
65
|
+
| "v0"
|
|
66
|
+
| "iflowcn"
|
|
67
|
+
| "synthetic"
|
|
68
|
+
| "deepinfra"
|
|
69
|
+
| "zhipuai"
|
|
70
|
+
| "submodel"
|
|
71
|
+
| "zai"
|
|
72
|
+
| "inference"
|
|
73
|
+
| "requesty"
|
|
74
|
+
| "morph"
|
|
75
|
+
| "lmstudio"
|
|
76
|
+
| "anthropic"
|
|
77
|
+
| "aihubmix"
|
|
78
|
+
| "fireworks-ai"
|
|
79
|
+
| "modelscope"
|
|
80
|
+
| "llama"
|
|
81
|
+
| "scaleway"
|
|
82
|
+
| "amazon-bedrock"
|
|
83
|
+
| "cerebras"
|
|
84
|
+
// oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
|
|
85
|
+
| (string & {});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The mark of an AI model provider (`anthropic`, `openai`, `google`, …), sized
|
|
90
|
+
* for a row in a model list.
|
|
91
|
+
*
|
|
92
|
+
* **`ModelProviderLogo` vs `ServiceLogo` (`@elabs-ai/components-icons`).** An AI
|
|
93
|
+
* provider mark with a zero-config remote default — name the provider and a logo
|
|
94
|
+
* appears, no registration step — is this component. Any other third-party
|
|
95
|
+
* service mark, resolved from a registry the app supplies and never fetched, is
|
|
96
|
+
* `ServiceLogo`. The two are not interchangeable: `ServiceLogo` has no
|
|
97
|
+
* per-instance `src` and no `onError`, and this one reaches a remote origin by
|
|
98
|
+
* default (see `docs/CSP-AND-NETWORK.md`).
|
|
99
|
+
*/
|
|
100
|
+
export const ModelProviderLogo = ({
|
|
101
|
+
provider,
|
|
102
|
+
className,
|
|
103
|
+
src,
|
|
104
|
+
fallback,
|
|
105
|
+
onError,
|
|
106
|
+
...props
|
|
107
|
+
}: ModelProviderLogoProps) => {
|
|
108
|
+
const [failed, setFailed] = useState(false);
|
|
109
|
+
|
|
110
|
+
// A blocked/missing logo must not leave a broken-image glyph in the row.
|
|
111
|
+
if (failed) {
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
{fallback ?? (
|
|
115
|
+
<BotIcon aria-label={`${provider} logo`} className={cn("size-3", className)} role="img" />
|
|
116
|
+
)}
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<img
|
|
123
|
+
{...props}
|
|
124
|
+
alt={`${provider} logo`}
|
|
125
|
+
className={cn("size-3 dark:invert", className)}
|
|
126
|
+
height={12}
|
|
127
|
+
onError={(event) => {
|
|
128
|
+
setFailed(true);
|
|
129
|
+
onError?.(event);
|
|
130
|
+
}}
|
|
131
|
+
// AFTER the spread, so the caller's `src` wins via the destructured
|
|
132
|
+
// default rather than being silently overwritten.
|
|
133
|
+
src={src ?? `${MODEL_PROVIDER_LOGO_BASE_URL}/${provider}.svg`}
|
|
134
|
+
width={12}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export type ModelProviderLogoGroupProps = ComponentProps<"div">;
|
|
140
|
+
|
|
141
|
+
export const ModelProviderLogoGroup = ({ className, ...props }: ModelProviderLogoGroupProps) => (
|
|
142
|
+
<div
|
|
143
|
+
className={cn(
|
|
144
|
+
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
|
145
|
+
className,
|
|
146
|
+
)}
|
|
147
|
+
{...props}
|
|
148
|
+
/>
|
|
149
|
+
);
|