@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.
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import { Fragment, type ReactNode } from "react";
4
+ import { cn } from "../../lib/utils.js";
5
+
6
+ /**
7
+ * A deliberately small Markdown subset — paragraphs, lists, fenced code, bold,
8
+ * italic, inline code and links.
9
+ *
10
+ * Everything is rendered as React elements; nothing goes through
11
+ * `dangerouslySetInnerHTML`. Model output lands in someone else's production
12
+ * page, so it is never trusted as markup.
13
+ */
14
+ export function Markdown({ text, className }: { text: string; className?: string }) {
15
+ return <div className={cn("frankie-prose", className)}>{renderBlocks(text)}</div>;
16
+ }
17
+
18
+ function renderBlocks(text: string): ReactNode[] {
19
+ const lines = text.split("\n");
20
+ const blocks: ReactNode[] = [];
21
+ let paragraph: string[] = [];
22
+ let list: { ordered: boolean; items: string[] } | null = null;
23
+ let code: { lang: string; lines: string[] } | null = null;
24
+
25
+ const flushParagraph = () => {
26
+ if (paragraph.length === 0) return;
27
+ blocks.push(<p key={`p${blocks.length}`}>{renderInline(paragraph.join(" "))}</p>);
28
+ paragraph = [];
29
+ };
30
+ const flushList = () => {
31
+ if (!list) return;
32
+ const items = list.items.map((item, index) => <li key={index}>{renderInline(item)}</li>);
33
+ blocks.push(list.ordered ? <ol key={`l${blocks.length}`}>{items}</ol> : <ul key={`l${blocks.length}`}>{items}</ul>);
34
+ list = null;
35
+ };
36
+
37
+ for (const line of lines) {
38
+ const fence = /^```(\w*)\s*$/.exec(line);
39
+ if (fence) {
40
+ if (code) {
41
+ blocks.push(
42
+ <pre key={`c${blocks.length}`}>
43
+ <code>{code.lines.join("\n")}</code>
44
+ </pre>,
45
+ );
46
+ code = null;
47
+ } else {
48
+ flushParagraph();
49
+ flushList();
50
+ code = { lang: fence[1] ?? "", lines: [] };
51
+ }
52
+ continue;
53
+ }
54
+ if (code) {
55
+ code.lines.push(line);
56
+ continue;
57
+ }
58
+
59
+ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
60
+ const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
61
+ if (bullet ?? ordered) {
62
+ flushParagraph();
63
+ const isOrdered = Boolean(ordered);
64
+ if (!list || list.ordered !== isOrdered) {
65
+ flushList();
66
+ list = { ordered: isOrdered, items: [] };
67
+ }
68
+ list.items.push((ordered?.[1] ?? bullet?.[1]) ?? "");
69
+ continue;
70
+ }
71
+
72
+ if (line.trim() === "") {
73
+ flushParagraph();
74
+ flushList();
75
+ continue;
76
+ }
77
+
78
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
79
+ if (heading) {
80
+ flushParagraph();
81
+ flushList();
82
+ blocks.push(<p key={`h${blocks.length}`} className="font-semibold">{renderInline(heading[1] ?? "")}</p>);
83
+ continue;
84
+ }
85
+
86
+ flushList();
87
+ paragraph.push(line.trim());
88
+ }
89
+
90
+ if (code) {
91
+ blocks.push(
92
+ <pre key={`c${blocks.length}`}>
93
+ <code>{code.lines.join("\n")}</code>
94
+ </pre>,
95
+ );
96
+ }
97
+ flushParagraph();
98
+ flushList();
99
+ return blocks;
100
+ }
101
+
102
+ const INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\((?:https?:\/\/|\/)[^)\s]+\)|\bhttps?:\/\/[^\s<>()]+)/g;
103
+
104
+ function renderInline(text: string): ReactNode[] {
105
+ const nodes: ReactNode[] = [];
106
+ let lastIndex = 0;
107
+
108
+ for (const match of text.matchAll(INLINE)) {
109
+ const token = match[0];
110
+ const start = match.index;
111
+ if (start > lastIndex) nodes.push(text.slice(lastIndex, start));
112
+ nodes.push(<Fragment key={start}>{renderToken(token)}</Fragment>);
113
+ lastIndex = start + token.length;
114
+ }
115
+
116
+ if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
117
+ return nodes;
118
+ }
119
+
120
+ function renderToken(token: string): ReactNode {
121
+ if (token.startsWith("**")) return <strong>{token.slice(2, -2)}</strong>;
122
+ if (token.startsWith("`")) return <code>{token.slice(1, -1)}</code>;
123
+
124
+ const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
125
+ const href = link ? (link[2] ?? "") : token;
126
+ const label = link ? (link[1] ?? "") : token;
127
+
128
+ // Only http(s) and same-site paths become links; anything else stays text,
129
+ // so a model can never emit a `javascript:` href into the host page.
130
+ if (!/^(https?:\/\/|\/)/.test(href)) return token;
131
+ return (
132
+ <a href={href} target="_blank" rel="noopener noreferrer nofollow">
133
+ {label}
134
+ </a>
135
+ );
136
+ }
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { cn } from "../../lib/utils.js";
5
+ import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
6
+ import { Avatar } from "../ui/avatar.js";
7
+ import { Markdown } from "./Markdown.js";
8
+ import { ToolActivityList } from "./ToolActivityList.js";
9
+
10
+ export interface MessageBubbleProps {
11
+ message: ChatMessage;
12
+ assistantName: string;
13
+ avatarUrl?: string;
14
+ showToolActivity: boolean;
15
+ renderTool?: (tool: ToolActivity) => ReactNode;
16
+ }
17
+
18
+ export function MessageBubble({
19
+ message,
20
+ assistantName,
21
+ avatarUrl,
22
+ showToolActivity,
23
+ renderTool,
24
+ }: MessageBubbleProps) {
25
+ if (message.role === "user") {
26
+ return (
27
+ <div className="flex justify-end">
28
+ <div className="max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">
29
+ {message.text}
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ const empty = message.text.length === 0 && message.tools.length === 0;
36
+
37
+ return (
38
+ <div className="flex gap-2">
39
+ <Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} className="mt-0.5" />
40
+ <div className="min-w-0 max-w-[85%]">
41
+ {message.status === "error" ? (
42
+ <p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
43
+ {message.error}
44
+ </p>
45
+ ) : empty && message.status === "streaming" ? (
46
+ <TypingDots />
47
+ ) : (
48
+ <Markdown text={message.text} className="text-sm" />
49
+ )}
50
+ {showToolActivity ? (
51
+ <ToolActivityList tools={message.tools} {...(renderTool ? { renderTool } : {})} />
52
+ ) : null}
53
+ </div>
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export function TypingDots() {
59
+ return (
60
+ <span className="inline-flex items-center gap-1 py-2" role="status" aria-label="Thinking">
61
+ {[0, 1, 2].map((index) => (
62
+ <span
63
+ key={index}
64
+ className={cn("size-1.5 animate-pulse rounded-full bg-muted-foreground")}
65
+ style={{ animationDelay: `${index * 150}ms` }}
66
+ />
67
+ ))}
68
+ </span>
69
+ );
70
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { Button } from "../ui/button.js";
4
+
5
+ export function SuggestedPrompts({
6
+ prompts,
7
+ onPick,
8
+ disabled,
9
+ }: {
10
+ prompts: string[];
11
+ onPick: (prompt: string) => void;
12
+ disabled?: boolean;
13
+ }) {
14
+ if (prompts.length === 0) return null;
15
+ return (
16
+ <div className="flex flex-wrap gap-2">
17
+ {prompts.map((prompt) => (
18
+ <Button
19
+ key={prompt}
20
+ type="button"
21
+ size="sm"
22
+ variant="outline"
23
+ disabled={disabled}
24
+ onClick={() => onPick(prompt)}
25
+ >
26
+ {prompt}
27
+ </Button>
28
+ ))}
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { Check, Loader2, ShieldAlert, X } from "lucide-react";
4
+ import { Badge } from "../ui/badge.js";
5
+ import type { ToolActivity } from "../../lib/messages.js";
6
+
7
+ export interface ToolActivityListProps {
8
+ tools: ToolActivity[];
9
+ /** Replace the default row rendering entirely. */
10
+ renderTool?: (tool: ToolActivity) => React.ReactNode;
11
+ }
12
+
13
+ /** The "what the assistant is doing" strip under a streaming answer. */
14
+ export function ToolActivityList({ tools, renderTool }: ToolActivityListProps) {
15
+ if (tools.length === 0) return null;
16
+ return (
17
+ <ul className="mt-2 flex flex-col gap-1">
18
+ {tools.map((tool) => (
19
+ <li key={tool.id}>{renderTool ? renderTool(tool) : <ToolActivityRow tool={tool} />}</li>
20
+ ))}
21
+ </ul>
22
+ );
23
+ }
24
+
25
+ export function ToolActivityRow({ tool }: { tool: ToolActivity }) {
26
+ return (
27
+ <Badge variant={badgeVariant(tool.status)} title={tool.detail ?? tool.name}>
28
+ <ToolIcon status={tool.status} />
29
+ <span className="truncate">{tool.label}</span>
30
+ </Badge>
31
+ );
32
+ }
33
+
34
+ function ToolIcon({ status }: { status: ToolActivity["status"] }) {
35
+ switch (status) {
36
+ case "running":
37
+ return <Loader2 className="size-3 animate-spin" aria-hidden="true" />;
38
+ case "done":
39
+ return <Check className="size-3" aria-hidden="true" />;
40
+ case "denied":
41
+ return <ShieldAlert className="size-3" aria-hidden="true" />;
42
+ default:
43
+ return <X className="size-3" aria-hidden="true" />;
44
+ }
45
+ }
46
+
47
+ function badgeVariant(status: ToolActivity["status"]): "default" | "success" | "danger" | "outline" {
48
+ if (status === "done") return "success";
49
+ if (status === "failed") return "danger";
50
+ if (status === "denied") return "outline";
51
+ return "default";
52
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import { cn } from "../../lib/utils.js";
4
+
5
+ export interface AvatarProps {
6
+ src?: string;
7
+ name: string;
8
+ className?: string;
9
+ }
10
+
11
+ /** Image avatar with an initials fallback — no image request when `src` is absent. */
12
+ export function Avatar({ src, name, className }: AvatarProps) {
13
+ const initials = name
14
+ .split(/\s+/)
15
+ .slice(0, 2)
16
+ .map((part) => part[0]?.toUpperCase() ?? "")
17
+ .join("");
18
+
19
+ return (
20
+ <span
21
+ className={cn(
22
+ "inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary text-[0.625rem] font-semibold text-primary-foreground",
23
+ className,
24
+ )}
25
+ aria-hidden="true"
26
+ >
27
+ {src ? <img src={src} alt="" className="size-full object-cover" /> : initials}
28
+ </span>
29
+ );
30
+ }
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority";
4
+ import type { HTMLAttributes } from "react";
5
+ import { cn } from "../../lib/utils.js";
6
+
7
+ const badgeVariants = cva(
8
+ "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.6875rem] font-medium",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "border-transparent bg-muted text-muted-foreground",
13
+ success: "border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
14
+ danger: "border-transparent bg-red-500/10 text-red-600 dark:text-red-400",
15
+ outline: "border-border text-muted-foreground",
16
+ },
17
+ },
18
+ defaultVariants: { variant: "default" },
19
+ },
20
+ );
21
+
22
+ export function Badge({
23
+ className,
24
+ variant,
25
+ ...props
26
+ }: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
27
+ return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
28
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { Slot } from "@radix-ui/react-slot";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import type { ButtonHTMLAttributes } from "react";
6
+ import { cn } from "../../lib/utils.js";
7
+
8
+ export const buttonVariants = cva(
9
+ "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",
10
+ {
11
+ variants: {
12
+ variant: {
13
+ default: "bg-primary text-primary-foreground hover:opacity-90",
14
+ secondary: "bg-secondary text-secondary-foreground hover:bg-muted",
15
+ outline: "border border-border bg-background hover:bg-muted",
16
+ ghost: "hover:bg-muted",
17
+ link: "text-primary underline-offset-4 hover:underline",
18
+ },
19
+ size: {
20
+ default: "h-9 px-4 py-2",
21
+ sm: "h-8 rounded-[calc(var(--frankie-radius)-2px)] px-3 text-xs",
22
+ lg: "h-10 px-6",
23
+ icon: "size-9",
24
+ },
25
+ },
26
+ defaultVariants: { variant: "default", size: "default" },
27
+ },
28
+ );
29
+
30
+ export interface ButtonProps
31
+ extends ButtonHTMLAttributes<HTMLButtonElement>,
32
+ VariantProps<typeof buttonVariants> {
33
+ asChild?: boolean;
34
+ }
35
+
36
+ export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
37
+ const Comp = asChild ? Slot : "button";
38
+ return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />;
39
+ }
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes } from "react";
4
+ import { cn } from "../../lib/utils.js";
5
+
6
+ export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
7
+ return (
8
+ <div
9
+ className={cn(
10
+ "flex flex-col rounded-[calc(var(--frankie-radius)+4px)] border border-border bg-background text-foreground shadow-lg",
11
+ className,
12
+ )}
13
+ {...props}
14
+ />
15
+ );
16
+ }
17
+
18
+ export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
19
+ return <div className={cn("flex items-center gap-3 border-b border-border px-4 py-3", className)} {...props} />;
20
+ }
21
+
22
+ export function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
23
+ return <div className={cn("text-sm font-semibold leading-tight", className)} {...props} />;
24
+ }
25
+
26
+ export function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
27
+ return <div className={cn("text-xs text-muted-foreground", className)} {...props} />;
28
+ }
29
+
30
+ export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
31
+ return <div className={cn("flex-1 overflow-hidden", className)} {...props} />;
32
+ }
33
+
34
+ export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
35
+ return <div className={cn("border-t border-border p-3", className)} {...props} />;
36
+ }
@@ -0,0 +1,15 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes } from "react";
4
+ import { cn } from "../../lib/utils.js";
5
+
6
+ /**
7
+ * A plain overflow container rather than Radix's ScrollArea: the widget lives
8
+ * inside someone else's page, and a native scroller inherits their scrollbar
9
+ * behaviour instead of fighting it.
10
+ */
11
+ export const ScrollArea = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
12
+ function ScrollArea({ className, ...props }, ref) {
13
+ return <div ref={ref} className={cn("frankie-scroll overflow-y-auto overscroll-contain", className)} {...props} />;
14
+ },
15
+ );
@@ -0,0 +1,7 @@
1
+ "use client";
2
+
3
+ import { cn } from "../../lib/utils.js";
4
+
5
+ export function Separator({ className }: { className?: string }) {
6
+ return <div role="separator" className={cn("h-px w-full bg-border", className)} />;
7
+ }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type TextareaHTMLAttributes } from "react";
4
+ import { cn } from "../../lib/utils.js";
5
+
6
+ /** `forwardRef` rather than React 19's ref-as-prop, so React 18 hosts work too. */
7
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
8
+ function Textarea({ className, ...props }, ref) {
9
+ return (
10
+ <textarea
11
+ ref={ref}
12
+ className={cn(
13
+ "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",
14
+ className,
15
+ )}
16
+ {...props}
17
+ />
18
+ );
19
+ },
20
+ );
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
4
+ import type { PublicWidgetConfig, WidgetTheme } from "@frankieai/core";
5
+ import { FrankieClient, type FrankieClientOptions, type PageContext } from "../lib/client.js";
6
+
7
+ export interface FrankieProviderProps extends FrankieClientOptions {
8
+ children: ReactNode;
9
+ /**
10
+ * Page context sent with every turn — current route, plan tier, whatever the
11
+ * agent should know. Treated as data by the server, never as instructions.
12
+ */
13
+ context?: PageContext;
14
+ /** Overrides for the server-side theme, so a host can restyle without a redeploy. */
15
+ theme?: WidgetTheme;
16
+ /** Skip the `/v1/config` fetch by supplying the config yourself. */
17
+ config?: PublicWidgetConfig;
18
+ }
19
+
20
+ interface FrankieContextValue {
21
+ client: FrankieClient;
22
+ config?: PublicWidgetConfig;
23
+ configError?: string;
24
+ loading: boolean;
25
+ context?: PageContext;
26
+ theme?: WidgetTheme;
27
+ }
28
+
29
+ const FrankieContext = createContext<FrankieContextValue | null>(null);
30
+
31
+ export function FrankieProvider({
32
+ children,
33
+ context,
34
+ theme,
35
+ config: providedConfig,
36
+ ...clientOptions
37
+ }: FrankieProviderProps) {
38
+ const client = useMemo(
39
+ () => new FrankieClient(clientOptions),
40
+ // The client holds session state; rebuild it only when the endpoint changes.
41
+ // eslint-disable-next-line react-hooks/exhaustive-deps
42
+ [clientOptions.baseUrl, clientOptions.publishableKey],
43
+ );
44
+
45
+ const [config, setConfig] = useState<PublicWidgetConfig | undefined>(providedConfig);
46
+ const [configError, setConfigError] = useState<string | undefined>(undefined);
47
+ const [loading, setLoading] = useState(!providedConfig);
48
+
49
+ useEffect(() => {
50
+ if (providedConfig) return;
51
+ const abort = new AbortController();
52
+ setLoading(true);
53
+ client
54
+ .getConfig(abort.signal)
55
+ .then((next) => {
56
+ setConfig(next);
57
+ setConfigError(undefined);
58
+ })
59
+ .catch((error: unknown) => {
60
+ if (abort.signal.aborted) return;
61
+ setConfigError(error instanceof Error ? error.message : "Could not load the assistant.");
62
+ })
63
+ .finally(() => {
64
+ if (!abort.signal.aborted) setLoading(false);
65
+ });
66
+ return () => abort.abort();
67
+ }, [client, providedConfig]);
68
+
69
+ const value = useMemo<FrankieContextValue>(
70
+ () => ({
71
+ client,
72
+ ...(config ? { config } : {}),
73
+ ...(configError ? { configError } : {}),
74
+ loading,
75
+ ...(context ? { context } : {}),
76
+ ...(theme ? { theme } : {}),
77
+ }),
78
+ [client, config, configError, loading, context, theme],
79
+ );
80
+
81
+ return <FrankieContext.Provider value={value}>{children}</FrankieContext.Provider>;
82
+ }
83
+
84
+ export function useFrankie(): FrankieContextValue {
85
+ const value = useContext(FrankieContext);
86
+ if (!value) throw new Error("Frankie components must be rendered inside <FrankieProvider>.");
87
+ return value;
88
+ }
89
+
90
+ /** The theme actually in effect: provider override wins over server config. */
91
+ export function useFrankieTheme(): WidgetTheme | undefined {
92
+ const { config, theme } = useFrankie();
93
+ return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
94
+ }