@usereq/widget 0.1.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.
Files changed (39) hide show
  1. package/README.md +166 -0
  2. package/dist/widget.css +2 -0
  3. package/dist/widget.js +3569 -0
  4. package/package.json +98 -0
  5. package/src/chat-widget/chat-widget-appearance.ts +55 -0
  6. package/src/chat-widget/chat-widget-defaults.tsx +45 -0
  7. package/src/chat-widget/components/chat-widget-frame.tsx +56 -0
  8. package/src/chat-widget/components/chat-widget-layout.ts +6 -0
  9. package/src/chat-widget/components/chat-widget-primitives.tsx +720 -0
  10. package/src/chat-widget/components/confirmation.tsx +155 -0
  11. package/src/chat-widget/components/conversation.tsx +110 -0
  12. package/src/chat-widget/components/message.tsx +325 -0
  13. package/src/chat-widget/index.ts +9 -0
  14. package/src/chat-widget/stop-confirmation.ts +288 -0
  15. package/src/chat-widget/styles/chat-widget-box.tsx +258 -0
  16. package/src/chat-widget/styles/chat-widget-bubble.tsx +238 -0
  17. package/src/chat-widget/styles/chat-widget-chatbar.tsx +248 -0
  18. package/src/custom-element/agent-widget-element.tsx +584 -0
  19. package/src/embed.ts +8 -0
  20. package/src/index.ts +10 -0
  21. package/src/register.ts +15 -0
  22. package/src/renderer/index.ts +6 -0
  23. package/src/renderer/widget-runtime.tsx +205 -0
  24. package/src/runtime/analytics.ts +327 -0
  25. package/src/runtime/api-origin.ts +29 -0
  26. package/src/runtime/api.ts +151 -0
  27. package/src/runtime/bootstrap.ts +309 -0
  28. package/src/runtime/messages.ts +12 -0
  29. package/src/runtime/session-storage.ts +32 -0
  30. package/src/runtime/token-renewal.ts +13 -0
  31. package/src/runtime/trigger-rule.ts +182 -0
  32. package/src/shared/analytics.ts +54 -0
  33. package/src/shared/shadow-css.ts +15 -0
  34. package/src/shared/shadow-theme.ts +85 -0
  35. package/src/shared/stop-confirmation.ts +20 -0
  36. package/src/shared/widget-config.ts +104 -0
  37. package/src/styles/widget.css.ts +27 -0
  38. package/src/types.ts +85 -0
  39. package/src/vite-env.d.ts +4 -0
