Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/plugins/AIComponents/AIMarkdown/AIMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import remarkGfm from 'remark-gfm';
import { Prism, type SyntaxHighlighterProps } from 'react-syntax-highlighter';
import clsx from 'clsx';

import { JsonChart } from './tools/charts/JsonChart';
import { SuspendedChart } from './tools/charts/SuspendedChart';

type CodeElementProps = {
Expand Down Expand Up @@ -198,7 +199,7 @@ export const AIMarkdown: AIMarkdown = (props) => {
const mergedToolComponents: ToolComponents = useMemo(
() => ({
chartjs: SuspendedChart,
json: SuspendedChart,
json: JsonChart,
...props.toolComponents,
}),
[props.toolComponents],
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/AIComponents/AIMarkdown/tools/charts/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
LinearScale,
LineElement,
PointElement,
RadialLinearScale,
Title as TitlePlugin,
Tooltip,
} from 'chart.js';
Expand All @@ -25,12 +26,15 @@ import type { ToolComponentProps } from '../../AIMarkdown';
import { useTranslationContext } from '../../../../../context/TranslationContext';
import { chartJsSchema } from './chartJsSchema';

// react-chartjs-2 registers each chart type's controller, but not the scales those controllers
// default to: `radar` and `polarArea` need `RadialLinearScale`.
ChartJS.register(
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale,
RadialLinearScale,
BarElement,
LineElement,
PointElement,
Expand Down
16 changes: 16 additions & 0 deletions src/plugins/AIComponents/AIMarkdown/tools/charts/JsonChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';

import type { ToolComponentProps } from '../../AIMarkdown';
import { looksLikeChartJsConfig } from './chartTypes';
import { SuspendedChart } from './SuspendedChart';

/**
* `json` fences render as a chart only when they hold a Chart.js config; any other JSON
* (an API response, a config file, a partial payload mid-stream) stays highlighted code.
*/
export const JsonChart = (props: ToolComponentProps) =>
looksLikeChartJsConfig(props.data) ? (
<SuspendedChart {...props} />
) : (
<>{props.fallback}</>
);
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as zod from 'zod';

import { CHART_JS_TYPES } from './chartTypes';

export const chartJsSchema = zod.object({
data: zod.object({
datasets: zod.array(
Expand Down Expand Up @@ -34,14 +36,5 @@ export const chartJsSchema = zod.object({
xLabels: zod.array(zod.union([zod.string(), zod.number()])).optional(),
yLabels: zod.array(zod.union([zod.string(), zod.number()])).optional(),
}),
type: zod.enum([
'pie',
'bar',
'line',
'bubble',
'doughnut',
'polarArea',
'radar',
'scatter',
]),
type: zod.enum(CHART_JS_TYPES),
});
33 changes: 33 additions & 0 deletions src/plugins/AIComponents/AIMarkdown/tools/charts/chartTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export const CHART_JS_TYPES = [
'pie',
'bar',
'line',
'bubble',
'doughnut',
'polarArea',
'radar',
'scatter',
] as const;

const chartJsTypes = new Set<string>(CHART_JS_TYPES);

/**
* Cheap structural check (no zod, no chart.js) for whether a `json` fence holds a Chart.js
* config. Full validation still happens in the lazily loaded chart chunk.
*/
export const looksLikeChartJsConfig = (data: string): boolean => {
try {
const parsed: unknown = JSON.parse(data);
if (!parsed || typeof parsed !== 'object') return false;
const { data: chartData, type } = parsed as { data?: unknown; type?: unknown };
return (
typeof type === 'string' &&
chartJsTypes.has(type) &&
!!chartData &&
typeof chartData === 'object' &&
Array.isArray((chartData as { datasets?: unknown }).datasets)
);
} catch {
return false;
}
};
2 changes: 1 addition & 1 deletion src/plugins/AIComponents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -619,5 +619,5 @@ function AIResponseStream({ response }) {
<br />

<a href="https://getstream.io?utm_source=Github&utm_medium=Github_Repo_Content&utm_content=Developer&utm_campaign=Github_React_AI_SDK&utm_term=DevRelOss">
<img src="https://user-images.githubusercontent.com/24237865/138428440-b92e5fb7-89f8-41aa-96b1-71a5486c5849.png" align="right" width="12%"/>
<img src="https://user-images.githubusercontent.com/24237865/138428440-b92e5fb7-89f8-41aa-96b1-71a5486c5849.png" alt="Stream" align="right" width="12%"/>
</a>
19 changes: 19 additions & 0 deletions src/plugins/AIComponents/__tests__/AIMarkdown.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,25 @@ describe('AIMarkdown', () => {
expect(screen.getByText('Loading chart...')).toBeInTheDocument();
});

it('renders a plain json fence as highlighted code, not as a chart', () => {
const { container } = render(
<AIMarkdown>{'```json\n{ "status": "ok", "items": [1, 2] }\n```'}</AIMarkdown>,
);
expect(screen.queryByText('Loading chart...')).toBeNull();
expect(
container.querySelector('.str-chat__ai-syntax-highlighter-pre'),
).toHaveTextContent('"status"');
});

it('renders a json fence that holds a Chart.js config as a chart', () => {
const chartConfig = JSON.stringify({
data: { datasets: [{ data: [1, 2, 3] }], labels: ['a', 'b', 'c'] },
type: 'bar',
});
render(<AIMarkdown>{`\`\`\`json\n${chartConfig}\n\`\`\``}</AIMarkdown>);
expect(screen.getByText('Loading chart...')).toBeInTheDocument();
});

it('has no a11y violations', async () => {
const { container } = render(<AIMarkdown>{'# Title\n\n- a\n- b'}</AIMarkdown>);
expect(await axe(container)).toHaveNoViolations();
Expand Down
28 changes: 28 additions & 0 deletions src/plugins/AIComponents/__tests__/chartRegistration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Chart as ChartJS } from 'chart.js';

import { CHART_JS_TYPES } from '../AIMarkdown/tools/charts/chartTypes';

// jsdom has no canvas context, so Chart.js bails out before building scales and a render test
// cannot see an unregistered scale. Check the registry instead: every chart type the schema
// accepts must have its controller and the scales that controller defaults to registered once
// the chart module has loaded.
describe('Chart.js registration', () => {
beforeAll(async () => {
await import('../AIMarkdown/tools/charts/Chart');
});

it.each(CHART_JS_TYPES)(
'registers the controller and default scales for %s',
(type) => {
expect(() => ChartJS.registry.getController(type)).not.toThrow();

const scales = Object.values(ChartJS.overrides[type]?.scales ?? {});
for (const scale of scales) {
const scaleType = (scale as { type?: string } | undefined)?.type;
if (scaleType) {
expect(() => ChartJS.registry.getScale(scaleType)).not.toThrow();
}
}
},
);
});
14 changes: 7 additions & 7 deletions src/plugins/AIComponents/styling/streaming-message.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,13 @@
hyphens: none;
}

code[class*='language-'] {
&::selection,
&::-moz-selection {
background: ai-token(syntax-selection-bg);
color: inherit;
text-shadow: none;
}
// `::selection` and `::-moz-selection` must not share a selector list: a browser that
// does not recognise one pseudo-element drops the whole rule (Chromium/WebKit drop
// `::-moz-selection`). Current Firefox supports `::selection`, so only that is needed.
code[class*='language-']::selection {
background: ai-token(syntax-selection-bg);
color: inherit;
text-shadow: none;
}

.token {
Expand Down
Loading