@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/frankie.css +2 -0
- package/dist/index.cjs +1065 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +310 -0
- package/dist/index.d.ts +310 -0
- package/dist/index.js +1003 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/components/chat/ChatPanel.tsx +147 -0
- package/src/components/chat/Composer.tsx +75 -0
- package/src/components/chat/FrankieInline.tsx +35 -0
- package/src/components/chat/FrankieWidget.tsx +88 -0
- package/src/components/chat/Markdown.tsx +136 -0
- package/src/components/chat/MessageBubble.tsx +70 -0
- package/src/components/chat/SuggestedPrompts.tsx +31 -0
- package/src/components/chat/ToolActivityList.tsx +52 -0
- package/src/components/ui/avatar.tsx +30 -0
- package/src/components/ui/badge.tsx +28 -0
- package/src/components/ui/button.tsx +39 -0
- package/src/components/ui/card.tsx +36 -0
- package/src/components/ui/scroll-area.tsx +15 -0
- package/src/components/ui/separator.tsx +7 -0
- package/src/components/ui/textarea.tsx +20 -0
- package/src/hooks/FrankieProvider.tsx +94 -0
- package/src/hooks/useFrankieChat.ts +148 -0
- package/src/index.ts +34 -0
- package/src/lib/client.ts +217 -0
- package/src/lib/messages.ts +121 -0
- package/src/lib/theme.ts +30 -0
- package/src/lib/utils.ts +7 -0
- package/src/styles/frankie.css +158 -0
- package/src/widget.test.tsx +217 -0
|
@@ -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,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
|
+
}
|