orchid-ai 2.0.2 → 2.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.
@@ -1,14 +1,113 @@
1
- import React, { useRef, useState, useMemo } from "react";
1
+ import React, { useRef, useState, useMemo, useEffect } from "react";
2
2
  import ReactMarkdown from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
4
  import AiVisualization from "./visualizations/AiVisualization";
5
5
  import { resolveChartBlock } from "./visualizations/parseChartBlock";
6
6
  import { isChartMarkdownLanguage } from "./visualizations/chartSchema";
7
+ import { orchidAiProcessTraceHasDisplayableContent, orchidAiProcessTraceEntryKind } from "../orchidAiProcessTrace";
7
8
 
8
9
  const IS_DEV = process.env.NODE_ENV === "development";
9
10
 
10
- const TITLE_RE = /<!--\s*title:\s*([^-][^>]*?)\s*-->/i;
11
+ /**
12
+ * Returns the substring up to (not including) the last unclosed ``` opener,
13
+ * or null when all code blocks are closed. Used to safely render partial
14
+ * markdown during streaming without passing broken fences to ReactMarkdown.
15
+ */
16
+ function getStreamingPrefix(content) {
17
+ const parts = content.split("```");
18
+ if (parts.length % 2 === 0) {
19
+ return content.slice(0, content.lastIndexOf("```"));
20
+ }
21
+ return null;
22
+ }
23
+
24
+ /** Extracts the language identifier from the currently-open (unclosed) code fence. */
25
+ function getOpenBlockLanguage(content) {
26
+ const lastIdx = content.lastIndexOf("```");
27
+ if (lastIdx === -1) return "";
28
+ const after = content.slice(lastIdx + 3);
29
+ return after.split("\n")[0].trim().toLowerCase();
30
+ }
31
+
32
+ const CODE_LANGS = new Set([
33
+ "javascript","js","typescript","ts","jsx","tsx",
34
+ "python","py","ruby","rb","go","rust","java",
35
+ "c","cpp","c++","cs","php","bash","sh","zsh",
36
+ "html","css","yaml","yml","xml","markdown","md",
37
+ ]);
38
+
39
+ function getBlockLabel(lang) {
40
+ const l = (lang || '').toLowerCase();
41
+ if (l.includes('orchid-ai-chart') || l.includes('hemiq-chart')) return 'Generating visualization';
42
+ if (l === 'json') return 'Generating data';
43
+ if (l === 'sql') return 'Generating query';
44
+ if (CODE_LANGS.has(l)) return 'Generating code';
45
+ if (!l) return 'Generating…';
46
+ return `Generating ${lang}`;
47
+ }
11
48
 
49
+ const UPPER_ABBREV = new Set(['id', 'url', 'api', 'uuid', 'ip', 'sku', 'po', 'eta', 'ref']);
50
+
51
+ function camelToTitleCase(str) {
52
+ const words = String(str)
53
+ .replace(/_/g, ' ')
54
+ .replace(/([A-Z])/g, ' $1')
55
+ .trim()
56
+ .split(/\s+/);
57
+ return words
58
+ .map((w) => {
59
+ const lower = w.toLowerCase();
60
+ if (UPPER_ABBREV.has(lower)) return lower.toUpperCase();
61
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
62
+ })
63
+ .join(' ');
64
+ }
65
+
66
+ function formatQueryValue(value) {
67
+ if (Array.isArray(value)) return value.map(String).join(', ');
68
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
69
+ return String(value);
70
+ }
71
+
72
+ function QuerySummaryPanel({ queryContext }) {
73
+ if (!queryContext || typeof queryContext !== 'object' || Array.isArray(queryContext)) return null;
74
+ const entries = Object.entries(queryContext).filter(
75
+ ([, v]) => v !== null && v !== undefined && v !== ''
76
+ );
77
+ if (!entries.length) return null;
78
+ return (
79
+ <details className="ai-chat-query-summary">
80
+ <summary className="ai-chat-query-summary__summary">
81
+ <span className="ai-chat-query-summary__label">Filters used</span>
82
+ <span className="ai-chat-query-summary__chevron" aria-hidden>▸</span>
83
+ </summary>
84
+ <ul className="ai-chat-query-summary__list">
85
+ {entries.map(([key, value]) => (
86
+ <li key={key} className="ai-chat-query-summary__item">
87
+ <span className="ai-chat-query-summary__key">{camelToTitleCase(key)}</span>
88
+ <span className="ai-chat-query-summary__value">{formatQueryValue(value)}</span>
89
+ </li>
90
+ ))}
91
+ </ul>
92
+ </details>
93
+ );
94
+ }
95
+
96
+ const TITLE_COMMENT_RE = /<!--\s*title:\s*([^-][^>]*?)\s*-->/gi;
97
+
98
+ /** Prefer the last `<!--title:...-->` so end-of-reply titles work; still removes legacy start-of-reply comments. */
99
+ function peelOrchidAiTitleComment(content, isUser) {
100
+ if (isUser) return { responseTitle: null, renderContent: content };
101
+ const s = String(content ?? "");
102
+ const matches = [...s.matchAll(TITLE_COMMENT_RE)];
103
+ if (matches.length === 0) return { responseTitle: null, renderContent: content };
104
+ const m = matches[matches.length - 1];
105
+ const full = m[0];
106
+ const idx = m.index;
107
+ const responseTitle = m[1].trim();
108
+ const renderContent = (s.slice(0, idx) + s.slice(idx + full.length)).replace(/^\s+/, "").replace(/\s+$/, "");
109
+ return { responseTitle, renderContent };
110
+ }
12
111
  /** Split on http(s) URLs for lightweight linkify in user bubbles (plain text, not full markdown). */
