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.
- package/README.md +304 -0
- package/orchid-ai.css +517 -0
- package/package.json +1 -1
- package/src/components/ChatWindow.jsx +28 -5
- package/src/components/Message.jsx +285 -14
- package/src/components/visualizations/chartSchema.js +79 -28
- package/src/constants/visualizationInstructions.js +2 -2
- package/src/hooks/useOrchidAiChat.js +93 -18
- package/src/index.d.ts +71 -0
- package/src/index.js +11 -0
- package/src/orchidAiProcessTrace.js +175 -0
- package/src/orchidAiStreamingTitle.js +12 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
100
|
-
|
|
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
|
-
*
|
|
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
|
|
231
|
+
if (!yLabel) {
|
|
192
232
|
return yRaw;
|
|
193
233
|
}
|
|
194
234
|
|
|
195
|
-
const
|
|
196
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|