orchid-ai 2.0.1 → 2.0.3

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,29 +1,209 @@
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
+ }
48
+
49
+ const TITLE_COMMENT_RE = /<!--\s*title:\s*([^-][^>]*?)\s*-->/gi;
50
+
51
+ /** Prefer the last `<!--title:...-->` so end-of-reply titles work; still removes legacy start-of-reply comments. */
52
+ function peelOrchidAiTitleComment(content, isUser) {
53
+ if (isUser) return { responseTitle: null, renderContent: content };
54
+ const s = String(content ?? "");
55
+ const matches = [...s.matchAll(TITLE_COMMENT_RE)];
56
+ if (matches.length === 0) return { responseTitle: null, renderContent: content };
57
+ const m = matches[matches.length - 1];
58
+ const full = m[0];
59
+ const idx = m.index;
60
+ const responseTitle = m[1].trim();
61
+ const renderContent = (s.slice(0, idx) + s.slice(idx + full.length)).replace(/^\s+/, "").replace(/\s+$/, "");
62
+ return { responseTitle, renderContent };
63
+ }
64
+ /** Split on http(s) URLs for lightweight linkify in user bubbles (plain text, not full markdown). */
65
+ const URL_INLINE_RE = /(https?:\/\/[^\s<>`]+)/gi;
66
+
67
+ function trimTrailingUrlPunctuation(href) {
68
+ return href.replace(/[),.;:!?'"\]}>]+$/g, "");
69
+ }
70
+
71
+ function linkifyUserLine(line) {
72
+ const parts = String(line).split(URL_INLINE_RE);
73
+ return parts.map((part, i) => {
74
+ if (part === "") return null;
75
+ if (/^https?:\/\//i.test(part)) {
76
+ const href = trimTrailingUrlPunctuation(part);
77
+ return (
78
+ <a
79
+ key={i}
80
+ href={href}
81
+ target="_blank"
82
+ rel="noopener noreferrer"
83
+ className="ai-chat-user-link"
84
+ >
85
+ {part}
86
+ </a>
87
+ );
88
+ }
89
+ return <React.Fragment key={i}>{part}</React.Fragment>;
90
+ });
91
+ }
92
+
93
+ function UserBubbleContent({ content }) {
94
+ const lines = String(content).split("\n");
95
+ return lines.map((line, li) => (
96
+ <React.Fragment key={li}>
97
+ {li > 0 ? <br /> : null}
98
+ {linkifyUserLine(line)}
99
+ </React.Fragment>
100
+ ));
101
+ }
11
102
 
12
- export default function Message({ role, content, truncated }) {
103
+ function ProcessTracePanel({ processTrace, processInterimLive, isStreaming, showProcessTracePanel }) {
104
+ if (showProcessTracePanel === false) return null;
105
+
106
+ const autoCollapsed = !isStreaming && processTrace?.defaultCollapsed !== false;
107
+ const [expanded, setExpanded] = useState(() => !autoCollapsed);
108
+
109
+ useEffect(() => {
110
+ setExpanded(!autoCollapsed);
111
+ }, [autoCollapsed]);
112
+
113
+ const hasContent = orchidAiProcessTraceHasDisplayableContent(processTrace, processInterimLive, {
114
+ isStreaming,
115
+ });
116
+ if (!hasContent) return null;
117
+
118
+ const items = processTrace?.items ?? [];
119
+
120
+ return (
121
+ <details
122
+ className="ai-chat-process-trace"
123
+ open={expanded}
124
+ onToggle={(e) => setExpanded(e.target.open)}
125
+ >
126
+ <summary className="ai-chat-process-trace__summary">
127
+ <span className="ai-chat-process-trace__summary-text">Working</span>
128
+ {isStreaming ? (
129
+ <span className="ai-chat-process-trace__mini-typing" aria-hidden title="In progress">
130
+ <span />
131
+ <span />
132
+ <span />
133
+ </span>
134
+ ) : (
135
+ <span className="ai-chat-process-trace__summary-chevron" aria-hidden>
136
+
137
+ </span>
138
+ )}
139
+ </summary>
140
+ <div className="ai-chat-process-trace__panel">
141
+ <ul className="ai-chat-process-trace__timeline">
142
+ {items.map((entry, i) => {
143
+ const lane = orchidAiProcessTraceEntryKind(entry);
144
+ const isLiveStatus = isStreaming && !processInterimLive && i === items.length - 1 && entry.type === "status";
145
+ return (
146
+ <li
147
+ key={i}
148
+ className={`ai-chat-process-trace__step ai-chat-process-trace__step--${lane}${isLiveStatus ? " ai-chat-process-trace__step--live" : ""}`}
149
+ >
150
+ <div className="ai-chat-process-trace__rail" aria-hidden>
151
+ <span className="ai-chat-process-trace__dot" />
152
+ </div>
153
+ <div className="ai-chat-process-trace__body">
154
+ {entry.type === "status" ? (
155
+ <span className="ai-chat-process-trace__line">{entry.value}</span>
156
+ ) : (
157
+ <div className="ai-chat-process-trace__prose">
158
+ {entry.value}
159
+ </div>
160
+ )}
161
+ </div>
162
+ </li>
163
+ );
164
+ })}
165
+ {processInterimLive ? (
166
+ <li className="ai-chat-process-trace__step ai-chat-process-trace__step--text ai-chat-process-trace__step--interim">
167
+ <div className="ai-chat-process-trace__rail" aria-hidden>
168
+ <span className="ai-chat-process-trace__dot" />
169
+ </div>
170
+ <div className="ai-chat-process-trace__body">
171
+ <div className="ai-chat-process-trace__prose">{processInterimLive}</div>
172
+ </div>
173
+ </li>
174
+ ) : null}
175
+ </ul>
176
+ </div>
177
+ </details>
178
+ );
179
+ }
180
+
181
+ export default function Message({
182
+ role,
183
+ content,
184
+ truncated,
185
+ exportPrefix = "orchid-ai",
186
+ isStreaming = false,
187
+ streamingStatusText,
188
+ processTrace,
189
+ processInterimLive = "",
190
+ showProcessTracePanel = true,
191
+ }) {
13
192
  const isUser = role === "user";
14
193
  const [copied, setCopied] = useState(false);
15
194
  const [isPrinting, setIsPrinting] = useState(false);
16
195
  const messageRef = useRef(null);
17
196
 
18
197
  // Extract AI-provided title comment and strip it from rendered content
19
- const { responseTitle, renderContent } = useMemo(() => {
20
- if (isUser) return { responseTitle: null, renderContent: content };
21
- const match = TITLE_RE.exec(content);
22
- return {
23
- responseTitle: match ? match[1].trim() : null,
24
- renderContent: match ? content.replace(match[0], "").replace(/^\s+/, "") : content,
25
- };
26
- }, [content, isUser]);
198
+ const { responseTitle, renderContent } = useMemo(
199
+ () => peelOrchidAiTitleComment(content, isUser),
200
+ [content, isUser]
201
+ );
202
+
203
+ // When streaming, detect an unclosed code block so we can show a labeled placeholder
204
+ // instead of passing a broken fence to ReactMarkdown.
205
+ const streamingPrefix = !isUser && isStreaming ? getStreamingPrefix(renderContent) : null;
206
+ const openBlockLabel = streamingPrefix !== null ? getBlockLabel(getOpenBlockLanguage(renderContent)) : null;
27
207
 
28
208
  const handleCopy = () => {
29
209
  const stripChartBlocks = (value) =>
@@ -47,32 +227,41 @@ export default function Message({ role, content, truncated }) {
47
227
  const bubbleContent = messageRef.current.querySelector(".ai-chat-message-content");
48
228
  if (!bubbleContent) return;
49
229
 
230
+ /** Omit interim “Working” tray + streaming status from PDF title fallback. */
231
+ const plainForTitle = (() => {
232
+ const c = bubbleContent.cloneNode(true);
233
+ c.querySelectorAll(".ai-chat-process-trace, .ai-chat-streaming-status").forEach((n) => n.remove());
234
+ return (c.textContent || "").trim();
235
+ })();
236
+
50
237
  // Build filename from AI-provided title or first meaningful line
51
238
  const titleText = responseTitle ||
52
- (bubbleContent.textContent || "").split("\n").map((l) => l.trim()).find(Boolean) ||
239
+ plainForTitle.split("\n").map((l) => l.trim()).find(Boolean) ||
53
240
  "response";
54
241
  const slug = titleText.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "response";
55
242
  const now = new Date();
56
243
  const pad = (v) => String(v).padStart(2, "0");
57
244
  const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}`;
58
245
 
246
+ setIsPrinting(true);
247
+
248
+ const clone = bubbleContent.cloneNode(true);
249
+ clone
250
+ .querySelectorAll(
251
+ ".ai-chart-export-actions, .ai-chat-message-actions, .ai-chat-process-trace, .ai-chat-streaming-status"
252
+ )
253
+ .forEach((n) => n.remove());
254
+
59
255
  setIsPrinting(true);
60
256
  const previousTitle = document.title;
61
- document.title = `orchid-ai-${slug}-${timestamp}`;
257
+ document.title = `${exportPrefix}-${slug}-${timestamp}`;
62
258
 
63
- // Clone the bubble content into an isolated print section at the body root.
64
- // This keeps the layout clean (no surrounding app chrome) while the CSS
65
- // animation: none rule ensures chart elements render at their final visible
66
- // state rather than being caught mid-animation by the print reflow.
67
259
  let printSection = document.getElementById("ai-cortex-print-section");
68
260
  if (!printSection) {
69
261
  printSection = document.createElement("div");
70
262
  printSection.id = "ai-cortex-print-section";
71
263
  document.body.appendChild(printSection);
72
264
  }
73
-
74
- const clone = bubbleContent.cloneNode(true);
75
- clone.querySelectorAll(".ai-chart-export-actions, .ai-chat-message-actions").forEach((n) => n.remove());
76
265
  printSection.innerHTML = "";
77
266
  printSection.appendChild(clone);
78
267
  document.body.classList.add("ai-chat-printing");
@@ -96,6 +285,12 @@ export default function Message({ role, content, truncated }) {
96
285
  window.print();
97
286
  };
98
287
 
288
+ // Hide the duplicate inline status row when the collapsible Process panel already lists statuses.
289
+ const processPanelVisible =
290
+ !isUser &&
291
+ showProcessTracePanel !== false &&
292
+ orchidAiProcessTraceHasDisplayableContent(processTrace, processInterimLive, { isStreaming });
293
+
99
294
  const markdownComponents = {
100
295
  pre({ children, ...props }) {
101
296
  const onlyChild = React.Children.toArray(children)[0];
@@ -147,6 +342,16 @@ export default function Message({ role, content, truncated }) {
147
342
  </code>
148
343
  );
149
344
  },
345
+ a({ href, children, ...props }) {
346
+ if (typeof href !== "string" || !/^https?:\/\//i.test(href)) {
347
+ return <span {...props}>{children}</span>;
348
+ }
349
+ return (
350
+ <a {...props} href={href} target="_blank" rel="noopener noreferrer">
351
+ {children}
352
+ </a>
353
+ );
354
+ },
150
355
  };
151
356
 
152
357
  return (
@@ -156,13 +361,42 @@ export default function Message({ role, content, truncated }) {
156
361
  </div>
157
362
  <div className={`ai-chat-bubble ${role}`}>
158
363
  <div className="ai-chat-message-content">
364
+ {!isUser ? (
365
+ <ProcessTracePanel
366
+ processTrace={processTrace}
367
+ processInterimLive={processInterimLive}
368
+ isStreaming={isStreaming}
369
+ showProcessTracePanel={showProcessTracePanel}
370
+ />
371
+ ) : null}
372
+ {!isUser && isStreaming && streamingStatusText && !processPanelVisible ? (
373
+ <div className="ai-chat-streaming-status" aria-live="polite">
374
+ <span className="ai-chat-status-text">{streamingStatusText}</span>
375
+ <div className="ai-chat-typing ai-chat-typing--inline" aria-hidden>
376
+ <span />
377
+ <span />
378
+ <span />
379
+ </div>
380
+ </div>
381
+ ) : null}
159
382
  {isUser ? (
160
- content.split("\n").map((line, i) => (
161
- <React.Fragment key={i}>
162
- {line}
163
- {i < content.split("\n").length - 1 && <br />}
164
- </React.Fragment>
165
- ))
383
+ <UserBubbleContent content={content} />
384
+ ) : streamingPrefix !== null ? (
385
+ <>
386
+ {streamingPrefix && (
387
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{streamingPrefix}</ReactMarkdown>
388
+ )}
389
+ <div className="ai-building-block" role="status" aria-label={openBlockLabel || "Building content"}>
390
+ {openBlockLabel ? (
391
+ <span className="ai-building-block__label">{openBlockLabel}</span>
392
+ ) : null}
393
+ <div className="ai-building-block__dots" aria-hidden>
394
+ <span className="ai-building-block__dot" />
395
+ <span className="ai-building-block__dot" />
396
+ <span className="ai-building-block__dot" />
397
+ </div>
398
+ </div>
399
+ </>
166
400
  ) : (
167
401
  <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{renderContent}</ReactMarkdown>
168
402
  )}
@@ -172,7 +406,7 @@ export default function Message({ role, content, truncated }) {
172
406
  </div>
173
407
  )}
174
408
  </div>
175
- {!isUser && (
409
+ {!isUser && !isStreaming && (
176
410
  <div className="ai-chat-message-actions">
177
411
  <button
178
412
  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.