@@ -0,0 +1,288 @@
1
+ export const STOP_CONFIRMATION_KIND = "stop_confirmation" as const;
2
+ export const STOP_CONFIRMATION_RESULT_KIND =
3
+ "stop_confirmation_result" as const;
4
+
5
+ export const DEFAULT_STOP_CONVERSATION_PROMPT =
6
+ "Do you want to stop this conversation?";
7
+
8
+ export type StopConfirmationState = "pending" | "accepted" | "rejected";
9
+ export type StopConfirmationDecision = "accepted" | "rejected";
10
+
11
+ export type StopConfirmationPromptEnvelope = {
12
+ kind: typeof STOP_CONFIRMATION_KIND;
13
+ confirmationId: string;
14
+ state: StopConfirmationState;
15
+ prompt: string;
16
+ };
17
+
18
+ export type StopConfirmationResultEnvelope = {
19
+ kind: typeof STOP_CONFIRMATION_RESULT_KIND;
20
+ confirmationId: string;
21
+ decision: StopConfirmationDecision;
22
+ };
23
+
24
+ export type StopConfirmationEnvelope =
25
+ | StopConfirmationPromptEnvelope
26
+ | StopConfirmationResultEnvelope;
27
+
28
+ export type StopConfirmationMessageLike = {
29
+ id: string;
30
+ content: string;
31
+ createdAt?: string;
32
+ };
33
+
34
+ export type StopConfirmationRenderItem<T extends StopConfirmationMessageLike> =
35
+ | {
36
+ type: "message";
37
+ message: T;
38
+ }
39
+ | {
40
+ type: "confirmation";
41
+ message: T;
42
+ envelope: StopConfirmationPromptEnvelope;
43
+ latestEnvelope: StopConfirmationEnvelope;
44
+ };
45
+
46
+ function isRecord(value: unknown): value is Record<string, unknown> {
47
+ return typeof value === "object" && value !== null && !Array.isArray(value);
48
+ }
49
+
50
+ function normalizeState(value: unknown): StopConfirmationState {
51
+ return value === "accepted" || value === "rejected" ? value : "pending";
52
+ }
53
+
54
+ function normalizeDecision(value: unknown): StopConfirmationDecision | null {
55
+ return value === "accepted" || value === "rejected" ? value : null;
56
+ }
57
+
58
+ function normalizePrompt(value: unknown): string {
59
+ if (typeof value !== "string") {
60
+ return DEFAULT_STOP_CONVERSATION_PROMPT;
61
+ }
62
+ const trimmed = value.trim();
63
+ return trimmed.length > 0 ? trimmed : DEFAULT_STOP_CONVERSATION_PROMPT;
64
+ }
65
+
66
+ export function createStopConfirmationPromptEnvelope(input: {
67
+ confirmationId: string;
68
+ prompt?: string;
69
+ state?: StopConfirmationState;
70
+ }): StopConfirmationPromptEnvelope {
71
+ return {
72
+ kind: STOP_CONFIRMATION_KIND,
73
+ confirmationId: input.confirmationId,
74
+ state: normalizeState(input.state),
75
+ prompt: normalizePrompt(input.prompt),
76
+ };
77
+ }
78
+
79
+ export function createStopConfirmationResultEnvelope(input: {
80
+ confirmationId: string;
81
+ decision: StopConfirmationDecision;
82
+ }): StopConfirmationResultEnvelope {
83
+ return {
84
+ kind: STOP_CONFIRMATION_RESULT_KIND,
85
+ confirmationId: input.confirmationId,
86
+ decision: input.decision,
87
+ };
88
+ }
89
+
90
+ export function encodeStopConfirmationEnvelope(
91
+ envelope: StopConfirmationEnvelope,
92
+ ): string {
93
+ return JSON.stringify(envelope);
94
+ }
95
+
96
+ export function encodeStopConfirmationPrompt(input: {
97
+ confirmationId: string;
98
+ prompt?: string;
99
+ state?: StopConfirmationState;
100
+ }): string {
101
+ return encodeStopConfirmationEnvelope(
102
+ createStopConfirmationPromptEnvelope(input),
103
+ );
104
+ }
105
+
106
+ export function encodeStopConfirmationResult(input: {
107
+ confirmationId: string;
108
+ decision: StopConfirmationDecision;
109
+ }): string {
110
+ return encodeStopConfirmationEnvelope(
111
+ createStopConfirmationResultEnvelope(input),
112
+ );
113
+ }
114
+
115
+ export function parseStopConfirmationEnvelope(
116
+ content: string | null | undefined,
117
+ ): StopConfirmationEnvelope | null {
118
+ if (typeof content !== "string" || content.trim() === "") {
119
+ return null;
120
+ }
121
+
122
+ let parsed: unknown;
123
+ try {
124
+ parsed = JSON.parse(content);
125
+ } catch {
126
+ return null;
127
+ }
128
+
129
+ if (!isRecord(parsed) || typeof parsed.kind !== "string") {
130
+ return null;
131
+ }
132
+
133
+ if (parsed.kind === STOP_CONFIRMATION_KIND) {
134
+ if (typeof parsed.confirmationId !== "string" || !parsed.confirmationId.trim()) {
135
+ return null;
136
+ }
137
+
138
+ return {
139
+ kind: STOP_CONFIRMATION_KIND,
140
+ confirmationId: parsed.confirmationId,
141
+ state: normalizeState(parsed.state),
142
+ prompt: normalizePrompt(parsed.prompt),
143
+ };
144
+ }
145
+
146
+ if (parsed.kind === STOP_CONFIRMATION_RESULT_KIND) {
147
+ const decision = normalizeDecision(parsed.decision);
148
+ if (
149
+ typeof parsed.confirmationId !== "string" ||
150
+ !parsed.confirmationId.trim() ||
151
+ decision == null
152
+ ) {
153
+ return null;
154
+ }
155
+
156
+ return {
157
+ kind: STOP_CONFIRMATION_RESULT_KIND,
158
+ confirmationId: parsed.confirmationId,
159
+ decision,
160
+ };
161
+ }
162
+
163
+ return null;
164
+ }
165
+
166
+ export function foldStopConfirmationMessages<T extends StopConfirmationMessageLike>(
167
+ messages: T[],
168
+ ): StopConfirmationRenderItem<T>[] {
169
+ const byConfirmationId = new Map<
170
+ string,
171
+ {
172
+ firstIndex: number;
173
+ latestIndex: number;
174
+ latestEnvelope: StopConfirmationEnvelope;
175
+ }
176
+ >();
177
+
178
+ messages.forEach((message, index) => {
179
+ const envelope = parseStopConfirmationEnvelope(message.content);
180
+ if (!envelope) {
181
+ return;
182
+ }
183
+
184
+ const entry = byConfirmationId.get(envelope.confirmationId);
185
+ const sortValue = safeCreatedAtValue(message.createdAt, index);
186
+ if (!entry) {
187
+ byConfirmationId.set(envelope.confirmationId, {
188
+ firstIndex: index,
189
+ latestIndex: index,
190
+ latestEnvelope: envelope,
191
+ });
192
+ return;
193
+ }
194
+
195
+ const currentSortValue = safeCreatedAtValue(
196
+ messages[entry.latestIndex]?.createdAt,
197
+ entry.latestIndex,
198
+ );
199
+
200
+ if (sortValue >= currentSortValue) {
201
+ byConfirmationId.set(envelope.confirmationId, {
202
+ firstIndex: entry.firstIndex,
203
+ latestIndex: index,
204
+ latestEnvelope: envelope,
205
+ });
206
+ }
207
+ });
208
+
209
+ const renderItems: StopConfirmationRenderItem<T>[] = [];
210
+
211
+ messages.forEach((message, index) => {
212
+ const envelope = parseStopConfirmationEnvelope(message.content);
213
+ if (!envelope) {
214
+ renderItems.push({ type: "message", message });
215
+ return;
216
+ }
217
+
218
+ const entry = byConfirmationId.get(envelope.confirmationId);
219
+ if (!entry || entry.firstIndex !== index) {
220
+ return;
221
+ }
222
+
223
+ const promptEnvelope =
224
+ entry.latestEnvelope.kind === STOP_CONFIRMATION_KIND
225
+ ? entry.latestEnvelope
226
+ : createStopConfirmationPromptEnvelope({
227
+ confirmationId: entry.latestEnvelope.confirmationId,
228
+ prompt: DEFAULT_STOP_CONVERSATION_PROMPT,
229
+ });
230
+
231
+ renderItems.push({
232
+ type: "confirmation",
233
+ message,
234
+ envelope: promptEnvelope,
235
+ latestEnvelope: entry.latestEnvelope,
236
+ });
237
+ });
238
+
239
+ return renderItems;
240
+ }
241
+
242
+ export function getPendingStopConfirmationId<T extends StopConfirmationMessageLike>(
243
+ messages: T[],
244
+ ): string | null {
245
+ const latestByConfirmationId = new Map<
246
+ string,
247
+ {
248
+ sortValue: number;
249
+ envelope: StopConfirmationEnvelope;
250
+ }
251
+ >();
252
+
253
+ messages.forEach((message, index) => {
254
+ const envelope = parseStopConfirmationEnvelope(message.content);
255
+ if (!envelope) {
256
+ return;
257
+ }
258
+
259
+ const sortValue = safeCreatedAtValue(message.createdAt, index);
260
+ const current = latestByConfirmationId.get(envelope.confirmationId);
261
+ if (!current || sortValue >= current.sortValue) {
262
+ latestByConfirmationId.set(envelope.confirmationId, {
263
+ sortValue,
264
+ envelope,
265
+ });
266
+ }
267
+ });
268
+
269
+ for (const [confirmationId, entry] of latestByConfirmationId.entries()) {
270
+ if (
271
+ entry.envelope.kind === STOP_CONFIRMATION_KIND &&
272
+ entry.envelope.state === "pending"
273
+ ) {
274
+ return confirmationId;
275
+ }
276
+ }
277
+
278
+ return null;
279
+ }
280
+
281
+ function safeCreatedAtValue(value: string | undefined, fallbackIndex: number): number {
282
+ if (typeof value !== "string") {
283
+ return fallbackIndex;
284
+ }
285
+
286
+ const timestamp = Date.parse(value);
287
+ return Number.isNaN(timestamp) ? fallbackIndex : timestamp;
288
+ }
@@ -0,0 +1,258 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { motion } from "motion/react";
5
+ import { cn } from "@usereq/ui/lib/utils";
6
+ import {
7
+ Avatar,
8
+ AvatarFallback,
9
+ AvatarImage,
10
+ } from "@usereq/ui/components/avatar";
11
+ import { Button } from "@usereq/ui/components/button";
12
+ import { ChevronDown } from "lucide-react";
13
+ import {
14
+ getChatWidgetAvatarStyle,
15
+ getChatWidgetGradientStyle,
16
+ } from "../chat-widget-appearance";
17
+ import {
18
+ DEFAULT_AGENT_NAME_BOX,
19
+ DEFAULT_POWERED_BY,
20
+ DEFAULT_SPOTLIGHT_MESSAGES,
21
+ DEFAULT_START_CHAT_LABEL,
22
+ } from "../chat-widget-defaults";
23
+ import {
24
+ ChatWidgetComposer,
25
+ ChatWidgetHeader,
26
+ ChatWidgetMessages,
27
+ ChatWidgetPanel,
28
+ ChatWidgetPoweredBy,
29
+ useIsChatWidgetFullscreenViewport,
30
+ useSpotlightTyping,
31
+ useStableSpotlightMessages,
32
+ type ChatWidgetDataProps,
33
+ } from "../components/chat-widget-primitives";
34
+
35
+ const PANEL_WIDTH = 360;
36
+ const PANEL_GAP = 12;
37
+ const LAUNCHER_CARD_WIDTH = 280;
38
+ const LAUNCHER_CARD_HEIGHT = 120;
39
+ /** When panel is open, launcher is just the close button (square). */
40
+ const LAUNCHER_BUTTON_SIZE = 56;
41
+
42
+ const panelTransition = {
43
+ type: "spring" as const,
44
+ stiffness: 400,
45
+ damping: 35,
46
+ };
47
+
48
+ /**
49
+ * ChatWidgetBox — closed: intro message + Start chat button; open: same panel as Bubble + chevron to close.
50
+ */
51
+ export function ChatWidgetBox({
52
+ agentName = DEFAULT_AGENT_NAME_BOX,
53
+ agentAvatarUrl,
54
+ appearance,
55
+ spotlightMessages = [],
56
+ messages = [],
57
+ poweredBy = DEFAULT_POWERED_BY,
58
+ headerStatus = "Online",
59
+ errorMessage,
60
+ status = "idle",
61
+ sendDisabled = false,
62
+ startChatLabel = DEFAULT_START_CHAT_LABEL,
63
+ onSendMessage,
64
+ isTyping,
65
+ onSuggestion,
66
+ onLauncherClick,
67
+ className,
68
+ open,
69
+ defaultOpen = false,
70
+ onOpenChange,
71
+ previewOnly = false,
72
+ onStopConversation,
73
+ stopDisabled,
74
+ onConfirmationDecision,
75
+ onLinkClick,
76
+ emptyAction,
77
+ portalContainer,
78
+ }: ChatWidgetDataProps & {
79
+ /** Button label on closed card */
80
+ startChatLabel?: string;
81
+ open?: boolean;
82
+ className?: string;
83
+ defaultOpen?: boolean;
84
+ onOpenChange?: (open: boolean) => void;
85
+ /** When true, launcher only (no open state); for layout editor. */
86
+ previewOnly?: boolean;
87
+ portalContainer?: React.ComponentProps<
88
+ typeof ChatWidgetPanel
89
+ >["portalContainer"];
90
+ }) {
91
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
92
+ previewOnly ? false : defaultOpen,
93
+ );
94
+ const [draftValue, setDraftValue] = React.useState("");
95
+ const effectiveOpen = previewOnly ? false : (open ?? uncontrolledOpen);
96
+ const handleOpenChange = React.useCallback(
97
+ (nextOpen: boolean) => {
98
+ if (onOpenChange) {
99
+ onOpenChange(nextOpen);
100
+ return;
101
+ }
102
+ setUncontrolledOpen(nextOpen);
103
+ },
104
+ [onOpenChange],
105
+ );
106
+ const stableSpotlightMessages = useStableSpotlightMessages(spotlightMessages);
107
+ const spotlightForTyping =
108
+ stableSpotlightMessages.length > 0
109
+ ? stableSpotlightMessages
110
+ : [...DEFAULT_SPOTLIGHT_MESSAGES];
111
+ const introTextTyping = useSpotlightTyping(spotlightForTyping);
112
+ const introText = introTextTyping || spotlightForTyping[0] || DEFAULT_SPOTLIGHT_MESSAGES[0];
113
+
114
+ const launcherHeight = effectiveOpen
115
+ ? LAUNCHER_BUTTON_SIZE
116
+ : LAUNCHER_CARD_HEIGHT;
117
+ const rootWidth = effectiveOpen ? LAUNCHER_BUTTON_SIZE : LAUNCHER_CARD_WIDTH;
118
+ const isFullscreenViewport = useIsChatWidgetFullscreenViewport();
119
+ const hideLauncher = isFullscreenViewport && effectiveOpen;
120
+ const closeButtonStyle: React.CSSProperties = {
121
+ width: LAUNCHER_BUTTON_SIZE,
122
+ height: LAUNCHER_BUTTON_SIZE,
123
+ ...(appearance ? getChatWidgetGradientStyle(appearance) : {}),
124
+ pointerEvents: effectiveOpen ? "auto" : "none",
125
+ };
126
+
127
+ return (
128
+ <div
129
+ className={cn("relative", className)}
130
+ style={{ width: rootWidth, height: launcherHeight }}
131
+ >
132
+ {/* Chat panel: full-screen on mobile, floating on desktop (hidden when previewOnly) */}
133
+ {!previewOnly && (
134
+ <ChatWidgetPanel
135
+ open={effectiveOpen}
136
+ onOpenChange={handleOpenChange}
137
+ portalContainer={portalContainer}
138
+ desktopClassName="absolute right-0 overflow-hidden rounded-2xl border bg-background shadow-xl"
139
+ desktopStyle={{
140
+ width: PANEL_WIDTH,
141
+ bottom: launcherHeight + PANEL_GAP,
142
+ }}
143
+ >
144
+ <div className="flex shrink-0 border-b p-4">
145
+ <ChatWidgetHeader
146
+ agentName={agentName}
147
+ agentAvatarUrl={agentAvatarUrl}
148
+ appearance={appearance}
149
+ statusText={headerStatus}
150
+ onStopConversation={onStopConversation ?? undefined}
151
+ stopDisabled={stopDisabled}
152
+ onClose={() => handleOpenChange(false)}
153
+ />
154
+ </div>
155
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden p-4 md:max-h-90">
156
+ <ChatWidgetMessages
157
+ messages={messages}
158
+ isTyping={isTyping ?? status === "sending"}
159
+ status={status}
160
+ errorMessage={errorMessage}
161
+ appearance={appearance}
162
+ onConfirmationDecision={onConfirmationDecision}
163
+ onLinkClick={onLinkClick}
164
+ onSuggestion={(value: string) => {
165
+ setDraftValue(value);
166
+ onSuggestion?.(value);
167
+ }}
168
+ emptyAction={emptyAction}
169
+ />
170
+ </div>
171
+ {!(emptyAction && messages.length === 0 && !isTyping) && (
172
+ <div className="border-t p-3 shrink-0">
173
+ <ChatWidgetComposer
174
+ value={draftValue}
175
+ onChange={setDraftValue}
176
+ onSubmit={onSendMessage}
177
+ appearance={appearance}
178
+ disabled={sendDisabled || status === "booting"}
179
+ />
180
+ <ChatWidgetPoweredBy text={poweredBy} className="mt-2" />
181
+ </div>
182
+ )}
183
+ </ChatWidgetPanel>
184
+ )}
185
+
186
+ {/* Closed: intro message + Start chat button */}
187
+ {!effectiveOpen && (
188
+ <motion.div
189
+ className="absolute bottom-0 right-0 flex flex-col gap-4 rounded-2xl border bg-background p-4 shadow-xl"
190
+ style={{ width: LAUNCHER_CARD_WIDTH, height: LAUNCHER_CARD_HEIGHT }}
191
+ initial={false}
192
+ animate={{ opacity: 1 }}
193
+ >
194
+ <div className="flex flex-1 items-start gap-2">
195
+ <Avatar
196
+ className="size-9 shrink-0"
197
+ style={
198
+ appearance ? getChatWidgetAvatarStyle(appearance) : undefined
199
+ }
200
+ >
201
+ {agentAvatarUrl ? (
202
+ <AvatarImage src={agentAvatarUrl} alt={agentName} />
203
+ ) : null}
204
+ <AvatarFallback className="bg-transparent text-white text-xs">
205
+ {agentName.slice(0, 2).toUpperCase()}
206
+ </AvatarFallback>
207
+ </Avatar>
208
+ {introText ? (
209
+ <p className="min-w-0 flex-1 text-sm leading-snug text-foreground">
210
+ {introText}
211
+ </p>
212
+ ) : null}
213
+ </div>
214
+ <Button
215
+ className={cn(
216
+ "w-full rounded-full",
217
+ previewOnly && "pointer-events-none",
218
+ )}
219
+ onClick={
220
+ previewOnly
221
+ ? undefined
222
+ : () => {
223
+ onLauncherClick?.();
224
+ handleOpenChange(true);
225
+ }
226
+ }
227
+ style={
228
+ appearance ? getChatWidgetGradientStyle(appearance) : undefined
229
+ }
230
+ >
231
+ {appearance?.actionText?.trim() || startChatLabel}
232
+ </Button>
233
+ </motion.div>
234
+ )}
235
+
236
+ {/* Open: square close button (matches launcher width/height when open) */}
237
+ {!previewOnly && !hideLauncher && (
238
+ <motion.button
239
+ type="button"
240
+ onClick={() => handleOpenChange(false)}
241
+ className="absolute bottom-0 right-0 flex items-center justify-center rounded-full text-primary-foreground shadow-lg"
242
+ style={closeButtonStyle as any}
243
+ aria-label="Close chat"
244
+ initial={false}
245
+ animate={{
246
+ opacity: effectiveOpen ? 1 : 0,
247
+ visibility: effectiveOpen ? "visible" : "hidden",
248
+ }}
249
+ transition={panelTransition}
250
+ whileHover={effectiveOpen ? { scale: 1.02 } : undefined}
251
+ whileTap={effectiveOpen ? { scale: 0.98 } : undefined}
252
+ >
253
+ <ChevronDown className="size-6" />
254
+ </motion.button>
255
+ )}
256
+ </div>
257
+ );
258
+ }