@runtypelabs/persona 3.15.1 → 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 +49 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -1
- package/dist/index.d.ts +216 -1
- package/dist/index.global.js +137 -82
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -48
- 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 +847 -127
- package/dist/theme-editor.d.cts +225 -2
- package/dist/theme-editor.d.ts +225 -2
- package/dist/theme-editor.js +845 -127
- 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 +197 -2
- package/src/components/composer-builder.ts +61 -10
- package/src/components/header-builder.ts +18 -7
- package/src/components/header-layouts.ts +3 -1
- 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 +22 -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 +221 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +79 -8
- 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
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";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildAssistantTurnFrames,
|
|
5
|
+
createMockSSEResponse,
|
|
6
|
+
createMockSSEStream,
|
|
7
|
+
} from "./mock-stream";
|
|
8
|
+
|
|
9
|
+
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
10
|
+
const reader = stream.getReader();
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
let out = "";
|
|
13
|
+
for (;;) {
|
|
14
|
+
const { done, value } = await reader.read();
|
|
15
|
+
if (done) break;
|
|
16
|
+
out += decoder.decode(value);
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("createMockSSEStream", () => {
|
|
22
|
+
it("emits bare `data:` frames by default", async () => {
|
|
23
|
+
const frames = [
|
|
24
|
+
{ type: "agent_turn_start", executionId: "e-1", turnId: "t-1" },
|
|
25
|
+
{ type: "agent_turn_delta", executionId: "e-1", turnId: "t-1", delta: "hi" },
|
|
26
|
+
{ type: "agent_turn_complete", executionId: "e-1", turnId: "t-1" },
|
|
27
|
+
];
|
|
28
|
+
const text = await readAll(createMockSSEStream(frames, { delayMs: 0 }));
|
|
29
|
+
|
|
30
|
+
expect(text).not.toContain("event:");
|
|
31
|
+
expect(text.split("\n\n").filter(Boolean)).toHaveLength(3);
|
|
32
|
+
expect(text).toContain('"type":"agent_turn_delta"');
|
|
33
|
+
expect(text).toContain('"delta":"hi"');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("emits named event frames when eventName is set", async () => {
|
|
37
|
+
const text = await readAll(
|
|
38
|
+
createMockSSEStream([{ type: "ping" }], { delayMs: 0, eventName: "message" })
|
|
39
|
+
);
|
|
40
|
+
expect(text.startsWith("event: message\n")).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("buildAssistantTurnFrames", () => {
|
|
45
|
+
it("chunks text into delta frames bracketed by start/complete", () => {
|
|
46
|
+
const frames = buildAssistantTurnFrames({
|
|
47
|
+
executionId: "exec-1",
|
|
48
|
+
turnId: "turn-1",
|
|
49
|
+
text: "abcdefghij",
|
|
50
|
+
chunkSize: 4,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(frames[0]).toEqual({ type: "agent_turn_start", executionId: "exec-1", turnId: "turn-1" });
|
|
54
|
+
expect(frames[frames.length - 1]).toEqual({
|
|
55
|
+
type: "agent_turn_complete",
|
|
56
|
+
executionId: "exec-1",
|
|
57
|
+
turnId: "turn-1",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const deltas = frames.filter((f) => f.type === "agent_turn_delta");
|
|
61
|
+
expect(deltas.map((f) => f.delta)).toEqual(["abcd", "efgh", "ij"]);
|
|
62
|
+
expect(deltas.every((f) => f.executionId === "exec-1" && f.turnId === "turn-1")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("defaults turnId and chunkSize", () => {
|
|
66
|
+
const frames = buildAssistantTurnFrames({ executionId: "exec-2", text: "hello" });
|
|
67
|
+
expect(frames[0].turnId).toBe("turn-1");
|
|
68
|
+
const deltaCount = frames.filter((f) => f.type === "agent_turn_delta").length;
|
|
69
|
+
expect(deltaCount).toBeGreaterThanOrEqual(1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("createMockSSEResponse", () => {
|
|
74
|
+
it("wraps the stream in a text/event-stream Response", async () => {
|
|
75
|
+
const res = createMockSSEResponse([{ type: "ping" }], { delayMs: 0 });
|
|
76
|
+
expect(res.status).toBe(200);
|
|
77
|
+
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
|
78
|
+
expect(await res.text()).toContain('"type":"ping"');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Shared helpers for mocking SSE streams in demos, previews, and tests. */
|
|
2
|
+
|
|
3
|
+
export interface MockSSEFrame {
|
|
4
|
+
type: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface CreateMockSSEStreamOptions {
|
|
9
|
+
/** Delay in ms between emitted frames. Default: 100. */
|
|
10
|
+
delayMs?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Named event name. When set, each frame is emitted as `event: <name>\ndata: ...\n\n`.
|
|
13
|
+
* Omit for bare `data: ...\n\n` form (both are valid SSE and the widget parser accepts either).
|
|
14
|
+
*/
|
|
15
|
+
eventName?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const encoder = new TextEncoder();
|
|
19
|
+
|
|
20
|
+
export function createMockSSEStream(
|
|
21
|
+
frames: ReadonlyArray<MockSSEFrame>,
|
|
22
|
+
options?: CreateMockSSEStreamOptions
|
|
23
|
+
): ReadableStream<Uint8Array> {
|
|
24
|
+
const delayMs = options?.delayMs ?? 100;
|
|
25
|
+
const prefix = options?.eventName ? `event: ${options.eventName}\n` : "";
|
|
26
|
+
let index = 0;
|
|
27
|
+
|
|
28
|
+
return new ReadableStream<Uint8Array>({
|
|
29
|
+
async pull(controller) {
|
|
30
|
+
if (index >= frames.length) {
|
|
31
|
+
controller.close();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
|
35
|
+
const payload = JSON.stringify(frames[index]);
|
|
36
|
+
controller.enqueue(encoder.encode(`${prefix}data: ${payload}\n\n`));
|
|
37
|
+
index += 1;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AssistantTurnFramesOptions {
|
|
43
|
+
/** Execution id shared across the turn's frames. */
|
|
44
|
+
executionId: string;
|
|
45
|
+
/** Turn id. Default: `turn-1`. */
|
|
46
|
+
turnId?: string;
|
|
47
|
+
/** Assistant text content to stream. */
|
|
48
|
+
text: string;
|
|
49
|
+
/** Approximate characters per `agent_turn_delta` frame. Default: 32. */
|
|
50
|
+
chunkSize?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Builds the standard `agent_turn_start` → many `agent_turn_delta` → `agent_turn_complete`
|
|
55
|
+
* frame sequence for simulating a streaming assistant reply. The frames drive the same
|
|
56
|
+
* client pipeline as real SSE, so stream animations (typewriter, word-fade, etc.) engage.
|
|
57
|
+
*/
|
|
58
|
+
export function buildAssistantTurnFrames(options: AssistantTurnFramesOptions): MockSSEFrame[] {
|
|
59
|
+
const { executionId, text } = options;
|
|
60
|
+
const turnId = options.turnId ?? "turn-1";
|
|
61
|
+
const chunkSize = Math.max(1, options.chunkSize ?? 32);
|
|
62
|
+
|
|
63
|
+
const frames: MockSSEFrame[] = [{ type: "agent_turn_start", executionId, turnId }];
|
|
64
|
+
for (let i = 0; i < text.length; i += chunkSize) {
|
|
65
|
+
frames.push({
|
|
66
|
+
type: "agent_turn_delta",
|
|
67
|
+
executionId,
|
|
68
|
+
turnId,
|
|
69
|
+
delta: text.slice(i, i + chunkSize),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
frames.push({ type: "agent_turn_complete", executionId, turnId });
|
|
73
|
+
return frames;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MockSSEResponseOptions extends CreateMockSSEStreamOptions {
|
|
77
|
+
status?: number;
|
|
78
|
+
headers?: Record<string, string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Convenience wrapper: returns a `Response` ready to hand back from a `customFetch` implementation. */
|
|
82
|
+
export function createMockSSEResponse(
|
|
83
|
+
frames: ReadonlyArray<MockSSEFrame>,
|
|
84
|
+
options?: MockSSEResponseOptions
|
|
85
|
+
): Response {
|
|
86
|
+
const stream = createMockSSEStream(frames, options);
|
|
87
|
+
return new Response(stream, {
|
|
88
|
+
status: options?.status ?? 200,
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "text/event-stream",
|
|
91
|
+
...options?.headers,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -84,6 +84,8 @@ export {
|
|
|
84
84
|
getPreviewTranscriptPresetLabel,
|
|
85
85
|
createPreviewTranscriptEntry,
|
|
86
86
|
appendPreviewTranscriptEntry,
|
|
87
|
+
presetStreamsText,
|
|
88
|
+
buildTranscriptStreamFrames,
|
|
87
89
|
createPreviewMessages,
|
|
88
90
|
applySceneConfig,
|
|
89
91
|
buildPreviewConfig,
|
|
@@ -94,6 +96,8 @@ export type {
|
|
|
94
96
|
PreviewTranscriptEntryPreset,
|
|
95
97
|
PreviewShellPalette,
|
|
96
98
|
PreviewConfigOptions,
|
|
99
|
+
TranscriptStreamFrame,
|
|
100
|
+
BuildTranscriptStreamFramesOptions,
|
|
97
101
|
} from './preview-utils';
|
|
98
102
|
|
|
99
103
|
// Role mappings (Interface Roles editor)
|
|
@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
appendPreviewTranscriptEntry,
|
|
5
5
|
buildPreviewConfig,
|
|
6
|
+
buildTranscriptStreamFrames,
|
|
6
7
|
createPreviewTranscriptEntry,
|
|
8
|
+
presetStreamsText,
|
|
7
9
|
} from "./preview-utils";
|
|
8
10
|
|
|
9
11
|
describe("theme editor preview demo data", () => {
|
|
@@ -55,4 +57,62 @@ describe("theme editor preview demo data", () => {
|
|
|
55
57
|
expect(updated[0].variant).toBe("tool");
|
|
56
58
|
expect(updated[1].variant).toBe("reasoning");
|
|
57
59
|
});
|
|
60
|
+
|
|
61
|
+
it("creates assistant code-block, table, and image presets with markdown content", () => {
|
|
62
|
+
const code = createPreviewTranscriptEntry("assistant-code-block", 1);
|
|
63
|
+
const table = createPreviewTranscriptEntry("assistant-markdown-table", 2);
|
|
64
|
+
const image = createPreviewTranscriptEntry("assistant-image", 3);
|
|
65
|
+
|
|
66
|
+
for (const message of [code, table, image]) {
|
|
67
|
+
expect(message.role).toBe("assistant");
|
|
68
|
+
expect(message.variant).toBeUndefined();
|
|
69
|
+
expect(typeof message.content).toBe("string");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(code.content).toContain("```ts");
|
|
73
|
+
expect(table.content).toContain("| Preset");
|
|
74
|
+
expect(image.content).toContain("![");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("flags assistant text presets as streaming and non-text presets as not", () => {
|
|
78
|
+
expect(presetStreamsText("assistant-message")).toBe(true);
|
|
79
|
+
expect(presetStreamsText("assistant-code-block")).toBe(true);
|
|
80
|
+
expect(presetStreamsText("assistant-markdown-table")).toBe(true);
|
|
81
|
+
expect(presetStreamsText("assistant-image")).toBe(true);
|
|
82
|
+
expect(presetStreamsText("user-message")).toBe(false);
|
|
83
|
+
expect(presetStreamsText("reasoning-streaming")).toBe(false);
|
|
84
|
+
expect(presetStreamsText("tool-running")).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("builds a single done frame for non-streaming presets", () => {
|
|
88
|
+
const frames = buildTranscriptStreamFrames("user-message", 0);
|
|
89
|
+
expect(frames).toHaveLength(1);
|
|
90
|
+
expect(frames[0].done).toBe(true);
|
|
91
|
+
expect(frames[0].delayMs).toBe(0);
|
|
92
|
+
expect(frames[0].message.role).toBe("user");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("builds progressive snapshots for assistant text presets ending with streaming:false", () => {
|
|
96
|
+
const frames = buildTranscriptStreamFrames("assistant-message", 0, { chunkSize: 10, delayMs: 5 });
|
|
97
|
+
expect(frames.length).toBeGreaterThan(1);
|
|
98
|
+
|
|
99
|
+
const first = frames[0];
|
|
100
|
+
expect(first.message.content).toBe("");
|
|
101
|
+
expect(first.message.streaming).toBe(true);
|
|
102
|
+
expect(first.delayMs).toBe(0);
|
|
103
|
+
expect(first.done).toBe(false);
|
|
104
|
+
|
|
105
|
+
for (let i = 1; i < frames.length - 1; i += 1) {
|
|
106
|
+
expect(frames[i].message.streaming).toBe(true);
|
|
107
|
+
expect(frames[i].done).toBe(false);
|
|
108
|
+
expect(frames[i].delayMs).toBe(5);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const last = frames[frames.length - 1];
|
|
112
|
+
expect(last.message.streaming).toBe(false);
|
|
113
|
+
expect(last.done).toBe(true);
|
|
114
|
+
const completed = createPreviewTranscriptEntry("assistant-message", 0);
|
|
115
|
+
expect(last.message.content).toBe(completed.content);
|
|
116
|
+
expect(last.message.id).toBe(completed.id);
|
|
117
|
+
});
|
|
58
118
|
});
|
|
@@ -200,6 +200,9 @@ export type PreviewScene = 'home' | 'conversation' | 'minimized' | 'artifact';
|
|
|
200
200
|
export type PreviewTranscriptEntryPreset =
|
|
201
201
|
| 'user-message'
|
|
202
202
|
| 'assistant-message'
|
|
203
|
+
| 'assistant-code-block'
|
|
204
|
+
| 'assistant-markdown-table'
|
|
205
|
+
| 'assistant-image'
|
|
203
206
|
| 'reasoning-streaming'
|
|
204
207
|
| 'reasoning-complete'
|
|
205
208
|
| 'tool-running'
|
|
@@ -208,6 +211,9 @@ export type PreviewTranscriptEntryPreset =
|
|
|
208
211
|
const PREVIEW_TRANSCRIPT_PRESET_LABELS: Record<PreviewTranscriptEntryPreset, string> = {
|
|
209
212
|
'user-message': 'User message',
|
|
210
213
|
'assistant-message': 'Assistant message',
|
|
214
|
+
'assistant-code-block': 'Assistant — code block',
|
|
215
|
+
'assistant-markdown-table': 'Assistant — markdown table',
|
|
216
|
+
'assistant-image': 'Assistant — image',
|
|
211
217
|
'reasoning-streaming': 'Reasoning (streaming)',
|
|
212
218
|
'reasoning-complete': 'Reasoning (complete)',
|
|
213
219
|
'tool-running': 'Tool call (running)',
|
|
@@ -240,6 +246,56 @@ export function createPreviewTranscriptEntry(
|
|
|
240
246
|
content: 'Absolutely. I can keep going and explain what happens next.',
|
|
241
247
|
createdAt,
|
|
242
248
|
};
|
|
249
|
+
case 'assistant-code-block':
|
|
250
|
+
return {
|
|
251
|
+
id: `preview-seq-assistant-code-${suffix}`,
|
|
252
|
+
role: 'assistant',
|
|
253
|
+
content: [
|
|
254
|
+
"Here's how you'd wire up a streaming animation:",
|
|
255
|
+
'',
|
|
256
|
+
'```ts',
|
|
257
|
+
"import { createAgentExperience } from '@runtypelabs/persona';",
|
|
258
|
+
'',
|
|
259
|
+
'createAgentExperience(el, {',
|
|
260
|
+
' features: {',
|
|
261
|
+
' streamAnimation: { type: "letter-rise", speed: 120 },',
|
|
262
|
+
' },',
|
|
263
|
+
'});',
|
|
264
|
+
'```',
|
|
265
|
+
'',
|
|
266
|
+
'Swap the `type` value to try the other presets.',
|
|
267
|
+
].join('\n'),
|
|
268
|
+
createdAt,
|
|
269
|
+
};
|
|
270
|
+
case 'assistant-markdown-table':
|
|
271
|
+
return {
|
|
272
|
+
id: `preview-seq-assistant-table-${suffix}`,
|
|
273
|
+
role: 'assistant',
|
|
274
|
+
content: [
|
|
275
|
+
'Here are the built-in streaming animations at a glance:',
|
|
276
|
+
'',
|
|
277
|
+
'| Preset | Wrap unit | Best for |',
|
|
278
|
+
'| ------------ | --------- | --------------------------- |',
|
|
279
|
+
'| Typewriter | Character | Classic terminal feel |',
|
|
280
|
+
'| Letter rise | Character | Soft, staggered entrance |',
|
|
281
|
+
'| Word fade | Word | Longer-form assistant replies |',
|
|
282
|
+
'| Pop bubble | Bubble | Short, punchy affirmations |',
|
|
283
|
+
].join('\n'),
|
|
284
|
+
createdAt,
|
|
285
|
+
};
|
|
286
|
+
case 'assistant-image':
|
|
287
|
+
return {
|
|
288
|
+
id: `preview-seq-assistant-image-${suffix}`,
|
|
289
|
+
role: 'assistant',
|
|
290
|
+
content: [
|
|
291
|
+
"Here's the reference diagram you asked for — let me know if you'd like a different view:",
|
|
292
|
+
'',
|
|
293
|
+
'',
|
|
294
|
+
'',
|
|
295
|
+
'The gradient shows how per-unit delays stagger across the reply.',
|
|
296
|
+
].join('\n'),
|
|
297
|
+
createdAt,
|
|
298
|
+
};
|
|
243
299
|
case 'reasoning-streaming':
|
|
244
300
|
return {
|
|
245
301
|
id: `preview-seq-reasoning-stream-${suffix}`,
|
|
@@ -312,6 +368,79 @@ export function appendPreviewTranscriptEntry(
|
|
|
312
368
|
return [...messages, createPreviewTranscriptEntry(preset, messages.length)];
|
|
313
369
|
}
|
|
314
370
|
|
|
371
|
+
/** Presets whose assistant content should stream in so Stream Animation settings engage. */
|
|
372
|
+
export function presetStreamsText(preset: PreviewTranscriptEntryPreset): boolean {
|
|
373
|
+
return (
|
|
374
|
+
preset === 'assistant-message' ||
|
|
375
|
+
preset === 'assistant-code-block' ||
|
|
376
|
+
preset === 'assistant-markdown-table' ||
|
|
377
|
+
preset === 'assistant-image'
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface TranscriptStreamFrame {
|
|
382
|
+
/** Message to upsert into the session. */
|
|
383
|
+
message: AgentWidgetMessage;
|
|
384
|
+
/** Delay from the previous frame in ms. The first frame uses 0. */
|
|
385
|
+
delayMs: number;
|
|
386
|
+
/** True when this is the final frame (message is no longer streaming). */
|
|
387
|
+
done: boolean;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface BuildTranscriptStreamFramesOptions {
|
|
391
|
+
/** Characters per progressive chunk. Default: 24. */
|
|
392
|
+
chunkSize?: number;
|
|
393
|
+
/** Delay between chunks in ms. Default: 42. */
|
|
394
|
+
delayMs?: number;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Builds progressive snapshots for a transcript preset suitable for feeding into
|
|
399
|
+
* `injectTestMessage({ type: 'message', message })` on a timer. Each frame upserts
|
|
400
|
+
* the same message id with more content, ending with `streaming: false`.
|
|
401
|
+
*
|
|
402
|
+
* - Streaming-capable presets (assistant text) yield many frames.
|
|
403
|
+
* - All other presets yield a single `done` frame matching `createPreviewTranscriptEntry`.
|
|
404
|
+
*/
|
|
405
|
+
export function buildTranscriptStreamFrames(
|
|
406
|
+
preset: PreviewTranscriptEntryPreset,
|
|
407
|
+
suffix: number,
|
|
408
|
+
options?: BuildTranscriptStreamFramesOptions
|
|
409
|
+
): TranscriptStreamFrame[] {
|
|
410
|
+
const completed = createPreviewTranscriptEntry(preset, suffix);
|
|
411
|
+
if (!presetStreamsText(preset) || typeof completed.content !== 'string') {
|
|
412
|
+
return [{ message: completed, delayMs: 0, done: true }];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const chunkSize = Math.max(1, options?.chunkSize ?? 24);
|
|
416
|
+
const delayMs = Math.max(0, options?.delayMs ?? 42);
|
|
417
|
+
const fullText = completed.content;
|
|
418
|
+
const frames: TranscriptStreamFrame[] = [];
|
|
419
|
+
|
|
420
|
+
// Seed with an empty streaming bubble so the animation plugin can attach from the first tick.
|
|
421
|
+
frames.push({
|
|
422
|
+
message: { ...completed, content: '', streaming: true },
|
|
423
|
+
delayMs: 0,
|
|
424
|
+
done: false,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
for (let i = chunkSize; i < fullText.length; i += chunkSize) {
|
|
428
|
+
frames.push({
|
|
429
|
+
message: { ...completed, content: fullText.slice(0, i), streaming: true },
|
|
430
|
+
delayMs,
|
|
431
|
+
done: false,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
frames.push({
|
|
436
|
+
message: { ...completed, content: fullText, streaming: false },
|
|
437
|
+
delayMs,
|
|
438
|
+
done: true,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
return frames;
|
|
442
|
+
}
|
|
443
|
+
|
|
315
444
|
const createAdvancedTranscriptPreviewMessages = (): AgentWidgetMessage[] => [
|
|
316
445
|
{
|
|
317
446
|
id: "preview-adv-1",
|
|
@@ -60,4 +60,23 @@ describe("theme editor scroll-to-bottom controls", () => {
|
|
|
60
60
|
expect(debugSection?.fields.some((field) => field.path === "features.reasoningDisplay.previewMaxLines")).toBe(true);
|
|
61
61
|
expect(debugSection?.fields.some((field) => field.path === "features.reasoningDisplay.activeMinHeight")).toBe(true);
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
it("exposes stream animation controls", () => {
|
|
65
|
+
const section = CONFIGURE_SECTIONS.find((entry) => entry.id === "stream-animation");
|
|
66
|
+
|
|
67
|
+
expect(section).toBeDefined();
|
|
68
|
+
const paths = section?.fields.map((field) => field.path) ?? [];
|
|
69
|
+
expect(paths).toEqual(
|
|
70
|
+
expect.arrayContaining([
|
|
71
|
+
"features.streamAnimation.type",
|
|
72
|
+
"features.streamAnimation.placeholder",
|
|
73
|
+
"features.streamAnimation.buffer",
|
|
74
|
+
"features.streamAnimation.speed",
|
|
75
|
+
"features.streamAnimation.duration",
|
|
76
|
+
])
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const speedField = section?.fields.find((field) => field.path === "features.streamAnimation.speed");
|
|
80
|
+
expect(speedField?.parseValue?.("240")).toBe(240);
|
|
81
|
+
});
|
|
63
82
|
});
|