@runtypelabs/persona 3.16.0 → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -0
- 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-cwY5HaFD.d.cts +307 -0
- package/dist/animations/types-cwY5HaFD.d.ts +307 -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 +504 -1
- package/dist/index.d.ts +504 -1
- package/dist/index.global.js +143 -88
- 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 +2095 -207
- package/dist/theme-editor.d.cts +432 -2
- package/dist/theme-editor.d.ts +432 -2
- package/dist/theme-editor.js +2093 -207
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/widget.css +565 -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 +275 -0
- package/src/client.ts +99 -0
- package/src/components/ask-user-question-bubble.test.ts +583 -0
- package/src/components/ask-user-question-bubble.ts +924 -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/messages.ts +33 -1
- package/src/components/panel.ts +45 -5
- package/src/defaults.ts +37 -0
- package/src/index-global.ts +31 -0
- package/src/index.ts +34 -1
- package/src/plugins/types.ts +57 -0
- package/src/session.test.ts +276 -1
- package/src/session.ts +247 -3
- package/src/styles/widget.css +565 -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/theme.ts +15 -0
- package/src/types.ts +360 -0
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.stop-button.test.ts +165 -0
- package/src/ui.ts +706 -11
- package/src/utils/message-fingerprint.ts +2 -0
- package/src/utils/morph.ts +7 -0
- package/src/utils/storage.ts +10 -2
- package/src/utils/stream-animation.test.ts +417 -0
- package/src/utils/stream-animation.ts +449 -0
- package/src/utils/theme.test.ts +36 -0
- package/src/utils/tokens.ts +23 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import type { StreamAnimationPlugin } from "../types";
|
|
2
|
+
import { registerStreamAnimationPlugin } from "../utils/stream-animation";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Glyph-cycle animation — "hacker-settling" reveal.
|
|
6
|
+
*
|
|
7
|
+
* Each arriving char briefly cycles through a sequence of random glyphs
|
|
8
|
+
* before locking in to its final character. Unlike a pure CSS effect, this
|
|
9
|
+
* needs JS because it mutates `textContent` per tick. To keep idiomorph from
|
|
10
|
+
* clobbering mid-cycle work, each cycling span carries
|
|
11
|
+
* `data-preserve-runtime="stream-glyph-cycle"` until its cycle completes —
|
|
12
|
+
* morph honors that attribute as an absolute skip marker.
|
|
13
|
+
*
|
|
14
|
+
* A short buffer (`BUFFER_THRESHOLD`) holds the bubble empty (with the
|
|
15
|
+
* typing indicator) until enough text has arrived to run a visible reveal.
|
|
16
|
+
* Once released, every char starts as a random glyph and settles in order
|
|
17
|
+
* from the start, staggered by one `--persona-stream-step` between chars.
|
|
18
|
+
* Live tokens arriving after release queue behind the stagger slot, so the
|
|
19
|
+
* settling wave flows through the full message.
|
|
20
|
+
*
|
|
21
|
+
* Ships as a subpath module so consumers who don't want it pay zero cost.
|
|
22
|
+
* Importing this module auto-registers the plugin globally — just add the
|
|
23
|
+
* import and set `features.streamAnimation.type = "glyph-cycle"`.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* import "@runtypelabs/persona/animations/glyph-cycle";
|
|
27
|
+
* createAgentExperience(el, {
|
|
28
|
+
* features: { streamAnimation: { type: "glyph-cycle" } },
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const STYLES = `
|
|
34
|
+
[data-persona-root] .persona-stream-glyph-cycle .persona-stream-char {
|
|
35
|
+
animation: persona-stream-glyph-cycle-fade
|
|
36
|
+
calc(var(--persona-stream-step, 120ms) * 1.5) ease-out both;
|
|
37
|
+
}
|
|
38
|
+
[data-persona-root] .persona-stream-glyph-cycle .persona-stream-char[data-glyph-cycle-final] {
|
|
39
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
40
|
+
}
|
|
41
|
+
@keyframes persona-stream-glyph-cycle-fade {
|
|
42
|
+
from { opacity: 0.35; }
|
|
43
|
+
to { opacity: 1; }
|
|
44
|
+
}
|
|
45
|
+
@media (prefers-reduced-motion: reduce) {
|
|
46
|
+
[data-persona-root] .persona-stream-glyph-cycle .persona-stream-char {
|
|
47
|
+
animation: none !important;
|
|
48
|
+
opacity: 1 !important;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`.trim();
|
|
52
|
+
|
|
53
|
+
// Matches the source design: alphanumerics + a small set of symbols.
|
|
54
|
+
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&@";
|
|
55
|
+
const TICK_COUNT = 10;
|
|
56
|
+
// Base interval between glyph swaps (ms). Scaled by the widget's configured
|
|
57
|
+
// `speed` so the cycle feels coherent with the rest of the stream animation —
|
|
58
|
+
// faster speed → snappier cycling, slower speed → more deliberate flicker.
|
|
59
|
+
const BASE_TICK_MS = 120;
|
|
60
|
+
const DEFAULT_STEP_MS = 120;
|
|
61
|
+
// Probability that each later still-cycling sibling gets re-randomized on
|
|
62
|
+
// every tick. Matches the source prototype's cross-glyph jitter feel.
|
|
63
|
+
const CROSS_FLICKER_PROBABILITY = 0.4;
|
|
64
|
+
// Hold the stream back until at least this many chars have arrived. Once
|
|
65
|
+
// released, every char is rendered as a random glyph and settles in order
|
|
66
|
+
// from the start — so the visible animation carries through the full text.
|
|
67
|
+
const BUFFER_THRESHOLD = 50;
|
|
68
|
+
|
|
69
|
+
const getStepMs = (container: HTMLElement | null): number => {
|
|
70
|
+
if (!container) return DEFAULT_STEP_MS;
|
|
71
|
+
const raw = container.style.getPropertyValue("--persona-stream-step")?.trim();
|
|
72
|
+
const match = raw.match(/([\d.]+)\s*ms/);
|
|
73
|
+
return match ? parseFloat(match[1]) : DEFAULT_STEP_MS;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const getTickMs = (span: HTMLElement): number => {
|
|
77
|
+
const container = span.closest(".persona-stream-glyph-cycle") as HTMLElement | null;
|
|
78
|
+
const step = getStepMs(container);
|
|
79
|
+
return (BASE_TICK_MS * step) / DEFAULT_STEP_MS;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const randomGlyph = (avoid?: string): string => {
|
|
83
|
+
let ch = GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
|
|
84
|
+
// Don't settle on the same random glyph twice in a row (small visual cue
|
|
85
|
+
// that something is actually cycling).
|
|
86
|
+
if (avoid && ch === avoid) {
|
|
87
|
+
ch = GLYPHS[(GLYPHS.indexOf(ch) + 1) % GLYPHS.length];
|
|
88
|
+
}
|
|
89
|
+
return ch;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Mirrors the source prototype's "lightly randomize later glyphs" step: while
|
|
93
|
+
// a char is cycling, every not-yet-settled char after it in DOM order has a
|
|
94
|
+
// chance to flicker too. Keeps the un-settled tail feeling alive.
|
|
95
|
+
const flickerLaterSiblings = (span: HTMLElement): void => {
|
|
96
|
+
const container = span.closest(".persona-stream-glyph-cycle") as HTMLElement | null;
|
|
97
|
+
if (!container) return;
|
|
98
|
+
// Any scheduled char that still has its final char stashed is "pending
|
|
99
|
+
// or cycling" — both kinds are valid flicker targets. Settled chars have
|
|
100
|
+
// `data-glyph-cycle-final` deleted and are excluded.
|
|
101
|
+
const unsettled = container.querySelectorAll<HTMLElement>(
|
|
102
|
+
".persona-stream-char[data-glyph-cycle-final]"
|
|
103
|
+
);
|
|
104
|
+
let seenSelf = false;
|
|
105
|
+
for (const other of Array.from(unsettled)) {
|
|
106
|
+
if (other === span) {
|
|
107
|
+
seenSelf = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!seenSelf) continue;
|
|
111
|
+
if (Math.random() < CROSS_FLICKER_PROBABILITY) {
|
|
112
|
+
other.textContent = randomGlyph();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Per-container timestamp tracking: when the next char's cycle should settle.
|
|
118
|
+
// Each char starts cycling immediately on schedule — no static placeholder
|
|
119
|
+
// wait — and finishes when `Date.now() >= settleAt`. This slot advances by
|
|
120
|
+
// one stagger step per schedule, so the burst settles left-to-right while
|
|
121
|
+
// every char is visibly flickering from the moment it enters the DOM.
|
|
122
|
+
const containerNextSettleAt = new WeakMap<Element, number>();
|
|
123
|
+
|
|
124
|
+
// Adaptive stagger state. We measure the real interval between scheduleCycle
|
|
125
|
+
// calls (= the stream's arrival cadence) and pace the glyph wave to it, a
|
|
126
|
+
// touch faster (DRAIN_FACTOR < 1) so any initial burst queue drains down to
|
|
127
|
+
// live-tracking instead of permanently lagging behind the live cursor.
|
|
128
|
+
const DEFAULT_ARRIVAL_MS = 25;
|
|
129
|
+
const STAGGER_DRAIN_FACTOR = 0.5;
|
|
130
|
+
const MIN_STAGGER_MS = 6;
|
|
131
|
+
const EMA_ALPHA = 0.25;
|
|
132
|
+
const containerLastScheduleAt = new WeakMap<Element, number>();
|
|
133
|
+
const containerArrivalMs = new WeakMap<Element, number>();
|
|
134
|
+
|
|
135
|
+
const observeStagger = (container: Element | null, now: number): number => {
|
|
136
|
+
if (!container) return DEFAULT_ARRIVAL_MS * STAGGER_DRAIN_FACTOR;
|
|
137
|
+
const last = containerLastScheduleAt.get(container);
|
|
138
|
+
containerLastScheduleAt.set(container, now);
|
|
139
|
+
let observed = containerArrivalMs.get(container) ?? DEFAULT_ARRIVAL_MS;
|
|
140
|
+
if (last !== undefined) {
|
|
141
|
+
const interval = now - last;
|
|
142
|
+
// Skip near-zero intervals (burst within one MutationObserver batch) —
|
|
143
|
+
// they don't reflect the real stream cadence.
|
|
144
|
+
if (interval > 1) {
|
|
145
|
+
observed = observed * (1 - EMA_ALPHA) + interval * EMA_ALPHA;
|
|
146
|
+
containerArrivalMs.set(container, observed);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return Math.max(MIN_STAGGER_MS, observed * STAGGER_DRAIN_FACTOR);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Per-message count of in-flight cycles (scheduled or actively ticking).
|
|
153
|
+
// Used by `isAnimating()` so message-bubble keeps rendering in animated
|
|
154
|
+
// mode until the cycle wave finishes, even after `message.streaming` is
|
|
155
|
+
// false.
|
|
156
|
+
const activeCyclesByMessage = new Map<string, number>();
|
|
157
|
+
|
|
158
|
+
const getMessageId = (span: HTMLElement): string | null => {
|
|
159
|
+
const bubble = span.closest<HTMLElement>("[data-message-id]");
|
|
160
|
+
return bubble?.dataset.messageId ?? null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const incrementActive = (messageId: string | null): void => {
|
|
164
|
+
if (!messageId) return;
|
|
165
|
+
activeCyclesByMessage.set(messageId, (activeCyclesByMessage.get(messageId) ?? 0) + 1);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const decrementActive = (messageId: string | null): void => {
|
|
169
|
+
if (!messageId) return;
|
|
170
|
+
const count = activeCyclesByMessage.get(messageId) ?? 0;
|
|
171
|
+
if (count <= 1) activeCyclesByMessage.delete(messageId);
|
|
172
|
+
else activeCyclesByMessage.set(messageId, count - 1);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const scheduleCycle = (span: HTMLElement): void => {
|
|
176
|
+
if (span.dataset.glyphCycleScheduled === "true") return;
|
|
177
|
+
const finalChar = span.textContent ?? "";
|
|
178
|
+
if (!finalChar || /\s/.test(finalChar)) return;
|
|
179
|
+
|
|
180
|
+
span.dataset.glyphCycleScheduled = "true";
|
|
181
|
+
// Stash the target char and immediately paint a random glyph so the span
|
|
182
|
+
// reads as "cycling" even before its staggered kickoff. Also opt out of
|
|
183
|
+
// morph — without this, streamed token re-renders would overwrite our
|
|
184
|
+
// placeholder glyph with the final char.
|
|
185
|
+
span.dataset.glyphCycleFinal = finalChar;
|
|
186
|
+
span.setAttribute("data-preserve-runtime", "stream-glyph-cycle");
|
|
187
|
+
span.textContent = randomGlyph();
|
|
188
|
+
// Record the owning message so `isAnimating()` can report in-flight work
|
|
189
|
+
// even after streaming has ended.
|
|
190
|
+
const messageId = getMessageId(span);
|
|
191
|
+
if (messageId) span.dataset.glyphCycleMessageId = messageId;
|
|
192
|
+
incrementActive(messageId);
|
|
193
|
+
|
|
194
|
+
const container = span.closest(".persona-stream-glyph-cycle") as HTMLElement | null;
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
const staggerMs = observeStagger(container, now);
|
|
197
|
+
const tickMs = getTickMs(span);
|
|
198
|
+
const baseDurationMs = TICK_COUNT * tickMs;
|
|
199
|
+
|
|
200
|
+
// Settle time = later of (this char's own cycle ends) or (previous settle
|
|
201
|
+
// slot + one stagger step). The `max()` keeps left-to-right order during
|
|
202
|
+
// bursts, while post-drain live chars just get the base cycle.
|
|
203
|
+
let settleAt = now + baseDurationMs;
|
|
204
|
+
if (container) {
|
|
205
|
+
const prev = containerNextSettleAt.get(container);
|
|
206
|
+
if (prev !== undefined) settleAt = Math.max(settleAt, prev);
|
|
207
|
+
containerNextSettleAt.set(container, settleAt + staggerMs);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
startCycle(span, finalChar, settleAt);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const startCycle = (
|
|
214
|
+
span: HTMLElement,
|
|
215
|
+
finalChar: string,
|
|
216
|
+
settleAt: number
|
|
217
|
+
): void => {
|
|
218
|
+
if (span.dataset.glyphCycleStarted === "true") return;
|
|
219
|
+
span.dataset.glyphCycleStarted = "true";
|
|
220
|
+
|
|
221
|
+
const tickMs = getTickMs(span);
|
|
222
|
+
// Seed `lastGlyph` from the placeholder scheduleCycle painted, so the
|
|
223
|
+
// first real tick guarantees a different glyph.
|
|
224
|
+
let lastGlyph: string | undefined = span.textContent ?? undefined;
|
|
225
|
+
const step = () => {
|
|
226
|
+
if (!span.isConnected) return;
|
|
227
|
+
if (Date.now() >= settleAt) {
|
|
228
|
+
span.textContent = finalChar;
|
|
229
|
+
span.removeAttribute("data-preserve-runtime");
|
|
230
|
+
delete span.dataset.glyphCycleStarted;
|
|
231
|
+
delete span.dataset.glyphCycleFinal;
|
|
232
|
+
decrementActive(span.dataset.glyphCycleMessageId ?? null);
|
|
233
|
+
delete span.dataset.glyphCycleMessageId;
|
|
234
|
+
// Keep `data-glyph-cycle-scheduled` set so the MutationObserver and
|
|
235
|
+
// processCharSpans selectors skip settled chars on future re-morphs.
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const glyph = randomGlyph(lastGlyph);
|
|
239
|
+
span.textContent = glyph;
|
|
240
|
+
lastGlyph = glyph;
|
|
241
|
+
flickerLaterSiblings(span);
|
|
242
|
+
setTimeout(step, tickMs);
|
|
243
|
+
};
|
|
244
|
+
setTimeout(step, tickMs);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const processCharSpans = (root: Element | Document | ShadowRoot): void => {
|
|
248
|
+
const spans = root.querySelectorAll?.(
|
|
249
|
+
".persona-stream-glyph-cycle .persona-stream-char:not([data-glyph-cycle-scheduled])"
|
|
250
|
+
);
|
|
251
|
+
if (!spans) return;
|
|
252
|
+
for (const span of Array.from(spans)) {
|
|
253
|
+
scheduleCycle(span as HTMLElement);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const isElement = (node: Node): node is HTMLElement => node.nodeType === 1;
|
|
258
|
+
|
|
259
|
+
export const glyphCycle: StreamAnimationPlugin = {
|
|
260
|
+
name: "glyph-cycle",
|
|
261
|
+
containerClass: "persona-stream-glyph-cycle",
|
|
262
|
+
wrap: "char",
|
|
263
|
+
// Narrow the default skip list so inline `<code>` and fenced `<pre>`
|
|
264
|
+
// code blocks both render as cycling glyphs along with everything else.
|
|
265
|
+
// Links stay clickable; <script>/<style> stay untouched.
|
|
266
|
+
skipTags: ["a", "script", "style"],
|
|
267
|
+
styles: STYLES,
|
|
268
|
+
bufferContent(content) {
|
|
269
|
+
// Hold the bubble empty until we've accumulated enough text to run a
|
|
270
|
+
// visible cycle from the start.
|
|
271
|
+
if (content.length < BUFFER_THRESHOLD) return "";
|
|
272
|
+
// Then trim to the last "safe" whitespace: one that sits outside any
|
|
273
|
+
// unclosed `**bold**` pair. Without this, whitespace INSIDE a partial
|
|
274
|
+
// bold pair (e.g. the space between `template` and `literal` in
|
|
275
|
+
// `**template literal**`) would trigger a render of the partial
|
|
276
|
+
// `**template`, which markdown emits as literal asterisks, gets
|
|
277
|
+
// wrapped + marked `data-preserve-runtime`, and leaks through later
|
|
278
|
+
// morphs — the final structure can't reconcile.
|
|
279
|
+
let boldPairs = 0;
|
|
280
|
+
let lastSafe = -1;
|
|
281
|
+
let i = 0;
|
|
282
|
+
while (i < content.length) {
|
|
283
|
+
if (content[i] === "*" && content[i + 1] === "*") {
|
|
284
|
+
boldPairs += 1;
|
|
285
|
+
i += 2;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (/\s/.test(content[i]) && boldPairs % 2 === 0) {
|
|
289
|
+
lastSafe = i;
|
|
290
|
+
}
|
|
291
|
+
i += 1;
|
|
292
|
+
}
|
|
293
|
+
if (lastSafe < 0) return "";
|
|
294
|
+
return content.slice(0, lastSafe);
|
|
295
|
+
},
|
|
296
|
+
isAnimating(message) {
|
|
297
|
+
return (activeCyclesByMessage.get(message.id) ?? 0) > 0;
|
|
298
|
+
},
|
|
299
|
+
onAttach(root) {
|
|
300
|
+
// Process any chars already in the DOM when the plugin activates.
|
|
301
|
+
processCharSpans(root as Element | Document | ShadowRoot);
|
|
302
|
+
|
|
303
|
+
const observer = new MutationObserver((mutations) => {
|
|
304
|
+
for (const mutation of mutations) {
|
|
305
|
+
for (const node of Array.from(mutation.addedNodes)) {
|
|
306
|
+
if (!isElement(node)) continue;
|
|
307
|
+
// Either the added node is a char span inside a glyph-cycle
|
|
308
|
+
// container, or it contains char spans (a word-group, paragraph,
|
|
309
|
+
// or whole bubble that was just morphed in).
|
|
310
|
+
if (
|
|
311
|
+
node.classList.contains("persona-stream-char") &&
|
|
312
|
+
node.closest(".persona-stream-glyph-cycle")
|
|
313
|
+
) {
|
|
314
|
+
scheduleCycle(node);
|
|
315
|
+
} else {
|
|
316
|
+
processCharSpans(node);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
322
|
+
|
|
323
|
+
return () => observer.disconnect();
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Auto-register on import. ESM consumers get zero cost if they don't import;
|
|
328
|
+
// once imported, the plugin is available globally by name without requiring
|
|
329
|
+
// an explicit `plugins: { "glyph-cycle": glyphCycle }` config.
|
|
330
|
+
registerStreamAnimationPlugin(glyphCycle);
|
|
331
|
+
|
|
332
|
+
export default glyphCycle;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { StreamAnimationPlugin } from "../types";
|
|
2
|
+
import { registerStreamAnimationPlugin } from "../utils/stream-animation";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wipe animation — per-word left-to-right mask reveal.
|
|
6
|
+
*
|
|
7
|
+
* Each arriving word is revealed via a soft feathered mask that sweeps from
|
|
8
|
+
* right to left. Uses `mask-image` (not `background-clip: text`) so the
|
|
9
|
+
* text's normal color is preserved and nested markdown formatting works.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import "@runtypelabs/persona/animations/wipe";
|
|
13
|
+
* createAgentExperience(el, {
|
|
14
|
+
* features: { streamAnimation: { type: "wipe" } },
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const STYLES = `
|
|
20
|
+
@keyframes persona-stream-wipe {
|
|
21
|
+
from { -webkit-mask-position: 100% 0; mask-position: 100% 0; }
|
|
22
|
+
to { -webkit-mask-position: 0% 0; mask-position: 0% 0; }
|
|
23
|
+
}
|
|
24
|
+
[data-persona-root] .persona-stream-wipe .persona-stream-word {
|
|
25
|
+
-webkit-mask-image: linear-gradient(
|
|
26
|
+
90deg,
|
|
27
|
+
black 0%,
|
|
28
|
+
black 45%,
|
|
29
|
+
transparent 55%,
|
|
30
|
+
transparent 100%
|
|
31
|
+
);
|
|
32
|
+
mask-image: linear-gradient(
|
|
33
|
+
90deg,
|
|
34
|
+
black 0%,
|
|
35
|
+
black 45%,
|
|
36
|
+
transparent 55%,
|
|
37
|
+
transparent 100%
|
|
38
|
+
);
|
|
39
|
+
-webkit-mask-size: 200% 100%;
|
|
40
|
+
mask-size: 200% 100%;
|
|
41
|
+
-webkit-mask-position: 100% 0;
|
|
42
|
+
mask-position: 100% 0;
|
|
43
|
+
-webkit-mask-repeat: no-repeat;
|
|
44
|
+
mask-repeat: no-repeat;
|
|
45
|
+
animation: persona-stream-wipe calc(var(--persona-stream-step, 120ms) * 3)
|
|
46
|
+
ease-out forwards;
|
|
47
|
+
}
|
|
48
|
+
@media (prefers-reduced-motion: reduce) {
|
|
49
|
+
[data-persona-root] .persona-stream-wipe .persona-stream-word {
|
|
50
|
+
animation: none !important;
|
|
51
|
+
-webkit-mask-image: none !important;
|
|
52
|
+
mask-image: none !important;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
`.trim();
|
|
56
|
+
|
|
57
|
+
export const wipe: StreamAnimationPlugin = {
|
|
58
|
+
name: "wipe",
|
|
59
|
+
containerClass: "persona-stream-wipe",
|
|
60
|
+
wrap: "word",
|
|
61
|
+
styles: STYLES,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
registerStreamAnimationPlugin(wipe);
|
|
65
|
+
|
|
66
|
+
export default wipe;
|
package/src/client.test.ts
CHANGED
|
@@ -2576,3 +2576,278 @@ describe('AgentWidgetClient - Out-of-Order Sequence Reordering', () => {
|
|
|
2576
2576
|
});
|
|
2577
2577
|
});
|
|
2578
2578
|
|
|
2579
|
+
// ============================================================================
|
|
2580
|
+
// stopReason wiring (agent_turn_complete / step_complete)
|
|
2581
|
+
// ============================================================================
|
|
2582
|
+
|
|
2583
|
+
describe('AgentWidgetClient - stopReason propagation', () => {
|
|
2584
|
+
const dispatchModeStream = (stopReason?: string) => {
|
|
2585
|
+
const data: Record<string, unknown> = {
|
|
2586
|
+
type: 'step_complete',
|
|
2587
|
+
id: 'step_1',
|
|
2588
|
+
stepType: 'prompt',
|
|
2589
|
+
result: { response: 'Hello there.' },
|
|
2590
|
+
};
|
|
2591
|
+
if (stopReason) data.stopReason = stopReason;
|
|
2592
|
+
return [
|
|
2593
|
+
`data: ${JSON.stringify(data)}\n\n`,
|
|
2594
|
+
`data: ${JSON.stringify({ type: 'flow_complete', success: true })}\n\n`,
|
|
2595
|
+
];
|
|
2596
|
+
};
|
|
2597
|
+
|
|
2598
|
+
const collectFinalAssistant = (events: AgentWidgetEvent[]): AgentWidgetMessage | null => {
|
|
2599
|
+
const messageEvents = events.filter(e => e.type === 'message');
|
|
2600
|
+
for (let i = messageEvents.length - 1; i >= 0; i--) {
|
|
2601
|
+
const ev = messageEvents[i];
|
|
2602
|
+
if (ev.type === 'message' && ev.message.role === 'assistant' && !ev.message.streaming) {
|
|
2603
|
+
return ev.message;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
return null;
|
|
2607
|
+
};
|
|
2608
|
+
|
|
2609
|
+
const runDispatch = async (chunks: string[]): Promise<AgentWidgetEvent[]> => {
|
|
2610
|
+
global.fetch = vi.fn().mockImplementation(async (_url: string, _options: any) => {
|
|
2611
|
+
const encoder = new TextEncoder();
|
|
2612
|
+
const stream = new ReadableStream({
|
|
2613
|
+
start(controller) {
|
|
2614
|
+
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
|
2615
|
+
controller.close();
|
|
2616
|
+
}
|
|
2617
|
+
});
|
|
2618
|
+
return { ok: true, body: stream };
|
|
2619
|
+
});
|
|
2620
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2621
|
+
const events: AgentWidgetEvent[] = [];
|
|
2622
|
+
await client.dispatch(
|
|
2623
|
+
{ messages: [{ id: 'usr_1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
|
|
2624
|
+
(e) => events.push(e)
|
|
2625
|
+
);
|
|
2626
|
+
return events;
|
|
2627
|
+
};
|
|
2628
|
+
|
|
2629
|
+
it.each(['end_turn', 'max_tool_calls', 'length', 'content_filter', 'error', 'unknown'] as const)(
|
|
2630
|
+
'attaches stopReason=%s from step_complete (dispatch / flow path)',
|
|
2631
|
+
async (stopReason) => {
|
|
2632
|
+
const events = await runDispatch(dispatchModeStream(stopReason));
|
|
2633
|
+
const final = collectFinalAssistant(events);
|
|
2634
|
+
expect(final).not.toBeNull();
|
|
2635
|
+
expect(final!.stopReason).toBe(stopReason);
|
|
2636
|
+
}
|
|
2637
|
+
);
|
|
2638
|
+
|
|
2639
|
+
it('leaves stopReason undefined when step_complete omits it (backcompat)', async () => {
|
|
2640
|
+
const events = await runDispatch(dispatchModeStream(undefined));
|
|
2641
|
+
const final = collectFinalAssistant(events);
|
|
2642
|
+
expect(final).not.toBeNull();
|
|
2643
|
+
expect(final!.stopReason).toBeUndefined();
|
|
2644
|
+
});
|
|
2645
|
+
|
|
2646
|
+
it('captures the empty-content + max_tool_calls regression case', async () => {
|
|
2647
|
+
// Symptom the upstream fix targets: model emits a tool call then gets cut
|
|
2648
|
+
// off before producing follow-up text. Persona must record stopReason so
|
|
2649
|
+
// the UI can render an affordance instead of an empty bubble.
|
|
2650
|
+
const events = await runDispatch([
|
|
2651
|
+
`data: ${JSON.stringify({
|
|
2652
|
+
type: 'step_complete',
|
|
2653
|
+
id: 'step_1',
|
|
2654
|
+
stepType: 'prompt',
|
|
2655
|
+
result: { response: '' },
|
|
2656
|
+
stopReason: 'max_tool_calls',
|
|
2657
|
+
})}\n\n`,
|
|
2658
|
+
`data: ${JSON.stringify({ type: 'flow_complete', success: true })}\n\n`,
|
|
2659
|
+
]);
|
|
2660
|
+
const final = collectFinalAssistant(events);
|
|
2661
|
+
expect(final).not.toBeNull();
|
|
2662
|
+
expect(final!.content).toBe('');
|
|
2663
|
+
expect(final!.stopReason).toBe('max_tool_calls');
|
|
2664
|
+
});
|
|
2665
|
+
|
|
2666
|
+
it('agent_turn_complete.stopReason overrides any earlier step_complete value (agent-loop path)', async () => {
|
|
2667
|
+
// Build an agent-mode stream that emits both events. agent_turn_complete
|
|
2668
|
+
// arrives last; its stopReason should win.
|
|
2669
|
+
const execId = 'exec_stopreason';
|
|
2670
|
+
global.fetch = createAgentStreamFetch([
|
|
2671
|
+
sseEvent('agent_start', {
|
|
2672
|
+
executionId: execId, agentId: 'virtual', agentName: 'Test',
|
|
2673
|
+
maxTurns: 1, startedAt: new Date().toISOString(), seq: 1,
|
|
2674
|
+
}),
|
|
2675
|
+
sseEvent('agent_iteration_start', {
|
|
2676
|
+
executionId: execId, iteration: 1, maxTurns: 1,
|
|
2677
|
+
startedAt: new Date().toISOString(), seq: 2,
|
|
2678
|
+
}),
|
|
2679
|
+
sseEvent('agent_turn_start', {
|
|
2680
|
+
executionId: execId, iteration: 1, turnIndex: 0,
|
|
2681
|
+
role: 'assistant', turnId: 'turn_1', seq: 3,
|
|
2682
|
+
}),
|
|
2683
|
+
sseEvent('agent_turn_delta', {
|
|
2684
|
+
executionId: execId, iteration: 1, delta: 'partial answer',
|
|
2685
|
+
contentType: 'text', turnId: 'turn_1', seq: 4,
|
|
2686
|
+
}),
|
|
2687
|
+
sseEvent('agent_turn_complete', {
|
|
2688
|
+
executionId: execId, iteration: 1, role: 'assistant',
|
|
2689
|
+
turnId: 'turn_1', completedAt: new Date().toISOString(),
|
|
2690
|
+
stopReason: 'max_tool_calls', seq: 5,
|
|
2691
|
+
}),
|
|
2692
|
+
sseEvent('agent_iteration_complete', {
|
|
2693
|
+
executionId: execId, iteration: 1, toolCallsMade: 0,
|
|
2694
|
+
stopConditionMet: true, completedAt: new Date().toISOString(), seq: 6,
|
|
2695
|
+
}),
|
|
2696
|
+
sseEvent('agent_complete', {
|
|
2697
|
+
executionId: execId, agentId: 'virtual', success: true,
|
|
2698
|
+
iterations: 1, stopReason: 'max_iterations',
|
|
2699
|
+
completedAt: new Date().toISOString(), seq: 7,
|
|
2700
|
+
}),
|
|
2701
|
+
]);
|
|
2702
|
+
|
|
2703
|
+
const client = new AgentWidgetClient({
|
|
2704
|
+
apiUrl: 'http://localhost:8000',
|
|
2705
|
+
agent: { name: 'Test', model: 'openai:gpt-4o-mini', systemPrompt: 'test' },
|
|
2706
|
+
});
|
|
2707
|
+
const events: AgentWidgetEvent[] = [];
|
|
2708
|
+
await client.dispatch(
|
|
2709
|
+
{ messages: [{ id: 'usr_1', role: 'user', content: 'Hi', createdAt: new Date().toISOString() }] },
|
|
2710
|
+
(e) => events.push(e)
|
|
2711
|
+
);
|
|
2712
|
+
|
|
2713
|
+
const final = collectFinalAssistant(events);
|
|
2714
|
+
expect(final).not.toBeNull();
|
|
2715
|
+
expect(final!.stopReason).toBe('max_tool_calls');
|
|
2716
|
+
expect(final!.agentMetadata?.turnId).toBe('turn_1');
|
|
2717
|
+
});
|
|
2718
|
+
});
|
|
2719
|
+
|
|
2720
|
+
// ============================================================================
|
|
2721
|
+
// step_await (LOCAL tool pause) + resumeFlow
|
|
2722
|
+
// ============================================================================
|
|
2723
|
+
|
|
2724
|
+
describe('AgentWidgetClient — step_await parsing', () => {
|
|
2725
|
+
const buildStepAwaitStream = (payload: Record<string, unknown>): ReadableStream<Uint8Array> => {
|
|
2726
|
+
const encoder = new TextEncoder();
|
|
2727
|
+
const body = `event: step_await\ndata: ${JSON.stringify({ type: 'step_await', ...payload })}\n\n`;
|
|
2728
|
+
return new ReadableStream({
|
|
2729
|
+
start(controller) {
|
|
2730
|
+
controller.enqueue(encoder.encode(body));
|
|
2731
|
+
controller.close();
|
|
2732
|
+
},
|
|
2733
|
+
});
|
|
2734
|
+
};
|
|
2735
|
+
|
|
2736
|
+
it('emits a complete tool message with awaitingLocalTool=true for local_tool_required', async () => {
|
|
2737
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
2738
|
+
ok: true,
|
|
2739
|
+
body: buildStepAwaitStream({
|
|
2740
|
+
awaitReason: 'local_tool_required',
|
|
2741
|
+
id: 'step-1',
|
|
2742
|
+
name: 'Test Step',
|
|
2743
|
+
stepType: 'prompt',
|
|
2744
|
+
index: 0,
|
|
2745
|
+
toolId: 'runtime_ask_user_question_123',
|
|
2746
|
+
toolName: 'ask_user_question',
|
|
2747
|
+
executionId: 'exec_abc',
|
|
2748
|
+
parameters: {
|
|
2749
|
+
questions: [{ question: 'Who?', options: [{ label: 'A' }, { label: 'B' }] }],
|
|
2750
|
+
},
|
|
2751
|
+
}),
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2755
|
+
const events: AgentWidgetEvent[] = [];
|
|
2756
|
+
await client.dispatch(
|
|
2757
|
+
{ messages: [{ id: 'u1', role: 'user', content: 'hi', createdAt: new Date().toISOString() }] },
|
|
2758
|
+
(e) => events.push(e)
|
|
2759
|
+
);
|
|
2760
|
+
|
|
2761
|
+
const toolMsg = events
|
|
2762
|
+
.filter((e) => e.type === 'message')
|
|
2763
|
+
.map((e) => (e as { message: AgentWidgetMessage }).message)
|
|
2764
|
+
.find((m) => m.variant === 'tool' && m.toolCall?.name === 'ask_user_question');
|
|
2765
|
+
|
|
2766
|
+
expect(toolMsg).toBeDefined();
|
|
2767
|
+
expect(toolMsg!.toolCall!.id).toBe('runtime_ask_user_question_123');
|
|
2768
|
+
expect(toolMsg!.toolCall!.status).toBe('complete');
|
|
2769
|
+
expect(toolMsg!.toolCall!.args).toMatchObject({
|
|
2770
|
+
questions: [{ question: 'Who?', options: [{ label: 'A' }, { label: 'B' }] }],
|
|
2771
|
+
});
|
|
2772
|
+
expect(toolMsg!.agentMetadata?.executionId).toBe('exec_abc');
|
|
2773
|
+
expect(toolMsg!.agentMetadata?.awaitingLocalTool).toBe(true);
|
|
2774
|
+
});
|
|
2775
|
+
|
|
2776
|
+
it('ignores step_await events whose awaitReason is not local_tool_required', async () => {
|
|
2777
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
2778
|
+
ok: true,
|
|
2779
|
+
body: buildStepAwaitStream({
|
|
2780
|
+
awaitReason: 'approval_required',
|
|
2781
|
+
toolId: 't1',
|
|
2782
|
+
toolName: 'some_tool',
|
|
2783
|
+
executionId: 'exec_abc',
|
|
2784
|
+
parameters: {},
|
|
2785
|
+
}),
|
|
2786
|
+
});
|
|
2787
|
+
|
|
2788
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:8000' });
|
|
2789
|
+
const events: AgentWidgetEvent[] = [];
|
|
2790
|
+
await client.dispatch(
|
|
2791
|
+
{ messages: [{ id: 'u1', role: 'user', content: 'hi', createdAt: new Date().toISOString() }] },
|
|
2792
|
+
(e) => events.push(e)
|
|
2793
|
+
);
|
|
2794
|
+
|
|
2795
|
+
const toolMsg = events
|
|
2796
|
+
.filter((e) => e.type === 'message')
|
|
2797
|
+
.map((e) => (e as { message: AgentWidgetMessage }).message)
|
|
2798
|
+
.find((m) => m.agentMetadata?.awaitingLocalTool);
|
|
2799
|
+
expect(toolMsg).toBeUndefined();
|
|
2800
|
+
});
|
|
2801
|
+
});
|
|
2802
|
+
|
|
2803
|
+
describe('AgentWidgetClient.resumeFlow', () => {
|
|
2804
|
+
it('POSTs to ${apiUrl}/resume with the expected body shape', async () => {
|
|
2805
|
+
let capturedUrl: string | undefined;
|
|
2806
|
+
let capturedBody: Record<string, unknown> | undefined;
|
|
2807
|
+
let capturedHeaders: Record<string, string> | undefined;
|
|
2808
|
+
global.fetch = vi.fn().mockImplementation(async (url: string, init: RequestInit) => {
|
|
2809
|
+
capturedUrl = url;
|
|
2810
|
+
capturedBody = JSON.parse(init.body as string);
|
|
2811
|
+
capturedHeaders = init.headers as Record<string, string>;
|
|
2812
|
+
return { ok: true, body: null };
|
|
2813
|
+
});
|
|
2814
|
+
|
|
2815
|
+
const client = new AgentWidgetClient({ apiUrl: 'https://api.runtype.com/v1/dispatch' });
|
|
2816
|
+
await client.resumeFlow('exec_xyz', { ["ask_user_question"]: 'Hobbyists' });
|
|
2817
|
+
|
|
2818
|
+
expect(capturedUrl).toBe('https://api.runtype.com/v1/dispatch/resume');
|
|
2819
|
+
expect(capturedBody).toEqual({
|
|
2820
|
+
executionId: 'exec_xyz',
|
|
2821
|
+
toolOutputs: { ["ask_user_question"]: 'Hobbyists' },
|
|
2822
|
+
streamResponse: true,
|
|
2823
|
+
});
|
|
2824
|
+
expect(capturedHeaders!['Content-Type']).toBe('application/json');
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
it('honors a custom streamResponse option', async () => {
|
|
2828
|
+
let capturedBody: Record<string, unknown> | undefined;
|
|
2829
|
+
global.fetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
|
2830
|
+
capturedBody = JSON.parse(init.body as string);
|
|
2831
|
+
return { ok: true, body: null };
|
|
2832
|
+
});
|
|
2833
|
+
|
|
2834
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:43111/api/chat/dispatch' });
|
|
2835
|
+
await client.resumeFlow('exec_abc', { t: 'ok' }, { streamResponse: false });
|
|
2836
|
+
|
|
2837
|
+
expect(capturedBody!.streamResponse).toBe(false);
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
it('derives the URL correctly for proxy-style dispatch paths', async () => {
|
|
2841
|
+
let capturedUrl: string | undefined;
|
|
2842
|
+
global.fetch = vi.fn().mockImplementation(async (url: string) => {
|
|
2843
|
+
capturedUrl = url;
|
|
2844
|
+
return { ok: true, body: null };
|
|
2845
|
+
});
|
|
2846
|
+
|
|
2847
|
+
const client = new AgentWidgetClient({ apiUrl: 'http://localhost:43111/api/chat/dispatch' });
|
|
2848
|
+
await client.resumeFlow('exec_abc', {});
|
|
2849
|
+
|
|
2850
|
+
expect(capturedUrl).toBe('http://localhost:43111/api/chat/dispatch/resume');
|
|
2851
|
+
});
|
|
2852
|
+
});
|
|
2853
|
+
|