@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
|
@@ -22,6 +22,13 @@ export interface ComposerElements {
|
|
|
22
22
|
actionsRow: HTMLElement;
|
|
23
23
|
leftActions: HTMLElement;
|
|
24
24
|
rightActions: HTMLElement;
|
|
25
|
+
/**
|
|
26
|
+
* Swap the send button between its idle ("send") appearance and its
|
|
27
|
+
* streaming ("stop") appearance. In icon mode this swaps the SVG; in text
|
|
28
|
+
* mode it swaps the button label. Tooltip text is updated when a tooltip
|
|
29
|
+
* element is present.
|
|
30
|
+
*/
|
|
31
|
+
setSendButtonMode: (mode: "send" | "stop") => void;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
/**
|
|
@@ -122,7 +129,11 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
122
129
|
const useIcon = sendButtonConfig.useIcon ?? false;
|
|
123
130
|
const iconText = sendButtonConfig.iconText ?? "↑";
|
|
124
131
|
const iconName = sendButtonConfig.iconName;
|
|
132
|
+
const stopIconName = sendButtonConfig.stopIconName ?? "square";
|
|
125
133
|
const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
|
|
134
|
+
const stopTooltipText = sendButtonConfig.stopTooltipText ?? "Stop generating";
|
|
135
|
+
const sendLabel = config?.copy?.sendButtonLabel ?? "Send";
|
|
136
|
+
const stopLabel = config?.copy?.stopButtonLabel ?? "Stop";
|
|
126
137
|
const showTooltip = sendButtonConfig.showTooltip ?? false;
|
|
127
138
|
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
128
139
|
const backgroundColor = sendButtonConfig.backgroundColor;
|
|
@@ -141,6 +152,11 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
141
152
|
sendButton.type = "submit";
|
|
142
153
|
sendButton.setAttribute("data-persona-composer-submit", "");
|
|
143
154
|
|
|
155
|
+
// Icons for both modes are pre-rendered so setSendButtonMode can swap them
|
|
156
|
+
// without having to re-render on every streaming state change.
|
|
157
|
+
let sendIcon: SVGElement | null = null;
|
|
158
|
+
let stopIcon: SVGElement | null = null;
|
|
159
|
+
|
|
144
160
|
if (useIcon) {
|
|
145
161
|
// Icon mode: circular button
|
|
146
162
|
sendButton.style.width = buttonSize;
|
|
@@ -160,13 +176,14 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
160
176
|
sendButton.style.color = "var(--persona-button-primary-fg, #ffffff)";
|
|
161
177
|
}
|
|
162
178
|
|
|
179
|
+
const iconSize = parseFloat(buttonSize) || 24;
|
|
180
|
+
const iconColor = textColor?.trim() || "currentColor";
|
|
181
|
+
|
|
163
182
|
// Use Lucide icon if iconName is provided, otherwise fall back to iconText
|
|
164
183
|
if (iconName) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (iconSvg) {
|
|
169
|
-
sendButton.appendChild(iconSvg);
|
|
184
|
+
sendIcon = renderLucideIcon(iconName, iconSize, iconColor, 2);
|
|
185
|
+
if (sendIcon) {
|
|
186
|
+
sendButton.appendChild(sendIcon);
|
|
170
187
|
} else {
|
|
171
188
|
sendButton.textContent = iconText;
|
|
172
189
|
}
|
|
@@ -174,6 +191,9 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
174
191
|
sendButton.textContent = iconText;
|
|
175
192
|
}
|
|
176
193
|
|
|
194
|
+
// Pre-render the stop icon so mode swaps are cheap; it starts detached.
|
|
195
|
+
stopIcon = renderLucideIcon(stopIconName, iconSize, iconColor, 2);
|
|
196
|
+
|
|
177
197
|
if (backgroundColor) {
|
|
178
198
|
sendButton.style.backgroundColor = backgroundColor;
|
|
179
199
|
} else {
|
|
@@ -181,7 +201,7 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
181
201
|
}
|
|
182
202
|
} else {
|
|
183
203
|
// Text mode: existing behavior
|
|
184
|
-
sendButton.textContent =
|
|
204
|
+
sendButton.textContent = sendLabel;
|
|
185
205
|
if (textColor) {
|
|
186
206
|
sendButton.style.color = textColor;
|
|
187
207
|
} else {
|
|
@@ -215,14 +235,44 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
215
235
|
}
|
|
216
236
|
|
|
217
237
|
// Add tooltip if enabled
|
|
238
|
+
let sendTooltip: HTMLElement | null = null;
|
|
218
239
|
if (showTooltip && tooltipText) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
sendButtonWrapper.appendChild(
|
|
240
|
+
sendTooltip = createElement("div", "persona-send-button-tooltip");
|
|
241
|
+
sendTooltip.textContent = tooltipText;
|
|
242
|
+
sendButtonWrapper.appendChild(sendTooltip);
|
|
222
243
|
}
|
|
223
244
|
|
|
245
|
+
sendButton.setAttribute("aria-label", tooltipText);
|
|
246
|
+
|
|
224
247
|
sendButtonWrapper.appendChild(sendButton);
|
|
225
248
|
|
|
249
|
+
let currentMode: "send" | "stop" = "send";
|
|
250
|
+
const setSendButtonMode = (mode: "send" | "stop") => {
|
|
251
|
+
if (mode === currentMode) return;
|
|
252
|
+
currentMode = mode;
|
|
253
|
+
const label = mode === "stop" ? stopTooltipText : tooltipText;
|
|
254
|
+
sendButton.setAttribute("aria-label", label);
|
|
255
|
+
if (sendTooltip) {
|
|
256
|
+
sendTooltip.textContent = label;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (useIcon) {
|
|
260
|
+
// Only swap icons if both were rendered successfully; otherwise the
|
|
261
|
+
// button is using textContent fallback and there's nothing to swap.
|
|
262
|
+
if (sendIcon && stopIcon) {
|
|
263
|
+
const next = mode === "stop" ? stopIcon : sendIcon;
|
|
264
|
+
const prev = mode === "stop" ? sendIcon : stopIcon;
|
|
265
|
+
if (prev.parentNode === sendButton) {
|
|
266
|
+
sendButton.replaceChild(next, prev);
|
|
267
|
+
} else {
|
|
268
|
+
sendButton.appendChild(next);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
sendButton.textContent = mode === "stop" ? stopLabel : sendLabel;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
226
276
|
// Voice recognition mic button
|
|
227
277
|
const voiceRecognitionConfig = config?.voiceRecognition ?? {};
|
|
228
278
|
const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
|
|
@@ -515,7 +565,8 @@ export const buildComposer = (context: ComposerBuildContext): ComposerElements =
|
|
|
515
565
|
// Actions row layout elements
|
|
516
566
|
actionsRow,
|
|
517
567
|
leftActions,
|
|
518
|
-
rightActions
|
|
568
|
+
rightActions,
|
|
569
|
+
setSendButtonMode
|
|
519
570
|
};
|
|
520
571
|
};
|
|
521
572
|
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, it, expect } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
createStandardBubble,
|
|
5
|
+
isSafeImageSrc,
|
|
6
|
+
resolveStopReasonNoticeText,
|
|
7
|
+
getDefaultStopReasonNoticeCopy,
|
|
8
|
+
} from "./message-bubble";
|
|
9
|
+
import type { AgentWidgetConfig, AgentWidgetMessage } from "../types";
|
|
5
10
|
|
|
6
11
|
const makeMessage = (overrides: Partial<AgentWidgetMessage> = {}): AgentWidgetMessage => ({
|
|
7
12
|
id: "msg-1",
|
|
@@ -95,3 +100,177 @@ describe("createStandardBubble", () => {
|
|
|
95
100
|
expect(previewImages[0]?.getAttribute("alt")).toBe("Safe image");
|
|
96
101
|
});
|
|
97
102
|
});
|
|
103
|
+
|
|
104
|
+
describe("resolveStopReasonNoticeText", () => {
|
|
105
|
+
it("returns null for natural completions", () => {
|
|
106
|
+
expect(resolveStopReasonNoticeText("end_turn")).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null for unknown reasons", () => {
|
|
110
|
+
expect(resolveStopReasonNoticeText("unknown")).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns null when stopReason is undefined", () => {
|
|
114
|
+
expect(resolveStopReasonNoticeText(undefined)).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns the default copy for actionable reasons", () => {
|
|
118
|
+
expect(resolveStopReasonNoticeText("max_tool_calls")).toBe(
|
|
119
|
+
getDefaultStopReasonNoticeCopy("max_tool_calls")
|
|
120
|
+
);
|
|
121
|
+
expect(resolveStopReasonNoticeText("length")).toBe(
|
|
122
|
+
getDefaultStopReasonNoticeCopy("length")
|
|
123
|
+
);
|
|
124
|
+
expect(resolveStopReasonNoticeText("content_filter")).toBe(
|
|
125
|
+
getDefaultStopReasonNoticeCopy("content_filter")
|
|
126
|
+
);
|
|
127
|
+
expect(resolveStopReasonNoticeText("error")).toBe(
|
|
128
|
+
getDefaultStopReasonNoticeCopy("error")
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("applies overrides on a per-key basis", () => {
|
|
133
|
+
expect(
|
|
134
|
+
resolveStopReasonNoticeText("max_tool_calls", {
|
|
135
|
+
["max_tool_calls" as const]: "Custom override.",
|
|
136
|
+
})
|
|
137
|
+
).toBe("Custom override.");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("falls back to defaults for keys not overridden", () => {
|
|
141
|
+
expect(
|
|
142
|
+
resolveStopReasonNoticeText("length", {
|
|
143
|
+
["max_tool_calls" as const]: "Custom.",
|
|
144
|
+
})
|
|
145
|
+
).toBe(getDefaultStopReasonNoticeCopy("length"));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("suppresses the notice when override is an empty string", () => {
|
|
149
|
+
expect(
|
|
150
|
+
resolveStopReasonNoticeText("max_tool_calls", {
|
|
151
|
+
["max_tool_calls" as const]: "",
|
|
152
|
+
})
|
|
153
|
+
).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("createStandardBubble — stopReason notice", () => {
|
|
158
|
+
const renderWithStopReason = (
|
|
159
|
+
overrides: Partial<AgentWidgetMessage>,
|
|
160
|
+
widgetConfig?: Partial<AgentWidgetConfig>
|
|
161
|
+
) =>
|
|
162
|
+
createStandardBubble(
|
|
163
|
+
makeMessage(overrides),
|
|
164
|
+
({ text }) => text,
|
|
165
|
+
undefined,
|
|
166
|
+
undefined,
|
|
167
|
+
undefined,
|
|
168
|
+
{ widgetConfig: widgetConfig as AgentWidgetConfig | undefined }
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
it("renders no notice for end_turn (natural completion)", () => {
|
|
172
|
+
const bubble = renderWithStopReason({
|
|
173
|
+
content: "All done.",
|
|
174
|
+
stopReason: "end_turn",
|
|
175
|
+
});
|
|
176
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("renders no notice when stopReason is absent (backcompat)", () => {
|
|
180
|
+
const bubble = renderWithStopReason({ content: "Hello." });
|
|
181
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("renders no notice for unknown reasons", () => {
|
|
185
|
+
const bubble = renderWithStopReason({
|
|
186
|
+
content: "Hello.",
|
|
187
|
+
stopReason: "unknown",
|
|
188
|
+
});
|
|
189
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("renders the default notice for max_tool_calls", () => {
|
|
193
|
+
const bubble = renderWithStopReason({
|
|
194
|
+
content: "Used a tool.",
|
|
195
|
+
stopReason: "max_tool_calls",
|
|
196
|
+
});
|
|
197
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
198
|
+
expect(notice).not.toBeNull();
|
|
199
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("max_tool_calls");
|
|
200
|
+
expect(notice?.textContent).toBe(getDefaultStopReasonNoticeCopy("max_tool_calls"));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("renders the default notice for length", () => {
|
|
204
|
+
const bubble = renderWithStopReason({
|
|
205
|
+
content: "Long answer cut off.",
|
|
206
|
+
stopReason: "length",
|
|
207
|
+
});
|
|
208
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
209
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("length");
|
|
210
|
+
expect(notice?.textContent).toBe(getDefaultStopReasonNoticeCopy("length"));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("renders the default notice for content_filter", () => {
|
|
214
|
+
const bubble = renderWithStopReason({
|
|
215
|
+
content: "Filtered.",
|
|
216
|
+
stopReason: "content_filter",
|
|
217
|
+
});
|
|
218
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
219
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("content_filter");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("renders the default notice for error", () => {
|
|
223
|
+
const bubble = renderWithStopReason({
|
|
224
|
+
content: "Provider blew up.",
|
|
225
|
+
stopReason: "error",
|
|
226
|
+
});
|
|
227
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
228
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("error");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("applies copy overrides from widgetConfig.copy.stopReasonNotice", () => {
|
|
232
|
+
const bubble = renderWithStopReason(
|
|
233
|
+
{ content: "x", stopReason: "max_tool_calls" },
|
|
234
|
+
{ copy: { stopReasonNotice: { ["max_tool_calls" as const]: "Custom copy." } } }
|
|
235
|
+
);
|
|
236
|
+
expect(bubble.querySelector(".persona-message-stop-reason")?.textContent).toBe(
|
|
237
|
+
"Custom copy."
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("hides the empty content div when content is empty + max_tool_calls", () => {
|
|
242
|
+
// Regression: the empty-bubble symptom the upstream Runtype fix targets.
|
|
243
|
+
// With no content and max_tool_calls, the notice carries the bubble alone;
|
|
244
|
+
// the empty content div must be hidden so we don't render whitespace
|
|
245
|
+
// above the notice.
|
|
246
|
+
const bubble = renderWithStopReason({
|
|
247
|
+
content: "",
|
|
248
|
+
stopReason: "max_tool_calls",
|
|
249
|
+
});
|
|
250
|
+
const contentDiv = bubble.querySelector(".persona-message-content") as HTMLElement | null;
|
|
251
|
+
expect(contentDiv).not.toBeNull();
|
|
252
|
+
expect(contentDiv!.style.display).toBe("none");
|
|
253
|
+
const notice = bubble.querySelector(".persona-message-stop-reason");
|
|
254
|
+
expect(notice).not.toBeNull();
|
|
255
|
+
expect(notice?.getAttribute("data-stop-reason")).toBe("max_tool_calls");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not render notice while message is still streaming", () => {
|
|
259
|
+
const bubble = renderWithStopReason({
|
|
260
|
+
content: "partial",
|
|
261
|
+
stopReason: "max_tool_calls",
|
|
262
|
+
streaming: true,
|
|
263
|
+
});
|
|
264
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("does not render notice on user messages", () => {
|
|
268
|
+
const bubble = renderWithStopReason({
|
|
269
|
+
role: "user",
|
|
270
|
+
content: "user msg",
|
|
271
|
+
// stopReason on a user message is nonsense, but guard against it
|
|
272
|
+
stopReason: "max_tool_calls",
|
|
273
|
+
});
|
|
274
|
+
expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -7,10 +7,85 @@ import {
|
|
|
7
7
|
AgentWidgetMessageActionsConfig,
|
|
8
8
|
AgentWidgetMessageFeedback,
|
|
9
9
|
LoadingIndicatorRenderContext,
|
|
10
|
-
ImageContentPart
|
|
10
|
+
ImageContentPart,
|
|
11
|
+
StopReasonKind
|
|
11
12
|
} from "../types";
|
|
12
13
|
import { createIconButton } from "../utils/buttons";
|
|
13
14
|
import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "../utils/content";
|
|
15
|
+
import {
|
|
16
|
+
applyStreamBuffer,
|
|
17
|
+
createSkeletonPlaceholder,
|
|
18
|
+
createStreamCaret,
|
|
19
|
+
resolveStreamAnimation,
|
|
20
|
+
resolveStreamAnimationPlugin,
|
|
21
|
+
wrapStreamAnimation,
|
|
22
|
+
} from "../utils/stream-animation";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default copy for the inline notice rendered when a turn ends with a
|
|
26
|
+
* non-natural stop reason. Deployers override per-reason via
|
|
27
|
+
* `config.copy.stopReasonNotice`. Returns `null` for natural completions
|
|
28
|
+
* (`end_turn`) and uninformative reasons (`unknown`) — those never render
|
|
29
|
+
* an affordance.
|
|
30
|
+
*/
|
|
31
|
+
export const getDefaultStopReasonNoticeCopy = (
|
|
32
|
+
stopReason: StopReasonKind
|
|
33
|
+
): string | null => {
|
|
34
|
+
switch (stopReason) {
|
|
35
|
+
case "max_tool_calls":
|
|
36
|
+
return "Stopped after calling a tool. Send a follow-up to continue.";
|
|
37
|
+
case "length":
|
|
38
|
+
return "Response cut off as max tokens reached. Ask for more to continue.";
|
|
39
|
+
case "content_filter":
|
|
40
|
+
return "The provider filtered this response.";
|
|
41
|
+
case "error":
|
|
42
|
+
return "Something went wrong generating this response.";
|
|
43
|
+
case "end_turn":
|
|
44
|
+
case "unknown":
|
|
45
|
+
default:
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the notice text for a stop reason, applying user overrides on
|
|
52
|
+
* top of the built-in defaults. Returns `null` when the reason does not
|
|
53
|
+
* warrant a notice or when the resolved string is empty (deployers can
|
|
54
|
+
* suppress per-reason by setting an empty override).
|
|
55
|
+
*/
|
|
56
|
+
export const resolveStopReasonNoticeText = (
|
|
57
|
+
stopReason: StopReasonKind | undefined,
|
|
58
|
+
overrides?: Partial<Record<StopReasonKind, string>>
|
|
59
|
+
): string | null => {
|
|
60
|
+
if (!stopReason) return null;
|
|
61
|
+
const fallback = getDefaultStopReasonNoticeCopy(stopReason);
|
|
62
|
+
// Reasons without a default (end_turn, unknown) never render — overrides
|
|
63
|
+
// for those keys are intentionally ignored.
|
|
64
|
+
if (fallback === null) return null;
|
|
65
|
+
const override = overrides?.[stopReason];
|
|
66
|
+
const text = override !== undefined ? override : fallback;
|
|
67
|
+
if (!text) return null;
|
|
68
|
+
return text;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the inline notice element rendered on assistant bubbles whose
|
|
73
|
+
* turn ended with `max_tool_calls`, `length`, `content_filter`, or `error`.
|
|
74
|
+
*/
|
|
75
|
+
const createStopReasonNotice = (
|
|
76
|
+
stopReason: StopReasonKind,
|
|
77
|
+
text: string
|
|
78
|
+
): HTMLElement => {
|
|
79
|
+
const notice = createElement(
|
|
80
|
+
"div",
|
|
81
|
+
"persona-message-stop-reason persona-text-xs persona-mt-2 persona-italic"
|
|
82
|
+
);
|
|
83
|
+
notice.setAttribute("data-stop-reason", stopReason);
|
|
84
|
+
notice.setAttribute("role", "note");
|
|
85
|
+
notice.style.opacity = "0.75";
|
|
86
|
+
notice.textContent = text;
|
|
87
|
+
return notice;
|
|
88
|
+
};
|
|
14
89
|
|
|
15
90
|
/** Validate that an image src URL uses a safe scheme (blocks javascript: and SVG data URIs). */
|
|
16
91
|
export const isSafeImageSrc = (src: string): boolean => {
|
|
@@ -507,24 +582,107 @@ export const createStandardBubble = (
|
|
|
507
582
|
imageParts.length > 0 && messageContentText === IMAGE_ONLY_MESSAGE_FALLBACK_TEXT;
|
|
508
583
|
const shouldHideTextUntilPreviewFails = isImageOnlyFallbackMessage;
|
|
509
584
|
|
|
585
|
+
const streamAnimation = resolveStreamAnimation(
|
|
586
|
+
options?.widgetConfig?.features?.streamAnimation
|
|
587
|
+
);
|
|
588
|
+
const streamPluginOverrides =
|
|
589
|
+
options?.widgetConfig?.features?.streamAnimation?.plugins;
|
|
590
|
+
const streamPlugin =
|
|
591
|
+
message.role === "assistant" && streamAnimation.type !== "none"
|
|
592
|
+
? resolveStreamAnimationPlugin(streamAnimation.type, streamPluginOverrides)
|
|
593
|
+
: null;
|
|
594
|
+
// Stay in "streaming-animated" mode while the plugin reports in-flight
|
|
595
|
+
// work for this message — e.g. glyph-cycle's tick loops still walking
|
|
596
|
+
// through the tail after the last token arrived. Without this, the final
|
|
597
|
+
// non-animated render rips out the cycling spans mid-animation.
|
|
598
|
+
const pluginStillAnimating =
|
|
599
|
+
message.role === "assistant" &&
|
|
600
|
+
streamPlugin?.isAnimating?.(message) === true;
|
|
601
|
+
const streamAnimationActive =
|
|
602
|
+
message.role === "assistant" &&
|
|
603
|
+
streamPlugin !== null &&
|
|
604
|
+
(Boolean(message.streaming) || pluginStillAnimating);
|
|
605
|
+
|
|
606
|
+
if (streamAnimationActive && streamPlugin?.bubbleClass) {
|
|
607
|
+
bubble.classList.add(streamPlugin.bubbleClass);
|
|
608
|
+
}
|
|
609
|
+
|
|
510
610
|
// Add message content
|
|
511
611
|
const contentDiv = document.createElement("div");
|
|
512
612
|
contentDiv.classList.add("persona-message-content");
|
|
613
|
+
|
|
614
|
+
if (streamAnimationActive && streamPlugin) {
|
|
615
|
+
if (streamPlugin.containerClass) {
|
|
616
|
+
contentDiv.classList.add(streamPlugin.containerClass);
|
|
617
|
+
}
|
|
618
|
+
contentDiv.style.setProperty("--persona-stream-step", `${streamAnimation.speed}ms`);
|
|
619
|
+
contentDiv.style.setProperty("--persona-stream-duration", `${streamAnimation.duration}ms`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const bufferedContent = streamAnimationActive
|
|
623
|
+
? applyStreamBuffer(
|
|
624
|
+
message.content ?? "",
|
|
625
|
+
streamAnimation.buffer,
|
|
626
|
+
streamPlugin,
|
|
627
|
+
message,
|
|
628
|
+
Boolean(message.streaming)
|
|
629
|
+
)
|
|
630
|
+
: (message.content ?? "");
|
|
631
|
+
|
|
513
632
|
const transformedContent = transform({
|
|
514
|
-
text:
|
|
633
|
+
text: bufferedContent,
|
|
515
634
|
message,
|
|
516
635
|
streaming: Boolean(message.streaming),
|
|
517
636
|
raw: message.rawContent
|
|
518
637
|
});
|
|
638
|
+
|
|
639
|
+
let animatedContent = transformedContent;
|
|
640
|
+
if (streamAnimationActive && streamPlugin?.wrap === "char") {
|
|
641
|
+
animatedContent = wrapStreamAnimation(transformedContent, "char", message.id, {
|
|
642
|
+
skipTags: streamPlugin.skipTags,
|
|
643
|
+
});
|
|
644
|
+
} else if (streamAnimationActive && streamPlugin?.wrap === "word") {
|
|
645
|
+
animatedContent = wrapStreamAnimation(transformedContent, "word", message.id, {
|
|
646
|
+
skipTags: streamPlugin.skipTags,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
519
650
|
let textContentDiv: HTMLElement | null = null;
|
|
520
651
|
|
|
521
652
|
if (shouldHideTextUntilPreviewFails) {
|
|
522
653
|
textContentDiv = document.createElement("div");
|
|
523
|
-
textContentDiv.innerHTML =
|
|
654
|
+
textContentDiv.innerHTML = animatedContent;
|
|
524
655
|
textContentDiv.style.display = "none";
|
|
525
656
|
contentDiv.appendChild(textContentDiv);
|
|
526
657
|
} else {
|
|
527
|
-
contentDiv.innerHTML =
|
|
658
|
+
contentDiv.innerHTML = animatedContent;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (
|
|
662
|
+
streamAnimationActive &&
|
|
663
|
+
streamPlugin?.useCaret &&
|
|
664
|
+
!shouldHideTextUntilPreviewFails &&
|
|
665
|
+
messageContentText
|
|
666
|
+
) {
|
|
667
|
+
const caret = createStreamCaret();
|
|
668
|
+
// Caret must sit on the same line as the final char. Markdown wraps text
|
|
669
|
+
// in block elements (<p>, <li>, <pre>), so appending to contentDiv would
|
|
670
|
+
// drop the caret onto a fresh line. Tuck it after the last char/word span,
|
|
671
|
+
// or fall back to the last block when no spans exist yet.
|
|
672
|
+
const spans = contentDiv.querySelectorAll(
|
|
673
|
+
".persona-stream-char, .persona-stream-word"
|
|
674
|
+
);
|
|
675
|
+
const lastSpan = spans[spans.length - 1];
|
|
676
|
+
if (lastSpan?.parentNode) {
|
|
677
|
+
lastSpan.parentNode.insertBefore(caret, lastSpan.nextSibling);
|
|
678
|
+
} else {
|
|
679
|
+
const lastChild = contentDiv.lastElementChild;
|
|
680
|
+
if (lastChild) {
|
|
681
|
+
lastChild.appendChild(caret);
|
|
682
|
+
} else {
|
|
683
|
+
contentDiv.appendChild(caret);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
528
686
|
}
|
|
529
687
|
|
|
530
688
|
// Add inline timestamp if configured
|
|
@@ -561,19 +719,56 @@ export const createStandardBubble = (
|
|
|
561
719
|
bubble.appendChild(timestamp);
|
|
562
720
|
}
|
|
563
721
|
|
|
564
|
-
//
|
|
722
|
+
// Resolve the stop-reason notice (if any). Only assistant messages can
|
|
723
|
+
// carry a stop reason worth surfacing.
|
|
724
|
+
const stopReasonNoticeText =
|
|
725
|
+
message.role === "assistant"
|
|
726
|
+
? resolveStopReasonNoticeText(
|
|
727
|
+
message.stopReason,
|
|
728
|
+
options?.widgetConfig?.copy?.stopReasonNotice
|
|
729
|
+
)
|
|
730
|
+
: null;
|
|
731
|
+
|
|
732
|
+
// Add typing indicator (or skeleton placeholder) for streaming assistant
|
|
733
|
+
// messages. Check the buffered content — a plugin's `bufferContent` may
|
|
734
|
+
// hold back the first N chars (e.g. glyph-cycle waits for 50 chars), during
|
|
735
|
+
// which the bubble would otherwise appear empty.
|
|
736
|
+
//
|
|
737
|
+
// When the `"line"` buffer strategy is paired with the skeleton placeholder,
|
|
738
|
+
// the skeleton trails below any already-revealed content to hint that more
|
|
739
|
+
// lines are on the way. It disappears on stream completion.
|
|
565
740
|
if (message.streaming && message.role === "assistant") {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
741
|
+
const hasVisibleContent = Boolean(bufferedContent && bufferedContent.trim());
|
|
742
|
+
const skeletonEnabled = streamAnimation.placeholder === "skeleton";
|
|
743
|
+
const trailSkeleton =
|
|
744
|
+
skeletonEnabled && streamAnimation.buffer === "line" && hasVisibleContent;
|
|
745
|
+
if (!hasVisibleContent) {
|
|
746
|
+
if (skeletonEnabled) {
|
|
747
|
+
bubble.appendChild(createSkeletonPlaceholder());
|
|
748
|
+
} else {
|
|
749
|
+
const indicator = renderLoadingIndicatorWithFallback(
|
|
750
|
+
'inline',
|
|
751
|
+
options?.loadingIndicatorRenderer,
|
|
752
|
+
options?.widgetConfig
|
|
753
|
+
);
|
|
754
|
+
if (indicator) {
|
|
755
|
+
bubble.appendChild(indicator);
|
|
756
|
+
}
|
|
575
757
|
}
|
|
758
|
+
} else if (trailSkeleton) {
|
|
759
|
+
bubble.appendChild(createSkeletonPlaceholder());
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Append the stop-reason notice for non-natural completions. When the
|
|
764
|
+
// assistant produced no text (the `max_tool_calls` empty-bubble symptom),
|
|
765
|
+
// hide the empty content div so the notice carries the entire bubble
|
|
766
|
+
// instead of trailing under a blank space.
|
|
767
|
+
if (stopReasonNoticeText && message.stopReason && !message.streaming) {
|
|
768
|
+
if (!messageContentText) {
|
|
769
|
+
contentDiv.style.display = "none";
|
|
576
770
|
}
|
|
771
|
+
bubble.appendChild(createStopReasonNotice(message.stopReason, stopReasonNoticeText));
|
|
577
772
|
}
|
|
578
773
|
|
|
579
774
|
// Add message actions for assistant messages (only when not streaming and has content)
|
|
@@ -4,6 +4,11 @@ import { MessageTransform, MessageActionCallbacks } from "./message-bubble";
|
|
|
4
4
|
import { createStandardBubble } from "./message-bubble";
|
|
5
5
|
import { createReasoningBubble } from "./reasoning-bubble";
|
|
6
6
|
import { createToolBubble } from "./tool-bubble";
|
|
7
|
+
import {
|
|
8
|
+
ensureAskUserQuestionSheet,
|
|
9
|
+
isAskUserQuestionMessage,
|
|
10
|
+
removeAskUserQuestionSheet,
|
|
11
|
+
} from "./ask-user-question-bubble";
|
|
7
12
|
|
|
8
13
|
export const renderMessages = (
|
|
9
14
|
container: HTMLElement,
|
|
@@ -12,16 +17,29 @@ export const renderMessages = (
|
|
|
12
17
|
showReasoning: boolean,
|
|
13
18
|
showToolCalls: boolean,
|
|
14
19
|
config?: AgentWidgetConfig,
|
|
15
|
-
actionCallbacks?: MessageActionCallbacks
|
|
20
|
+
actionCallbacks?: MessageActionCallbacks,
|
|
21
|
+
composerOverlay?: HTMLElement | null
|
|
16
22
|
) => {
|
|
17
23
|
container.innerHTML = "";
|
|
18
24
|
const fragment = createFragment();
|
|
19
25
|
|
|
26
|
+
// Track which ask_user_question tool-call ids are currently in the message
|
|
27
|
+
// list, so we can prune stale sheets from the overlay afterward.
|
|
28
|
+
const liveAskToolIds = new Set<string>();
|
|
29
|
+
|
|
20
30
|
messages.forEach((message) => {
|
|
21
31
|
let bubble: HTMLElement;
|
|
22
32
|
if (message.variant === "reasoning" && message.reasoning) {
|
|
23
33
|
if (!showReasoning) return;
|
|
24
34
|
bubble = createReasoningBubble(message, config);
|
|
35
|
+
} else if (isAskUserQuestionMessage(message)) {
|
|
36
|
+
// No transcript bubble — the overlay sheet is the only question UI.
|
|
37
|
+
if (config?.features?.askUserQuestion?.enabled === false) return;
|
|
38
|
+
if (!message.agentMetadata?.askUserQuestionAnswered) {
|
|
39
|
+
if (message.toolCall?.id) liveAskToolIds.add(message.toolCall.id);
|
|
40
|
+
ensureAskUserQuestionSheet(message, config, composerOverlay ?? null);
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
25
43
|
} else if (message.variant === "tool" && message.toolCall) {
|
|
26
44
|
if (!showToolCalls) return;
|
|
27
45
|
bubble = createToolBubble(message, config);
|
|
@@ -45,6 +63,20 @@ export const renderMessages = (
|
|
|
45
63
|
|
|
46
64
|
container.appendChild(fragment);
|
|
47
65
|
container.scrollTop = container.scrollHeight;
|
|
66
|
+
|
|
67
|
+
// Clean up any orphaned ask_user_question sheets whose source message is no
|
|
68
|
+
// longer in the list (e.g. after clearChat or a message splice).
|
|
69
|
+
if (composerOverlay) {
|
|
70
|
+
const sheets = composerOverlay.querySelectorAll<HTMLElement>(
|
|
71
|
+
'[data-persona-ask-sheet-for]'
|
|
72
|
+
);
|
|
73
|
+
sheets.forEach((sheet) => {
|
|
74
|
+
const id = sheet.getAttribute('data-persona-ask-sheet-for');
|
|
75
|
+
if (id && !liveAskToolIds.has(id)) {
|
|
76
|
+
removeAskUserQuestionSheet(composerOverlay, id);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
48
80
|
};
|
|
49
81
|
|
|
50
82
|
|