@runtypelabs/persona 4.11.1 → 4.13.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/codegen.cjs +13 -13
- package/dist/codegen.d.cts +9 -0
- package/dist/codegen.d.ts +9 -0
- package/dist/codegen.js +13 -13
- package/dist/index.cjs +45 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.global.js +34 -34
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +46 -46
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/theme-editor-preview.cjs +44 -44
- package/dist/theme-editor-preview.js +33 -33
- package/package.json +1 -1
- package/src/codegen.test.ts +43 -0
- package/src/install.test.ts +83 -0
- package/src/install.ts +48 -2
- package/src/ui.scroll-additive.test.ts +365 -0
- package/src/ui.scroll.test.ts +19 -6
- package/src/ui.ts +115 -28
- package/src/utils/auto-follow.test.ts +38 -0
- package/src/utils/auto-follow.ts +35 -0
- package/src/utils/code-generators.ts +30 -4
package/src/ui.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
createFollowStateController,
|
|
65
65
|
getScrollBottomOffset,
|
|
66
66
|
hasSelectionWithin,
|
|
67
|
+
isAttentionWorthyMessage,
|
|
67
68
|
isElementNearBottom,
|
|
68
69
|
resolveFollowStateFromScroll,
|
|
69
70
|
resolveFollowStateFromWheel
|
|
@@ -3537,6 +3538,24 @@ export const createAgentExperience = (
|
|
|
3537
3538
|
// pinned and never re-arms the fallback, so a late-loading embed can't yank
|
|
3538
3539
|
// the viewport down to the bottom.
|
|
3539
3540
|
let currentTurnAnchored = false;
|
|
3541
|
+
// Which message the anchor is currently parked on. Starts as the sent user
|
|
3542
|
+
// message and moves once to the turn's first attention-worthy block (see
|
|
3543
|
+
// `maybeAnchorToFirstUnread`).
|
|
3544
|
+
let anchoredMessageId: string | null = null;
|
|
3545
|
+
// Has the reader taken over positioning this turn? First-unread anchoring is
|
|
3546
|
+
// a guess about where someone who ISN'T watching wants to land; the moment
|
|
3547
|
+
// they demonstrably are watching, the guess stands down and never moves the
|
|
3548
|
+
// viewport under them. Reset on each user send (an explicit "take me along").
|
|
3549
|
+
//
|
|
3550
|
+
// Driven by real INPUT (wheel, touch drag, pointer press, transcript
|
|
3551
|
+
// keyboard nav, focus, selection) rather than by scroll deltas: streaming
|
|
3552
|
+
// content under a pinned anchor makes the browser clamp and then restore
|
|
3553
|
+
// scrollTop, which is indistinguishable from a deliberate scroll if you only
|
|
3554
|
+
// look at the scroll event.
|
|
3555
|
+
let readerEngagedThisTurn = false;
|
|
3556
|
+
const markReaderEngaged = () => {
|
|
3557
|
+
readerEngagedThisTurn = true;
|
|
3558
|
+
};
|
|
3540
3559
|
// Dedupes assistant-turn detection across token-by-token re-renders.
|
|
3541
3560
|
let lastHandledAssistantId: string | null = null;
|
|
3542
3561
|
|
|
@@ -3940,6 +3959,24 @@ export const createAgentExperience = (
|
|
|
3940
3959
|
return top;
|
|
3941
3960
|
};
|
|
3942
3961
|
|
|
3962
|
+
// Defined here (rather than beside the scroll listeners) because
|
|
3963
|
+
// `maybeAnchorToFirstUnread` runs on every render and would otherwise hit
|
|
3964
|
+
// these consts in their temporal dead zone on a synchronous first paint.
|
|
3965
|
+
const getTranscriptSelection = (): Selection | null => {
|
|
3966
|
+
// Selections inside a shadow root are not always reflected by
|
|
3967
|
+
// document.getSelection(); prefer the shadow root's view when available
|
|
3968
|
+
// (non-standard but supported where it matters).
|
|
3969
|
+
const root = body.getRootNode();
|
|
3970
|
+
const shadowSelection =
|
|
3971
|
+
typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
|
|
3972
|
+
.getSelection === "function"
|
|
3973
|
+
? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
|
|
3974
|
+
: null;
|
|
3975
|
+
return shadowSelection ?? body.ownerDocument.getSelection();
|
|
3976
|
+
};
|
|
3977
|
+
const hasActiveTranscriptSelection = () =>
|
|
3978
|
+
hasSelectionWithin(getTranscriptSelection(), body);
|
|
3979
|
+
|
|
3943
3980
|
// Principle 11: reopen where the reader left off. When `restorePosition` is
|
|
3944
3981
|
// "last-user-turn" and there is pre-existing history, land with the last user
|
|
3945
3982
|
// message pinned near the top of the viewport instead of jumping to the
|
|
@@ -4002,11 +4039,10 @@ export const createAgentExperience = (
|
|
|
4002
4039
|
anchorSpacer.style.height = "0px";
|
|
4003
4040
|
};
|
|
4004
4041
|
|
|
4005
|
-
// Anchor-top mode: scroll
|
|
4006
|
-
//
|
|
4007
|
-
//
|
|
4008
|
-
|
|
4009
|
-
const scheduleAnchorToUserMessage = (messageId: string) => {
|
|
4042
|
+
// Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
|
|
4043
|
+
// viewport top and hold it there while the response streams in beneath it.
|
|
4044
|
+
// Deferred one frame so the message bubble has been rendered and laid out.
|
|
4045
|
+
const scheduleAnchorToMessage = (messageId: string) => {
|
|
4010
4046
|
if (anchorRAF !== null) {
|
|
4011
4047
|
cancelAnimationFrame(anchorRAF);
|
|
4012
4048
|
}
|
|
@@ -4087,7 +4123,11 @@ export const createAgentExperience = (
|
|
|
4087
4123
|
// next user send.
|
|
4088
4124
|
followFallbackActive = false;
|
|
4089
4125
|
currentTurnAnchored = true;
|
|
4090
|
-
|
|
4126
|
+
// A send is an explicit "take me along": it re-arms first-unread
|
|
4127
|
+
// positioning even if the reader had scrolled away during the last turn.
|
|
4128
|
+
readerEngagedThisTurn = false;
|
|
4129
|
+
anchoredMessageId = messageId;
|
|
4130
|
+
scheduleAnchorToMessage(messageId);
|
|
4091
4131
|
}
|
|
4092
4132
|
};
|
|
4093
4133
|
|
|
@@ -4110,6 +4150,44 @@ export const createAgentExperience = (
|
|
|
4110
4150
|
scheduleAutoScroll(true);
|
|
4111
4151
|
};
|
|
4112
4152
|
|
|
4153
|
+
// Anchor-top positioning: park on the turn's FIRST UNREAD attention-worthy
|
|
4154
|
+
// block.
|
|
4155
|
+
//
|
|
4156
|
+
// The read boundary is the last user send — the reader wrote it, so it (and
|
|
4157
|
+
// everything above) is read; every attention-worthy block after it is not.
|
|
4158
|
+
// Tool calls and reasoning are chrome and never targeted, so they scroll
|
|
4159
|
+
// past underneath rather than parking the reader on progress output.
|
|
4160
|
+
//
|
|
4161
|
+
// The policy assumes the reader is NOT watching the stream, and picks the
|
|
4162
|
+
// best place for them to land when they look back: the start of what they
|
|
4163
|
+
// haven't seen. `readerEngagedThisTurn` is the escape hatch — once someone
|
|
4164
|
+
// is demonstrably driving, their position wins and this stands down until
|
|
4165
|
+
// the next send.
|
|
4166
|
+
//
|
|
4167
|
+
// Anchoring to the first unread (not the newest) block means this fires at
|
|
4168
|
+
// most once per turn in practice, and later blocks stream in below the
|
|
4169
|
+
// anchor exactly as they do today.
|
|
4170
|
+
const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
|
|
4171
|
+
if (getScrollMode() !== "anchor-top") return;
|
|
4172
|
+
if (!currentTurnAnchored) return;
|
|
4173
|
+
if (readerEngagedThisTurn) return;
|
|
4174
|
+
if (hasActiveTranscriptSelection()) return;
|
|
4175
|
+
|
|
4176
|
+
let pastReadBoundary = lastSentUserMessageId === null;
|
|
4177
|
+
for (const message of messages) {
|
|
4178
|
+
if (!pastReadBoundary) {
|
|
4179
|
+
if (message.id === lastSentUserMessageId) pastReadBoundary = true;
|
|
4180
|
+
continue;
|
|
4181
|
+
}
|
|
4182
|
+
if (!isAttentionWorthyMessage(message)) continue;
|
|
4183
|
+
// Already parked on it: hold still while it streams.
|
|
4184
|
+
if (message.id === anchoredMessageId) return;
|
|
4185
|
+
anchoredMessageId = message.id;
|
|
4186
|
+
scheduleAnchorToMessage(message.id);
|
|
4187
|
+
return;
|
|
4188
|
+
}
|
|
4189
|
+
};
|
|
4190
|
+
|
|
4113
4191
|
const trackMessages = (messages: AgentWidgetMessage[]) => {
|
|
4114
4192
|
const nextState = new Map<
|
|
4115
4193
|
string,
|
|
@@ -5998,6 +6076,8 @@ export const createAgentExperience = (
|
|
|
5998
6076
|
resetAnchorState();
|
|
5999
6077
|
followFallbackActive = true;
|
|
6000
6078
|
currentTurnAnchored = false;
|
|
6079
|
+
anchoredMessageId = null;
|
|
6080
|
+
readerEngagedThisTurn = false;
|
|
6001
6081
|
}
|
|
6002
6082
|
if (!scrollSendSeeded || suppressScrollSend) {
|
|
6003
6083
|
scrollSendSeeded = true;
|
|
@@ -6013,6 +6093,7 @@ export const createAgentExperience = (
|
|
|
6013
6093
|
handleAssistantTurnStarted();
|
|
6014
6094
|
}
|
|
6015
6095
|
if (lastAssistantMessage) lastHandledAssistantId = lastAssistantMessage.id;
|
|
6096
|
+
maybeAnchorToFirstUnread(messages);
|
|
6016
6097
|
|
|
6017
6098
|
const prevLastUserMessageId = voiceState.lastUserMessageId;
|
|
6018
6099
|
if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
|
|
@@ -7341,21 +7422,6 @@ export const createAgentExperience = (
|
|
|
7341
7422
|
lastScrollTop = body.scrollTop;
|
|
7342
7423
|
let lastBottomOffset = getScrollBottomOffset(body);
|
|
7343
7424
|
|
|
7344
|
-
const getTranscriptSelection = (): Selection | null => {
|
|
7345
|
-
// Selections inside a shadow root are not always reflected by
|
|
7346
|
-
// document.getSelection(); prefer the shadow root's view when available
|
|
7347
|
-
// (non-standard but supported where it matters).
|
|
7348
|
-
const root = body.getRootNode();
|
|
7349
|
-
const shadowSelection =
|
|
7350
|
-
typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
|
|
7351
|
-
.getSelection === "function"
|
|
7352
|
-
? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
|
|
7353
|
-
: null;
|
|
7354
|
-
return shadowSelection ?? body.ownerDocument.getSelection();
|
|
7355
|
-
};
|
|
7356
|
-
const hasActiveTranscriptSelection = () =>
|
|
7357
|
-
hasSelectionWithin(getTranscriptSelection(), body);
|
|
7358
|
-
|
|
7359
7425
|
const handleScroll = () => {
|
|
7360
7426
|
const scrollTop = body.scrollTop;
|
|
7361
7427
|
// When content mutates (e.g. stream-animation plugins re-rendering text)
|
|
@@ -7373,6 +7439,14 @@ export const createAgentExperience = (
|
|
|
7373
7439
|
if (!isFollowEffective()) {
|
|
7374
7440
|
// No follow state to manage (anchored anchor-top / none): just keep the
|
|
7375
7441
|
// scroll-to-bottom affordance in sync with the user's position.
|
|
7442
|
+
//
|
|
7443
|
+
// Reader intent is deliberately NOT inferred from scroll deltas here.
|
|
7444
|
+
// Growing the transcript under a pinned anchor makes the browser clamp
|
|
7445
|
+
// scrollTop and then restore it, which lands as a matched pair of real
|
|
7446
|
+
// scroll events (e.g. -12 then +12) with no user involved. The restore
|
|
7447
|
+
// arrives while the bottom offset is GROWING, so the shrink mask above
|
|
7448
|
+
// doesn't cover it, and it reads as a deliberate scroll. Engagement is
|
|
7449
|
+
// tracked from actual input instead (see `markReaderEngaged` callers).
|
|
7376
7450
|
lastScrollTop = scrollTop;
|
|
7377
7451
|
syncScrollToBottomButton();
|
|
7378
7452
|
return;
|
|
@@ -7459,21 +7533,23 @@ export const createAgentExperience = (
|
|
|
7459
7533
|
"ArrowDown",
|
|
7460
7534
|
]);
|
|
7461
7535
|
const handleTranscriptKeydown = (event: KeyboardEvent) => {
|
|
7536
|
+
if (!NAV_KEYS.has(event.key)) return;
|
|
7537
|
+
// Reader intent for first-unread anchoring is tracked unconditionally;
|
|
7538
|
+
// only the follow-mode *pause* below stays behind the opt-in flag.
|
|
7539
|
+
markReaderEngaged();
|
|
7462
7540
|
if (!isPauseOnInteractionEnabled()) return;
|
|
7463
7541
|
if (!isFollowEffective()) return;
|
|
7464
7542
|
if (!autoFollow.isFollowing()) return;
|
|
7465
|
-
|
|
7466
|
-
pauseAutoScroll();
|
|
7467
|
-
}
|
|
7543
|
+
pauseAutoScroll();
|
|
7468
7544
|
};
|
|
7469
7545
|
const handleTranscriptFocusIn = (event: FocusEvent) => {
|
|
7546
|
+
const target = event.target as Element | null;
|
|
7547
|
+
if (!target?.closest("a, button, [tabindex], input, textarea, select")) return;
|
|
7548
|
+
markReaderEngaged();
|
|
7470
7549
|
if (!isPauseOnInteractionEnabled()) return;
|
|
7471
7550
|
if (!isFollowEffective()) return;
|
|
7472
7551
|
if (!autoFollow.isFollowing()) return;
|
|
7473
|
-
|
|
7474
|
-
if (target && target.closest("a, button, [tabindex], input, textarea, select")) {
|
|
7475
|
-
pauseAutoScroll();
|
|
7476
|
-
}
|
|
7552
|
+
pauseAutoScroll();
|
|
7477
7553
|
};
|
|
7478
7554
|
body.addEventListener("keydown", handleTranscriptKeydown);
|
|
7479
7555
|
body.addEventListener("focusin", handleTranscriptFocusIn);
|
|
@@ -7483,6 +7559,9 @@ export const createAgentExperience = (
|
|
|
7483
7559
|
});
|
|
7484
7560
|
|
|
7485
7561
|
const handleWheel = (event: WheelEvent) => {
|
|
7562
|
+
// A wheel tick is unambiguous reader intent in every mode — including
|
|
7563
|
+
// anchored anchor-top, where there is no follow state to pause.
|
|
7564
|
+
if (event.deltaY !== 0) markReaderEngaged();
|
|
7486
7565
|
if (!isFollowEffective()) return;
|
|
7487
7566
|
const action = resolveFollowStateFromWheel({
|
|
7488
7567
|
following: autoFollow.isFollowing(),
|
|
@@ -7498,6 +7577,14 @@ export const createAgentExperience = (
|
|
|
7498
7577
|
}
|
|
7499
7578
|
};
|
|
7500
7579
|
body.addEventListener("wheel", handleWheel, { passive: true });
|
|
7580
|
+
// Touch drag and pointer press (scrollbar drags, tapping a tool bubble open)
|
|
7581
|
+
// are the remaining ways a reader takes over without a wheel or key event.
|
|
7582
|
+
body.addEventListener("touchmove", markReaderEngaged, { passive: true });
|
|
7583
|
+
body.addEventListener("pointerdown", markReaderEngaged, { passive: true });
|
|
7584
|
+
destroyCallbacks.push(() => {
|
|
7585
|
+
body.removeEventListener("touchmove", markReaderEngaged);
|
|
7586
|
+
body.removeEventListener("pointerdown", markReaderEngaged);
|
|
7587
|
+
});
|
|
7501
7588
|
destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
|
|
7502
7589
|
scrollToBottomButton.addEventListener("click", () => {
|
|
7503
7590
|
// Jumping to the latest abandons the current anchor: drop the spacer
|
|
@@ -6,10 +6,12 @@ import {
|
|
|
6
6
|
createFollowStateController,
|
|
7
7
|
getScrollBottomOffset,
|
|
8
8
|
hasSelectionWithin,
|
|
9
|
+
isAttentionWorthyMessage,
|
|
9
10
|
isElementNearBottom,
|
|
10
11
|
resolveFollowStateFromScroll,
|
|
11
12
|
resolveFollowStateFromWheel
|
|
12
13
|
} from "./auto-follow";
|
|
14
|
+
import type { AgentWidgetMessage } from "../types";
|
|
13
15
|
|
|
14
16
|
describe("auto-follow utilities", () => {
|
|
15
17
|
it("tracks pause and resume state", () => {
|
|
@@ -199,3 +201,39 @@ describe("auto-follow utilities", () => {
|
|
|
199
201
|
).toBe("resume");
|
|
200
202
|
});
|
|
201
203
|
});
|
|
204
|
+
|
|
205
|
+
describe("isAttentionWorthyMessage", () => {
|
|
206
|
+
const msg = (over: Partial<AgentWidgetMessage>): AgentWidgetMessage => ({
|
|
207
|
+
id: "m",
|
|
208
|
+
role: "assistant",
|
|
209
|
+
content: "",
|
|
210
|
+
createdAt: "2026-07-28T00:00:00.000Z",
|
|
211
|
+
...over,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("treats prose, artifacts/components, images and approvals as output", () => {
|
|
215
|
+
expect(isAttentionWorthyMessage(msg({ content: "Here is the answer." }))).toBe(true);
|
|
216
|
+
// Artifact / component blocks carry their payload in rawContent with an
|
|
217
|
+
// empty `content` — the case a text-only test silently drops.
|
|
218
|
+
expect(
|
|
219
|
+
isAttentionWorthyMessage(msg({ rawContent: '{"component":"Chart","props":{}}' }))
|
|
220
|
+
).toBe(true);
|
|
221
|
+
expect(
|
|
222
|
+
isAttentionWorthyMessage(
|
|
223
|
+
msg({ contentParts: [{ type: "image", image: "data:image/png;base64,x" }] as never })
|
|
224
|
+
)
|
|
225
|
+
).toBe(true);
|
|
226
|
+
// Blocked on the reader deciding: must never sit off-screen.
|
|
227
|
+
expect(isAttentionWorthyMessage(msg({ variant: "approval" }))).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("treats tool calls and reasoning as progress chrome", () => {
|
|
231
|
+
expect(isAttentionWorthyMessage(msg({ variant: "tool", content: "searching" }))).toBe(false);
|
|
232
|
+
expect(isAttentionWorthyMessage(msg({ variant: "reasoning", content: "thinking" }))).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("skips the empty streaming placeholder and non-assistant roles", () => {
|
|
236
|
+
expect(isAttentionWorthyMessage(msg({ content: " " }))).toBe(false);
|
|
237
|
+
expect(isAttentionWorthyMessage(msg({ role: "user", content: "hi" }))).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
});
|
package/src/utils/auto-follow.ts
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
import type { AgentWidgetMessage } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Is this message *output a human came for*, as opposed to progress chrome?
|
|
5
|
+
*
|
|
6
|
+
* This is the classifier behind anchor-top's "first unread" positioning: the
|
|
7
|
+
* transcript anchors to the first attention-worthy block of a response and
|
|
8
|
+
* leaves everything else to scroll past underneath. Two categories:
|
|
9
|
+
*
|
|
10
|
+
* - **Chrome** — tool calls and reasoning. Rendered so the reader *can* audit
|
|
11
|
+
* the work, never positioned to. A response that opens with six tool calls
|
|
12
|
+
* should not park the reader on the sixth one.
|
|
13
|
+
* - **Attention-worthy** — anything that carries the answer: prose, an
|
|
14
|
+
* artifact or component block (charts, forms, documents — these carry their
|
|
15
|
+
* payload in `rawContent` with an EMPTY `content`, so testing text alone
|
|
16
|
+
* silently drops them), multi-modal parts (images), and approval requests
|
|
17
|
+
* (which are the strongest case of all: the turn is blocked on the reader
|
|
18
|
+
* deciding, so it must never sit off-screen).
|
|
19
|
+
*
|
|
20
|
+
* The empty streaming placeholder — an assistant bubble created at
|
|
21
|
+
* `text_start` before its first token — is deliberately NOT attention-worthy,
|
|
22
|
+
* so the anchor waits for content rather than jumping to a blank bubble.
|
|
23
|
+
*/
|
|
24
|
+
export function isAttentionWorthyMessage(message: AgentWidgetMessage): boolean {
|
|
25
|
+
if (message.role !== "assistant") return false;
|
|
26
|
+
if (message.variant === "tool" || message.variant === "reasoning") return false;
|
|
27
|
+
// Blocked on the reader: attention-worthy even with no text of its own.
|
|
28
|
+
if (message.variant === "approval") return true;
|
|
29
|
+
return Boolean(
|
|
30
|
+
message.content?.trim() ||
|
|
31
|
+
message.rawContent?.trim() ||
|
|
32
|
+
message.contentParts?.length
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
1
36
|
export type FollowStateAction = "none" | "pause" | "resume";
|
|
2
37
|
|
|
3
38
|
export type FollowStateController = {
|
|
@@ -151,8 +151,33 @@ export type CodeGeneratorOptions = {
|
|
|
151
151
|
* @default "body"
|
|
152
152
|
*/
|
|
153
153
|
target?: string;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Base URL of the directory holding the widget assets (install.global.js,
|
|
157
|
+
* index.global.js, widget.css) for the script formats (script-installer,
|
|
158
|
+
* script-manual, script-advanced). Pass a first-party base such as
|
|
159
|
+
* "https://cdn.runtype.com/persona/latest" when the embedding page's CSP
|
|
160
|
+
* does not allow third-party CDNs. A trailing slash is tolerated.
|
|
161
|
+
* @default the jsDelivr dist directory pinned to this package version
|
|
162
|
+
*/
|
|
163
|
+
cdnBase?: string;
|
|
154
164
|
};
|
|
155
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Asset directory for script formats: explicit cdnBase or the version-pinned
|
|
168
|
+
* jsDelivr dist. The base is emitted into single-quoted JS literals AND
|
|
169
|
+
* double-quoted HTML attributes, so percent-encode the characters that could
|
|
170
|
+
* break out of either context. All of them are illegal unencoded in a URL
|
|
171
|
+
* (RFC 3986), so encoding never changes the meaning of a valid base URL.
|
|
172
|
+
*/
|
|
173
|
+
function resolveCdnBase(options?: CodeGeneratorOptions): string {
|
|
174
|
+
const base = options?.cdnBase
|
|
175
|
+
?.trim()
|
|
176
|
+
.replace(/\/+$/, "")
|
|
177
|
+
.replace(/[\\'"<>`]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
178
|
+
return base || `https://cdn.jsdelivr.net/npm/@runtypelabs/persona@${VERSION}/dist`;
|
|
179
|
+
}
|
|
180
|
+
|
|
156
181
|
// Internal type for normalized hooks (always strings)
|
|
157
182
|
type NormalizedHooks = {
|
|
158
183
|
[K in keyof CodeGeneratorHooks]: string | undefined;
|
|
@@ -1402,7 +1427,7 @@ function generateScriptInstallerCode(config: any, options?: CodeGeneratorOptions
|
|
|
1402
1427
|
// Escape single quotes in JSON for HTML attribute
|
|
1403
1428
|
const configJson = JSON.stringify(payload, null, 0).replace(/'/g, "'");
|
|
1404
1429
|
|
|
1405
|
-
return `<script src="
|
|
1430
|
+
return `<script src="${resolveCdnBase(options)}/install.global.js" data-config='${configJson}'></script>`;
|
|
1406
1431
|
}
|
|
1407
1432
|
|
|
1408
1433
|
function generateScriptManualCode(config: any, options?: CodeGeneratorOptions): string {
|
|
@@ -1410,12 +1435,13 @@ function generateScriptManualCode(config: any, options?: CodeGeneratorOptions):
|
|
|
1410
1435
|
const parserType = getParserTypeFromConfig(config as AgentWidgetConfig);
|
|
1411
1436
|
const shouldEmitParserType = parserType !== "plain";
|
|
1412
1437
|
|
|
1438
|
+
const cdnBase = resolveCdnBase(options);
|
|
1413
1439
|
const lines: string[] = [
|
|
1414
1440
|
"<!-- Load CSS -->",
|
|
1415
|
-
`<link rel="stylesheet" href="
|
|
1441
|
+
`<link rel="stylesheet" href="${cdnBase}/widget.css" />`,
|
|
1416
1442
|
"",
|
|
1417
1443
|
"<!-- Load JavaScript -->",
|
|
1418
|
-
`<script src="
|
|
1444
|
+
`<script src="${cdnBase}/index.global.js"></script>`,
|
|
1419
1445
|
"",
|
|
1420
1446
|
"<!-- Initialize widget -->",
|
|
1421
1447
|
"<script>",
|
|
@@ -1565,7 +1591,7 @@ function generateScriptAdvancedCode(config: any, options?: CodeGeneratorOptions)
|
|
|
1565
1591
|
` var CONFIG = ${configJson.split('\n').map((line, i) => i === 0 ? line : ' ' + line).join('\n')};`,
|
|
1566
1592
|
"",
|
|
1567
1593
|
" // Constants",
|
|
1568
|
-
` var CDN_BASE = '
|
|
1594
|
+
` var CDN_BASE = '${resolveCdnBase(options)}';`,
|
|
1569
1595
|
" var STORAGE_KEY = 'chat-widget-state';",
|
|
1570
1596
|
" var PROCESSED_ACTIONS_KEY = 'chat-widget-processed-actions';",
|
|
1571
1597
|
"",
|