orchid-ai 2.0.2 → 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.
- package/orchid-ai.css +382 -0
- package/package.json +1 -1
- package/src/components/ChatWindow.jsx +25 -5
- package/src/components/Message.jsx +206 -14
- package/src/components/visualizations/chartSchema.js +79 -28
- package/src/constants/visualizationInstructions.js +2 -2
- package/src/hooks/useOrchidAiChat.js +83 -17
- package/src/index.d.ts +57 -0
- package/src/index.js +11 -0
- package/src/orchidAiProcessTrace.js +163 -0
- package/src/orchidAiStreamingTitle.js +12 -0
package/orchid-ai.css
CHANGED
|
@@ -382,6 +382,323 @@
|
|
|
382
382
|
border-bottom-left-radius: 4px;
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/* ── Tool / preamble trace (Claude-style timeline, light theme) ── */
|
|
386
|
+
|
|
387
|
+
.ai-chat-process-trace {
|
|
388
|
+
--ai-process-bg: #f4f6f8;
|
|
389
|
+
--ai-process-border: #e4e8ed;
|
|
390
|
+
--ai-process-rail: #d8dde4;
|
|
391
|
+
--ai-process-text: #5c6570;
|
|
392
|
+
--ai-process-text-strong: #2d3748;
|
|
393
|
+
--ai-process-dot: #9aa5b1;
|
|
394
|
+
--ai-process-dot-tool: #059669;
|
|
395
|
+
--ai-process-dot-compile: #4f46e5;
|
|
396
|
+
--ai-process-dot-mind: #94a3b8;
|
|
397
|
+
--ai-process-interim-fg: #475569;
|
|
398
|
+
|
|
399
|
+
margin: 0 0 12px;
|
|
400
|
+
border-radius: 12px;
|
|
401
|
+
border: 1px solid transparent;
|
|
402
|
+
background: transparent;
|
|
403
|
+
box-shadow: none;
|
|
404
|
+
font-size: 12.5px;
|
|
405
|
+
line-height: 1.45;
|
|
406
|
+
color: var(--ai-process-text);
|
|
407
|
+
overflow: visible;
|
|
408
|
+
transition:
|
|
409
|
+
background 0.22s ease,
|
|
410
|
+
border-color 0.22s ease,
|
|
411
|
+
box-shadow 0.22s ease;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/* Collapsed: no chrome — summary reads as muted underlined text on the bubble background */
|
|
415
|
+
.ai-chat-process-trace:not([open]) {
|
|
416
|
+
overflow: visible;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.ai-chat-process-trace[open] {
|
|
420
|
+
overflow: hidden;
|
|
421
|
+
border-color: var(--ai-process-border);
|
|
422
|
+
background: linear-gradient(165deg, var(--ai-process-bg) 0%, #eef1f4 100%);
|
|
423
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.ai-chat-process-trace__summary {
|
|
427
|
+
cursor: pointer;
|
|
428
|
+
list-style: none;
|
|
429
|
+
display: flex;
|
|
430
|
+
align-items: center;
|
|
431
|
+
gap: 8px;
|
|
432
|
+
padding: 4px 0 10px;
|
|
433
|
+
margin: 0;
|
|
434
|
+
font-weight: 500;
|
|
435
|
+
font-size: 13px;
|
|
436
|
+
letter-spacing: 0.01em;
|
|
437
|
+
text-transform: none;
|
|
438
|
+
color: #6b7280;
|
|
439
|
+
text-decoration: none;
|
|
440
|
+
user-select: none;
|
|
441
|
+
background: transparent;
|
|
442
|
+
border-bottom: none;
|
|
443
|
+
transition:
|
|
444
|
+
color 0.18s ease,
|
|
445
|
+
padding 0.2s ease,
|
|
446
|
+
font-size 0.2s ease,
|
|
447
|
+
font-weight 0.2s ease,
|
|
448
|
+
letter-spacing 0.2s ease,
|
|
449
|
+
background 0.22s ease,
|
|
450
|
+
border-color 0.22s ease;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* Collapsed: underline only the label — chevron / dots sit beside it, not underlined */
|
|
454
|
+
.ai-chat-process-trace:not([open]) > .ai-chat-process-trace__summary {
|
|
455
|
+
gap: 5px;
|
|
456
|
+
width: fit-content;
|
|
457
|
+
max-width: 100%;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.ai-chat-process-trace:not([open]) > .ai-chat-process-trace__summary:hover {
|
|
461
|
+
color: #4b5563;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.ai-chat-process-trace:not([open]) > .ai-chat-process-trace__summary:hover .ai-chat-process-trace__summary-text {
|
|
465
|
+
text-decoration-color: #4b5563;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.ai-chat-process-trace:not([open]) > .ai-chat-process-trace__summary:focus-visible {
|
|
469
|
+
outline: 2px solid #93c5fd;
|
|
470
|
+
outline-offset: 2px;
|
|
471
|
+
border-radius: 4px;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/* Expanded: restore panel header strip inside the tinted card */
|
|
475
|
+
.ai-chat-process-trace[open] > .ai-chat-process-trace__summary {
|
|
476
|
+
padding: 10px 14px;
|
|
477
|
+
font-weight: 600;
|
|
478
|
+
font-size: 12px;
|
|
479
|
+
letter-spacing: 0.02em;
|
|
480
|
+
text-transform: uppercase;
|
|
481
|
+
color: var(--ai-process-text-strong);
|
|
482
|
+
text-decoration: none;
|
|
483
|
+
background: rgba(255, 255, 255, 0.45);
|
|
484
|
+
border-bottom: 1px solid var(--ai-process-border);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.ai-chat-process-trace__summary::-webkit-details-marker {
|
|
488
|
+
display: none;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.ai-chat-process-trace__summary-text {
|
|
492
|
+
flex: 1;
|
|
493
|
+
text-decoration: none;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.ai-chat-process-trace:not([open]) .ai-chat-process-trace__summary-text {
|
|
497
|
+
flex: 0 1 auto;
|
|
498
|
+
text-decoration: underline;
|
|
499
|
+
text-underline-offset: 3px;
|
|
500
|
+
text-decoration-thickness: 1px;
|
|
501
|
+
transition: text-decoration-color 0.18s ease;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.ai-chat-process-trace[open] .ai-chat-process-trace__summary-text {
|
|
505
|
+
flex: 1;
|
|
506
|
+
text-decoration: none;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/* Single expand chevron (right); hidden while streaming — dots replace it. */
|
|
510
|
+
.ai-chat-process-trace__summary-chevron {
|
|
511
|
+
display: inline-flex;
|
|
512
|
+
align-items: center;
|
|
513
|
+
justify-content: center;
|
|
514
|
+
flex-shrink: 0;
|
|
515
|
+
min-width: 1.25em;
|
|
516
|
+
font-size: 15px;
|
|
517
|
+
font-weight: 700;
|
|
518
|
+
line-height: 1;
|
|
519
|
+
opacity: 0.55;
|
|
520
|
+
text-decoration: none;
|
|
521
|
+
transition: transform 0.18s ease, opacity 0.15s ease;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.ai-chat-process-trace:not([open]) .ai-chat-process-trace__summary-chevron {
|
|
525
|
+
opacity: 0.45;
|
|
526
|
+
min-width: auto;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
.ai-chat-process-trace:not([open]) .ai-chat-process-trace__mini-typing span {
|
|
530
|
+
opacity: 0.72;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.ai-chat-process-trace[open] > .ai-chat-process-trace__summary .ai-chat-process-trace__summary-chevron {
|
|
534
|
+
transform: rotate(90deg);
|
|
535
|
+
opacity: 0.78;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.ai-chat-process-trace__mini-typing {
|
|
539
|
+
display: inline-flex;
|
|
540
|
+
align-items: center;
|
|
541
|
+
flex-shrink: 0;
|
|
542
|
+
gap: 3px;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.ai-chat-process-trace__mini-typing span {
|
|
546
|
+
width: 4px;
|
|
547
|
+
height: 4px;
|
|
548
|
+
border-radius: 50%;
|
|
549
|
+
background: var(--ai-process-dot-tool);
|
|
550
|
+
opacity: 0.85;
|
|
551
|
+
animation: ai-chat-process-mini-dot 1.05s ease-in-out infinite;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.ai-chat-process-trace__mini-typing span:nth-child(2) {
|
|
555
|
+
animation-delay: 0.12s;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
.ai-chat-process-trace__mini-typing span:nth-child(3) {
|
|
559
|
+
animation-delay: 0.24s;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
@keyframes ai-chat-process-mini-dot {
|
|
563
|
+
0%,
|
|
564
|
+
80%,
|
|
565
|
+
100% {
|
|
566
|
+
transform: translateY(0);
|
|
567
|
+
opacity: 0.35;
|
|
568
|
+
}
|
|
569
|
+
40% {
|
|
570
|
+
transform: translateY(-2px);
|
|
571
|
+
opacity: 1;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
@keyframes ai-chat-process-pulse {
|
|
576
|
+
0% {
|
|
577
|
+
box-shadow: 0 0 0 0 rgba(5, 150, 105, 0.35);
|
|
578
|
+
}
|
|
579
|
+
70% {
|
|
580
|
+
box-shadow: 0 0 0 6px rgba(5, 150, 105, 0);
|
|
581
|
+
}
|
|
582
|
+
100% {
|
|
583
|
+
box-shadow: 0 0 0 0 rgba(5, 150, 105, 0);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.ai-chat-process-trace__panel {
|
|
588
|
+
padding: 10px 12px 12px 8px;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
.ai-chat-process-trace__timeline {
|
|
592
|
+
list-style: none;
|
|
593
|
+
margin: 0;
|
|
594
|
+
padding: 0;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
.ai-chat-process-trace__step {
|
|
598
|
+
display: flex;
|
|
599
|
+
align-items: stretch;
|
|
600
|
+
gap: 10px;
|
|
601
|
+
margin: 0;
|
|
602
|
+
padding: 0 0 14px;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
.ai-chat-process-trace__step:last-child {
|
|
606
|
+
padding-bottom: 0;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
.ai-chat-process-trace__rail {
|
|
610
|
+
position: relative;
|
|
611
|
+
width: 18px;
|
|
612
|
+
flex-shrink: 0;
|
|
613
|
+
display: flex;
|
|
614
|
+
justify-content: center;
|
|
615
|
+
padding-top: 4px;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
.ai-chat-process-trace__rail::after {
|
|
619
|
+
content: "";
|
|
620
|
+
position: absolute;
|
|
621
|
+
top: 13px;
|
|
622
|
+
bottom: -14px;
|
|
623
|
+
left: 50%;
|
|
624
|
+
width: 1px;
|
|
625
|
+
transform: translateX(-50%);
|
|
626
|
+
background: var(--ai-process-rail);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
.ai-chat-process-trace__step:last-child .ai-chat-process-trace__rail::after {
|
|
630
|
+
display: none;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
.ai-chat-process-trace__dot {
|
|
634
|
+
position: relative;
|
|
635
|
+
z-index: 1;
|
|
636
|
+
width: 7px;
|
|
637
|
+
height: 7px;
|
|
638
|
+
border-radius: 50%;
|
|
639
|
+
background: var(--ai-process-dot);
|
|
640
|
+
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.ai-chat-process-trace__step--tool .ai-chat-process-trace__dot {
|
|
644
|
+
background: var(--ai-process-dot-tool);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.ai-chat-process-trace__step--compile .ai-chat-process-trace__dot {
|
|
648
|
+
background: var(--ai-process-dot-compile);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.ai-chat-process-trace__step--mind .ai-chat-process-trace__dot {
|
|
652
|
+
background: var(--ai-process-dot-mind);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.ai-chat-process-trace__step--text .ai-chat-process-trace__dot {
|
|
656
|
+
background: var(--ai-process-dot);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.ai-chat-process-trace__step--interim .ai-chat-process-trace__dot {
|
|
660
|
+
background: var(--ai-process-dot);
|
|
661
|
+
animation: ai-chat-process-pulse 1.4s ease-out infinite;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.ai-chat-process-trace__step--live .ai-chat-process-trace__dot {
|
|
665
|
+
animation: ai-chat-process-pulse 1.2s ease-out infinite;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.ai-chat-process-trace__body {
|
|
669
|
+
flex: 1;
|
|
670
|
+
min-width: 0;
|
|
671
|
+
padding-top: 1px;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
.ai-chat-process-trace__line {
|
|
675
|
+
display: block;
|
|
676
|
+
color: var(--ai-process-text-strong);
|
|
677
|
+
font-weight: 500;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.ai-chat-process-trace__step--tool .ai-chat-process-trace__line {
|
|
681
|
+
color: #047857;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
.ai-chat-process-trace__step--compile .ai-chat-process-trace__line {
|
|
685
|
+
color: #4338ca;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
.ai-chat-process-trace__prose {
|
|
689
|
+
margin: 0;
|
|
690
|
+
white-space: pre-wrap;
|
|
691
|
+
word-break: break-word;
|
|
692
|
+
color: var(--ai-process-interim-fg);
|
|
693
|
+
font-weight: 400;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.ai-chat-process-trace__step--interim .ai-chat-process-trace__prose {
|
|
697
|
+
border-left: 2px solid var(--ai-process-rail);
|
|
698
|
+
padding-left: 10px;
|
|
699
|
+
margin-left: 2px;
|
|
700
|
+
}
|
|
701
|
+
|
|
385
702
|
/* ── Markdown Typography (assistant bubbles) ── */
|
|
386
703
|
|
|
387
704
|
.ai-chat-bubble.assistant p {
|
|
@@ -1816,6 +2133,29 @@
|
|
|
1816
2133
|
animation-delay: 0.4s;
|
|
1817
2134
|
}
|
|
1818
2135
|
|
|
2136
|
+
/* Smaller dots for inline status (next to “Compiling…”) */
|
|
2137
|
+
.ai-chat-typing--inline {
|
|
2138
|
+
gap: 4px;
|
|
2139
|
+
padding: 0;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
.ai-chat-typing--inline span {
|
|
2143
|
+
width: 5px;
|
|
2144
|
+
height: 5px;
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
.ai-chat-streaming-status {
|
|
2148
|
+
display: flex;
|
|
2149
|
+
align-items: center;
|
|
2150
|
+
gap: 8px;
|
|
2151
|
+
flex-wrap: wrap;
|
|
2152
|
+
margin-bottom: 8px;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
.ai-chat-streaming-status .ai-chat-status-text {
|
|
2156
|
+
margin: 0;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
1819
2159
|
@keyframes typing {
|
|
1820
2160
|
0%,
|
|
1821
2161
|
60%,
|
|
@@ -1829,6 +2169,48 @@
|
|
|
1829
2169
|
}
|
|
1830
2170
|
}
|
|
1831
2171
|
|
|
2172
|
+
/* ── Building Block (streaming code-block placeholder) ── */
|
|
2173
|
+
|
|
2174
|
+
.ai-building-block {
|
|
2175
|
+
display: flex;
|
|
2176
|
+
align-items: center;
|
|
2177
|
+
gap: 10px;
|
|
2178
|
+
flex-wrap: wrap;
|
|
2179
|
+
padding: 8px 12px;
|
|
2180
|
+
border-radius: 8px;
|
|
2181
|
+
margin: 6px 0 0;
|
|
2182
|
+
background: #f3f4f6;
|
|
2183
|
+
border: 1px solid #e5e7eb;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
.ai-building-block__label {
|
|
2187
|
+
font-size: 13px;
|
|
2188
|
+
font-weight: 600;
|
|
2189
|
+
color: #4b5563;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
.ai-building-block__dots {
|
|
2193
|
+
display: flex;
|
|
2194
|
+
align-items: center;
|
|
2195
|
+
gap: 4px;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
.ai-building-block__dot {
|
|
2199
|
+
width: 5px;
|
|
2200
|
+
height: 5px;
|
|
2201
|
+
border-radius: 50%;
|
|
2202
|
+
background: #9ca3af;
|
|
2203
|
+
animation: typing 1.4s infinite;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
.ai-building-block__dots .ai-building-block__dot:nth-child(2) {
|
|
2207
|
+
animation-delay: 0.2s;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
.ai-building-block__dots .ai-building-block__dot:nth-child(3) {
|
|
2211
|
+
animation-delay: 0.4s;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
1832
2214
|
/* ── Input Area ── */
|
|
1833
2215
|
|
|
1834
2216
|
.ai-chat-input-form {
|
package/package.json
CHANGED
|
@@ -22,13 +22,14 @@ export default function ChatWindow({
|
|
|
22
22
|
emptyDescription,
|
|
23
23
|
suggestions = DEFAULT_SUGGESTIONS,
|
|
24
24
|
suggestionsDisabled = false,
|
|
25
|
+
showProcessTracePanel = true,
|
|
25
26
|
}) {
|
|
26
27
|
const exportPrefix = appName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
27
28
|
const bottomRef = useRef(null);
|
|
28
29
|
|
|
29
30
|
useEffect(() => {
|
|
30
31
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
31
|
-
}, [messages, loading]);
|
|
32
|
+
}, [messages, loading, statusText]);
|
|
32
33
|
|
|
33
34
|
const renderEmptyState = () => {
|
|
34
35
|
if (!aiEnabled) {
|
|
@@ -100,13 +101,32 @@ export default function ChatWindow({
|
|
|
100
101
|
);
|
|
101
102
|
};
|
|
102
103
|
|
|
104
|
+
const lastMsg = messages?.[messages.length - 1];
|
|
105
|
+
const hasStreamingMessage = lastMsg?.isStreaming === true;
|
|
106
|
+
|
|
103
107
|
return (
|
|
104
108
|
<div className="ai-chat-window">
|
|
105
109
|
{messages?.length === 0 && !loading && renderEmptyState()}
|
|
106
|
-
{(messages ?? []).map((msg, i) =>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
{(messages ?? []).map((msg, i) => {
|
|
111
|
+
const isLast = i === (messages?.length ?? 0) - 1;
|
|
112
|
+
const streamingStatusText =
|
|
113
|
+
loading && isLast && msg.role === 'assistant' && msg.isStreaming ? statusText : undefined;
|
|
114
|
+
return (
|
|
115
|
+
<Message
|
|
116
|
+
key={i}
|
|
117
|
+
role={msg.role}
|
|
118
|
+
content={msg.content}
|
|
119
|
+
truncated={msg.truncated}
|
|
120
|
+
exportPrefix={exportPrefix}
|
|
121
|
+
isStreaming={msg.isStreaming}
|
|
122
|
+
streamingStatusText={streamingStatusText}
|
|
123
|
+
processTrace={msg.processTrace}
|
|
124
|
+
processInterimLive={msg.processInterimLive}
|
|
125
|
+
showProcessTracePanel={showProcessTracePanel}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
{loading && !hasStreamingMessage && (
|
|
110
130
|
<div className="ai-chat-message assistant">
|
|
111
131
|
<div className="ai-chat-avatar assistant">AI</div>
|
|
112
132
|
<div className="ai-chat-bubble assistant">
|
|
@@ -1,14 +1,66 @@
|
|
|
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
|
+
}
|
|
48
|
+
|
|
49
|
+
const TITLE_COMMENT_RE = /<!--\s*title:\s*([^-][^>]*?)\s*-->/gi;
|
|
11
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
|
+
}
|
|
12
64
|
/** Split on http(s) URLs for lightweight linkify in user bubbles (plain text, not full markdown). */
|
|
13
65
|
const URL_INLINE_RE = /(https?:\/\/[^\s<>`]+)/gi;
|
|
14
66
|
|
|
@@ -48,21 +100,110 @@ function UserBubbleContent({ content }) {
|
|
|
48
100
|
));
|
|
49
101
|
}
|
|
50
102
|
|
|
51
|
-
|
|
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
|
+
}) {
|
|
52
192
|
const isUser = role === "user";
|
|
53
193
|
const [copied, setCopied] = useState(false);
|
|
54
194
|
const [isPrinting, setIsPrinting] = useState(false);
|
|
55
195
|
const messageRef = useRef(null);
|
|
56
196
|
|
|
57
197
|
// Extract AI-provided title comment and strip it from rendered content
|
|
58
|
-
const { responseTitle, renderContent } = useMemo(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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;
|
|
66
207
|
|
|
67
208
|
const handleCopy = () => {
|
|
68
209
|
const stripChartBlocks = (value) =>
|
|
@@ -86,9 +227,16 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
|
|
|
86
227
|
const bubbleContent = messageRef.current.querySelector(".ai-chat-message-content");
|
|
87
228
|
if (!bubbleContent) return;
|
|
88
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
|
+
|
|
89
237
|
// Build filename from AI-provided title or first meaningful line
|
|
90
238
|
const titleText = responseTitle ||
|
|
91
|
-
|
|
239
|
+
plainForTitle.split("\n").map((l) => l.trim()).find(Boolean) ||
|
|
92
240
|
"response";
|
|
93
241
|
const slug = titleText.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "response";
|
|
94
242
|
const now = new Date();
|
|
@@ -98,7 +246,11 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
|
|
|
98
246
|
setIsPrinting(true);
|
|
99
247
|
|
|
100
248
|
const clone = bubbleContent.cloneNode(true);
|
|
101
|
-
clone
|
|
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());
|
|
102
254
|
|
|
103
255
|
setIsPrinting(true);
|
|
104
256
|
const previousTitle = document.title;
|
|
@@ -133,6 +285,12 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
|
|
|
133
285
|
window.print();
|
|
134
286
|
};
|
|
135
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
|
+
|
|
136
294
|
const markdownComponents = {
|
|
137
295
|
pre({ children, ...props }) {
|
|
138
296
|
const onlyChild = React.Children.toArray(children)[0];
|
|
@@ -203,8 +361,42 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
|
|
|
203
361
|
</div>
|
|
204
362
|
<div className={`ai-chat-bubble ${role}`}>
|
|
205
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}
|
|
206
382
|
{isUser ? (
|
|
207
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
|
+
</>
|
|
208
400
|
) : (
|
|
209
401
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>{renderContent}</ReactMarkdown>
|
|
210
402
|
)}
|
|
@@ -214,7 +406,7 @@ export default function Message({ role, content, truncated, exportPrefix = "orch
|
|
|
214
406
|
</div>
|
|
215
407
|
)}
|
|
216
408
|
</div>
|
|
217
|
-
{!isUser && (
|
|
409
|
+
{!isUser && !isStreaming && (
|
|
218
410
|
<div className="ai-chat-message-actions">
|
|
219
411
|
<button
|
|
220
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
|
|
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.
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ORCHID_AI_SSE_STATUS_CLEAR_STREAM,
|
|
4
|
+
orchidAiStatusClearsStreamBuffer,
|
|
5
|
+
} from '../orchidAiStreamingTitle';
|
|
6
|
+
import {
|
|
7
|
+
augmentLiveProcessTraceSnapshot,
|
|
8
|
+
createOrchidAiProcessTraceCollector,
|
|
9
|
+
snapshotOrchidAiProcessTraceItems,
|
|
10
|
+
orchidAiProcessTraceHasDisplayableContent,
|
|
11
|
+
} from '../orchidAiProcessTrace';
|
|
2
12
|
|
|
3
13
|
/**
|
|
4
14
|
* Default status message strings. Export these so server-side code can import
|
|
5
15
|
* and reference the same values, making it easy to keep client and server in sync
|
|
6
16
|
* or swap them out per-app.
|
|
7
17
|
*
|
|
8
|
-
* Set showStatus: false on the hook to
|
|
18
|
+
* Set showStatus: false on the hook to suppress status display entirely.
|
|
9
19
|
*/
|
|
10
20
|
export const ORCHID_AI_DEFAULT_STATUS = {
|
|
11
21
|
thinking: 'Thinking',
|
|
12
|
-
compilingResponse:
|
|
22
|
+
compilingResponse: ORCHID_AI_SSE_STATUS_CLEAR_STREAM,
|
|
13
23
|
lookingUpData: 'Looking up data',
|
|
14
24
|
};
|
|
15
25
|
|
|
@@ -32,12 +42,8 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
|
|
|
32
42
|
const [loading, setLoading] = useState(false);
|
|
33
43
|
const [statusText, setStatusText] = useState('');
|
|
34
44
|
|
|
35
|
-
// Track messages in a ref so sendMessage always reads the latest without needing
|
|
36
|
-
// messages in its dependency array (avoids capturing stale history).
|
|
37
45
|
const messagesRef = useRef(initialMessages);
|
|
38
46
|
|
|
39
|
-
// Keep latest callbacks in refs so sendMessage identity stays stable regardless
|
|
40
|
-
// of whether the parent re-creates buildBody/getHeaders each render.
|
|
41
47
|
const buildBodyRef = useRef(buildBody);
|
|
42
48
|
const getHeadersRef = useRef(getHeaders);
|
|
43
49
|
buildBodyRef.current = buildBody;
|
|
@@ -70,10 +76,39 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
|
|
|
70
76
|
|
|
71
77
|
const contentType = response.headers.get('content-type') ?? '';
|
|
72
78
|
|
|
73
|
-
if (contentType.includes('text/event-stream')) {
|
|
79
|
+
if (contentType.includes('text/event-stream') && response.body) {
|
|
74
80
|
const reader = response.body.getReader();
|
|
75
81
|
const decoder = new TextDecoder();
|
|
76
82
|
let buffer = '';
|
|
83
|
+
const collector = createOrchidAiProcessTraceCollector();
|
|
84
|
+
|
|
85
|
+
addMessage({
|
|
86
|
+
role: 'assistant',
|
|
87
|
+
content: '',
|
|
88
|
+
isStreaming: true,
|
|
89
|
+
processTrace: snapshotOrchidAiProcessTraceItems(collector),
|
|
90
|
+
processInterimLive: '',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const patchStreamingAssistant = () => {
|
|
94
|
+
setMessages((prev) => {
|
|
95
|
+
const next = [...prev];
|
|
96
|
+
const last = next[next.length - 1];
|
|
97
|
+
if (last?.role !== 'assistant' || last?.isStreaming !== true) return prev;
|
|
98
|
+
next[next.length - 1] = {
|
|
99
|
+
...last,
|
|
100
|
+
content: collector.getLiveMain(),
|
|
101
|
+
processTrace: augmentLiveProcessTraceSnapshot(
|
|
102
|
+
snapshotOrchidAiProcessTraceItems(collector),
|
|
103
|
+
collector.getLiveMain(),
|
|
104
|
+
collector.getLiveInterim()
|
|
105
|
+
),
|
|
106
|
+
processInterimLive: collector.getLiveInterim(),
|
|
107
|
+
};
|
|
108
|
+
messagesRef.current = next;
|
|
109
|
+
return next;
|
|
110
|
+
});
|
|
111
|
+
};
|
|
77
112
|
|
|
78
113
|
while (true) {
|
|
79
114
|
const { done, value } = await reader.read();
|
|
@@ -86,18 +121,50 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
|
|
|
86
121
|
if (!line.startsWith('data: ')) continue;
|
|
87
122
|
try {
|
|
88
123
|
const event = JSON.parse(line.slice(6));
|
|
89
|
-
if (event.type === 'status'
|
|
90
|
-
setStatusText(event.text);
|
|
124
|
+
if (event.type === 'status') {
|
|
125
|
+
if (showStatus) setStatusText(event.text);
|
|
126
|
+
collector.onStatus(event.text, {
|
|
127
|
+
isClearStream: orchidAiStatusClearsStreamBuffer(event.text),
|
|
128
|
+
});
|
|
129
|
+
patchStreamingAssistant();
|
|
130
|
+
} else if (event.type === 'delta') {
|
|
131
|
+
collector.onDelta(event.text);
|
|
132
|
+
patchStreamingAssistant();
|
|
91
133
|
} else if (event.type === 'done') {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
134
|
+
const rawTrace = collector.buildPersistedTrace();
|
|
135
|
+
const trace =
|
|
136
|
+
rawTrace && orchidAiProcessTraceHasDisplayableContent(rawTrace, '')
|
|
137
|
+
? rawTrace
|
|
138
|
+
: undefined;
|
|
139
|
+
setMessages((prev) => {
|
|
140
|
+
const next = [...prev];
|
|
141
|
+
const last = next[next.length - 1];
|
|
142
|
+
const finalMsg = {
|
|
143
|
+
role: 'assistant',
|
|
144
|
+
content: event.response,
|
|
145
|
+
truncated: event.truncated === true,
|
|
146
|
+
...(trace ? { processTrace: trace } : {}),
|
|
147
|
+
};
|
|
148
|
+
if (last?.role === 'assistant' && last?.isStreaming) {
|
|
149
|
+
next[next.length - 1] = finalMsg;
|
|
150
|
+
} else {
|
|
151
|
+
next.push(finalMsg);
|
|
152
|
+
}
|
|
153
|
+
messagesRef.current = next;
|
|
154
|
+
return next;
|
|
96
155
|
});
|
|
97
156
|
} else if (event.type === 'error') {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
157
|
+
setMessages((prev) => {
|
|
158
|
+
const next = [...prev];
|
|
159
|
+
const last = next[next.length - 1];
|
|
160
|
+
const errMsg = { role: 'assistant', content: event.error || 'Something went wrong.' };
|
|
161
|
+
if (last?.role === 'assistant' && last?.isStreaming) {
|
|
162
|
+
next[next.length - 1] = errMsg;
|
|
163
|
+
} else {
|
|
164
|
+
next.push(errMsg);
|
|
165
|
+
}
|
|
166
|
+
messagesRef.current = next;
|
|
167
|
+
return next;
|
|
101
168
|
});
|
|
102
169
|
}
|
|
103
170
|
} catch {
|
|
@@ -106,7 +173,6 @@ export function useOrchidAiChat({ endpoint, buildBody, getHeaders, showStatus =
|
|
|
106
173
|
}
|
|
107
174
|
}
|
|
108
175
|
} else {
|
|
109
|
-
// Plain JSON fallback for non-streaming backends
|
|
110
176
|
const data = await response.json();
|
|
111
177
|
if (response.ok) {
|
|
112
178
|
addMessage({ role: 'assistant', content: data.response, truncated: data.truncated });
|
package/src/index.d.ts
CHANGED
|
@@ -6,6 +6,15 @@ export interface ChatMessage {
|
|
|
6
6
|
role: 'user' | 'assistant';
|
|
7
7
|
content: string;
|
|
8
8
|
truncated?: boolean;
|
|
9
|
+
/** When true, UI may show streaming placeholders (orchid-ai ChatWindow). */
|
|
10
|
+
isStreaming?: boolean;
|
|
11
|
+
/** Collapsible interim trace (statuses + pre-final text). Persisted for Hermes-style chats. */
|
|
12
|
+
processTrace?: {
|
|
13
|
+
items: Array<{ type: 'status' | 'text'; value: string }>;
|
|
14
|
+
defaultCollapsed?: boolean;
|
|
15
|
+
};
|
|
16
|
+
/** Live tool preamble not yet flushed into processTrace.items (streaming only). */
|
|
17
|
+
processInterimLive?: string;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
export interface ChatWindowProps {
|
|
@@ -15,6 +24,8 @@ export interface ChatWindowProps {
|
|
|
15
24
|
onSuggestionClick?: (text: string) => void;
|
|
16
25
|
aiEnabled?: boolean;
|
|
17
26
|
organisationName?: string;
|
|
27
|
+
/** When false, tool/interim statuses show inline next to typing dots (Hermes / iLink config). */
|
|
28
|
+
showProcessTracePanel?: boolean;
|
|
18
29
|
/** Any extra props are forwarded to the root element */
|
|
19
30
|
[key: string]: unknown;
|
|
20
31
|
}
|
|
@@ -31,6 +42,13 @@ export interface MessageProps {
|
|
|
31
42
|
role: 'user' | 'assistant';
|
|
32
43
|
content: string;
|
|
33
44
|
truncated?: boolean;
|
|
45
|
+
exportPrefix?: string;
|
|
46
|
+
isStreaming?: boolean;
|
|
47
|
+
/** Shown above streaming content while server emits status (e.g. “Compiling response”). */
|
|
48
|
+
streamingStatusText?: string;
|
|
49
|
+
processTrace?: ChatMessage['processTrace'];
|
|
50
|
+
processInterimLive?: string;
|
|
51
|
+
showProcessTracePanel?: boolean;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
export const ChatWindow: React.FC<ChatWindowProps>;
|
|
@@ -134,6 +152,45 @@ export function validateTablePayload(data: unknown): boolean;
|
|
|
134
152
|
export function parseChartBlock(block: string): unknown;
|
|
135
153
|
export function resolveChartBlock(block: string): unknown;
|
|
136
154
|
|
|
155
|
+
// ─── Process trace + SSE markers (Hermes) ───────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export function orchidAiProcessTraceEntryKind(entry: {
|
|
158
|
+
type: 'status' | 'text';
|
|
159
|
+
value: string;
|
|
160
|
+
}): 'text' | 'tool' | 'compile' | 'mind';
|
|
161
|
+
|
|
162
|
+
export function createOrchidAiProcessTraceCollector(): {
|
|
163
|
+
onStatus(text: string, opts?: { isClearStream?: boolean }): void;
|
|
164
|
+
onDelta(text: string): void;
|
|
165
|
+
getLiveMain(): string;
|
|
166
|
+
getLiveInterim(): string;
|
|
167
|
+
getItems(): Array<{ type: 'status' | 'text'; value: string }>;
|
|
168
|
+
reset(): void;
|
|
169
|
+
buildPersistedTrace():
|
|
170
|
+
| { items: Array<{ type: 'status' | 'text'; value: string }>; defaultCollapsed: boolean }
|
|
171
|
+
| undefined;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export function snapshotOrchidAiProcessTraceItems(
|
|
175
|
+
collector: ReturnType<typeof createOrchidAiProcessTraceCollector>
|
|
176
|
+
): { items: Array<{ type: 'status' | 'text'; value: string }>; defaultCollapsed: boolean };
|
|
177
|
+
|
|
178
|
+
export function augmentLiveProcessTraceSnapshot(
|
|
179
|
+
trace: { items: Array<{ type: 'status' | 'text'; value: string }>; defaultCollapsed: boolean },
|
|
180
|
+
streamingMain: string,
|
|
181
|
+
liveInterim?: string
|
|
182
|
+
): { items: Array<{ type: 'status' | 'text'; value: string }>; defaultCollapsed: boolean };
|
|
183
|
+
|
|
184
|
+
export function orchidAiProcessTraceHasDisplayableContent(
|
|
185
|
+
trace: ChatMessage['processTrace'] | undefined,
|
|
186
|
+
liveInterim: string | undefined,
|
|
187
|
+
options?: { isStreaming?: boolean }
|
|
188
|
+
): boolean;
|
|
189
|
+
|
|
190
|
+
/** SSE status before post-tool assistant text; client separates interim vs final answer. */
|
|
191
|
+
export const ORCHID_AI_SSE_STATUS_CLEAR_STREAM: string;
|
|
192
|
+
export function orchidAiStatusClearsStreamBuffer(statusText: unknown): boolean;
|
|
193
|
+
|
|
137
194
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
138
195
|
|
|
139
196
|
export const ORCHID_AI_VISUALIZATION_INSTRUCTIONS: string;
|
package/src/index.js
CHANGED
|
@@ -50,3 +50,14 @@ export {
|
|
|
50
50
|
|
|
51
51
|
// AI system prompt constant
|
|
52
52
|
export { ORCHID_AI_VISUALIZATION_INSTRUCTIONS } from './constants/visualizationInstructions';
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
augmentLiveProcessTraceSnapshot,
|
|
56
|
+
createOrchidAiProcessTraceCollector,
|
|
57
|
+
orchidAiProcessTraceHasDisplayableContent,
|
|
58
|
+
snapshotOrchidAiProcessTraceItems,
|
|
59
|
+
orchidAiProcessTraceEntryKind,
|
|
60
|
+
} from './orchidAiProcessTrace';
|
|
61
|
+
|
|
62
|
+
// SSE + interim/process trace (Hermes)
|
|
63
|
+
export { ORCHID_AI_SSE_STATUS_CLEAR_STREAM, orchidAiStatusClearsStreamBuffer } from './orchidAiStreamingTitle';
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ORCHID_AI_SSE_STATUS_CLEAR_STREAM } from './orchidAiStreamingTitle';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Visual timeline lane for Message.jsx: tool-style statuses vs compile vs generic vs prose.
|
|
5
|
+
* @param {{ type: 'status' | 'text', value: string }} entry
|
|
6
|
+
* @returns {'text' | 'tool' | 'compile' | 'mind'}
|
|
7
|
+
*/
|
|
8
|
+
export function orchidAiProcessTraceEntryKind(entry) {
|
|
9
|
+
if (!entry || entry.type === 'text') return 'text';
|
|
10
|
+
const t = String(entry.value || '');
|
|
11
|
+
if (t.trim() === ORCHID_AI_SSE_STATUS_CLEAR_STREAM) return 'compile';
|
|
12
|
+
if (
|
|
13
|
+
/^Looking up/i.test(t) ||
|
|
14
|
+
/^Found \d+/i.test(t) ||
|
|
15
|
+
/^Searching the web/i.test(t) ||
|
|
16
|
+
/^Searching knowledge base/i.test(t)
|
|
17
|
+
) {
|
|
18
|
+
return 'tool';
|
|
19
|
+
}
|
|
20
|
+
return 'mind';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Collects interim tool/preamble text + statuses for the Hermes / Orchid SSE stream.
|
|
25
|
+
* Final answer deltas go to getLiveMain(); pre-tool/streaming preamble goes to
|
|
26
|
+
* getLiveInterim() and (on "Compiling response") into frozen `items`.
|
|
27
|
+
*
|
|
28
|
+
* Phase machine:
|
|
29
|
+
* - `open_answer`: deltas accumulate in main (direct replies, or post-compile answer).
|
|
30
|
+
* - When a tool-style status arrives (e.g. "Looking up jobs"), migrate main → interim and enter `tool_interim`.
|
|
31
|
+
* - `tool_interim`: deltas go to interim; on `Compiling response`, flush interim to items and return to `open_answer`.
|
|
32
|
+
*/
|
|
33
|
+
export function createOrchidAiProcessTraceCollector() {
|
|
34
|
+
/** @type {'open_answer' | 'tool_interim'} */
|
|
35
|
+
let phase = 'open_answer';
|
|
36
|
+
let preBuf = '';
|
|
37
|
+
let mainBuf = '';
|
|
38
|
+
/** @type {{ type: 'status' | 'text', value: string }[]} */
|
|
39
|
+
const items = [];
|
|
40
|
+
|
|
41
|
+
/** @param {string} text */
|
|
42
|
+
function toolishStatus(text) {
|
|
43
|
+
const t = String(text || '');
|
|
44
|
+
return (
|
|
45
|
+
/^Looking up/i.test(t) ||
|
|
46
|
+
/^Found \d+/i.test(t) ||
|
|
47
|
+
/^Searching the web/i.test(t) ||
|
|
48
|
+
/^Searching knowledge base/i.test(t)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
/**
|
|
54
|
+
* @param {string} text
|
|
55
|
+
* @param {{ isClearStream?: boolean }} [opts] — true for {@link ORCHID_AI_SSE_STATUS_CLEAR_STREAM}
|
|
56
|
+
*/
|
|
57
|
+
onStatus(text, opts = {}) {
|
|
58
|
+
const isClearStream = opts.isClearStream === true;
|
|
59
|
+
if (isClearStream) {
|
|
60
|
+
if (preBuf) items.push({ type: 'text', value: preBuf });
|
|
61
|
+
preBuf = '';
|
|
62
|
+
phase = 'open_answer';
|
|
63
|
+
items.push({ type: 'status', value: String(text) });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (toolishStatus(text) && phase === 'open_answer') {
|
|
67
|
+
phase = 'tool_interim';
|
|
68
|
+
if (mainBuf) {
|
|
69
|
+
preBuf = mainBuf;
|
|
70
|
+
mainBuf = '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
items.push({ type: 'status', value: String(text) });
|
|
74
|
+
},
|
|
75
|
+
/** @param {string} text */
|
|
76
|
+
onDelta(text) {
|
|
77
|
+
const t = String(text);
|
|
78
|
+
if (phase === 'tool_interim') preBuf += t;
|
|
79
|
+
else mainBuf += t;
|
|
80
|
+
},
|
|
81
|
+
getLiveMain() {
|
|
82
|
+
return mainBuf;
|
|
83
|
+
},
|
|
84
|
+
getLiveInterim() {
|
|
85
|
+
return phase === 'tool_interim' ? preBuf : '';
|
|
86
|
+
},
|
|
87
|
+
/** @returns {{ type: 'status' | 'text', value: string }[]} */
|
|
88
|
+
getItems() {
|
|
89
|
+
return items;
|
|
90
|
+
},
|
|
91
|
+
reset() {
|
|
92
|
+
phase = 'open_answer';
|
|
93
|
+
preBuf = '';
|
|
94
|
+
mainBuf = '';
|
|
95
|
+
items.length = 0;
|
|
96
|
+
},
|
|
97
|
+
/** Persisted shape for assistant bubbles; omit when nothing to show. */
|
|
98
|
+
buildPersistedTrace() {
|
|
99
|
+
if (!items.length) return undefined;
|
|
100
|
+
return {
|
|
101
|
+
items: items.map((x) => ({ type: x.type, value: x.value })),
|
|
102
|
+
defaultCollapsed: true,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Snapshot `items` for React state / persisted shape (live streaming; not collapsed). */
|
|
109
|
+
export function snapshotOrchidAiProcessTraceItems(collector) {
|
|
110
|
+
const items = collector.getItems();
|
|
111
|
+
return {
|
|
112
|
+
items: items.map((x) => ({ type: x.type, value: x.value })),
|
|
113
|
+
defaultCollapsed: false,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Display-only: Hermes sends one `status` ("Thinking") then `delta` chunks for direct replies.
|
|
119
|
+
* Deltas never become timeline rows, so the Working panel would stay on Thinking forever.
|
|
120
|
+
* Append a synthetic step while streamed content exists (not persisted — see `buildPersistedTrace`).
|
|
121
|
+
*
|
|
122
|
+
* @param {{ items: Array<{ type: 'status' | 'text', value: string }>, defaultCollapsed: boolean }} trace
|
|
123
|
+
* @param {string} streamingMain
|
|
124
|
+
* @param {string} [liveInterim]
|
|
125
|
+
*/
|
|
126
|
+
export function augmentLiveProcessTraceSnapshot(trace, streamingMain, liveInterim = '') {
|
|
127
|
+
if (!trace || !Array.isArray(trace.items)) return trace;
|
|
128
|
+
const items = trace.items;
|
|
129
|
+
const mainTrim = typeof streamingMain === 'string' ? streamingMain.trim() : '';
|
|
130
|
+
const interimTrim = typeof liveInterim === 'string' ? liveInterim.trim() : '';
|
|
131
|
+
|
|
132
|
+
const onlyThinking =
|
|
133
|
+
items.length === 1 &&
|
|
134
|
+
items[0]?.type === 'status' &&
|
|
135
|
+
String(items[0].value || '').trim().toLowerCase() === 'thinking';
|
|
136
|
+
|
|
137
|
+
if (!onlyThinking) return trace;
|
|
138
|
+
|
|
139
|
+
if (mainTrim.length > 0) {
|
|
140
|
+
return {
|
|
141
|
+
...trace,
|
|
142
|
+
items: [...items, { type: 'status', value: 'Drafting response' }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (interimTrim.length > 0) {
|
|
146
|
+
return {
|
|
147
|
+
...trace,
|
|
148
|
+
items: [...items, { type: 'status', value: 'Gathering details' }],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return trace;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Whether to render the process trace panel. Pass `{ isStreaming: true }` during SSE so a lone “Thinking” status still opens the live tray. */
|
|
155
|
+
export function orchidAiProcessTraceHasDisplayableContent(trace, liveInterim, options) {
|
|
156
|
+
const isStreaming = options?.isStreaming === true;
|
|
157
|
+
if (typeof liveInterim === 'string' && liveInterim.trim() !== '') return true;
|
|
158
|
+
const list = trace?.items;
|
|
159
|
+
if (!Array.isArray(list) || list.length === 0) return false;
|
|
160
|
+
if (isStreaming) return true;
|
|
161
|
+
if (list.some((i) => i && i.type === 'text' && String(i.value || '').trim() !== '')) return true;
|
|
162
|
+
return list.length > 1;
|
|
163
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE `status` emitted by Hermes immediately before streaming the post-tool assistant message.
|
|
3
|
+
* Clients use this to separate interim text from the final answer (see `orchidAiProcessTrace`).
|
|
4
|
+
*
|
|
5
|
+
* Keep in sync with `onStatus?.('Compiling response')` in iLink `orchidAiAnthropicChat.js`.
|
|
6
|
+
*/
|
|
7
|
+
export const ORCHID_AI_SSE_STATUS_CLEAR_STREAM = 'Compiling response';
|
|
8
|
+
|
|
9
|
+
/** @param {unknown} statusText from `{ type: 'status', text }` */
|
|
10
|
+
export function orchidAiStatusClearsStreamBuffer(statusText) {
|
|
11
|
+
return typeof statusText === 'string' && statusText.trim() === ORCHID_AI_SSE_STATUS_CLEAR_STREAM;
|
|
12
|
+
}
|