@frankieai/react 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.
package/dist/index.js ADDED
@@ -0,0 +1,1003 @@
1
+ "use client";
2
+
3
+ // src/hooks/FrankieProvider.tsx
4
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
5
+
6
+ // src/lib/client.ts
7
+ var FRANKIE_CLOUD_URL = "https://frankie-data-gq5o5syq2q-uc.a.run.app";
8
+ var FrankieApiError = class extends Error {
9
+ constructor(message, status, code) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ }
14
+ status;
15
+ code;
16
+ };
17
+ var FrankieClient = class {
18
+ constructor(options) {
19
+ this.options = options;
20
+ this.baseUrl = (options.baseUrl ?? FRANKIE_CLOUD_URL).replace(/\/+$/, "");
21
+ this.doFetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
22
+ this.session = this.readStored();
23
+ }
24
+ options;
25
+ baseUrl;
26
+ doFetch;
27
+ session;
28
+ get sessionId() {
29
+ return this.session?.sessionId;
30
+ }
31
+ /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
32
+ get persist() {
33
+ return this.options.persist !== false && typeof window !== "undefined";
34
+ }
35
+ /** localStorage namespace for this tenant, shared with the chat hook. */
36
+ get namespace() {
37
+ return `frankie.${this.options.publishableKey}`;
38
+ }
39
+ readStored() {
40
+ if (!this.persist) return void 0;
41
+ try {
42
+ const raw = window.localStorage.getItem(`${this.namespace}.session`);
43
+ if (!raw) return void 0;
44
+ const session = JSON.parse(raw);
45
+ return new Date(session.expiresAt).getTime() > Date.now() + 3e4 ? session : void 0;
46
+ } catch {
47
+ return void 0;
48
+ }
49
+ }
50
+ writeStored() {
51
+ if (!this.persist) return;
52
+ try {
53
+ if (this.session) window.localStorage.setItem(`${this.namespace}.session`, JSON.stringify(this.session));
54
+ else window.localStorage.removeItem(`${this.namespace}.session`);
55
+ } catch {
56
+ }
57
+ }
58
+ async getConfig(signal) {
59
+ const response = await this.doFetch(`${this.baseUrl}/v1/config`, {
60
+ headers: { "x-frankie-key": this.options.publishableKey },
61
+ ...signal ? { signal } : {}
62
+ });
63
+ return await this.unwrap(response);
64
+ }
65
+ /** Opens (or reuses) a chat session. */
66
+ async ensureSession(context) {
67
+ if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) {
68
+ return this.session;
69
+ }
70
+ const userToken = await this.options.getUserToken?.();
71
+ const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
74
+ body: JSON.stringify({ ...userToken ? { userToken } : {}, ...context ? { context } : {} })
75
+ });
76
+ this.session = await this.unwrap(response);
77
+ this.writeStored();
78
+ return this.session;
79
+ }
80
+ /** Forget the current session so the next send starts a fresh conversation. */
81
+ resetSession() {
82
+ this.session = void 0;
83
+ this.writeStored();
84
+ }
85
+ /**
86
+ * Sends a message and invokes `onEvent` for each server event as it arrives.
87
+ * Resolves when the turn ends.
88
+ */
89
+ async send(input, onEvent) {
90
+ const session = await this.ensureSession(input.context);
91
+ const body = {
92
+ sessionId: session.sessionId,
93
+ message: input.message,
94
+ ...input.context ? { context: input.context } : {}
95
+ };
96
+ const response = await this.doFetch(`${this.baseUrl}/v1/chat`, {
97
+ method: "POST",
98
+ headers: {
99
+ "content-type": "application/json",
100
+ "x-frankie-key": this.options.publishableKey,
101
+ authorization: `Bearer ${session.sessionToken}`
102
+ },
103
+ body: JSON.stringify(body),
104
+ ...input.signal ? { signal: input.signal } : {}
105
+ });
106
+ if (!response.ok || !response.body) {
107
+ if (response.status === 401 || response.status === 404) this.resetSession();
108
+ await this.unwrap(response);
109
+ return;
110
+ }
111
+ for await (const event of readEventStream(response.body)) {
112
+ onEvent(event);
113
+ }
114
+ }
115
+ async unwrap(response) {
116
+ if (response.ok) return response.json();
117
+ let code = "internal";
118
+ let message = `Request failed with status ${response.status}.`;
119
+ try {
120
+ const body = await response.json();
121
+ code = body.error?.code ?? code;
122
+ message = body.error?.message ?? message;
123
+ } catch {
124
+ }
125
+ throw new FrankieApiError(message, response.status, code);
126
+ }
127
+ };
128
+ async function* readEventStream(body) {
129
+ const reader = body.getReader();
130
+ const decoder = new TextDecoder();
131
+ let buffer = "";
132
+ try {
133
+ for (; ; ) {
134
+ const { done, value } = await reader.read();
135
+ if (done) break;
136
+ buffer += decoder.decode(value, { stream: true });
137
+ let split = buffer.indexOf("\n\n");
138
+ while (split !== -1) {
139
+ const frame = buffer.slice(0, split);
140
+ buffer = buffer.slice(split + 2);
141
+ const event = parseFrame(frame);
142
+ if (event) yield event;
143
+ split = buffer.indexOf("\n\n");
144
+ }
145
+ }
146
+ } finally {
147
+ reader.releaseLock();
148
+ }
149
+ }
150
+ function parseFrame(frame) {
151
+ const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
152
+ if (!data) return null;
153
+ try {
154
+ return JSON.parse(data);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // src/hooks/FrankieProvider.tsx
161
+ import { jsx } from "react/jsx-runtime";
162
+ var FrankieContext = createContext(null);
163
+ function FrankieProvider({
164
+ children,
165
+ context,
166
+ theme,
167
+ config: providedConfig,
168
+ ...clientOptions
169
+ }) {
170
+ const client = useMemo(
171
+ () => new FrankieClient(clientOptions),
172
+ // The client holds session state; rebuild it only when the endpoint changes.
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
174
+ [clientOptions.baseUrl, clientOptions.publishableKey]
175
+ );
176
+ const [config, setConfig] = useState(providedConfig);
177
+ const [configError, setConfigError] = useState(void 0);
178
+ const [loading, setLoading] = useState(!providedConfig);
179
+ useEffect(() => {
180
+ if (providedConfig) return;
181
+ const abort = new AbortController();
182
+ setLoading(true);
183
+ client.getConfig(abort.signal).then((next) => {
184
+ setConfig(next);
185
+ setConfigError(void 0);
186
+ }).catch((error) => {
187
+ if (abort.signal.aborted) return;
188
+ setConfigError(error instanceof Error ? error.message : "Could not load the assistant.");
189
+ }).finally(() => {
190
+ if (!abort.signal.aborted) setLoading(false);
191
+ });
192
+ return () => abort.abort();
193
+ }, [client, providedConfig]);
194
+ const value = useMemo(
195
+ () => ({
196
+ client,
197
+ ...config ? { config } : {},
198
+ ...configError ? { configError } : {},
199
+ loading,
200
+ ...context ? { context } : {},
201
+ ...theme ? { theme } : {}
202
+ }),
203
+ [client, config, configError, loading, context, theme]
204
+ );
205
+ return /* @__PURE__ */ jsx(FrankieContext.Provider, { value, children });
206
+ }
207
+ function useFrankie() {
208
+ const value = useContext(FrankieContext);
209
+ if (!value) throw new Error("Frankie components must be rendered inside <FrankieProvider>.");
210
+ return value;
211
+ }
212
+ function useFrankieTheme() {
213
+ const { config, theme } = useFrankie();
214
+ return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
215
+ }
216
+
217
+ // src/hooks/useFrankieChat.ts
218
+ import { useCallback, useEffect as useEffect2, useRef, useState as useState2 } from "react";
219
+
220
+ // src/lib/messages.ts
221
+ function applyEvent(message, event) {
222
+ switch (event.type) {
223
+ case "text_delta":
224
+ return { ...message, text: message.text + event.text };
225
+ case "thinking_delta":
226
+ return { ...message, thinking: message.thinking + event.text };
227
+ case "tool_start":
228
+ return {
229
+ ...message,
230
+ tools: [
231
+ ...message.tools,
232
+ { id: event.toolUseId, name: event.name, label: event.label, status: "running" }
233
+ ]
234
+ };
235
+ case "tool_end":
236
+ return {
237
+ ...message,
238
+ tools: message.tools.map(
239
+ (tool) => tool.id === event.toolUseId ? {
240
+ ...tool,
241
+ status: event.ok ? "done" : "failed",
242
+ ...event.preview ? { detail: event.preview } : {}
243
+ } : tool
244
+ )
245
+ };
246
+ case "tool_denied":
247
+ return {
248
+ ...message,
249
+ tools: upsertTool(message.tools, {
250
+ id: event.toolUseId,
251
+ name: event.name,
252
+ label: event.name,
253
+ status: "denied",
254
+ detail: event.reason
255
+ })
256
+ };
257
+ case "turn_end":
258
+ return {
259
+ ...message,
260
+ status: message.status === "error" ? "error" : "done",
261
+ // A turn that produced nothing still needs to say something.
262
+ text: message.text || fallbackText(event.stopReason)
263
+ };
264
+ case "error":
265
+ return { ...message, status: "error", error: event.message };
266
+ default:
267
+ return message;
268
+ }
269
+ }
270
+ function upsertTool(tools, next) {
271
+ return tools.some((tool) => tool.id === next.id) ? tools.map((tool) => tool.id === next.id ? { ...tool, ...next } : tool) : [...tools, next];
272
+ }
273
+ function fallbackText(stopReason) {
274
+ switch (stopReason) {
275
+ case "max_turns":
276
+ return "I got stuck working on that. Could you narrow it down a little?";
277
+ case "aborted":
278
+ return "Stopped.";
279
+ case "error":
280
+ return "Something went wrong before I could answer. Try again?";
281
+ default:
282
+ return "";
283
+ }
284
+ }
285
+ function newAssistantMessage(id) {
286
+ return { id, role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() };
287
+ }
288
+ function newUserMessage(id, text) {
289
+ return { id, role: "user", text, createdAt: Date.now() };
290
+ }
291
+
292
+ // src/hooks/useFrankieChat.ts
293
+ var counter = 0;
294
+ var nextId = (prefix) => `${prefix}_${Date.now().toString(36)}_${counter += 1}`;
295
+ function useFrankieChat() {
296
+ const { client, context } = useFrankie();
297
+ const [messages, setMessages] = useState2([]);
298
+ const [busy, setBusy] = useState2(false);
299
+ const [error, setError] = useState2(void 0);
300
+ const abortRef = useRef(null);
301
+ const mounted = useRef(true);
302
+ useEffect2(() => {
303
+ mounted.current = true;
304
+ return () => {
305
+ mounted.current = false;
306
+ abortRef.current?.abort();
307
+ };
308
+ }, []);
309
+ const storageKey = `${client.namespace}.messages`;
310
+ useEffect2(() => {
311
+ if (!client.persist) return;
312
+ try {
313
+ const raw = window.localStorage.getItem(storageKey);
314
+ const stored = raw ? JSON.parse(raw) : [];
315
+ if (Array.isArray(stored) && stored.length) setMessages(stored);
316
+ } catch {
317
+ }
318
+ }, [storageKey]);
319
+ useEffect2(() => {
320
+ if (!client.persist || busy) return;
321
+ try {
322
+ if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
323
+ else window.localStorage.removeItem(storageKey);
324
+ } catch {
325
+ }
326
+ }, [messages, busy, client, storageKey]);
327
+ const patchAssistant = useCallback((id, update) => {
328
+ setMessages(
329
+ (current) => current.map(
330
+ (message) => message.id === id && message.role === "assistant" ? update(message) : message
331
+ )
332
+ );
333
+ }, []);
334
+ const send = useCallback(
335
+ async (text) => {
336
+ const trimmed = text.trim();
337
+ if (!trimmed || busy) return;
338
+ const assistantId = nextId("a");
339
+ setError(void 0);
340
+ setBusy(true);
341
+ setMessages((current) => [
342
+ ...current,
343
+ newUserMessage(nextId("u"), trimmed),
344
+ newAssistantMessage(assistantId)
345
+ ]);
346
+ const abort = new AbortController();
347
+ abortRef.current = abort;
348
+ try {
349
+ await client.send({ message: trimmed, ...context ? { context } : {}, signal: abort.signal }, (event) => {
350
+ if (event.type === "ping" || event.type === "session") return;
351
+ patchAssistant(assistantId, (message) => applyEvent(message, event));
352
+ });
353
+ } catch (caught) {
354
+ if (abort.signal.aborted) {
355
+ patchAssistant(assistantId, (message) => ({
356
+ ...message,
357
+ status: "done",
358
+ text: message.text || "Stopped."
359
+ }));
360
+ } else {
361
+ const message = caught instanceof FrankieApiError ? caught.message : "Could not reach the assistant. Check your connection and try again.";
362
+ if (mounted.current) setError(message);
363
+ patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
364
+ }
365
+ } finally {
366
+ if (mounted.current) {
367
+ setBusy(false);
368
+ patchAssistant(
369
+ assistantId,
370
+ (message) => message.status === "streaming" ? { ...message, status: "done" } : message
371
+ );
372
+ }
373
+ abortRef.current = null;
374
+ }
375
+ },
376
+ [busy, client, context, patchAssistant]
377
+ );
378
+ const stop = useCallback(() => abortRef.current?.abort(), []);
379
+ const reset = useCallback(() => {
380
+ abortRef.current?.abort();
381
+ client.resetSession();
382
+ setMessages([]);
383
+ setError(void 0);
384
+ setBusy(false);
385
+ try {
386
+ if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
387
+ } catch {
388
+ }
389
+ }, [client]);
390
+ return { messages, busy, ...error ? { error } : {}, send, stop, reset };
391
+ }
392
+
393
+ // src/components/chat/FrankieWidget.tsx
394
+ import { MessageCircle, X as X3 } from "lucide-react";
395
+ import { useEffect as useEffect5, useState as useState4 } from "react";
396
+
397
+ // src/lib/theme.ts
398
+ function themeToCssVars(theme) {
399
+ if (!theme) return {};
400
+ const vars = {};
401
+ if (theme.primary) vars["--frankie-primary"] = theme.primary;
402
+ if (theme.primaryForeground) vars["--frankie-primary-foreground"] = theme.primaryForeground;
403
+ if (theme.background) vars["--frankie-background"] = theme.background;
404
+ if (theme.foreground) vars["--frankie-foreground"] = theme.foreground;
405
+ if (theme.muted) vars["--frankie-muted"] = theme.muted;
406
+ if (theme.border) vars["--frankie-border"] = theme.border;
407
+ if (theme.radius) vars["--frankie-radius"] = `${theme.radius}rem`;
408
+ if (theme.fontFamily) vars["--frankie-font"] = theme.fontFamily;
409
+ return vars;
410
+ }
411
+ function resolveMode(mode) {
412
+ if (mode === "light" || mode === "dark") return mode;
413
+ if (typeof window === "undefined" || !window.matchMedia) return "light";
414
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
415
+ }
416
+
417
+ // src/lib/utils.ts
418
+ import { clsx } from "clsx";
419
+ import { twMerge } from "tailwind-merge";
420
+ function cn(...inputs) {
421
+ return twMerge(clsx(inputs));
422
+ }
423
+
424
+ // src/components/ui/button.tsx
425
+ import { Slot } from "@radix-ui/react-slot";
426
+ import { cva } from "class-variance-authority";
427
+ import { jsx as jsx2 } from "react/jsx-runtime";
428
+ var buttonVariants = cva(
429
+ "frankie-focus inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--frankie-radius)] text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
430
+ {
431
+ variants: {
432
+ variant: {
433
+ default: "bg-primary text-primary-foreground hover:opacity-90",
434
+ secondary: "bg-secondary text-secondary-foreground hover:bg-muted",
435
+ outline: "border border-border bg-background hover:bg-muted",
436
+ ghost: "hover:bg-muted",
437
+ link: "text-primary underline-offset-4 hover:underline"
438
+ },
439
+ size: {
440
+ default: "h-9 px-4 py-2",
441
+ sm: "h-8 rounded-[calc(var(--frankie-radius)-2px)] px-3 text-xs",
442
+ lg: "h-10 px-6",
443
+ icon: "size-9"
444
+ }
445
+ },
446
+ defaultVariants: { variant: "default", size: "default" }
447
+ }
448
+ );
449
+ function Button({ className, variant, size, asChild = false, ...props }) {
450
+ const Comp = asChild ? Slot : "button";
451
+ return /* @__PURE__ */ jsx2(Comp, { className: cn(buttonVariants({ variant, size }), className), ...props });
452
+ }
453
+
454
+ // src/components/chat/ChatPanel.tsx
455
+ import { RotateCcw, X as X2 } from "lucide-react";
456
+ import { useEffect as useEffect4, useRef as useRef3 } from "react";
457
+
458
+ // src/components/ui/avatar.tsx
459
+ import { jsx as jsx3 } from "react/jsx-runtime";
460
+ function Avatar({ src, name, className }) {
461
+ const initials = name.split(/\s+/).slice(0, 2).map((part) => part[0]?.toUpperCase() ?? "").join("");
462
+ return /* @__PURE__ */ jsx3(
463
+ "span",
464
+ {
465
+ className: cn(
466
+ "inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary text-[0.625rem] font-semibold text-primary-foreground",
467
+ className
468
+ ),
469
+ "aria-hidden": "true",
470
+ children: src ? /* @__PURE__ */ jsx3("img", { src, alt: "", className: "size-full object-cover" }) : initials
471
+ }
472
+ );
473
+ }
474
+
475
+ // src/components/ui/card.tsx
476
+ import { jsx as jsx4 } from "react/jsx-runtime";
477
+ function Card({ className, ...props }) {
478
+ return /* @__PURE__ */ jsx4(
479
+ "div",
480
+ {
481
+ className: cn(
482
+ "flex flex-col rounded-[calc(var(--frankie-radius)+4px)] border border-border bg-background text-foreground shadow-lg",
483
+ className
484
+ ),
485
+ ...props
486
+ }
487
+ );
488
+ }
489
+ function CardHeader({ className, ...props }) {
490
+ return /* @__PURE__ */ jsx4("div", { className: cn("flex items-center gap-3 border-b border-border px-4 py-3", className), ...props });
491
+ }
492
+ function CardTitle({ className, ...props }) {
493
+ return /* @__PURE__ */ jsx4("div", { className: cn("text-sm font-semibold leading-tight", className), ...props });
494
+ }
495
+ function CardDescription({ className, ...props }) {
496
+ return /* @__PURE__ */ jsx4("div", { className: cn("text-xs text-muted-foreground", className), ...props });
497
+ }
498
+ function CardContent({ className, ...props }) {
499
+ return /* @__PURE__ */ jsx4("div", { className: cn("flex-1 overflow-hidden", className), ...props });
500
+ }
501
+ function CardFooter({ className, ...props }) {
502
+ return /* @__PURE__ */ jsx4("div", { className: cn("border-t border-border p-3", className), ...props });
503
+ }
504
+
505
+ // src/components/ui/scroll-area.tsx
506
+ import { forwardRef } from "react";
507
+ import { jsx as jsx5 } from "react/jsx-runtime";
508
+ var ScrollArea = forwardRef(
509
+ function ScrollArea2({ className, ...props }, ref) {
510
+ return /* @__PURE__ */ jsx5("div", { ref, className: cn("frankie-scroll overflow-y-auto overscroll-contain", className), ...props });
511
+ }
512
+ );
513
+
514
+ // src/components/chat/Composer.tsx
515
+ import { ArrowUp, Square } from "lucide-react";
516
+ import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
517
+
518
+ // src/components/ui/textarea.tsx
519
+ import { forwardRef as forwardRef2 } from "react";
520
+ import { jsx as jsx6 } from "react/jsx-runtime";
521
+ var Textarea = forwardRef2(
522
+ function Textarea2({ className, ...props }, ref) {
523
+ return /* @__PURE__ */ jsx6(
524
+ "textarea",
525
+ {
526
+ ref,
527
+ className: cn(
528
+ "frankie-focus flex min-h-[2.25rem] w-full resize-none rounded-[var(--frankie-radius)] border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
529
+ className
530
+ ),
531
+ ...props
532
+ }
533
+ );
534
+ }
535
+ );
536
+
537
+ // src/components/chat/Composer.tsx
538
+ import { jsx as jsx7, jsxs } from "react/jsx-runtime";
539
+ var MAX_ROWS_PX = 140;
540
+ function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus }) {
541
+ const [value, setValue] = useState3("");
542
+ const textareaRef = useRef2(null);
543
+ useEffect3(() => {
544
+ const node = textareaRef.current;
545
+ if (!node) return;
546
+ node.style.height = "auto";
547
+ node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`;
548
+ }, [value]);
549
+ const submit = useCallback2(() => {
550
+ const text = value.trim();
551
+ if (!text || busy || disabled) return;
552
+ setValue("");
553
+ onSend(text);
554
+ }, [busy, disabled, onSend, value]);
555
+ const onKeyDown = (event) => {
556
+ if (event.key === "Enter" && !event.shiftKey) {
557
+ event.preventDefault();
558
+ submit();
559
+ }
560
+ };
561
+ return /* @__PURE__ */ jsxs(
562
+ "form",
563
+ {
564
+ className: "flex items-end gap-2",
565
+ onSubmit: (event) => {
566
+ event.preventDefault();
567
+ submit();
568
+ },
569
+ children: [
570
+ /* @__PURE__ */ jsx7(
571
+ Textarea,
572
+ {
573
+ ref: textareaRef,
574
+ rows: 1,
575
+ value,
576
+ disabled,
577
+ autoFocus,
578
+ onChange: (event) => setValue(event.target.value),
579
+ onKeyDown,
580
+ placeholder: placeholder ?? "Ask anything\u2026",
581
+ "aria-label": "Message"
582
+ }
583
+ ),
584
+ busy ? /* @__PURE__ */ jsx7(Button, { type: "button", size: "icon", variant: "outline", onClick: onStop, "aria-label": "Stop", children: /* @__PURE__ */ jsx7(Square, {}) }) : /* @__PURE__ */ jsx7(Button, { type: "submit", size: "icon", disabled: disabled || value.trim().length === 0, "aria-label": "Send", children: /* @__PURE__ */ jsx7(ArrowUp, {}) })
585
+ ]
586
+ }
587
+ );
588
+ }
589
+
590
+ // src/components/chat/Markdown.tsx
591
+ import { Fragment } from "react";
592
+ import { jsx as jsx8 } from "react/jsx-runtime";
593
+ function Markdown({ text, className }) {
594
+ return /* @__PURE__ */ jsx8("div", { className: cn("frankie-prose", className), children: renderBlocks(text) });
595
+ }
596
+ function renderBlocks(text) {
597
+ const lines = text.split("\n");
598
+ const blocks = [];
599
+ let paragraph = [];
600
+ let list = null;
601
+ let code = null;
602
+ const flushParagraph = () => {
603
+ if (paragraph.length === 0) return;
604
+ blocks.push(/* @__PURE__ */ jsx8("p", { children: renderInline(paragraph.join(" ")) }, `p${blocks.length}`));
605
+ paragraph = [];
606
+ };
607
+ const flushList = () => {
608
+ if (!list) return;
609
+ const items = list.items.map((item, index) => /* @__PURE__ */ jsx8("li", { children: renderInline(item) }, index));
610
+ blocks.push(list.ordered ? /* @__PURE__ */ jsx8("ol", { children: items }, `l${blocks.length}`) : /* @__PURE__ */ jsx8("ul", { children: items }, `l${blocks.length}`));
611
+ list = null;
612
+ };
613
+ for (const line of lines) {
614
+ const fence = /^```(\w*)\s*$/.exec(line);
615
+ if (fence) {
616
+ if (code) {
617
+ blocks.push(
618
+ /* @__PURE__ */ jsx8("pre", { children: /* @__PURE__ */ jsx8("code", { children: code.lines.join("\n") }) }, `c${blocks.length}`)
619
+ );
620
+ code = null;
621
+ } else {
622
+ flushParagraph();
623
+ flushList();
624
+ code = { lang: fence[1] ?? "", lines: [] };
625
+ }
626
+ continue;
627
+ }
628
+ if (code) {
629
+ code.lines.push(line);
630
+ continue;
631
+ }
632
+ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
633
+ const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
634
+ if (bullet ?? ordered) {
635
+ flushParagraph();
636
+ const isOrdered = Boolean(ordered);
637
+ if (!list || list.ordered !== isOrdered) {
638
+ flushList();
639
+ list = { ordered: isOrdered, items: [] };
640
+ }
641
+ list.items.push(ordered?.[1] ?? bullet?.[1] ?? "");
642
+ continue;
643
+ }
644
+ if (line.trim() === "") {
645
+ flushParagraph();
646
+ flushList();
647
+ continue;
648
+ }
649
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
650
+ if (heading) {
651
+ flushParagraph();
652
+ flushList();
653
+ blocks.push(/* @__PURE__ */ jsx8("p", { className: "font-semibold", children: renderInline(heading[1] ?? "") }, `h${blocks.length}`));
654
+ continue;
655
+ }
656
+ flushList();
657
+ paragraph.push(line.trim());
658
+ }
659
+ if (code) {
660
+ blocks.push(
661
+ /* @__PURE__ */ jsx8("pre", { children: /* @__PURE__ */ jsx8("code", { children: code.lines.join("\n") }) }, `c${blocks.length}`)
662
+ );
663
+ }
664
+ flushParagraph();
665
+ flushList();
666
+ return blocks;
667
+ }
668
+ var INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\((?:https?:\/\/|\/)[^)\s]+\)|\bhttps?:\/\/[^\s<>()]+)/g;
669
+ function renderInline(text) {
670
+ const nodes = [];
671
+ let lastIndex = 0;
672
+ for (const match of text.matchAll(INLINE)) {
673
+ const token = match[0];
674
+ const start = match.index;
675
+ if (start > lastIndex) nodes.push(text.slice(lastIndex, start));
676
+ nodes.push(/* @__PURE__ */ jsx8(Fragment, { children: renderToken(token) }, start));
677
+ lastIndex = start + token.length;
678
+ }
679
+ if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
680
+ return nodes;
681
+ }
682
+ function renderToken(token) {
683
+ if (token.startsWith("**")) return /* @__PURE__ */ jsx8("strong", { children: token.slice(2, -2) });
684
+ if (token.startsWith("`")) return /* @__PURE__ */ jsx8("code", { children: token.slice(1, -1) });
685
+ const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
686
+ const href = link ? link[2] ?? "" : token;
687
+ const label = link ? link[1] ?? "" : token;
688
+ if (!/^(https?:\/\/|\/)/.test(href)) return token;
689
+ return /* @__PURE__ */ jsx8("a", { href, target: "_blank", rel: "noopener noreferrer nofollow", children: label });
690
+ }
691
+
692
+ // src/components/chat/ToolActivityList.tsx
693
+ import { Check, Loader2, ShieldAlert, X } from "lucide-react";
694
+
695
+ // src/components/ui/badge.tsx
696
+ import { cva as cva2 } from "class-variance-authority";
697
+ import { jsx as jsx9 } from "react/jsx-runtime";
698
+ var badgeVariants = cva2(
699
+ "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.6875rem] font-medium",
700
+ {
701
+ variants: {
702
+ variant: {
703
+ default: "border-transparent bg-muted text-muted-foreground",
704
+ success: "border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
705
+ danger: "border-transparent bg-red-500/10 text-red-600 dark:text-red-400",
706
+ outline: "border-border text-muted-foreground"
707
+ }
708
+ },
709
+ defaultVariants: { variant: "default" }
710
+ }
711
+ );
712
+ function Badge({
713
+ className,
714
+ variant,
715
+ ...props
716
+ }) {
717
+ return /* @__PURE__ */ jsx9("span", { className: cn(badgeVariants({ variant }), className), ...props });
718
+ }
719
+
720
+ // src/components/chat/ToolActivityList.tsx
721
+ import { jsx as jsx10, jsxs as jsxs2 } from "react/jsx-runtime";
722
+ function ToolActivityList({ tools, renderTool }) {
723
+ if (tools.length === 0) return null;
724
+ return /* @__PURE__ */ jsx10("ul", { className: "mt-2 flex flex-col gap-1", children: tools.map((tool) => /* @__PURE__ */ jsx10("li", { children: renderTool ? renderTool(tool) : /* @__PURE__ */ jsx10(ToolActivityRow, { tool }) }, tool.id)) });
725
+ }
726
+ function ToolActivityRow({ tool }) {
727
+ return /* @__PURE__ */ jsxs2(Badge, { variant: badgeVariant(tool.status), title: tool.detail ?? tool.name, children: [
728
+ /* @__PURE__ */ jsx10(ToolIcon, { status: tool.status }),
729
+ /* @__PURE__ */ jsx10("span", { className: "truncate", children: tool.label })
730
+ ] });
731
+ }
732
+ function ToolIcon({ status }) {
733
+ switch (status) {
734
+ case "running":
735
+ return /* @__PURE__ */ jsx10(Loader2, { className: "size-3 animate-spin", "aria-hidden": "true" });
736
+ case "done":
737
+ return /* @__PURE__ */ jsx10(Check, { className: "size-3", "aria-hidden": "true" });
738
+ case "denied":
739
+ return /* @__PURE__ */ jsx10(ShieldAlert, { className: "size-3", "aria-hidden": "true" });
740
+ default:
741
+ return /* @__PURE__ */ jsx10(X, { className: "size-3", "aria-hidden": "true" });
742
+ }
743
+ }
744
+ function badgeVariant(status) {
745
+ if (status === "done") return "success";
746
+ if (status === "failed") return "danger";
747
+ if (status === "denied") return "outline";
748
+ return "default";
749
+ }
750
+
751
+ // src/components/chat/MessageBubble.tsx
752
+ import { jsx as jsx11, jsxs as jsxs3 } from "react/jsx-runtime";
753
+ function MessageBubble({
754
+ message,
755
+ assistantName,
756
+ avatarUrl,
757
+ showToolActivity,
758
+ renderTool
759
+ }) {
760
+ if (message.role === "user") {
761
+ return /* @__PURE__ */ jsx11("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx11("div", { className: "max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground", children: message.text }) });
762
+ }
763
+ const empty = message.text.length === 0 && message.tools.length === 0;
764
+ return /* @__PURE__ */ jsxs3("div", { className: "flex gap-2", children: [
765
+ /* @__PURE__ */ jsx11(Avatar, { ...avatarUrl ? { src: avatarUrl } : {}, name: assistantName, className: "mt-0.5" }),
766
+ /* @__PURE__ */ jsxs3("div", { className: "min-w-0 max-w-[85%]", children: [
767
+ message.status === "error" ? /* @__PURE__ */ jsx11("p", { className: "rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400", children: message.error }) : empty && message.status === "streaming" ? /* @__PURE__ */ jsx11(TypingDots, {}) : /* @__PURE__ */ jsx11(Markdown, { text: message.text, className: "text-sm" }),
768
+ showToolActivity ? /* @__PURE__ */ jsx11(ToolActivityList, { tools: message.tools, ...renderTool ? { renderTool } : {} }) : null
769
+ ] })
770
+ ] });
771
+ }
772
+ function TypingDots() {
773
+ return /* @__PURE__ */ jsx11("span", { className: "inline-flex items-center gap-1 py-2", role: "status", "aria-label": "Thinking", children: [0, 1, 2].map((index) => /* @__PURE__ */ jsx11(
774
+ "span",
775
+ {
776
+ className: cn("size-1.5 animate-pulse rounded-full bg-muted-foreground"),
777
+ style: { animationDelay: `${index * 150}ms` }
778
+ },
779
+ index
780
+ )) });
781
+ }
782
+
783
+ // src/components/chat/SuggestedPrompts.tsx
784
+ import { jsx as jsx12 } from "react/jsx-runtime";
785
+ function SuggestedPrompts({
786
+ prompts,
787
+ onPick,
788
+ disabled
789
+ }) {
790
+ if (prompts.length === 0) return null;
791
+ return /* @__PURE__ */ jsx12("div", { className: "flex flex-wrap gap-2", children: prompts.map((prompt) => /* @__PURE__ */ jsx12(
792
+ Button,
793
+ {
794
+ type: "button",
795
+ size: "sm",
796
+ variant: "outline",
797
+ disabled,
798
+ onClick: () => onPick(prompt),
799
+ children: prompt
800
+ },
801
+ prompt
802
+ )) });
803
+ }
804
+
805
+ // src/components/chat/ChatPanel.tsx
806
+ import { jsx as jsx13, jsxs as jsxs4 } from "react/jsx-runtime";
807
+ function ChatPanel({
808
+ className,
809
+ header,
810
+ footer,
811
+ emptyState,
812
+ renderMessage,
813
+ renderTool,
814
+ placeholder,
815
+ onClose,
816
+ autoFocus,
817
+ fill
818
+ }) {
819
+ const { config, configError, loading } = useFrankie();
820
+ const theme = useFrankieTheme();
821
+ const { messages, busy, error, send, stop, reset } = useFrankieChat();
822
+ const scrollRef = useRef3(null);
823
+ useEffect4(() => {
824
+ const node = scrollRef.current;
825
+ if (!node) return;
826
+ const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
827
+ if (distanceFromBottom < 160) node.scrollTop = node.scrollHeight;
828
+ }, [messages]);
829
+ const name = config?.branding.name ?? "Assistant";
830
+ const mode = resolveMode(theme?.mode);
831
+ return /* @__PURE__ */ jsxs4(
832
+ Card,
833
+ {
834
+ className: cn(
835
+ "frankie-root",
836
+ fill ? "h-full w-full rounded-none border-0 shadow-none" : "h-[32rem] w-[22rem] max-w-[calc(100vw-2rem)]",
837
+ mode === "dark" && "dark",
838
+ className
839
+ ),
840
+ style: themeToCssVars(theme),
841
+ "data-frankie-mode": mode,
842
+ children: [
843
+ header === false ? null : header !== void 0 ? header : /* @__PURE__ */ jsxs4(CardHeader, { children: [
844
+ /* @__PURE__ */ jsx13(Avatar, { ...config?.branding.avatarUrl ? { src: config.branding.avatarUrl } : {}, name }),
845
+ /* @__PURE__ */ jsxs4("div", { className: "min-w-0 flex-1", children: [
846
+ /* @__PURE__ */ jsx13(CardTitle, { children: name }),
847
+ config?.branding.tagline ? /* @__PURE__ */ jsx13(CardDescription, { children: config.branding.tagline }) : null
848
+ ] }),
849
+ /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: reset, "aria-label": "Start a new conversation", title: "New conversation", children: /* @__PURE__ */ jsx13(RotateCcw, {}) }),
850
+ onClose ? /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsx13(X2, {}) }) : null
851
+ ] }),
852
+ /* @__PURE__ */ jsx13(CardContent, { children: /* @__PURE__ */ jsx13(ScrollArea, { ref: scrollRef, className: "flex h-full flex-col gap-4 p-4", "aria-live": "polite", children: configError ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: configError }) : loading ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: "Loading\u2026" }) : messages.length === 0 ? emptyState ?? /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-4", children: [
853
+ /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: config?.greeting }),
854
+ /* @__PURE__ */ jsx13(SuggestedPrompts, { prompts: config?.suggestedPrompts ?? [], onPick: send, disabled: busy })
855
+ ] }) : /* @__PURE__ */ jsx13("div", { className: "flex flex-col gap-4", children: messages.map(
856
+ (message) => renderMessage ? /* @__PURE__ */ jsx13("div", { children: renderMessage(message) }, message.id) : /* @__PURE__ */ jsx13(
857
+ MessageBubble,
858
+ {
859
+ message,
860
+ assistantName: name,
861
+ ...config?.branding.avatarUrl ? { avatarUrl: config.branding.avatarUrl } : {},
862
+ showToolActivity: config?.showToolActivity ?? true,
863
+ ...renderTool ? { renderTool } : {}
864
+ },
865
+ message.id
866
+ )
867
+ ) }) }) }),
868
+ /* @__PURE__ */ jsxs4(CardFooter, { className: "flex flex-col gap-2", children: [
869
+ error ? /* @__PURE__ */ jsx13("p", { className: "w-full text-xs text-red-600 dark:text-red-400", children: error }) : null,
870
+ /* @__PURE__ */ jsx13("div", { className: "w-full", children: /* @__PURE__ */ jsx13(
871
+ Composer,
872
+ {
873
+ onSend: send,
874
+ onStop: stop,
875
+ busy,
876
+ disabled: Boolean(configError),
877
+ ...placeholder ? { placeholder } : {},
878
+ ...autoFocus ? { autoFocus } : {}
879
+ }
880
+ ) }),
881
+ footer
882
+ ] })
883
+ ]
884
+ }
885
+ );
886
+ }
887
+
888
+ // src/components/chat/FrankieWidget.tsx
889
+ import { jsx as jsx14, jsxs as jsxs5 } from "react/jsx-runtime";
890
+ function FrankieWidget({
891
+ position = "bottom-right",
892
+ offset = 24,
893
+ defaultOpen = false,
894
+ open: controlledOpen,
895
+ onOpenChange,
896
+ renderLauncher,
897
+ ...panelProps
898
+ }) {
899
+ const { config } = useFrankie();
900
+ const theme = useFrankieTheme();
901
+ const [uncontrolledOpen, setUncontrolledOpen] = useState4(defaultOpen);
902
+ const open = controlledOpen ?? uncontrolledOpen;
903
+ const setOpen = (next) => {
904
+ if (controlledOpen === void 0) setUncontrolledOpen(next);
905
+ onOpenChange?.(next);
906
+ };
907
+ useEffect5(() => {
908
+ if (!open) return;
909
+ const onKeyDown = (event) => {
910
+ if (event.key === "Escape") setOpen(false);
911
+ };
912
+ window.addEventListener("keydown", onKeyDown);
913
+ return () => window.removeEventListener("keydown", onKeyDown);
914
+ }, [open]);
915
+ const label = config?.branding.launcherLabel ?? "Need a hand?";
916
+ const mode = resolveMode(theme?.mode);
917
+ const side = position === "bottom-right" ? { right: offset } : { left: offset };
918
+ return /* @__PURE__ */ jsx14(
919
+ "div",
920
+ {
921
+ className: cn("frankie-root frankie-widget", mode === "dark" && "dark"),
922
+ style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex: 2147483e3 },
923
+ "data-frankie-mode": mode,
924
+ children: /* @__PURE__ */ jsxs5("div", { className: cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end"), children: [
925
+ open ? /* @__PURE__ */ jsx14(ChatPanel, { ...panelProps, onClose: () => setOpen(false), autoFocus: true }) : null,
926
+ renderLauncher ? renderLauncher({ open, toggle: () => setOpen(!open), label }) : /* @__PURE__ */ jsx14(
927
+ Button,
928
+ {
929
+ size: "icon",
930
+ className: "size-12 rounded-full shadow-lg",
931
+ onClick: () => setOpen(!open),
932
+ "aria-expanded": open,
933
+ "aria-label": open ? "Close the assistant" : label,
934
+ title: label,
935
+ children: open ? /* @__PURE__ */ jsx14(X3, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
936
+ }
937
+ )
938
+ ] })
939
+ }
940
+ );
941
+ }
942
+
943
+ // src/components/chat/FrankieInline.tsx
944
+ import { jsx as jsx15 } from "react/jsx-runtime";
945
+ function FrankieInline({ className, style, ...panelProps }) {
946
+ const theme = useFrankieTheme();
947
+ const mode = resolveMode(theme?.mode);
948
+ return /* @__PURE__ */ jsx15(
949
+ "div",
950
+ {
951
+ className: cn("frankie-root frankie-inline", mode === "dark" && "dark", className),
952
+ style: { ...themeToCssVars(theme), display: "flex", height: "100%", width: "100%", ...style },
953
+ "data-frankie-mode": mode,
954
+ children: /* @__PURE__ */ jsx15(ChatPanel, { ...panelProps, fill: true })
955
+ }
956
+ );
957
+ }
958
+
959
+ // src/components/ui/separator.tsx
960
+ import { jsx as jsx16 } from "react/jsx-runtime";
961
+ function Separator({ className }) {
962
+ return /* @__PURE__ */ jsx16("div", { role: "separator", className: cn("h-px w-full bg-border", className) });
963
+ }
964
+ export {
965
+ Avatar,
966
+ Badge,
967
+ Button,
968
+ Card,
969
+ CardContent,
970
+ CardDescription,
971
+ CardFooter,
972
+ CardHeader,
973
+ CardTitle,
974
+ ChatPanel,
975
+ Composer,
976
+ FRANKIE_CLOUD_URL,
977
+ FrankieApiError,
978
+ FrankieClient,
979
+ FrankieInline,
980
+ FrankieProvider,
981
+ FrankieWidget,
982
+ Markdown,
983
+ MessageBubble,
984
+ ScrollArea,
985
+ Separator,
986
+ SuggestedPrompts,
987
+ Textarea,
988
+ ToolActivityList,
989
+ ToolActivityRow,
990
+ TypingDots,
991
+ applyEvent,
992
+ buttonVariants,
993
+ cn,
994
+ newAssistantMessage,
995
+ newUserMessage,
996
+ readEventStream,
997
+ resolveMode,
998
+ themeToCssVars,
999
+ useFrankie,
1000
+ useFrankieChat,
1001
+ useFrankieTheme
1002
+ };
1003
+ //# sourceMappingURL=index.js.map