13
112
  const URL_INLINE_RE = /(https?:\/\/[^\s<>`]+)/gi;
14
113
 
@@ -48,21 +147,142 @@ function UserBubbleContent({ content }) {
48
147
  ));
49
148
  }
50
149
 
51
- export default function Message({ role, content, truncated, exportPrefix = "orchid-ai" }) {
150
+ function formatToolName(tool) {
151
+ return String(tool)
152
+ .replace(/^(query_|get_)/, '')
153
+ .replace(/_/g, ' ')
154
+ .replace(/\b\w/g, (c) => c.toUpperCase());
155
+ }
156
+
157
+ function QueryStep({ entry }) {
158
+ const params = Object.entries(entry.input || {}).filter(([, v]) => v !== null && v !== undefined && v !== '');
159
+ return (
160
+ <li className="ai-chat-process-trace__step ai-chat-process-trace__step--query">
161
+ <div className="ai-chat-process-trace__rail" aria-hidden>
162
+ <span className="ai-chat-process-trace__dot" />
163
+ </div>
164
+ <div className="ai-chat-process-trace__body">
165
+ <span className="ai-chat-process-trace__query-collection">{formatToolName(entry.tool)}</span>
166
+ {params.length > 0 && (
167
+ <dl className="ai-chat-process-trace__query-params">
168
+ {params.map(([key, value]) => (
169
+ <div key={key} className="ai-chat-process-trace__query-param">
170
+ <dt>{camelToTitleCase(key)}</dt>
171
+ <dd>{formatQueryValue(value)}</dd>
172
+ </div>
173
+ ))}
174
+ </dl>
175
+ )}
176
+ </div>
177
+ </li>
178
+ );
179
+ }
180
+
181
+ function ProcessTracePanel({ processTrace, processInterimLive, isStreaming, showProcessTracePanel }) {
182
+ if (showProcessTracePanel === false) return null;
183
+
184
+ const autoCollapsed = !isStreaming && processTrace?.defaultCollapsed !== false;
185
+ const [expanded, setExpanded] = useState(() => !autoCollapsed);
186
+
187
+ useEffect(() => {
188
+ setExpanded(!autoCollapsed);
189
+ }, [autoCollapsed]);
190
+
191
+ const hasContent = orchidAiProcessTraceHasDisplayableContent(processTrace, processInterimLive, {
192
+ isStreaming,
193
+ });
194
+ if (!hasContent) return null;
195
+
196
+ const items = processTrace?.items ?? [];
197
+
198
+ return (
199
+ <details
200
+ className="ai-chat-process-trace"
201
+ open={expanded}
202
+ onToggle={(e) => setExpanded(e.target.open)}
203
+ >
204
+ <summary className="ai-chat-process-trace__summary">
205
+ <span className="ai-chat-process-trace__summary-text">Working</span>
206
+ {isStreaming ? (
207
+ <span className="ai-chat-process-trace__mini-typing" aria-hidden title="In progress">
208
+ <span />
209
+ <span />
210
+ <span />
211
+ </span>
212
+ ) : (
213
+ <span className="ai-chat-process-trace__summary-chevron" aria-hidden>
214
+
215
+ </span>
216
+ )}
217
+ </summary>
218
+ <div className="ai-chat-process-trace__panel">
219
+ <ul className="ai-chat-process-trace__timeline">
220
+ {items.map((entry, i) => {
221
+ if (entry.type === 'query') {
222
+ return <QueryStep key={i} entry={entry} />;
223
+ }
224
+ const lane = orchidAiProcessTraceEntryKind(entry);
225
+ const isLiveStatus = isStreaming && !processInterimLive && i === items.length - 1 && entry.type === "status";
226
+ return (
227
+ <li
228
+ key={i}
229
+ className={`ai-chat-process-trace__step ai-chat-process-trace__step--${lane}${isLiveStatus ? " ai-chat-process-trace__step--live" : ""}`}
230
+ >
231
+ <div className="ai-chat-process-trace__rail" aria-hidden>
232
+ <span className="ai-chat-process-trace__dot" />
233
+ </div>
234
+ <div className="ai-chat-process-trace__body">
235
+ {entry.type === "status" ? (
236
+ <span className="ai-chat-process-trace__line">{entry.value}</span>
237
+ ) : (
238
+ <div className="ai-chat-process-trace__prose">{entry.value}</div>
239
+ )}
240
+ </div>
241
+ </li>
242
+ );
243
+ })}
244
+ {processInterimLive ? (
245
+ <li className="ai-chat-process-trace__step ai-chat-process-trace__step--text ai-chat-process-trace__step--interim">
246
+ <div className="ai-chat-process-trace__rail" aria-hidden>
247
+ <span className="ai-chat-process-trace__dot" />
248
+ </div>
249
+ <div className="ai-chat-process-trace__body">
250
+ <div className="ai-chat-process-trace__prose">{processInterimLive}</div>
251
+ </div>
252
+ </li>
253
+ ) : null}
254
+ </ul>
255
+ </div>
256
+ </details>
257
+ );
258
+ }
259
+
260
+ export default function Message({
261
+ role,
262
+ content,
263
+ truncated,
264
+ exportPrefix = "orchid-ai",
265
+ isStreaming = false,
266
+ streamingStatusText,
267
+ processTrace,
268
+ processInterimLive = "",
269
+ showProcessTracePanel = true,
270
+ }) {
52
271
  const isUser = role === "user";
53
272
  const [copied, setCopied] = useState(false);
54
273
  const [isPrinting, setIsPrinting] = useState(false);
55
274
  const messageRef = useRef(null);
56
275
 
57
276
  // Extract AI-provided title comment and strip it from rendered content
58
- const { responseTitle, renderContent } = useMemo(() => {
59
- if (isUser) return { responseTitle: null, renderContent: content };
60
- const match = TITLE_RE.exec(content);
61
- return {
62
- responseTitle: match ? match[1].trim() : null,
63
- renderContent: match ? content.replace(match[0], "").replace(/^\s+/, "") : content,
64
- };
65
- }, [content, isUser]);
277
+ const { responseTitle, renderContent } = useMemo(
278
+ () => peelOrchidAiTitleComment(content, isUser),
279
+ [content, isUser]
280
+ );
281
+
282
+ // When streaming, detect an unclosed code block so we can show a labeled placeholder
283
+ // instead of passing a broken fence to ReactMarkdown.
284
+ const streamingPrefix = !isUser && isStreaming ? getStreamingPrefix(renderContent) : null;
285
+ const openBlockLabel = streamingPrefix !== null ? getBlockLabel(getOpenBlockLanguage(renderContent)) : null;
66
286
 
67
287
  const handleCopy = () => {
68
288
  const stripChartBlocks = (value) =>
@@ -86,9 +306,16 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
86
306
  const bubbleContent = messageRef.current.querySelector(".ai-chat-message-content");
87
307
  if (!bubbleContent) return;
88
308
 
309
+ /** Omit interim “Working” tray + streaming status from PDF title fallback. */
310
+ const plainForTitle = (() => {
311
+ const c = bubbleContent.cloneNode(true);
312
+ c.querySelectorAll(".ai-chat-process-trace, .ai-chat-streaming-status").forEach((n) => n.remove());
313
+ return (c.textContent || "").trim();
314
+ })();
315
+
89
316
  // Build filename from AI-provided title or first meaningful line
90
317
  const titleText = responseTitle ||
91
- (bubbleContent.textContent || "").split("\n").map((l) => l.trim()).find(Boolean) ||
318
+ plainForTitle.split("\n").map((l) => l.trim()).find(Boolean) ||
92
319
  "response";
93
320
  const slug = titleText.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "response";
94
321
  const now = new Date();
@@ -98,7 +325,11 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
98
325
  setIsPrinting(true);
99
326
 
100
327
  const clone = bubbleContent.cloneNode(true);
101
- clone.querySelectorAll(".ai-chart-export-actions, .ai-chat-message-actions").forEach((n) => n.remove());
328
+ clone
329
+ .querySelectorAll(
330
+ ".ai-chart-export-actions, .ai-chat-message-actions, .ai-chat-process-trace, .ai-chat-streaming-status"
331
+ )
332
+ .forEach((n) => n.remove());
102
333
 
103
334
  setIsPrinting(true);
104
335
  const previousTitle = document.title;
@@ -133,6 +364,12 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
133
364
  window.print();
134
365
  };
135
366
 
367
+ // Hide the duplicate inline status row when the collapsible Process panel already lists statuses.
368
+ const processPanelVisible =
369
+ !isUser &&
370
+ showProcessTracePanel !== false &&
371
+ orchidAiProcessTraceHasDisplayableContent(processTrace, processInterimLive, { isStreaming });
372
+
136
373
  const markdownComponents = {
137
374
  pre({ children, ...props }) {
138
375
  const onlyChild = React.Children.toArray(children)[0];
@@ -203,8 +440,42 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
203
440
  </div>
204
441
  <div className={`ai-chat-bubble ${role}`}>
205
442
  <div className="ai-chat-message-content">
443
+ {!isUser ? (
444
+ <ProcessTracePanel
445
+ processTrace={processTrace}
446
+ processInterimLive={processInterimLive}
447
+ isStreaming={isStreaming}
448
+ showProcessTracePanel={showProcessTracePanel}
449
+ />
450
+ ) : null}
451
+ {!isUser && isStreaming && streamingStatusText && !processPanelVisible ? (
452
+ <div className="ai-chat-streaming-status" aria-live="polite">
453
+ <span className="ai-chat-status-text">{streamingStatusText}</span>
454
+ <div className="ai-chat-typing ai-chat-typing--inline" aria-hidden>
455
+ <span />
456
+ <span />
457
+ <span />
458
+ </div>
459
+ </div>
460
+ ) : null}
206
461
  {isUser ? (
207
462
  <UserBubbleContent content={content} />
463
+ ) : streamingPrefix !== null ? (
464
+ <>
465
+ {streamingPrefix && (
466
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{streamingPrefix}</ReactMarkdown>
467
+ )}
468
+ <div className="ai-building-block" role="status" aria-label={openBlockLabel || "Building content"}>
469
+ {openBlockLabel ? (
470
+ <span className="ai-building-block__label">{openBlockLabel}</span>
471
+ ) : null}
472
+ <div className="ai-building-block__dots" aria-hidden>
473
+ <span className="ai-building-block__dot" />
474
+ <span className="ai-building-block__dot" />
475
+ <span className="ai-building-block__dot" />
476
+ </div>
477
+ </div>
478
+ </>
208
479
  ) : (
209
480
  <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{renderContent}</ReactMarkdown>
210
481
  )}
@@ -214,7 +485,7 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
214
485
  </div>
215
486
  )}
216
487
  </div>
217
- {!isUser && (
488
+ {!isUser && !isStreaming && (
218
489
  <div className="ai-chat-message-actions">
219
490
  <button
220
491
  type="button"
@@ -91,15 +91,16 @@ function normalizeYAxis(axis) {
91
91
  return { label, categories };
92
92
  }
93
93
 
94
+ /** When models omit `xAxis.label`, charts still need a string for the axis caption. */
95
+ const DEFAULT_CATEGORY_AXIS_LABEL = "Category";
96
+
94
97
  function normalizeCategoryAxis(axis) {
95
98
  if (!isObject(axis) || !Array.isArray(axis.categories)) {
96
99
  return null;
97
100
  }
98
101
 
99
- const label = typeof axis.label === "string" ? axis.label.trim() : "";
100
- if (!label) {
101
- return null;
102
- }
102
+ const rawLabel = typeof axis.label === "string" ? axis.label.trim() : "";
103
+ const label = rawLabel || DEFAULT_CATEGORY_AXIS_LABEL;
103
104
 
104
105
  const categories = axis.categories
105
106
  .map((category) => {
@@ -171,9 +172,48 @@ function normalizeSeries(series, allowedCategoryKeys) {
171
172
  return normalized.length > 0 ? normalized : null;
172
173
  }
173
174
 
175
+ /**
176
+ * Distinct categorical `y` values from dot_chart payloads (series-based and/or flat `points`).
177
+ */
178
+ function collectDotChartYCategoryKeys(payload) {
179
+ const ordered = [];
180
+ const seen = new Set();
181
+ const addKey = (rawY) => {
182
+ const key = String(rawY ?? "").trim();
183
+ if (!key || seen.has(key)) {
184
+ return;
185
+ }
186
+ seen.add(key);
187
+ ordered.push(key);
188
+ };
189
+
190
+ if (Array.isArray(payload.series)) {
191
+ for (const entry of payload.series) {
192
+ if (!isObject(entry) || !Array.isArray(entry.points)) {
193
+ continue;
194
+ }
195
+ for (const pt of entry.points) {
196
+ if (!isObject(pt)) {
197
+ continue;
198
+ }
199
+ addKey(pt.y);
200
+ }
201
+ }
202
+ }
203
+ if (Array.isArray(payload.points)) {
204
+ for (const pt of payload.points) {
205
+ if (!isObject(pt)) {
206
+ continue;
207
+ }
208
+ addKey(pt.y);
209
+ }
210
+ }
211
+ return ordered;
212
+ }
213
+
174
214
  /**
175
215
  * Dot charts: models often send yAxis.label only and put category keys in point.y.
176
- * Build categories from series points when yAxis.categories is missing or invalid.
216
+ * Infer categories from `series[].points` and/or top-level `points`.
177
217
  */
178
218
  function mergeDotChartYAxisWithInferredCategories(payload) {
179
219
  if (!isObject(payload) || payload.type !== DOT_CHART_TYPE) {
@@ -188,37 +228,30 @@ function mergeDotChartYAxisWithInferredCategories(payload) {
188
228
  }
189
229
 
190
230
  const yLabel = typeof yRaw.label === "string" ? yRaw.label.trim() : "";
191
- if (!yLabel || !Array.isArray(payload.series)) {
231
+ if (!yLabel) {
192
232
  return yRaw;
193
233
  }
194
234
 
195
- const seen = new Map();
196
- for (const entry of payload.series) {
197
- if (!isObject(entry) || !Array.isArray(entry.points)) {
198
- continue;
199
- }
200
- for (const pt of entry.points) {
201
- if (!isObject(pt)) {
202
- continue;
203
- }
204
- const k = String(pt.y || "").trim();
205
- if (!k || seen.has(k)) {
206
- continue;
207
- }
208
- const disp =
209
- typeof pt.label === "string" && pt.label.trim() ? String(pt.label).trim() : k;
210
- seen.set(k, disp);
211
- }
212
- }
213
-
214
- if (seen.size === 0) {
235
+ const keys = collectDotChartYCategoryKeys(payload);
236
+ if (keys.length === 0) {
215
237
  return yRaw;
216
238
  }
217
239
 
218
- const categories = [...seen.entries()].map(([key, label]) => ({ key: key, label: label }));
240
+ const categories = keys.map((key) => ({ key: key, label: key }));
219
241
  return { ...yRaw, categories: categories };
220
242
  }
221
243
 
244
+ /** Prefer `series`; otherwise wrap flat `points` for {@link normalizeSeries}. */
245
+ function getDotChartSeriesInput(payload) {
246
+ if (Array.isArray(payload.series) && payload.series.length > 0) {
247
+ return payload.series;
248
+ }
249
+ if (Array.isArray(payload.points) && payload.points.length > 0) {
250
+ return [{ name: "Points", points: payload.points }];
251
+ }
252
+ return null;
253
+ }
254
+
222
255
  function normalizeValueSeries(series, allowedCategoryKeys, options = {}) {
223
256
  const { allowNegativeValues = true } = options;
224
257
 
@@ -560,6 +593,23 @@ function tryCoerceHistogramBinsFromLLM(bins) {
560
593
  continue;
561
594
  }
562
595
 
596
+ /** e.g. `"8"` or `"12"` — treat as unit-width bucket [n, n + 1) for numeric axes */
597
+ const loneNumMatch = /^(\d+(?:\.\d+)?)\s*$/i.exec(rangeStr);
598
+ if (loneNumMatch) {
599
+ const a = Number(loneNumMatch[1]);
600
+ if (!Number.isFinite(a)) {
601
+ return null;
602
+ }
603
+ work.push({
604
+ start: a,
605
+ end: a + 1,
606
+ value: value,
607
+ label: rangeStr,
608
+ open: false,
609
+ });
610
+ continue;
611
+ }
612
+
563
613
  return null;
564
614
  }
565
615
 
@@ -1137,8 +1187,9 @@ export function validateDotChartPayload(payload) {
1137
1187
  return { valid: false, error: "Invalid yAxis. Expected label and at least one category with key/label." };
1138
1188
  }
1139
1189
 
1190
+ const seriesInput = getDotChartSeriesInput(payload);
1140
1191
  const series = normalizeSeries(
1141
- payload.series,
1192
+ seriesInput,
1142
1193
  yAxis.categories.map((category) => category.key)
1143
1194
  );
1144
1195
  if (!series) {
@@ -14,9 +14,9 @@ table | bar_chart | line_chart | stacked_bar_chart | grouped_bar_chart | dot_cha
14
14
 
15
15
  Do not invent other type names. For several metrics at a glance use **stat_cards** (\`cards\`: [{ \`label\` or \`title\`, \`value\`, optional \`subtitle\`, \`unit\`, \`trend\`: up|down|neutral }]); for tabular listings use **table**; category counts use **bar_chart** (\`bars\`: [{ "label", "value" }], values ≥ 0). **table**: either \`columns\`: [{ "key", "label" }] with \`rows\`: objects keyed by those keys, OR \`columns\` as a string array (headers) with each \`row\` a string array of values in column order.
16
16
 
17
- **line_chart** / **stacked_bar_chart** / **grouped_bar_chart**: \`xAxis.categories\` (≥2 entries with key+label), \`yAxis\` with label, \`series[].points\` with x = category key and numeric value.
17
+ **line_chart** / **stacked_bar_chart** / **grouped_bar_chart**: \`xAxis\` must include **\`label\`** (human-readable axis title, e.g. \`"Date"\`, \`"Week"\`) **and** \`categories\` (≥2 objects, each with \`key\` + \`label\`). \`yAxis\` must include \`label\`. \`series[].points\`: \`x\` = each category’s \`key\`, \`value\` = number (stacked/grouped: values ≥ 0).
18
18
 
19
- **timeline**: \`items\` or \`events\` with label, start, end (ISO 8601). **dot_chart**: numeric \`x\`, categorical \`y\` per point; \`yAxis.categories\` may be omitted (inferred from point \`y\` / \`label\`). **histogram**: bins as \`start\`/\`end\`/\`value\`, or \`range\` (\`"0-1"\`, \`"6+"\`) with \`count\` or \`value\`. **scatter_plot**: standard numeric axes/series.
19
+ **timeline**: \`items\` or \`events\` with label, start, end (ISO 8601). **dot_chart**: numeric \`x\`, categorical \`y\` (row/category key) per point; optional per-point \`label\` for tooltips only. Use either \`series\`: [{ \`name\`, \`points\` }] **or** a top-level \`points\` array; \`yAxis\` needs \`label\`; \`yAxis.categories\` may be omitted (inferred from distinct \`y\`). **histogram**: bins prefer \`start\`, \`end\`, \`value\` (non-negative, end > start); or \`range\` + \`count\`/\`value\` where \`range\` is \`"min-max"\`, \`"8"\` (single numeric bucket), or \`"6+"\` (open upper). **scatter_plot**: standard numeric axes/series.
20
20
 
21
21
  In prose-only replies (capabilities, no data): do not include an orchid-ai-chart block. When rendering data, skip long schema tutorials—output valid JSON only in the fence.
22
22
  ~10 table rows unless the user asks for more.