@runtypelabs/persona 3.16.0 → 3.17.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/dist/animations/glyph-cycle.cjs +279 -0
- package/dist/animations/glyph-cycle.d.cts +5 -0
- package/dist/animations/glyph-cycle.d.ts +5 -0
- package/dist/animations/glyph-cycle.js +252 -0
- package/dist/animations/types-HPZY7oAI.d.cts +282 -0
- package/dist/animations/types-HPZY7oAI.d.ts +282 -0
- package/dist/animations/wipe.cjs +107 -0
- package/dist/animations/wipe.d.cts +5 -0
- package/dist/animations/wipe.d.ts +5 -0
- package/dist/animations/wipe.js +80 -0
- package/dist/index.cjs +48 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +205 -1
- package/dist/index.d.ts +205 -1
- package/dist/index.global.js +136 -81
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +48 -47
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +85 -0
- package/dist/testing.d.cts +39 -0
- package/dist/testing.d.ts +39 -0
- package/dist/testing.js +56 -0
- package/dist/theme-editor.cjs +714 -99
- package/dist/theme-editor.d.cts +214 -2
- package/dist/theme-editor.d.ts +214 -2
- package/dist/theme-editor.js +712 -99
- package/dist/widget.css +133 -0
- package/package.json +20 -3
- package/src/animations/glyph-cycle.ts +332 -0
- package/src/animations/wipe.ts +66 -0
- package/src/client.test.ts +141 -0
- package/src/client.ts +28 -0
- package/src/components/composer-builder.ts +61 -10
- package/src/components/message-bubble.test.ts +181 -2
- package/src/components/message-bubble.ts +209 -14
- package/src/components/panel.ts +4 -1
- package/src/defaults.ts +16 -0
- package/src/index-global.ts +31 -0
- package/src/index.ts +18 -0
- package/src/session.test.ts +93 -1
- package/src/session.ts +5 -0
- package/src/styles/widget.css +133 -0
- package/src/testing/index.ts +11 -0
- package/src/testing/mock-stream.test.ts +80 -0
- package/src/testing/mock-stream.ts +94 -0
- package/src/testing.ts +2 -0
- package/src/theme-editor/index.ts +4 -0
- package/src/theme-editor/preview-utils.test.ts +60 -0
- package/src/theme-editor/preview-utils.ts +129 -0
- package/src/theme-editor/sections.test.ts +19 -0
- package/src/theme-editor/sections.ts +84 -1
- package/src/types.ts +210 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +75 -6
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/morph.ts +7 -0
- package/src/utils/stream-animation.test.ts +417 -0
- package/src/utils/stream-animation.ts +449 -0
|
@@ -7,10 +7,85 @@ import {
|
|
|
7
7
|
AgentWidgetMessageActionsConfig,
|
|
8
8
|
AgentWidgetMessageFeedback,
|
|
9
9
|
LoadingIndicatorRenderContext,
|
|
10
|
-
ImageContentPart
|
|
10
|
+
ImageContentPart,
|
|
11
|
+
StopReasonKind
|
|
11
12
|
} from "../types";
|
|
12
13
|
import { createIconButton } from "../utils/buttons";
|
|
13
14
|
import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "../utils/content";
|
|
15
|
+
import {
|
|
16
|
+
applyStreamBuffer,
|
|
17
|
+
createSkeletonPlaceholder,
|
|
18
|
+
createStreamCaret,
|
|
19
|
+
resolveStreamAnimation,
|
|
20
|
+
resolveStreamAnimationPlugin,
|
|
21
|
+
wrapStreamAnimation,
|
|
22
|
+
} from "../utils/stream-animation";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default copy for the inline notice rendered when a turn ends with a
|
|
26
|
+
* non-natural stop reason. Deployers override per-reason via
|
|
27
|
+
* `config.copy.stopReasonNotice`. Returns `null` for natural completions
|
|
28
|
+
* (`end_turn`) and uninformative reasons (`unknown`) — those never render
|
|
29
|
+
* an affordance.
|
|
30
|
+
*/
|
|
31
|
+
export const getDefaultStopReasonNoticeCopy = (
|
|
32
|
+
stopReason: StopReasonKind
|
|
33
|
+
): string | null => {
|
|
34
|
+
switch (stopReason) {
|
|
35
|
+
case "max_tool_calls":
|
|
36
|
+
return "Stopped after calling a tool. Send a follow-up to continue.";
|
|
37
|
+
case "length":
|
|
38
|
+
return "Response cut off as max tokens reached. Ask for more to continue.";
|
|
39
|
+
case "content_filter":
|
|
40
|
+
return "The provider filtered this response.";
|
|
41
|
+
case "error":
|
|
42
|
+
return "Something went wrong generating this response.";
|
|
43
|
+
case "end_turn":
|
|
44
|
+
case "unknown":
|
|
45
|
+
default:
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the notice text for a stop reason, applying user overrides on
|
|
52
|
+
* top of the built-in defaults. Returns `null` when the reason does not
|
|
53
|
+
* warrant a notice or when the resolved string is empty (deployers can
|
|
54
|
+
* suppress per-reason by setting an empty override).
|
|
55
|
+
*/
|
|
56
|
+
export const resolveStopReasonNoticeText = (
|
|
57
|
+
stopReason: StopReasonKind | undefined,
|
|
58
|
+
overrides?: Partial<Record<StopReasonKind, string>>
|
|
59
|
+
): string | null => {
|
|
60
|
+
if (!stopReason) return null;
|
|
61
|
+
const fallback = getDefaultStopReasonNoticeCopy(stopReason);
|
|
62
|
+
// Reasons without a default (end_turn, unknown) never render — overrides
|
|
63
|
+
// for those keys are intentionally ignored.
|
|
64
|
+
if (fallback === null) return null;
|
|
65
|
+
const override = overrides?.[stopReason];
|
|
66
|
+
const text = override !== undefined ? override : fallback;
|
|
67
|
+
if (!text) return null;
|
|
68
|
+
return text;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the inline notice element rendered on assistant bubbles whose
|
|
73
|
+
* turn ended with `max_tool_calls`, `length`, `content_filter`, or `error`.
|
|
74
|
+
*/
|
|
75
|
+
const createStopReasonNotice = (
|
|
76
|
+
stopReason: StopReasonKind,
|
|
77
|
+
text: string
|
|
78
|
+
): HTMLElement => {
|
|
79
|
+
const notice = createElement(
|
|
80
|
+
"div",
|
|
81
|
+
"persona-message-stop-reason persona-text-xs persona-mt-2 persona-italic"
|
|
82
|
+
);
|
|
83
|
+
notice.setAttribute("data-stop-reason", stopReason);
|
|
84
|
+
notice.setAttribute("role", "note");
|
|
85
|
+
notice.style.opacity = "0.75";
|
|
86
|
+
notice.textContent = text;
|
|
87
|
+
return notice;
|
|
88
|
+
};
|
|
14
89
|
|
|
15
90
|
/** Validate that an image src URL uses a safe scheme (blocks javascript: and SVG data URIs). */
|
|
16
91
|
export const isSafeImageSrc = (src: string): boolean => {
|
|
@@ -507,24 +582,107 @@ export const createStandardBubble = (
|
|
|
507
582
|
imageParts.length > 0 && messageContentText === IMAGE_ONLY_MESSAGE_FALLBACK_TEXT;
|
|
508
583
|
const shouldHideTextUntilPreviewFails = isImageOnlyFallbackMessage;
|
|
509
584
|
|
|
585
|
+
const streamAnimation = resolveStreamAnimation(
|
|
586
|
+
options?.widgetConfig?.features?.streamAnimation
|
|
587
|
+
);
|
|
588
|
+
const streamPluginOverrides =
|
|
589
|
+
options?.widgetConfig?.features?.streamAnimation?.plugins;
|
|
590
|
+
const streamPlugin =
|
|
591
|
+
message.role === "assistant" && streamAnimation.type !== "none"
|
|
592
|
+
? resolveStreamAnimationPlugin(streamAnimation.type, streamPluginOverrides)
|
|
593
|
+
: null;
|
|
594
|
+
// Stay in "streaming-animated" mode while the plugin reports in-flight
|
|
595
|
+
// work for this message — e.g. glyph-cycle's tick loops still walking
|
|
596
|
+
// through the tail after the last token arrived. Without this, the final
|
|
597
|
+
// non-animated render rips out the cycling spans mid-animation.
|
|
598
|
+
const pluginStillAnimating =
|
|
599
|
+
message.role === "assistant" &&
|
|
600
|
+
streamPlugin?.isAnimating?.(message) === true;
|
|
601
|
+
const streamAnimationActive =
|
|
602
|
+
message.role === "assistant" &&
|
|
603
|
+
streamPlugin !== null &&
|
|
604
|
+
(Boolean(message.streaming) || pluginStillAnimating);
|
|
605
|
+
|
|
606
|
+
if (streamAnimationActive && streamPlugin?.bubbleClass) {
|
|
607
|
+
bubble.classList.add(streamPlugin.bubbleClass);
|
|
608
|
+
}
|
|
609
|
+
|
|
510
610
|
// Add message content
|
|
511
611
|
const contentDiv = document.createElement("div");
|
|
512
612
|
contentDiv.classList.add("persona-message-content");
|
|
613
|
+
|
|
614
|
+
if (streamAnimationActive && streamPlugin) {
|
|
615
|
+
if (streamPlugin.containerClass) {
|
|
616
|
+
contentDiv.classList.add(streamPlugin.containerClass);
|
|
617
|
+
}
|
|
618
|
+
contentDiv.style.setProperty("--persona-stream-step", `${streamAnimation.speed}ms`);
|
|
619
|
+
contentDiv.style.setProperty("--persona-stream-duration", `${streamAnimation.duration}ms`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const bufferedContent = streamAnimationActive
|
|
623
|
+
? applyStreamBuffer(
|
|
624
|
+
message.content ?? "",
|
|
625
|
+
streamAnimation.buffer,
|
|
626
|
+
streamPlugin,
|
|
627
|
+
message,
|
|
628
|
+
Boolean(message.streaming)
|
|
629
|
+
)
|
|
630
|
+
: (message.content ?? "");
|
|
631
|
+
|
|
513
632
|
const transformedContent = transform({
|
|
514
|
-
text:
|
|
633
|
+
text: bufferedContent,
|
|
515
634
|
message,
|
|
516
635
|
streaming: Boolean(message.streaming),
|
|
517
636
|
raw: message.rawContent
|
|
518
637
|
});
|
|
638
|
+
|
|
639
|
+
let animatedContent = transformedContent;
|
|
640
|
+
if (streamAnimationActive && streamPlugin?.wrap === "char") {
|
|
641
|
+
animatedContent = wrapStreamAnimation(transformedContent, "char", message.id, {
|
|
642
|
+
skipTags: streamPlugin.skipTags,
|
|
643
|
+
});
|
|
644
|
+
} else if (streamAnimationActive && streamPlugin?.wrap === "word") {
|
|
645
|
+
animatedContent = wrapStreamAnimation(transformedContent, "word", message.id, {
|
|
646
|
+
skipTags: streamPlugin.skipTags,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
519
650
|
let textContentDiv: HTMLElement | null = null;
|
|
520
651
|
|
|
521
652
|
if (shouldHideTextUntilPreviewFails) {
|
|
522
653
|
textContentDiv = document.createElement("div");
|
|
523
|
-
textContentDiv.innerHTML =
|
|
654
|
+
textContentDiv.innerHTML = animatedContent;
|
|
524
655
|
textContentDiv.style.display = "none";
|
|
525
656
|
contentDiv.appendChild(textContentDiv);
|
|
526
657
|
} else {
|
|
527
|
-
contentDiv.innerHTML =
|
|
658
|
+
contentDiv.innerHTML = animatedContent;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (
|
|
662
|
+
streamAnimationActive &&
|
|
663
|
+
streamPlugin?.useCaret &&
|
|
664
|
+
!shouldHideTextUntilPreviewFails &&
|
|
665
|
+
messageContentText
|
|
666
|
+
) {
|
|
667
|
+
const caret = createStreamCaret();
|
|
668
|
+
// Caret must sit on the same line as the final char. Markdown wraps text
|
|
669
|
+
// in block elements (<p>, <li>, <pre>), so appending to contentDiv would
|
|
670
|
+
// drop the caret onto a fresh line. Tuck it after the last char/word span,
|
|
671
|
+
// or fall back to the last block when no spans exist yet.
|
|
672
|
+
const spans = contentDiv.querySelectorAll(
|
|
673
|
+
".persona-stream-char, .persona-stream-word"
|
|
674
|
+
);
|
|
675
|
+
const lastSpan = spans[spans.length - 1];
|
|
676
|
+
if (lastSpan?.parentNode) {
|
|
677
|
+
lastSpan.parentNode.insertBefore(caret, lastSpan.nextSibling);
|
|
678
|
+
} else {
|
|
679
|
+
const lastChild = contentDiv.lastElementChild;
|
|
680
|
+
if (lastChild) {
|
|
681
|
+
lastChild.appendChild(caret);
|
|
682
|
+
} else {
|
|
683
|
+
contentDiv.appendChild(caret);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
528
686
|
}
|
|
529
687
|
|
|
530
688
|
// Add inline timestamp if configured
|
|
@@ -561,19 +719,56 @@ export const createStandardBubble = (
|
|
|
561
719
|
bubble.appendChild(timestamp);
|
|
562
720
|
}
|
|
563
721
|
|
|
564
|
-
//
|
|
722
|
+
// Resolve the stop-reason notice (if any). Only assistant messages can
|
|
723
|
+
// carry a stop reason worth surfacing.
|
|
724
|
+
const stopReasonNoticeText =
|
|
725
|
+
message.role === "assistant"
|
|
726
|
+
? resolveStopReasonNoticeText(
|
|
727
|
+
message.stopReason,
|
|
728
|
+
options?.widgetConfig?.copy?.stopReasonNotice
|
|
729
|
+
)
|
|
730
|
+
: null;
|
|
731
|
+
|
|
732
|
+
// Add typing indicator (or skeleton placeholder) for streaming assistant
|
|
733
|
+
// messages. Check the buffered content — a plugin's `bufferContent` may
|
|
734
|
+
// hold back the first N chars (e.g. glyph-cycle waits for 50 chars), during
|
|
735
|
+
// which the bubble would otherwise appear empty.
|
|
736
|
+
//
|
|
737
|
+
// When the `"line"` buffer strategy is paired with the skeleton placeholder,
|
|
738
|
+
// the skeleton trails below any already-revealed content to hint that more
|
|
739
|
+
// lines are on the way. It disappears on stream completion.
|
|
565
740
|
if (message.streaming && message.role === "assistant") {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
741
|
+
const hasVisibleContent = Boolean(bufferedContent && bufferedContent.trim());
|
|
742
|
+
const skeletonEnabled = streamAnimation.placeholder === "skeleton";
|
|
743
|
+
const trailSkeleton =
|
|
744
|
+
skeletonEnabled && streamAnimation.buffer === "line" && hasVisibleContent;
|
|
745
|
+
if (!hasVisibleContent) {
|
|
746
|
+
if (skeletonEnabled) {
|
|
747
|
+
bubble.appendChild(createSkeletonPlaceholder());
|
|
748
|
+
} else {
|
|
749
|
+
const indicator = renderLoadingIndicatorWithFallback(
|
|
750
|
+
'inline',
|
|
751
|
+
options?.loadingIndicatorRenderer,
|
|
752
|
+
options?.widgetConfig
|
|
753
|
+
);
|
|
754
|
+
if (indicator) {
|
|
755
|
+
bubble.appendChild(indicator);
|
|
756
|
+
}
|
|
575
757
|
}
|
|
758
|
+
} else if (trailSkeleton) {
|
|
759
|
+
bubble.appendChild(createSkeletonPlaceholder());
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Append the stop-reason notice for non-natural completions. When the
|
|
764
|
+
// assistant produced no text (the `max_tool_calls` empty-bubble symptom),
|
|
765
|
+
// hide the empty content div so the notice carries the entire bubble
|
|
766
|
+
// instead of trailing under a blank space.
|
|
767
|
+
if (stopReasonNoticeText && message.stopReason && !message.streaming) {
|
|
768
|
+
if (!messageContentText) {
|
|
769
|
+
contentDiv.style.display = "none";
|
|
576
770
|
}
|
|
771
|
+
bubble.appendChild(createStopReasonNotice(message.stopReason, stopReasonNoticeText));
|
|
577
772
|
}
|
|
578
773
|
|
|
579
774
|
// Add message actions for assistant messages (only when not streaming and has content)
|
package/src/components/panel.ts
CHANGED
|
@@ -110,6 +110,8 @@ export interface PanelElements {
|
|
|
110
110
|
actionsRow: HTMLElement;
|
|
111
111
|
leftActions: HTMLElement;
|
|
112
112
|
rightActions: HTMLElement;
|
|
113
|
+
/** Swap the send button between its send and stop appearances. */
|
|
114
|
+
setSendButtonMode: (mode: "send" | "stop") => void;
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelElements => {
|
|
@@ -230,7 +232,8 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
|
|
|
230
232
|
// Actions row layout elements
|
|
231
233
|
actionsRow: composerElements.actionsRow,
|
|
232
234
|
leftActions: composerElements.leftActions,
|
|
233
|
-
rightActions: composerElements.rightActions
|
|
235
|
+
rightActions: composerElements.rightActions,
|
|
236
|
+
setSendButtonMode: composerElements.setSendButtonMode
|
|
234
237
|
};
|
|
235
238
|
};
|
|
236
239
|
|
package/src/defaults.ts
CHANGED
|
@@ -137,6 +137,12 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
|
|
|
137
137
|
expandable: true,
|
|
138
138
|
loadingAnimation: "none",
|
|
139
139
|
},
|
|
140
|
+
streamAnimation: {
|
|
141
|
+
type: "none",
|
|
142
|
+
placeholder: "none",
|
|
143
|
+
speed: 120,
|
|
144
|
+
duration: 1800,
|
|
145
|
+
},
|
|
140
146
|
},
|
|
141
147
|
suggestionChips: [
|
|
142
148
|
"What can you help me with?",
|
|
@@ -251,6 +257,8 @@ export function mergeWithDefaults(
|
|
|
251
257
|
const ca = config.features?.artifacts;
|
|
252
258
|
const dsb = DEFAULT_WIDGET_CONFIG.features?.scrollToBottom;
|
|
253
259
|
const csb = config.features?.scrollToBottom;
|
|
260
|
+
const dsa = DEFAULT_WIDGET_CONFIG.features?.streamAnimation;
|
|
261
|
+
const csa = config.features?.streamAnimation;
|
|
254
262
|
const mergedArtifacts =
|
|
255
263
|
da === undefined && ca === undefined
|
|
256
264
|
? undefined
|
|
@@ -269,11 +277,19 @@ export function mergeWithDefaults(
|
|
|
269
277
|
...dsb,
|
|
270
278
|
...csb,
|
|
271
279
|
};
|
|
280
|
+
const mergedStreamAnimation =
|
|
281
|
+
dsa === undefined && csa === undefined
|
|
282
|
+
? undefined
|
|
283
|
+
: {
|
|
284
|
+
...dsa,
|
|
285
|
+
...csa,
|
|
286
|
+
};
|
|
272
287
|
return {
|
|
273
288
|
...DEFAULT_WIDGET_CONFIG.features,
|
|
274
289
|
...config.features,
|
|
275
290
|
...(mergedScrollToBottom !== undefined ? { scrollToBottom: mergedScrollToBottom } : {}),
|
|
276
291
|
...(mergedArtifacts !== undefined ? { artifacts: mergedArtifacts } : {}),
|
|
292
|
+
...(mergedStreamAnimation !== undefined ? { streamAnimation: mergedStreamAnimation } : {}),
|
|
277
293
|
};
|
|
278
294
|
})(),
|
|
279
295
|
suggestionChips: config.suggestionChips ?? DEFAULT_WIDGET_CONFIG.suggestionChips,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IIFE entry point — bundled for `<script>` tag consumers.
|
|
3
|
+
*
|
|
4
|
+
* This file re-exports everything from the main entry AND side-imports all
|
|
5
|
+
* built-in subpath animations so they register automatically. Script-tag
|
|
6
|
+
* users who include the global build don't need extra script tags or
|
|
7
|
+
* registration calls — setting `features.streamAnimation.type` to any
|
|
8
|
+
* built-in name just works.
|
|
9
|
+
*
|
|
10
|
+
* npm consumers continue to import from the main entry (`import ... from
|
|
11
|
+
* "@runtypelabs/persona"`) — those animations stay in their subpath
|
|
12
|
+
* modules so bundlers can tree-shake them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Re-export the full public API.
|
|
16
|
+
export * from "./index";
|
|
17
|
+
|
|
18
|
+
// Side-import the remaining subpath animations so they're available to
|
|
19
|
+
// script-tag consumers without an explicit import. (`letter-rise` and
|
|
20
|
+
// `word-fade` are core built-ins and register automatically.)
|
|
21
|
+
import "./animations/wipe";
|
|
22
|
+
import "./animations/glyph-cycle";
|
|
23
|
+
|
|
24
|
+
// Expose plugin-registration helpers on the global so custom animations
|
|
25
|
+
// can be registered from inline `<script>` blocks or third-party CDN scripts.
|
|
26
|
+
export {
|
|
27
|
+
registerStreamAnimationPlugin,
|
|
28
|
+
unregisterStreamAnimationPlugin,
|
|
29
|
+
listRegisteredStreamAnimations,
|
|
30
|
+
} from "./utils/stream-animation";
|
|
31
|
+
export type { StreamAnimationPlugin, StreamAnimationContext } from "./types";
|
package/src/index.ts
CHANGED
|
@@ -178,6 +178,24 @@ export type { AgentWidgetInitHandle };
|
|
|
178
178
|
export type { AgentWidgetPlugin } from "./plugins/types";
|
|
179
179
|
export { pluginRegistry } from "./plugins/registry";
|
|
180
180
|
|
|
181
|
+
// Stream animation plugin API — lets consumers register custom animations
|
|
182
|
+
// that match the built-in surface (typewriter, pop-bubble) and subpath
|
|
183
|
+
// modules (letter-rise, word-fade, wipe, glyph-cycle).
|
|
184
|
+
export {
|
|
185
|
+
registerStreamAnimationPlugin,
|
|
186
|
+
unregisterStreamAnimationPlugin,
|
|
187
|
+
listRegisteredStreamAnimations,
|
|
188
|
+
} from "./utils/stream-animation";
|
|
189
|
+
export type {
|
|
190
|
+
StreamAnimationPlugin,
|
|
191
|
+
StreamAnimationContext,
|
|
192
|
+
AgentWidgetStreamAnimationBuffer,
|
|
193
|
+
AgentWidgetStreamAnimationBuiltinType,
|
|
194
|
+
AgentWidgetStreamAnimationType,
|
|
195
|
+
AgentWidgetStreamAnimationFeature,
|
|
196
|
+
AgentWidgetStreamAnimationPlaceholder,
|
|
197
|
+
} from "./types";
|
|
198
|
+
|
|
181
199
|
// Dropdown utility exports
|
|
182
200
|
export { createDropdownMenu } from "./utils/dropdown";
|
|
183
201
|
export type { DropdownMenuItem, CreateDropdownOptions, DropdownMenuHandle } from "./utils/dropdown";
|
package/src/session.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
2
|
import { AgentWidgetSession, AgentWidgetSessionStatus } from './session';
|
|
3
3
|
import { AgentWidgetMessage } from './types';
|
|
4
4
|
|
|
@@ -245,3 +245,95 @@ describe('AgentWidgetSession - Message Injection', () => {
|
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
247
|
});
|
|
248
|
+
|
|
249
|
+
describe('AgentWidgetSession - cancel()', () => {
|
|
250
|
+
const originalFetch = global.fetch;
|
|
251
|
+
|
|
252
|
+
afterEach(() => {
|
|
253
|
+
global.fetch = originalFetch;
|
|
254
|
+
vi.restoreAllMocks();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('aborts the in-flight dispatch and flips streaming/status back to idle', async () => {
|
|
258
|
+
let capturedSignal: AbortSignal | null = null;
|
|
259
|
+
// Fetch returns a promise that only settles when the AbortSignal fires —
|
|
260
|
+
// modeling a dispatch that's still receiving SSE tokens.
|
|
261
|
+
global.fetch = vi.fn().mockImplementation((_url: string, options: any) => {
|
|
262
|
+
capturedSignal = options.signal as AbortSignal;
|
|
263
|
+
return new Promise((_resolve, reject) => {
|
|
264
|
+
options.signal?.addEventListener('abort', () => {
|
|
265
|
+
const err = new Error('aborted');
|
|
266
|
+
err.name = 'AbortError';
|
|
267
|
+
reject(err);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
let streaming = false;
|
|
273
|
+
let status: AgentWidgetSessionStatus = 'idle';
|
|
274
|
+
const session = new AgentWidgetSession(
|
|
275
|
+
{ apiUrl: 'http://example.invalid/chat' },
|
|
276
|
+
{
|
|
277
|
+
onMessagesChanged: () => {},
|
|
278
|
+
onStatusChanged: (s) => { status = s; },
|
|
279
|
+
onStreamingChanged: (s) => { streaming = s; }
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// Kick off the dispatch but don't await — we want it in-flight when we cancel.
|
|
284
|
+
const dispatchPromise = session.sendMessage('Hello');
|
|
285
|
+
// Let the session set up the AbortController and call fetch.
|
|
286
|
+
await Promise.resolve();
|
|
287
|
+
await Promise.resolve();
|
|
288
|
+
|
|
289
|
+
expect(streaming).toBe(true);
|
|
290
|
+
expect(session.isStreaming()).toBe(true);
|
|
291
|
+
expect(capturedSignal).not.toBeNull();
|
|
292
|
+
expect(capturedSignal!.aborted).toBe(false);
|
|
293
|
+
|
|
294
|
+
session.cancel();
|
|
295
|
+
|
|
296
|
+
expect(session.isStreaming()).toBe(false);
|
|
297
|
+
expect(streaming).toBe(false);
|
|
298
|
+
expect(status).toBe('idle');
|
|
299
|
+
expect(capturedSignal!.aborted).toBe(true);
|
|
300
|
+
|
|
301
|
+
// Drain the dispatch promise so the test doesn't leak a rejection.
|
|
302
|
+
await dispatchPromise;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('is a no-op when not streaming', () => {
|
|
306
|
+
const session = new AgentWidgetSession(
|
|
307
|
+
{ apiUrl: 'http://example.invalid/chat' },
|
|
308
|
+
{
|
|
309
|
+
onMessagesChanged: () => {},
|
|
310
|
+
onStatusChanged: () => {},
|
|
311
|
+
onStreamingChanged: () => {}
|
|
312
|
+
}
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(session.isStreaming()).toBe(false);
|
|
316
|
+
expect(() => session.cancel()).not.toThrow();
|
|
317
|
+
expect(session.isStreaming()).toBe(false);
|
|
318
|
+
expect(session.getStatus()).toBe('idle');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('stops in-progress audio playback (TTS + voice provider) on cancel', () => {
|
|
322
|
+
const session = new AgentWidgetSession(
|
|
323
|
+
{ apiUrl: 'http://example.invalid/chat' },
|
|
324
|
+
{
|
|
325
|
+
onMessagesChanged: () => {},
|
|
326
|
+
onStatusChanged: () => {},
|
|
327
|
+
onStreamingChanged: () => {}
|
|
328
|
+
}
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const stopSpeakingSpy = vi.spyOn(session, 'stopSpeaking');
|
|
332
|
+
const stopVoicePlaybackSpy = vi.spyOn(session, 'stopVoicePlayback');
|
|
333
|
+
|
|
334
|
+
session.cancel();
|
|
335
|
+
|
|
336
|
+
expect(stopSpeakingSpy).toHaveBeenCalledTimes(1);
|
|
337
|
+
expect(stopVoicePlaybackSpy).toHaveBeenCalledTimes(1);
|
|
338
|
+
});
|
|
339
|
+
});
|
package/src/session.ts
CHANGED
|
@@ -971,6 +971,11 @@ export class AgentWidgetSession {
|
|
|
971
971
|
public cancel() {
|
|
972
972
|
this.abortController?.abort();
|
|
973
973
|
this.abortController = null;
|
|
974
|
+
// Stop any in-progress audio too — when the user hits "stop", they want
|
|
975
|
+
// the assistant to actually stop talking, not just stop generating tokens.
|
|
976
|
+
// Both helpers are safe no-ops when audio isn't configured.
|
|
977
|
+
this.stopSpeaking();
|
|
978
|
+
this.stopVoicePlayback();
|
|
974
979
|
this.setStreaming(false);
|
|
975
980
|
this.setStatus("idle");
|
|
976
981
|
}
|
package/src/styles/widget.css
CHANGED
|
@@ -2824,3 +2824,136 @@
|
|
|
2824
2824
|
transform: translateX(0);
|
|
2825
2825
|
}
|
|
2826
2826
|
}
|
|
2827
|
+
|
|
2828
|
+
/* ============================================================
|
|
2829
|
+
Stream animations — reveal effects for assistant message text
|
|
2830
|
+
while streaming. Opt-in via `features.streamAnimation.type`.
|
|
2831
|
+
Units are staggered via `--char-index` / `--word-index`
|
|
2832
|
+
(set inline on each wrapper span). Timing is configured via
|
|
2833
|
+
`--persona-stream-step` and `--persona-stream-duration` on
|
|
2834
|
+
the `.persona-message-content` container.
|
|
2835
|
+
============================================================ */
|
|
2836
|
+
|
|
2837
|
+
/* Per-char/per-word spans need to be inline-block so `transform` works for
|
|
2838
|
+
the rise/fade animations. Each span animates from the moment it is first
|
|
2839
|
+
added to the DOM — streaming itself provides the visible stagger, so the
|
|
2840
|
+
CSS animation has no per-index delay. An index-based delay would compound
|
|
2841
|
+
with the stream's arrival cadence and leave later chars permanently hidden. */
|
|
2842
|
+
[data-persona-root] .persona-stream-char,
|
|
2843
|
+
[data-persona-root] .persona-stream-word {
|
|
2844
|
+
display: inline-block;
|
|
2845
|
+
will-change: opacity, transform, filter;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/* Group chars belonging to the same word so the browser treats the word as a
|
|
2849
|
+
single break unit. Without this, every inline-block char introduces a break
|
|
2850
|
+
opportunity and words get split mid-letter during streaming, then snap back
|
|
2851
|
+
when the final content replaces the wrapped spans. */
|
|
2852
|
+
[data-persona-root] .persona-stream-word-group {
|
|
2853
|
+
white-space: nowrap;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
/* ---------- typewriter: fade per arriving char + blinking caret ---------- */
|
|
2857
|
+
@keyframes persona-stream-typewriter-in {
|
|
2858
|
+
from { opacity: 0; }
|
|
2859
|
+
to { opacity: 1; }
|
|
2860
|
+
}
|
|
2861
|
+
[data-persona-root] .persona-stream-typewriter .persona-stream-char {
|
|
2862
|
+
animation: persona-stream-typewriter-in var(--persona-stream-step, 120ms) ease-out both;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
/* ---------- letter-rise: per-char translateY + fade ---------- */
|
|
2866
|
+
@keyframes persona-stream-letter-rise {
|
|
2867
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
2868
|
+
to { opacity: 1; transform: translateY(0); }
|
|
2869
|
+
}
|
|
2870
|
+
[data-persona-root] .persona-stream-letter-rise .persona-stream-char {
|
|
2871
|
+
animation: persona-stream-letter-rise calc(var(--persona-stream-step, 120ms) * 2)
|
|
2872
|
+
ease-out both;
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
/* ---------- word-fade: per-word blur + translateY fade-in ---------- */
|
|
2876
|
+
@keyframes persona-stream-word-fade {
|
|
2877
|
+
from { opacity: 0; filter: blur(4px); transform: translateY(3px); }
|
|
2878
|
+
to { opacity: 1; filter: blur(0); transform: translateY(0); }
|
|
2879
|
+
}
|
|
2880
|
+
[data-persona-root] .persona-stream-word-fade .persona-stream-word {
|
|
2881
|
+
animation: persona-stream-word-fade calc(var(--persona-stream-step, 120ms) * 3)
|
|
2882
|
+
ease-out both;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
/* The following animations live in subpath plugin modules — their CSS is
|
|
2886
|
+
injected by the plugin when activated, not by the core stylesheet:
|
|
2887
|
+
- `wipe` → @runtypelabs/persona/animations/wipe
|
|
2888
|
+
- `glyph-cycle` → @runtypelabs/persona/animations/glyph-cycle */
|
|
2889
|
+
|
|
2890
|
+
/* ---------- pop-bubble: scale + opacity entrance on the bubble ---------- */
|
|
2891
|
+
@keyframes persona-stream-pop-in {
|
|
2892
|
+
0% { transform: scale(0.6); opacity: 0; }
|
|
2893
|
+
100% { transform: scale(1); opacity: 1; }
|
|
2894
|
+
}
|
|
2895
|
+
[data-persona-root] .persona-stream-pop {
|
|
2896
|
+
transform-origin: bottom left;
|
|
2897
|
+
animation: persona-stream-pop-in 400ms cubic-bezier(0.2, 0.9, 0.3, 1.4) both;
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
/* ---------- caret used by typewriter ---------- */
|
|
2901
|
+
@keyframes persona-stream-blink {
|
|
2902
|
+
0%, 50% { opacity: 1; }
|
|
2903
|
+
50.01%, 100% { opacity: 0; }
|
|
2904
|
+
}
|
|
2905
|
+
[data-persona-root] .persona-stream-caret {
|
|
2906
|
+
display: inline-block;
|
|
2907
|
+
width: 2px;
|
|
2908
|
+
height: 1em;
|
|
2909
|
+
margin-left: 1px;
|
|
2910
|
+
vertical-align: -2px;
|
|
2911
|
+
background: currentColor;
|
|
2912
|
+
animation: persona-stream-blink 1s steps(1) infinite;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
/* ---------- skeleton placeholder (pre-first-token) ---------- */
|
|
2916
|
+
@keyframes persona-stream-skeleton-shimmer {
|
|
2917
|
+
0% { background-position: 200% 0; }
|
|
2918
|
+
100% { background-position: -200% 0; }
|
|
2919
|
+
}
|
|
2920
|
+
[data-persona-root] .persona-stream-skeleton {
|
|
2921
|
+
padding: 2px 0;
|
|
2922
|
+
/* The assistant bubble sizes to content. Give the skeleton an intrinsic
|
|
2923
|
+
width so the bubble expands; the bubble's own `max-width: 85%` clamps
|
|
2924
|
+
the upper bound. */
|
|
2925
|
+
width: 260px;
|
|
2926
|
+
max-width: 100%;
|
|
2927
|
+
}
|
|
2928
|
+
[data-persona-root] .persona-stream-skeleton-line {
|
|
2929
|
+
width: 100%;
|
|
2930
|
+
height: 10px;
|
|
2931
|
+
border-radius: 3px;
|
|
2932
|
+
background: linear-gradient(
|
|
2933
|
+
90deg,
|
|
2934
|
+
color-mix(in srgb, currentColor 12%, transparent) 0%,
|
|
2935
|
+
color-mix(in srgb, currentColor 22%, transparent) 50%,
|
|
2936
|
+
color-mix(in srgb, currentColor 12%, transparent) 100%
|
|
2937
|
+
);
|
|
2938
|
+
background-size: 200% 100%;
|
|
2939
|
+
animation: persona-stream-skeleton-shimmer 1.4s linear infinite;
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
/* ---------- reduced-motion: disable per-unit and container animations ---------- */
|
|
2943
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2944
|
+
[data-persona-root] .persona-stream-typewriter .persona-stream-char,
|
|
2945
|
+
[data-persona-root] .persona-stream-letter-rise .persona-stream-char,
|
|
2946
|
+
[data-persona-root] .persona-stream-word-fade .persona-stream-word,
|
|
2947
|
+
[data-persona-root] .persona-stream-pop,
|
|
2948
|
+
[data-persona-root] .persona-stream-caret,
|
|
2949
|
+
[data-persona-root] .persona-stream-skeleton-line {
|
|
2950
|
+
animation: none !important;
|
|
2951
|
+
opacity: 1 !important;
|
|
2952
|
+
filter: none !important;
|
|
2953
|
+
transform: none !important;
|
|
2954
|
+
color: inherit !important;
|
|
2955
|
+
background: none !important;
|
|
2956
|
+
-webkit-background-clip: border-box !important;
|
|
2957
|
+
background-clip: border-box !important;
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** @runtypelabs/persona/testing — Helpers for mocking SSE streams in demos, previews, and tests. */
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
createMockSSEStream,
|
|
5
|
+
createMockSSEResponse,
|
|
6
|
+
buildAssistantTurnFrames,
|
|
7
|
+
type MockSSEFrame,
|
|
8
|
+
type CreateMockSSEStreamOptions,
|
|
9
|
+
type MockSSEResponseOptions,
|
|
10
|
+
type AssistantTurnFramesOptions,
|
|
11
|
+
} from "./mock-stream";
|