@runtypelabs/persona 3.19.0 → 3.21.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 +44 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-cwY5HaFD.d.cts → types-CWPIj66R.d.cts} +19 -1
- package/dist/animations/{types-cwY5HaFD.d.ts → types-CWPIj66R.d.ts} +19 -1
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +46 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +122 -4
- package/dist/index.d.ts +122 -4
- package/dist/index.global.js +79 -79
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -46
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +340 -11
- package/dist/theme-editor.d.cts +92 -1
- package/dist/theme-editor.d.ts +92 -1
- package/dist/theme-editor.js +340 -11
- package/package.json +1 -1
- package/src/client.test.ts +521 -0
- package/src/client.ts +150 -1
- package/src/components/message-bubble.test.ts +192 -0
- package/src/components/message-bubble.ts +200 -0
- package/src/index.ts +1 -0
- package/src/session.test.ts +123 -0
- package/src/session.ts +58 -4
- package/src/types.ts +102 -2
- package/src/ui.ts +18 -0
- package/src/utils/component-middleware.test.ts +134 -0
- package/src/utils/component-middleware.ts +44 -13
package/src/session.test.ts
CHANGED
|
@@ -244,6 +244,129 @@ describe('AgentWidgetSession - Message Injection', () => {
|
|
|
244
244
|
expect(messages[0].content).toBe('Legacy message');
|
|
245
245
|
});
|
|
246
246
|
});
|
|
247
|
+
|
|
248
|
+
describe('rawContent forwarding', () => {
|
|
249
|
+
it('preserves rawContent on injected messages', () => {
|
|
250
|
+
const directive = JSON.stringify({
|
|
251
|
+
text: 'Booking form',
|
|
252
|
+
component: 'BookingForm',
|
|
253
|
+
props: { title: 'Schedule' }
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const result = session.injectMessage({
|
|
257
|
+
role: 'assistant',
|
|
258
|
+
content: 'Booking form',
|
|
259
|
+
rawContent: directive
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(result.rawContent).toBe(directive);
|
|
263
|
+
expect(messages[0].rawContent).toBe(directive);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('forwards rawContent through injectAssistantMessage', () => {
|
|
267
|
+
const directive = JSON.stringify({ text: 'Form', component: 'Form', props: {} });
|
|
268
|
+
|
|
269
|
+
const result = session.injectAssistantMessage({
|
|
270
|
+
content: 'Form',
|
|
271
|
+
rawContent: directive
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(result.rawContent).toBe(directive);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('forwards rawContent through injectMessageBatch', () => {
|
|
278
|
+
const directiveA = JSON.stringify({ text: 'A', component: 'CompA', props: {} });
|
|
279
|
+
const directiveB = JSON.stringify({ text: 'B', component: 'CompB', props: {} });
|
|
280
|
+
|
|
281
|
+
const results = session.injectMessageBatch([
|
|
282
|
+
{ role: 'assistant', content: 'A', rawContent: directiveA },
|
|
283
|
+
{ role: 'assistant', content: 'B', rawContent: directiveB }
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
expect(results[0].rawContent).toBe(directiveA);
|
|
287
|
+
expect(results[1].rawContent).toBe(directiveB);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('omits rawContent when not provided', () => {
|
|
291
|
+
const result = session.injectMessage({
|
|
292
|
+
role: 'assistant',
|
|
293
|
+
content: 'plain'
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(result.rawContent).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('injectComponentDirective', () => {
|
|
301
|
+
it('builds rawContent from component + props + text', () => {
|
|
302
|
+
const result = session.injectComponentDirective({
|
|
303
|
+
component: 'DynamicForm',
|
|
304
|
+
props: { title: 'Book a demo', fields: [{ label: 'Email' }] },
|
|
305
|
+
text: 'Share your details to book a demo.'
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(result.role).toBe('assistant');
|
|
309
|
+
expect(result.content).toBe('Share your details to book a demo.');
|
|
310
|
+
expect(result.id).toMatch(/^ast_/);
|
|
311
|
+
expect(result.rawContent).toBeDefined();
|
|
312
|
+
|
|
313
|
+
const parsed = JSON.parse(result.rawContent as string);
|
|
314
|
+
expect(parsed).toEqual({
|
|
315
|
+
text: 'Share your details to book a demo.',
|
|
316
|
+
component: 'DynamicForm',
|
|
317
|
+
props: { title: 'Book a demo', fields: [{ label: 'Email' }] }
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('defaults text to empty string and props to {}', () => {
|
|
322
|
+
const result = session.injectComponentDirective({ component: 'DynamicForm' });
|
|
323
|
+
|
|
324
|
+
expect(result.content).toBe('');
|
|
325
|
+
const parsed = JSON.parse(result.rawContent as string);
|
|
326
|
+
expect(parsed).toEqual({ text: '', component: 'DynamicForm', props: {} });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('forwards llmContent for redacted LLM context', () => {
|
|
330
|
+
const result = session.injectComponentDirective({
|
|
331
|
+
component: 'DynamicForm',
|
|
332
|
+
props: { title: 'Book a demo' },
|
|
333
|
+
text: 'Booking form below.',
|
|
334
|
+
llmContent: '[Showed booking form]'
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
expect(result.llmContent).toBe('[Showed booking form]');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('honors custom id, createdAt, sequence', () => {
|
|
341
|
+
const result = session.injectComponentDirective({
|
|
342
|
+
component: 'DynamicForm',
|
|
343
|
+
id: 'my-form-1',
|
|
344
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
345
|
+
sequence: 999
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
expect(result.id).toBe('my-form-1');
|
|
349
|
+
expect(result.createdAt).toBe('2026-01-01T00:00:00.000Z');
|
|
350
|
+
expect(result.sequence).toBe(999);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('upserts an existing directive by id', () => {
|
|
354
|
+
session.injectComponentDirective({
|
|
355
|
+
component: 'DynamicForm',
|
|
356
|
+
props: { title: 'v1' },
|
|
357
|
+
id: 'reuse-me'
|
|
358
|
+
});
|
|
359
|
+
session.injectComponentDirective({
|
|
360
|
+
component: 'DynamicForm',
|
|
361
|
+
props: { title: 'v2' },
|
|
362
|
+
id: 'reuse-me'
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
expect(messages).toHaveLength(1);
|
|
366
|
+
const parsed = JSON.parse(messages[0].rawContent as string);
|
|
367
|
+
expect(parsed.props.title).toBe('v2');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
247
370
|
});
|
|
248
371
|
|
|
249
372
|
describe('AgentWidgetSession - cancel()', () => {
|
package/src/session.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
InjectAssistantMessageOptions,
|
|
12
12
|
InjectUserMessageOptions,
|
|
13
13
|
InjectSystemMessageOptions,
|
|
14
|
+
InjectComponentDirectiveOptions,
|
|
14
15
|
PersonaArtifactRecord,
|
|
15
16
|
PersonaArtifactManualUpsert
|
|
16
17
|
} from "./types";
|
|
@@ -572,7 +573,8 @@ export class AgentWidgetSession {
|
|
|
572
573
|
createdAt,
|
|
573
574
|
sequence,
|
|
574
575
|
streaming = false,
|
|
575
|
-
voiceProcessing
|
|
576
|
+
voiceProcessing,
|
|
577
|
+
rawContent
|
|
576
578
|
} = options;
|
|
577
579
|
|
|
578
580
|
// Generate appropriate ID based on role
|
|
@@ -594,7 +596,8 @@ export class AgentWidgetSession {
|
|
|
594
596
|
// Only include optional fields if provided
|
|
595
597
|
...(llmContent !== undefined && { llmContent }),
|
|
596
598
|
...(contentParts !== undefined && { contentParts }),
|
|
597
|
-
...(voiceProcessing !== undefined && { voiceProcessing })
|
|
599
|
+
...(voiceProcessing !== undefined && { voiceProcessing }),
|
|
600
|
+
...(rawContent !== undefined && { rawContent })
|
|
598
601
|
};
|
|
599
602
|
|
|
600
603
|
// Use upsert to handle both new messages and updates (streaming)
|
|
@@ -673,7 +676,9 @@ export class AgentWidgetSession {
|
|
|
673
676
|
id,
|
|
674
677
|
createdAt,
|
|
675
678
|
sequence,
|
|
676
|
-
streaming = false
|
|
679
|
+
streaming = false,
|
|
680
|
+
voiceProcessing,
|
|
681
|
+
rawContent
|
|
677
682
|
} = options;
|
|
678
683
|
|
|
679
684
|
const messageId =
|
|
@@ -692,7 +697,9 @@ export class AgentWidgetSession {
|
|
|
692
697
|
sequence: sequence ?? this.nextSequence(),
|
|
693
698
|
streaming,
|
|
694
699
|
...(llmContent !== undefined && { llmContent }),
|
|
695
|
-
...(contentParts !== undefined && { contentParts })
|
|
700
|
+
...(contentParts !== undefined && { contentParts }),
|
|
701
|
+
...(voiceProcessing !== undefined && { voiceProcessing }),
|
|
702
|
+
...(rawContent !== undefined && { rawContent })
|
|
696
703
|
};
|
|
697
704
|
|
|
698
705
|
results.push(message);
|
|
@@ -705,6 +712,53 @@ export class AgentWidgetSession {
|
|
|
705
712
|
return results;
|
|
706
713
|
}
|
|
707
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Convenience method for injecting a registered component directive as
|
|
717
|
+
* an assistant message — the same shape Persona produces from a streamed
|
|
718
|
+
* `{ "text": "...", "component": "...", "props": {...} }` payload.
|
|
719
|
+
*
|
|
720
|
+
* Sets `content` to `text`, `rawContent` to the JSON directive (so
|
|
721
|
+
* `extractComponentDirectiveFromMessage` can find it), and forwards
|
|
722
|
+
* `llmContent` / `id` / `createdAt` / `sequence`.
|
|
723
|
+
*
|
|
724
|
+
* @example
|
|
725
|
+
* session.injectComponentDirective({
|
|
726
|
+
* component: "DynamicForm",
|
|
727
|
+
* props: { title: "Book a demo", fields: [...] },
|
|
728
|
+
* text: "Share your details to book a demo.",
|
|
729
|
+
* llmContent: "[Showed booking form]"
|
|
730
|
+
* });
|
|
731
|
+
*/
|
|
732
|
+
public injectComponentDirective(
|
|
733
|
+
options: InjectComponentDirectiveOptions
|
|
734
|
+
): AgentWidgetMessage {
|
|
735
|
+
const {
|
|
736
|
+
component,
|
|
737
|
+
props = {},
|
|
738
|
+
text = "",
|
|
739
|
+
llmContent,
|
|
740
|
+
id,
|
|
741
|
+
createdAt,
|
|
742
|
+
sequence
|
|
743
|
+
} = options;
|
|
744
|
+
|
|
745
|
+
const directive: { text: string; component: string; props: Record<string, unknown> } = {
|
|
746
|
+
text,
|
|
747
|
+
component,
|
|
748
|
+
props
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
return this.injectMessage({
|
|
752
|
+
role: "assistant",
|
|
753
|
+
content: text,
|
|
754
|
+
rawContent: JSON.stringify(directive),
|
|
755
|
+
...(llmContent !== undefined && { llmContent }),
|
|
756
|
+
...(id !== undefined && { id }),
|
|
757
|
+
...(createdAt !== undefined && { createdAt }),
|
|
758
|
+
...(sequence !== undefined && { sequence })
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
708
762
|
public async sendMessage(
|
|
709
763
|
rawInput: string,
|
|
710
764
|
options?: {
|
package/src/types.ts
CHANGED
|
@@ -30,15 +30,40 @@ export type ImageContentPart = {
|
|
|
30
30
|
*/
|
|
31
31
|
export type FileContentPart = {
|
|
32
32
|
type: 'file';
|
|
33
|
-
data: string; // base64 data URI
|
|
33
|
+
data: string; // base64 data URI or URL
|
|
34
34
|
mimeType: string;
|
|
35
35
|
filename: string;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Audio content part for multi-modal messages
|
|
40
|
+
* Supports base64 data URIs or URLs
|
|
41
|
+
*/
|
|
42
|
+
export type AudioContentPart = {
|
|
43
|
+
type: 'audio';
|
|
44
|
+
audio: string; // base64 data URI or URL
|
|
45
|
+
mimeType?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Video content part for multi-modal messages
|
|
50
|
+
* Supports base64 data URIs or URLs
|
|
51
|
+
*/
|
|
52
|
+
export type VideoContentPart = {
|
|
53
|
+
type: 'video';
|
|
54
|
+
video: string; // base64 data URI or URL
|
|
55
|
+
mimeType?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
38
58
|
/**
|
|
39
59
|
* Union type for all content part types
|
|
40
60
|
*/
|
|
41
|
-
export type ContentPart =
|
|
61
|
+
export type ContentPart =
|
|
62
|
+
| TextContentPart
|
|
63
|
+
| ImageContentPart
|
|
64
|
+
| FileContentPart
|
|
65
|
+
| AudioContentPart
|
|
66
|
+
| VideoContentPart;
|
|
42
67
|
|
|
43
68
|
/**
|
|
44
69
|
* Message content can be a simple string or an array of content parts
|
|
@@ -3768,6 +3793,23 @@ export type InjectMessageOptions = {
|
|
|
3768
3793
|
* Consumers can detect this in `messageTransform` to render custom UI.
|
|
3769
3794
|
*/
|
|
3770
3795
|
voiceProcessing?: boolean;
|
|
3796
|
+
|
|
3797
|
+
/**
|
|
3798
|
+
* Raw structured payload (typically a JSON string) representing the
|
|
3799
|
+
* full directive that produced this message — e.g. `{ "text": "...",
|
|
3800
|
+
* "component": "Foo", "props": {...} }`.
|
|
3801
|
+
*
|
|
3802
|
+
* Mirrors the field populated by stream parsers during normal LLM
|
|
3803
|
+
* responses. Set this when injecting a message that should render as a
|
|
3804
|
+
* component directive (`hasComponentDirective` /
|
|
3805
|
+
* `extractComponentDirectiveFromMessage` look at `rawContent` first).
|
|
3806
|
+
*
|
|
3807
|
+
* Priority for the API payload remains:
|
|
3808
|
+
* `contentParts > llmContent > rawContent > content`. Pass `llmContent`
|
|
3809
|
+
* alongside `rawContent` if the LLM should see something other than the
|
|
3810
|
+
* raw directive.
|
|
3811
|
+
*/
|
|
3812
|
+
rawContent?: string;
|
|
3771
3813
|
};
|
|
3772
3814
|
|
|
3773
3815
|
/**
|
|
@@ -3788,6 +3830,64 @@ export type InjectUserMessageOptions = Omit<InjectMessageOptions, "role">;
|
|
|
3788
3830
|
*/
|
|
3789
3831
|
export type InjectSystemMessageOptions = Omit<InjectMessageOptions, "role">;
|
|
3790
3832
|
|
|
3833
|
+
/**
|
|
3834
|
+
* Options for injecting an assistant message that renders as a component
|
|
3835
|
+
* directive — sugar over `injectAssistantMessage` for the common case of
|
|
3836
|
+
* "render this registered component, same as if the LLM had emitted it".
|
|
3837
|
+
*
|
|
3838
|
+
* Equivalent to calling `injectAssistantMessage({ content: text, rawContent:
|
|
3839
|
+
* JSON.stringify({ text, component, props }), llmContent })`.
|
|
3840
|
+
*
|
|
3841
|
+
* @example
|
|
3842
|
+
* widget.injectComponentDirective({
|
|
3843
|
+
* component: "DynamicForm",
|
|
3844
|
+
* props: { title: "Book a demo", fields: [...] },
|
|
3845
|
+
* text: "Share your details to book a demo.",
|
|
3846
|
+
* llmContent: "[Showed booking form]"
|
|
3847
|
+
* });
|
|
3848
|
+
*/
|
|
3849
|
+
export type InjectComponentDirectiveOptions = {
|
|
3850
|
+
/**
|
|
3851
|
+
* Name of a renderer registered via `componentRegistry.register(...)`.
|
|
3852
|
+
*/
|
|
3853
|
+
component: string;
|
|
3854
|
+
|
|
3855
|
+
/**
|
|
3856
|
+
* Props passed to the component renderer.
|
|
3857
|
+
*/
|
|
3858
|
+
props?: Record<string, unknown>;
|
|
3859
|
+
|
|
3860
|
+
/**
|
|
3861
|
+
* Bubble copy displayed above (or with) the rendered component.
|
|
3862
|
+
* Mirrors the `text` field in a streamed JSON directive.
|
|
3863
|
+
* @default ""
|
|
3864
|
+
*/
|
|
3865
|
+
text?: string;
|
|
3866
|
+
|
|
3867
|
+
/**
|
|
3868
|
+
* Content sent to the LLM in API requests. When omitted, the raw
|
|
3869
|
+
* directive JSON is what the LLM would see (per the standard
|
|
3870
|
+
* priority chain). Provide a redacted/short version to avoid sending
|
|
3871
|
+
* the full directive in subsequent turns.
|
|
3872
|
+
*/
|
|
3873
|
+
llmContent?: string;
|
|
3874
|
+
|
|
3875
|
+
/**
|
|
3876
|
+
* Optional message ID. If omitted, an assistant id is auto-generated.
|
|
3877
|
+
*/
|
|
3878
|
+
id?: string;
|
|
3879
|
+
|
|
3880
|
+
/**
|
|
3881
|
+
* Optional creation timestamp (ISO string). If omitted, uses current time.
|
|
3882
|
+
*/
|
|
3883
|
+
createdAt?: string;
|
|
3884
|
+
|
|
3885
|
+
/**
|
|
3886
|
+
* Optional sequence number for ordering.
|
|
3887
|
+
*/
|
|
3888
|
+
sequence?: number;
|
|
3889
|
+
};
|
|
3890
|
+
|
|
3791
3891
|
export type PersonaArtifactRecord = {
|
|
3792
3892
|
id: string;
|
|
3793
3893
|
artifactType: PersonaArtifactKind;
|
package/src/ui.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
InjectAssistantMessageOptions,
|
|
20
20
|
InjectUserMessageOptions,
|
|
21
21
|
InjectSystemMessageOptions,
|
|
22
|
+
InjectComponentDirectiveOptions,
|
|
22
23
|
LoadingIndicatorRenderContext,
|
|
23
24
|
IdleIndicatorRenderContext,
|
|
24
25
|
VoiceStatus,
|
|
@@ -296,6 +297,14 @@ type Controller = {
|
|
|
296
297
|
* Inject multiple messages in a single batch with one sort and one render pass.
|
|
297
298
|
*/
|
|
298
299
|
injectMessageBatch: (optionsList: InjectMessageOptions[]) => AgentWidgetMessage[];
|
|
300
|
+
/**
|
|
301
|
+
* Convenience method for injecting an assistant message that renders as a
|
|
302
|
+
* registered component — same shape Persona produces from a streamed
|
|
303
|
+
* `{ "text": "...", "component": "...", "props": {...} }` payload.
|
|
304
|
+
*/
|
|
305
|
+
injectComponentDirective: (
|
|
306
|
+
options: InjectComponentDirectiveOptions
|
|
307
|
+
) => AgentWidgetMessage;
|
|
299
308
|
/**
|
|
300
309
|
* @deprecated Use injectMessage() instead.
|
|
301
310
|
*/
|
|
@@ -7030,6 +7039,15 @@ export const createAgentExperience = (
|
|
|
7030
7039
|
}
|
|
7031
7040
|
return session.injectMessageBatch(optionsList);
|
|
7032
7041
|
},
|
|
7042
|
+
injectComponentDirective(
|
|
7043
|
+
options: InjectComponentDirectiveOptions
|
|
7044
|
+
): AgentWidgetMessage {
|
|
7045
|
+
// Auto-open widget if closed and the panel is toggleable
|
|
7046
|
+
if (!open && isPanelToggleable()) {
|
|
7047
|
+
setOpenState(true, "system");
|
|
7048
|
+
}
|
|
7049
|
+
return session.injectComponentDirective(options);
|
|
7050
|
+
},
|
|
7033
7051
|
/** @deprecated Use injectMessage() instead */
|
|
7034
7052
|
injectTestMessage(event: AgentWidgetEvent) {
|
|
7035
7053
|
// Auto-open widget if closed and the panel is toggleable
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
extractComponentDirectiveFromMessage,
|
|
5
|
+
hasComponentDirective
|
|
6
|
+
} from "./component-middleware";
|
|
7
|
+
import type { AgentWidgetMessage } from "../types";
|
|
8
|
+
|
|
9
|
+
const baseMessage = (overrides: Partial<AgentWidgetMessage>): AgentWidgetMessage => ({
|
|
10
|
+
id: "msg-1",
|
|
11
|
+
role: "assistant",
|
|
12
|
+
content: "",
|
|
13
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
14
|
+
...overrides
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("extractComponentDirectiveFromMessage", () => {
|
|
18
|
+
it("extracts directive from rawContent (streamed path)", () => {
|
|
19
|
+
const directive = {
|
|
20
|
+
text: "Booking form",
|
|
21
|
+
component: "DynamicForm",
|
|
22
|
+
props: { title: "Book a demo" }
|
|
23
|
+
};
|
|
24
|
+
const message = baseMessage({
|
|
25
|
+
content: "Booking form",
|
|
26
|
+
rawContent: JSON.stringify(directive)
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = extractComponentDirectiveFromMessage(message);
|
|
30
|
+
|
|
31
|
+
expect(result).not.toBeNull();
|
|
32
|
+
expect(result?.component).toBe("DynamicForm");
|
|
33
|
+
expect(result?.props).toEqual({ title: "Book a demo" });
|
|
34
|
+
expect(result?.raw).toBe(JSON.stringify(directive));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls back to content when rawContent is missing and content looks like JSON", () => {
|
|
38
|
+
const directive = {
|
|
39
|
+
text: "Booking form",
|
|
40
|
+
component: "DynamicForm",
|
|
41
|
+
props: { title: "Book a demo" }
|
|
42
|
+
};
|
|
43
|
+
const message = baseMessage({
|
|
44
|
+
content: JSON.stringify(directive)
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const result = extractComponentDirectiveFromMessage(message);
|
|
48
|
+
|
|
49
|
+
expect(result).not.toBeNull();
|
|
50
|
+
expect(result?.component).toBe("DynamicForm");
|
|
51
|
+
expect(result?.props).toEqual({ title: "Book a demo" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("prefers rawContent over content when both are present", () => {
|
|
55
|
+
const message = baseMessage({
|
|
56
|
+
rawContent: JSON.stringify({
|
|
57
|
+
text: "Raw form",
|
|
58
|
+
component: "RawComponent",
|
|
59
|
+
props: { source: "raw" }
|
|
60
|
+
}),
|
|
61
|
+
content: JSON.stringify({
|
|
62
|
+
text: "Content form",
|
|
63
|
+
component: "ContentComponent",
|
|
64
|
+
props: { source: "content" }
|
|
65
|
+
})
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = extractComponentDirectiveFromMessage(message);
|
|
69
|
+
|
|
70
|
+
expect(result?.component).toBe("RawComponent");
|
|
71
|
+
expect(result?.props).toEqual({ source: "raw" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns null for plain-text content", () => {
|
|
75
|
+
const message = baseMessage({ content: "Hello, how can I help?" });
|
|
76
|
+
expect(extractComponentDirectiveFromMessage(message)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns null when content is JSON without a component field", () => {
|
|
80
|
+
const message = baseMessage({
|
|
81
|
+
content: JSON.stringify({ text: "Just text", foo: "bar" })
|
|
82
|
+
});
|
|
83
|
+
expect(extractComponentDirectiveFromMessage(message)).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null for empty rawContent and empty content", () => {
|
|
87
|
+
const message = baseMessage({ rawContent: "", content: "" });
|
|
88
|
+
expect(extractComponentDirectiveFromMessage(message)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns null when JSON is malformed", () => {
|
|
92
|
+
const message = baseMessage({ rawContent: '{"component": "Foo"' });
|
|
93
|
+
expect(extractComponentDirectiveFromMessage(message)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("defaults props to {} when the directive omits or nulls them", () => {
|
|
97
|
+
const message = baseMessage({
|
|
98
|
+
rawContent: JSON.stringify({ text: "x", component: "Foo" })
|
|
99
|
+
});
|
|
100
|
+
const result = extractComponentDirectiveFromMessage(message);
|
|
101
|
+
expect(result?.props).toEqual({});
|
|
102
|
+
|
|
103
|
+
const messageNullProps = baseMessage({
|
|
104
|
+
rawContent: JSON.stringify({ text: "x", component: "Foo", props: null })
|
|
105
|
+
});
|
|
106
|
+
expect(extractComponentDirectiveFromMessage(messageNullProps)?.props).toEqual({});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("hasComponentDirective", () => {
|
|
111
|
+
it("returns true when rawContent carries a directive", () => {
|
|
112
|
+
const message = baseMessage({
|
|
113
|
+
rawContent: JSON.stringify({ text: "x", component: "Foo", props: {} })
|
|
114
|
+
});
|
|
115
|
+
expect(hasComponentDirective(message)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns true when only content carries a directive", () => {
|
|
119
|
+
const message = baseMessage({
|
|
120
|
+
content: JSON.stringify({ text: "x", component: "Foo", props: {} })
|
|
121
|
+
});
|
|
122
|
+
expect(hasComponentDirective(message)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns false for plain content", () => {
|
|
126
|
+
const message = baseMessage({ content: "Hello!" });
|
|
127
|
+
expect(hasComponentDirective(message)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns false for malformed JSON", () => {
|
|
131
|
+
const message = baseMessage({ rawContent: "{not json" });
|
|
132
|
+
expect(hasComponentDirective(message)).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -87,18 +87,42 @@ export function createComponentMiddleware() {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
90
|
+
* Picks the field that may carry a JSON directive payload. Streamed messages
|
|
91
|
+
* populate `rawContent`; manually injected messages may pass the JSON via
|
|
92
|
+
* `content` directly. We try `rawContent` first, then fall back to `content`
|
|
93
|
+
* when it looks like JSON, so both code paths render the same way.
|
|
94
|
+
*/
|
|
95
|
+
function selectDirectiveSource(message: AgentWidgetMessage): string | null {
|
|
96
|
+
if (typeof message.rawContent === "string" && message.rawContent.length > 0) {
|
|
97
|
+
return message.rawContent;
|
|
98
|
+
}
|
|
99
|
+
if (typeof message.content === "string") {
|
|
100
|
+
const trimmed = message.content.trim();
|
|
101
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
102
|
+
return message.content;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Checks if a message contains a component directive.
|
|
110
|
+
*
|
|
111
|
+
* Looks at `rawContent` first (the field set by stream parsers); falls back
|
|
112
|
+
* to `content` when it looks like JSON, so injected messages that pass the
|
|
113
|
+
* directive via `content` (or have no `rawContent`) are still recognized.
|
|
91
114
|
*/
|
|
92
115
|
export function hasComponentDirective(message: AgentWidgetMessage): boolean {
|
|
93
|
-
|
|
94
|
-
|
|
116
|
+
const source = selectDirectiveSource(message);
|
|
117
|
+
if (!source) return false;
|
|
118
|
+
|
|
95
119
|
try {
|
|
96
|
-
const parsed = JSON.parse(
|
|
120
|
+
const parsed = JSON.parse(source);
|
|
97
121
|
return (
|
|
98
122
|
typeof parsed === "object" &&
|
|
99
123
|
parsed !== null &&
|
|
100
124
|
"component" in parsed &&
|
|
101
|
-
typeof parsed.component === "string"
|
|
125
|
+
typeof (parsed as { component: unknown }).component === "string"
|
|
102
126
|
);
|
|
103
127
|
} catch {
|
|
104
128
|
return false;
|
|
@@ -106,27 +130,34 @@ export function hasComponentDirective(message: AgentWidgetMessage): boolean {
|
|
|
106
130
|
}
|
|
107
131
|
|
|
108
132
|
/**
|
|
109
|
-
* Extracts component directive from a complete message
|
|
133
|
+
* Extracts component directive from a complete message.
|
|
134
|
+
*
|
|
135
|
+
* Looks at `rawContent` first (the field set by stream parsers); falls back
|
|
136
|
+
* to `content` when it looks like JSON, so injected messages that pass the
|
|
137
|
+
* directive via `content` (or have no `rawContent`) render the same as
|
|
138
|
+
* streamed ones.
|
|
110
139
|
*/
|
|
111
140
|
export function extractComponentDirectiveFromMessage(
|
|
112
141
|
message: AgentWidgetMessage
|
|
113
142
|
): ComponentDirective | null {
|
|
114
|
-
|
|
143
|
+
const source = selectDirectiveSource(message);
|
|
144
|
+
if (!source) return null;
|
|
115
145
|
|
|
116
146
|
try {
|
|
117
|
-
const parsed = JSON.parse(
|
|
147
|
+
const parsed = JSON.parse(source);
|
|
118
148
|
if (
|
|
119
149
|
typeof parsed === "object" &&
|
|
120
150
|
parsed !== null &&
|
|
121
151
|
"component" in parsed &&
|
|
122
|
-
typeof parsed.component === "string"
|
|
152
|
+
typeof (parsed as { component: unknown }).component === "string"
|
|
123
153
|
) {
|
|
154
|
+
const directive = parsed as { component: string; props?: unknown };
|
|
124
155
|
return {
|
|
125
|
-
component:
|
|
126
|
-
props: (
|
|
127
|
-
?
|
|
156
|
+
component: directive.component,
|
|
157
|
+
props: (directive.props && typeof directive.props === "object" && directive.props !== null
|
|
158
|
+
? directive.props
|
|
128
159
|
: {}) as Record<string, unknown>,
|
|
129
|
-
raw:
|
|
160
|
+
raw: source
|
|
130
161
|
};
|
|
131
162
|
}
|
|
132
163
|
} catch {
|