@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,155 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { CheckCircle2, XCircle } from "lucide-react";
5
+ import { Button } from "@usereq/ui/components/button";
6
+ import {
7
+ Alert,
8
+ AlertDescription,
9
+ AlertTitle,
10
+ } from "@usereq/ui/components/alert";
11
+ import { cn } from "@usereq/ui/lib/utils";
12
+ import type {
13
+ StopConfirmationDecision,
14
+ StopConfirmationState,
15
+ } from "../stop-confirmation";
16
+
17
+ type ConfirmationContextValue = {
18
+ state: StopConfirmationState;
19
+ approvalId?: string;
20
+ };
21
+
22
+ const ConfirmationContext =
23
+ React.createContext<ConfirmationContextValue | null>(null);
24
+
25
+ function useConfirmationContext() {
26
+ const context = React.useContext(ConfirmationContext);
27
+ if (!context) {
28
+ throw new Error("Confirmation components must be used within <Confirmation />");
29
+ }
30
+ return context;
31
+ }
32
+
33
+ export type ConfirmationProps = {
34
+ approval?: { id: string };
35
+ state?: StopConfirmationState;
36
+ className?: string;
37
+ children?: React.ReactNode;
38
+ };
39
+
40
+ export function Confirmation({
41
+ approval,
42
+ state = "pending",
43
+ className,
44
+ children,
45
+ }: ConfirmationProps): React.ReactElement {
46
+ return (
47
+ <Alert className={cn("gap-2 rounded-2xl border-border/70", className)}>
48
+ <ConfirmationContext.Provider
49
+ value={{ state, approvalId: approval?.id }}
50
+ >
51
+ {children}
52
+ </ConfirmationContext.Provider>
53
+ </Alert>
54
+ );
55
+ }
56
+
57
+ export function ConfirmationTitle({
58
+ className,
59
+ ...props
60
+ }: React.ComponentProps<typeof AlertTitle>): React.ReactElement {
61
+ return (
62
+ <AlertTitle className={cn("text-sm font-semibold", className)} {...props} />
63
+ );
64
+ }
65
+
66
+ export function ConfirmationRequest({
67
+ className,
68
+ ...props
69
+ }: React.ComponentProps<typeof AlertDescription>): React.ReactElement | null {
70
+ const { state } = useConfirmationContext();
71
+ if (state !== "pending") {
72
+ return null;
73
+ }
74
+
75
+ return (
76
+ <AlertDescription
77
+ className={cn("text-sm leading-relaxed", className)}
78
+ {...props}
79
+ />
80
+ );
81
+ }
82
+
83
+ export function ConfirmationAccepted({
84
+ className,
85
+ children,
86
+ }: React.ComponentProps<"div">): React.ReactElement | null {
87
+ const { state } = useConfirmationContext();
88
+ if (state !== "accepted") {
89
+ return null;
90
+ }
91
+
92
+ return (
93
+ <div
94
+ className={cn(
95
+ "flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400",
96
+ className,
97
+ )}
98
+ >
99
+ <CheckCircle2 className="size-4 shrink-0" />
100
+ <span>{children ?? "Conversation stopped."}</span>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ export function ConfirmationRejected({
106
+ className,
107
+ children,
108
+ }: React.ComponentProps<"div">): React.ReactElement | null {
109
+ const { state } = useConfirmationContext();
110
+ if (state !== "rejected") {
111
+ return null;
112
+ }
113
+
114
+ return (
115
+ <div
116
+ className={cn(
117
+ "flex items-center gap-2 text-sm font-medium text-rose-600 dark:text-rose-400",
118
+ className,
119
+ )}
120
+ >
121
+ <XCircle className="size-4 shrink-0" />
122
+ <span>{children ?? "Conversation continues."}</span>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ export function ConfirmationActions({
128
+ className,
129
+ ...props
130
+ }: React.ComponentProps<"div">): React.ReactElement | null {
131
+ const { state } = useConfirmationContext();
132
+ if (state !== "pending") {
133
+ return null;
134
+ }
135
+
136
+ return (
137
+ <div
138
+ className={cn("flex w-full flex-wrap justify-end gap-2 pt-1", className)}
139
+ {...props}
140
+ />
141
+ );
142
+ }
143
+
144
+ export function ConfirmationAction({
145
+ children,
146
+ ...props
147
+ }: React.ComponentProps<typeof Button>): React.ReactElement {
148
+ return (
149
+ <Button {...props}>
150
+ {typeof children === "string" ? children : children}
151
+ </Button>
152
+ );
153
+ }
154
+
155
+ export type ConfirmationDecision = StopConfirmationDecision;
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Button } from "@usereq/ui/components/button";
5
+ import { cn } from "@usereq/ui/lib/utils";
6
+ import { ArrowDown } from "lucide-react";
7
+ import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
8
+
9
+ export type ConversationProps = React.ComponentProps<typeof StickToBottom>;
10
+
11
+ export function Conversation({
12
+ className,
13
+ ...props
14
+ }: ConversationProps): React.ReactElement {
15
+ return (
16
+ <StickToBottom
17
+ className={cn("relative size-full overflow-y-hidden", className)}
18
+ initial="instant"
19
+ resize="smooth"
20
+ role="log"
21
+ {...props}
22
+ />
23
+ );
24
+ }
25
+
26
+ export type ConversationContentProps = React.ComponentProps<
27
+ typeof StickToBottom.Content
28
+ >;
29
+
30
+ export function ConversationContent({
31
+ className,
32
+ children,
33
+ ...props
34
+ }: ConversationContentProps): React.ReactElement {
35
+ return (
36
+ <StickToBottom.Content
37
+ className={cn("flex min-h-full flex-1 flex-col gap-2 p-0", className)}
38
+ {...props}
39
+ >
40
+ {children}
41
+ </StickToBottom.Content>
42
+ );
43
+ }
44
+
45
+ export type ConversationEmptyStateProps = React.ComponentProps<"div"> & {
46
+ icon?: React.ReactNode;
47
+ title?: string;
48
+ description?: string;
49
+ };
50
+
51
+ export function ConversationEmptyState({
52
+ icon,
53
+ title,
54
+ description,
55
+ className,
56
+ children,
57
+ ...props
58
+ }: ConversationEmptyStateProps): React.ReactElement {
59
+ return (
60
+ <div
61
+ className={cn(
62
+ "flex flex-col items-center justify-center gap-3 text-center",
63
+ className,
64
+ )}
65
+ {...props}
66
+ >
67
+ {icon ? <div className="text-muted-foreground">{icon}</div> : null}
68
+ {title ? (
69
+ <p className="text-lg font-semibold leading-tight text-foreground">
70
+ {title}
71
+ </p>
72
+ ) : null}
73
+ {description ? (
74
+ <p className="text-sm text-muted-foreground">{description}</p>
75
+ ) : null}
76
+ {children}
77
+ </div>
78
+ );
79
+ }
80
+
81
+ export type ConversationScrollButtonProps = React.ComponentProps<typeof Button>;
82
+
83
+ export function ConversationScrollButton({
84
+ className,
85
+ children,
86
+ ...props
87
+ }: ConversationScrollButtonProps): React.ReactElement | null {
88
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
89
+
90
+ if (isAtBottom) {
91
+ return null;
92
+ }
93
+
94
+ return (
95
+ <Button
96
+ type="button"
97
+ size="icon"
98
+ variant="outline"
99
+ className={cn(
100
+ "absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full shadow-lg",
101
+ className,
102
+ )}
103
+ onClick={() => scrollToBottom()}
104
+ aria-label="Scroll to bottom"
105
+ {...props}
106
+ >
107
+ {children ?? <ArrowDown className="size-4" />}
108
+ </Button>
109
+ );
110
+ }
@@ -0,0 +1,325 @@
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 { mermaid } from "@streamdown/mermaid";
7
+ import { Streamdown } from "streamdown";
8
+ import "streamdown/styles.css";
9
+ import {
10
+ Confirmation,
11
+ ConfirmationAccepted,
12
+ ConfirmationAction,
13
+ ConfirmationActions,
14
+ ConfirmationRejected,
15
+ ConfirmationRequest,
16
+ ConfirmationTitle,
17
+ } from "./confirmation";
18
+ import {
19
+ DEFAULT_STOP_CONVERSATION_PROMPT,
20
+ parseStopConfirmationEnvelope,
21
+ STOP_CONFIRMATION_KIND,
22
+ type StopConfirmationEnvelope,
23
+ } from "../stop-confirmation";
24
+
25
+ export type MessageRole = "assistant" | "user" | "system";
26
+
27
+ export interface MessageProps {
28
+ children?: React.ReactNode;
29
+ from?: MessageRole;
30
+ role?: MessageRole;
31
+ className?: string;
32
+ }
33
+
34
+ export function Message({
35
+ children,
36
+ from,
37
+ role,
38
+ className,
39
+ }: MessageProps): React.ReactElement {
40
+ const resolvedRole = from ?? role ?? "assistant";
41
+
42
+ return (
43
+ <motion.div
44
+ initial={{ opacity: 0, y: 8 }}
45
+ animate={{ opacity: 1, y: 0 }}
46
+ transition={{ duration: 0.2, ease: "easeOut" }}
47
+ className={cn(
48
+ "w-full flex",
49
+ resolvedRole === "user" ? "justify-end" : "justify-start",
50
+ className,
51
+ )}
52
+ >
53
+ {children}
54
+ </motion.div>
55
+ );
56
+ }
57
+
58
+ export function MessageContent({
59
+ children,
60
+ className,
61
+ ...props
62
+ }: React.HTMLAttributes<HTMLDivElement>): React.ReactElement {
63
+ return (
64
+ <div
65
+ className={cn(
66
+ "rounded-2xl px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap",
67
+ className,
68
+ )}
69
+ {...props}
70
+ >
71
+ {children}
72
+ </div>
73
+ );
74
+ }
75
+
76
+ type StreamdownProps = React.ComponentProps<typeof Streamdown>;
77
+
78
+ export type MessageResponseProps = Omit<StreamdownProps, "children"> & {
79
+ className?: string;
80
+ children?: string;
81
+ onLinkClick?: (linkUrl: string) => void;
82
+ confirmation?: StopConfirmationEnvelope | null;
83
+ confirmationPrompt?: string;
84
+ confirmationActionsDisabled?: boolean;
85
+ onConfirmationDecision?: (
86
+ confirmationId: string,
87
+ accepted: boolean,
88
+ ) => void | Promise<void>;
89
+ };
90
+
91
+ export function MessageResponse({
92
+ children = "",
93
+ className,
94
+ onLinkClick,
95
+ plugins,
96
+ mode = "static",
97
+ linkSafety: linkSafetyProp,
98
+ confirmation,
99
+ confirmationPrompt,
100
+ confirmationActionsDisabled,
101
+ onConfirmationDecision,
102
+ ...props
103
+ }: MessageResponseProps): React.ReactElement {
104
+ const parsedConfirmation =
105
+ confirmation ?? parseStopConfirmationEnvelope(children);
106
+
107
+ if (parsedConfirmation) {
108
+ const confirmationId = parsedConfirmation.confirmationId;
109
+ const state =
110
+ parsedConfirmation.kind === STOP_CONFIRMATION_KIND
111
+ ? parsedConfirmation.state
112
+ : parsedConfirmation.decision === "accepted"
113
+ ? "accepted"
114
+ : "rejected";
115
+ const prompt =
116
+ confirmationPrompt ??
117
+ (parsedConfirmation.kind === STOP_CONFIRMATION_KIND
118
+ ? parsedConfirmation.prompt
119
+ : DEFAULT_STOP_CONVERSATION_PROMPT);
120
+
121
+ return (
122
+ <Confirmation
123
+ approval={{ id: confirmationId }}
124
+ state={state}
125
+ className={cn("w-full", className)}
126
+ >
127
+ <ConfirmationTitle>Stop conversation</ConfirmationTitle>
128
+ {state === "pending" && <ConfirmationRequest>{prompt}</ConfirmationRequest>}
129
+ {state === "accepted" && (
130
+ <ConfirmationAccepted>Conversation stopped.</ConfirmationAccepted>
131
+ )}
132
+ {state === "rejected" && (
133
+ <ConfirmationRejected>Conversation continues.</ConfirmationRejected>
134
+ )}
135
+ {state === "pending" && onConfirmationDecision && (
136
+ <ConfirmationActions className="pt-2">
137
+ <ConfirmationAction
138
+ variant="outline"
139
+ disabled={confirmationActionsDisabled}
140
+ onClick={() => void onConfirmationDecision(confirmationId, false)}
141
+ >
142
+ No
143
+ </ConfirmationAction>
144
+ <ConfirmationAction
145
+ variant="default"
146
+ disabled={confirmationActionsDisabled}
147
+ onClick={() => void onConfirmationDecision(confirmationId, true)}
148
+ >
149
+ Yes
150
+ </ConfirmationAction>
151
+ </ConfirmationActions>
152
+ )}
153
+ </Confirmation>
154
+ );
155
+ }
156
+
157
+ const lastTrackedLinkRef = React.useRef<{
158
+ href: string;
159
+ at: number;
160
+ } | null>(null);
161
+
162
+ const emitLinkClick = React.useCallback(
163
+ (href: string | null | undefined) => {
164
+ const normalizedHref = href?.trim();
165
+ if (!normalizedHref) return;
166
+
167
+ const now = Date.now();
168
+ const lastTrackedLink = lastTrackedLinkRef.current;
169
+ if (
170
+ lastTrackedLink &&
171
+ lastTrackedLink.href === normalizedHref &&
172
+ now - lastTrackedLink.at < 250
173
+ ) {
174
+ return;
175
+ }
176
+
177
+ lastTrackedLinkRef.current = {
178
+ href: normalizedHref,
179
+ at: now,
180
+ };
181
+ onLinkClick?.(normalizedHref);
182
+ },
183
+ [onLinkClick],
184
+ );
185
+
186
+ const resolveAnchorFromTarget = React.useCallback((target: EventTarget | null) => {
187
+ if (target instanceof HTMLAnchorElement) {
188
+ return target;
189
+ }
190
+
191
+ if (target instanceof Element) {
192
+ const anchor = target.closest("a[href]");
193
+ return anchor instanceof HTMLAnchorElement ? anchor : null;
194
+ }
195
+
196
+ if (target instanceof Node) {
197
+ const anchor = target.parentElement?.closest("a[href]");
198
+ return anchor instanceof HTMLAnchorElement ? anchor : null;
199
+ }
200
+
201
+ return null;
202
+ }, []);
203
+
204
+ const handleClickCapture = React.useCallback(
205
+ (event: React.MouseEvent<HTMLDivElement>) => {
206
+ if (!onLinkClick) return;
207
+ const composedPath = event.nativeEvent.composedPath?.() ?? [];
208
+ const pathAnchor = composedPath.find(
209
+ (entry) =>
210
+ entry instanceof HTMLAnchorElement && entry.href?.trim().length > 0,
211
+ );
212
+ const anchor =
213
+ (pathAnchor instanceof HTMLAnchorElement ? pathAnchor : null) ??
214
+ resolveAnchorFromTarget(event.target);
215
+ if (!anchor) return;
216
+ emitLinkClick(anchor.href);
217
+ },
218
+ [emitLinkClick, onLinkClick, resolveAnchorFromTarget],
219
+ );
220
+
221
+ const handleLinkSafetyCheck = React.useCallback(
222
+ async (url: string): Promise<boolean> => {
223
+ const href = url?.trim();
224
+ if (linkSafetyProp?.onLinkCheck) {
225
+ const shouldOpenDirectly = await linkSafetyProp.onLinkCheck(url);
226
+ if (!shouldOpenDirectly && href) {
227
+ emitLinkClick(href);
228
+ }
229
+ return shouldOpenDirectly;
230
+ }
231
+
232
+ if (href) {
233
+ emitLinkClick(href);
234
+ }
235
+ return false;
236
+ },
237
+ [emitLinkClick, linkSafetyProp],
238
+ );
239
+
240
+ const resolvedLinkSafety = React.useMemo(() => {
241
+ if (!onLinkClick) {
242
+ return linkSafetyProp;
243
+ }
244
+
245
+ if (linkSafetyProp?.enabled === false) {
246
+ return linkSafetyProp;
247
+ }
248
+
249
+ return {
250
+ ...(linkSafetyProp ?? {}),
251
+ enabled: linkSafetyProp?.enabled ?? true,
252
+ onLinkCheck: handleLinkSafetyCheck,
253
+ };
254
+ }, [handleLinkSafetyCheck, linkSafetyProp, onLinkClick]);
255
+
256
+ return (
257
+ <div onClickCapture={handleClickCapture}>
258
+ <Streamdown
259
+ {...props}
260
+ mode={mode}
261
+ linkSafety={resolvedLinkSafety}
262
+ plugins={{ mermaid, ...plugins }}
263
+ className={cn(
264
+ "text-sm leading-relaxed whitespace-normal",
265
+ // "[&_[data-streamdown='table-wrapper']]:my-0",
266
+ // "[&_[data-streamdown='table-wrapper']]:w-fit",
267
+ // "[&_[data-streamdown='table-wrapper']]:max-w-full",
268
+ // "[&_[data-streamdown='table-wrapper']]:gap-1",
269
+ // "[&_[data-streamdown='table-wrapper']]:p-1",
270
+ // "[&_[data-streamdown='table-wrapper']]:rounded-xl",
271
+ className,
272
+ )}
273
+ >
274
+ {children}
275
+ </Streamdown>
276
+ </div>
277
+ );
278
+ }
279
+
280
+ export type AssistantMessageRevealSource = {
281
+ id: string;
282
+ content: string;
283
+ };
284
+
285
+ export type AssistantMessageRevealStep<
286
+ T extends AssistantMessageRevealSource = AssistantMessageRevealSource,
287
+ > = {
288
+ message: T;
289
+ delayMs: number;
290
+ };
291
+
292
+ export function buildAssistantMessageRevealSteps<
293
+ T extends AssistantMessageRevealSource,
294
+ >(
295
+ messages: T[],
296
+ options?: {
297
+ initialDelayMs?: number;
298
+ typingHoldMs?: number;
299
+ interMessageDelayMs?: number;
300
+ interMessageDelayJitterMs?: number;
301
+ },
302
+ ): AssistantMessageRevealStep<T>[] {
303
+ if (messages.length === 0) {
304
+ return [];
305
+ }
306
+
307
+ const initialDelayMs = options?.initialDelayMs ?? 0;
308
+ const typingHoldMs = options?.typingHoldMs ?? 1260;
309
+ const interMessageDelayMs = options?.interMessageDelayMs ?? 1520;
310
+ const jitterMs = Math.max(0, options?.interMessageDelayJitterMs ?? 100);
311
+
312
+ return messages.map((message, index) => {
313
+ const baseDelay =
314
+ index === 0 ? initialDelayMs + typingHoldMs : interMessageDelayMs;
315
+ const jitter =
316
+ index === 0 || jitterMs === 0
317
+ ? 0
318
+ : Math.round((Math.random() * 2 - 1) * jitterMs);
319
+
320
+ return {
321
+ message,
322
+ delayMs: Math.max(0, baseDelay + jitter),
323
+ };
324
+ });
325
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./components/chat-widget-frame";
2
+ export * from "./components/chat-widget-primitives";
3
+ export * from "./components/confirmation";
4
+ export * from "./components/conversation";
5
+ export * from "./components/message";
6
+ export * from "./stop-confirmation";
7
+ export * from "./styles/chat-widget-bubble";
8
+ export * from "./styles/chat-widget-box";
9
+ export * from "./styles/chat-widget-chatbar";