@frankieai/react 0.1.5 → 0.2.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 +1 -1
- package/dist/index.cjs +247 -154
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -11
- package/dist/index.d.ts +16 -11
- package/dist/index.js +245 -152
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ChatPanel.tsx +3 -2
- package/src/components/chat/FrankieChatPage.tsx +73 -101
- package/src/components/chat/FrankieWidget.tsx +7 -3
- package/src/hooks/FrankieProvider.tsx +12 -4
- package/src/hooks/useFrankieChat.ts +19 -6
- package/src/lib/client.ts +16 -6
- package/src/styles/frankie.css +22 -1
- package/src/widget.test.tsx +35 -1
|
@@ -49,7 +49,7 @@ export function ChatPanel({
|
|
|
49
49
|
}: ChatPanelProps) {
|
|
50
50
|
const { config, configError, loading } = useFrankie();
|
|
51
51
|
const theme = useFrankieTheme();
|
|
52
|
-
const { messages, busy, error, send, stop, reset } = useFrankieChat();
|
|
52
|
+
const { messages, busy, restoring, loadedSkills, error, send, stop, reset } = useFrankieChat();
|
|
53
53
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
54
54
|
|
|
55
55
|
const features = config?.features;
|
|
@@ -137,13 +137,14 @@ export function ChatPanel({
|
|
|
137
137
|
</CardContent>
|
|
138
138
|
|
|
139
139
|
<CardFooter className="flex flex-col gap-2">
|
|
140
|
+
{loadedSkills !== null ? <details className="frankie-loaded-skills"><summary role="status">{loadedSkills.length ? `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded` : "No skills loaded for this turn"}</summary>{loadedSkills.length ? <ul>{loadedSkills.map(skill => <li key={skill.id}>{skill.name}<small>v{skill.revision}</small></li>)}</ul> : <p>Your assistant can still answer using its available tools.</p>}</details> : null}
|
|
140
141
|
{error ? <p className="w-full text-xs text-red-600 dark:text-red-400">{error}</p> : null}
|
|
141
142
|
<div className="w-full">
|
|
142
143
|
<Composer
|
|
143
144
|
onSend={send}
|
|
144
145
|
onStop={stop}
|
|
145
146
|
busy={busy}
|
|
146
|
-
disabled={Boolean(configError)}
|
|
147
|
+
disabled={loading || restoring || Boolean(configError)}
|
|
147
148
|
{...(placeholder ? { placeholder } : {})}
|
|
148
149
|
{...(autoFocus ? { autoFocus } : {})}
|
|
149
150
|
{...(features?.attachments.enabled ? { attachments: features.attachments } : {})}
|
|
@@ -1,121 +1,93 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
|
4
|
+
import { History, Menu, Plus, Settings, X } from "lucide-react";
|
|
4
5
|
import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
|
|
5
|
-
import { FrankieProvider, useFrankie } from "../../hooks/FrankieProvider.jsx";
|
|
6
|
+
import { FrankieProvider, useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
6
7
|
import { useFrankieChat } from "../../hooks/useFrankieChat.js";
|
|
8
|
+
import { themeToCssVars, resolveMode } from "../../lib/theme.js";
|
|
9
|
+
import type { ChatConversation } from "../../lib/client.js";
|
|
7
10
|
import { FrankieInline } from "./FrankieInline.js";
|
|
8
11
|
import type { ChatPanelProps } from "./ChatPanel.js";
|
|
9
12
|
|
|
10
|
-
export interface FrankieChatPageProps
|
|
11
|
-
|
|
12
|
-
Pick<
|
|
13
|
-
ChatPanelProps,
|
|
14
|
-
"header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"
|
|
15
|
-
> {
|
|
13
|
+
export interface FrankieChatPageProps extends Omit<FrankieProviderProps, "children">,
|
|
14
|
+
Pick<ChatPanelProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"> {
|
|
16
15
|
className?: string;
|
|
17
16
|
style?: CSSProperties;
|
|
17
|
+
/** Include the built-in conversation sidebar. Defaults to true. */
|
|
18
|
+
showHistory?: boolean;
|
|
19
|
+
/** Optional host navigation, e.g. a Back to dashboard link. */
|
|
20
|
+
navigation?: React.ReactNode;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
function PageEmptyState() {
|
|
21
24
|
const { config } = useFrankie();
|
|
22
|
-
const { send, busy } = useFrankieChat();
|
|
25
|
+
const { send, busy, restoring } = useFrankieChat();
|
|
23
26
|
const name = config?.branding.name ?? "Frankie";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
<div>
|
|
30
|
-
<p className="frankie-page-kicker">{name} assistant</p>
|
|
31
|
-
<h1>What would you like to do?</h1>
|
|
32
|
-
<p>{config?.greeting ?? "Ask a question or choose a task to get started."}</p>
|
|
33
|
-
</div>
|
|
34
|
-
{prompts.length ? (
|
|
35
|
-
<div className="frankie-page-prompts">
|
|
36
|
-
{prompts.map((prompt, index) => (
|
|
37
|
-
<button key={prompt} type="button" disabled={busy} onClick={() => send(prompt)}>
|
|
38
|
-
<span>{String(index + 1).padStart(2, "0")}</span>
|
|
39
|
-
<strong>{prompt}</strong>
|
|
40
|
-
<b aria-hidden>↗</b>
|
|
41
|
-
</button>
|
|
42
|
-
))}
|
|
43
|
-
</div>
|
|
44
|
-
) : null}
|
|
45
|
-
</section>
|
|
46
|
-
);
|
|
27
|
+
return <section className="frankie-page-empty" aria-label="Start a conversation">
|
|
28
|
+
<span className="frankie-page-avatar" aria-hidden>{name.trim().slice(0, 1).toUpperCase()}</span>
|
|
29
|
+
<div><p className="frankie-page-kicker">Your product assistant</p><h1>How can I help?</h1><p>{config?.greeting ?? "Ask a question or choose a task to get started."}</p></div>
|
|
30
|
+
<div className="frankie-page-prompts">{config?.suggestedPrompts?.slice(0, 3).map(prompt => <button key={prompt} type="button" disabled={busy || restoring} onClick={() => void send(prompt)}><strong>{prompt}</strong><b aria-hidden>↗</b></button>)}</div>
|
|
31
|
+
</section>;
|
|
47
32
|
}
|
|
48
33
|
|
|
49
|
-
function PageChrome({
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
34
|
+
function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...panel }: Pick<FrankieChatPageProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus" | "navigation"> & { showHistory: boolean; currentId: string; onSelect: (id: string) => void; onNew: () => void }) {
|
|
35
|
+
const { client, config } = useFrankie();
|
|
36
|
+
const { busy } = useFrankieChat();
|
|
37
|
+
const theme = useFrankieTheme();
|
|
38
|
+
const [conversations, setConversations] = useState<ChatConversation[]>([]);
|
|
39
|
+
const [historyError, setHistoryError] = useState("");
|
|
40
|
+
const [historyLoading, setHistoryLoading] = useState(false);
|
|
41
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
42
|
+
const [query, setQuery] = useState("");
|
|
43
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
44
|
+
const menu = useRef<HTMLButtonElement>(null);
|
|
45
|
+
const requestVersion = useRef(0);
|
|
60
46
|
const name = config?.branding.name ?? "Frankie";
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
47
|
+
const refresh = useCallback(async () => {
|
|
48
|
+
if (!showHistory || !config || config.allowAnonymous) { setHistoryError(""); return; }
|
|
49
|
+
const version = ++requestVersion.current;
|
|
50
|
+
setHistoryLoading(true); setHistoryError("");
|
|
51
|
+
try { const items = await client.listConversations(); if (version === requestVersion.current) setConversations(items); }
|
|
52
|
+
catch { if (version === requestVersion.current) setHistoryError("History couldn’t load. Check your sign-in and try again."); }
|
|
53
|
+
finally { if (version === requestVersion.current) setHistoryLoading(false); }
|
|
54
|
+
}, [client, showHistory, config?.allowAnonymous]);
|
|
55
|
+
useEffect(() => { if (!busy) void refresh(); return () => { requestVersion.current++; }; }, [refresh, busy]);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const node = dialog.current;
|
|
58
|
+
if (drawerOpen) { node?.showModal?.(); }
|
|
59
|
+
else { node?.close?.(); }
|
|
60
|
+
}, [drawerOpen]);
|
|
61
|
+
const close = () => { setDrawerOpen(false); menu.current?.focus(); };
|
|
62
|
+
const select = (id: string) => { if (!busy) { onSelect(id); close(); } };
|
|
63
|
+
const history = <>
|
|
64
|
+
<div className="frankie-history-heading"><strong>Conversations</strong><button type="button" className="frankie-mobile-only" aria-label="Close conversations" onClick={close}><X size={18} /></button></div>
|
|
65
|
+
<button type="button" className="frankie-new-chat" disabled={busy} onClick={() => { onNew(); close(); }}><Plus size={17} />New chat</button>
|
|
66
|
+
<label className="frankie-history-search"><span>Search conversations</span><input type="search" placeholder="Search chats…" value={query} onChange={event => setQuery(event.target.value)} /></label>
|
|
67
|
+
<nav className="frankie-history-list" aria-label="Conversations">{conversations.filter(item => item.title.toLowerCase().includes(query.toLowerCase())).map(item => <button type="button" key={item.id} aria-current={item.id === currentId ? "page" : undefined} disabled={busy} onClick={() => select(item.id)}><span>{item.title}</span><small>{new Date(item.updatedAt).toLocaleDateString()}</small></button>)}</nav>
|
|
68
|
+
{historyLoading ? <p role="status">Loading history…</p> : historyError ? <p role="status">{historyError}<button type="button" onClick={() => void refresh()}>Try again</button></p> : !conversations.length ? <p>{config?.allowAnonymous ? "Sign in to sync conversations across devices." : "Your conversations will appear here."}</p> : null}
|
|
69
|
+
{navigation ? <div className="frankie-host-navigation">{navigation}</div> : null}
|
|
70
|
+
</>;
|
|
71
|
+
return <div className={"frankie-page-shell" + (resolveMode(theme?.mode) === "dark" ? " dark" : "")} style={themeToCssVars(theme)}>
|
|
72
|
+
{showHistory ? <aside className="frankie-history-desktop">{history}</aside> : null}
|
|
73
|
+
<div className="frankie-page-main">
|
|
74
|
+
<header className="frankie-page-header"><div>{showHistory ? <button type="button" className="frankie-mobile-only" ref={menu} aria-label="Open conversations" aria-haspopup="dialog" aria-expanded={drawerOpen} onClick={() => setDrawerOpen(true)}><Menu size={20} /></button> : null}<strong>{name}</strong><span className="frankie-header-caption">Product assistant</span></div><div>
|
|
75
|
+
<button type="button" disabled={busy} onClick={onNew} aria-label="Start a new conversation"><Plus size={16} /><span>New chat</span></button>
|
|
76
|
+
<details className="frankie-chat-settings"><summary aria-label="Chat settings"><Settings size={19} /></summary><div><strong>Chat settings</strong><p>History syncs automatically for signed-in users.</p><button type="button" onClick={() => void refresh()} disabled={historyLoading || !showHistory}><History size={16} />Refresh history</button><small>Skills are checked at the start of each message.</small></div></details>
|
|
77
|
+
</div></header>
|
|
78
|
+
<div className="frankie-page-canvas"><FrankieInline {...panel} header={panel.header ?? false} emptyState={panel.emptyState ?? <PageEmptyState />} /></div>
|
|
79
|
+
</div>
|
|
80
|
+
{showHistory ? <dialog ref={dialog} className="frankie-history-dialog frankie-root" aria-label="Chat conversations" style={themeToCssVars(theme)} onCancel={close} onClick={event => { if (event.target === event.currentTarget) close(); }}><aside>{history}</aside></dialog> : null}
|
|
81
|
+
</div>;
|
|
84
82
|
}
|
|
85
83
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
footer,
|
|
96
|
-
emptyState,
|
|
97
|
-
renderMessage,
|
|
98
|
-
renderTool,
|
|
99
|
-
placeholder,
|
|
100
|
-
autoFocus,
|
|
101
|
-
...providerProps
|
|
102
|
-
}: FrankieChatPageProps) {
|
|
103
|
-
return (
|
|
104
|
-
<main
|
|
105
|
-
className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")}
|
|
106
|
-
style={{ height: "100dvh", minHeight: "100dvh", width: "100%", ...style }}
|
|
107
|
-
>
|
|
108
|
-
<FrankieProvider {...providerProps}>
|
|
109
|
-
<PageChrome
|
|
110
|
-
header={header}
|
|
111
|
-
footer={footer}
|
|
112
|
-
emptyState={emptyState}
|
|
113
|
-
renderMessage={renderMessage}
|
|
114
|
-
renderTool={renderTool}
|
|
115
|
-
placeholder={placeholder}
|
|
116
|
-
autoFocus={autoFocus}
|
|
117
|
-
/>
|
|
118
|
-
</FrankieProvider>
|
|
119
|
-
</main>
|
|
120
|
-
);
|
|
84
|
+
/** Reusable product chat with one shared conversation state and scoped styling. */
|
|
85
|
+
export function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, showHistory = true, navigation, ...provider }: FrankieChatPageProps) {
|
|
86
|
+
const [id, setId] = useState(() => provider.conversationId ?? crypto.randomUUID());
|
|
87
|
+
const onNew = () => setId(crypto.randomUUID());
|
|
88
|
+
return <div className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")} style={{ height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style }}>
|
|
89
|
+
<FrankieProvider {...provider} conversationId={id} storageNamespace={(provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id}>
|
|
90
|
+
<PageChrome showHistory={showHistory} navigation={navigation} currentId={id} onSelect={setId} onNew={onNew} header={header} footer={footer} emptyState={emptyState} renderMessage={renderMessage} renderTool={renderTool} placeholder={placeholder} autoFocus={autoFocus} />
|
|
91
|
+
</FrankieProvider>
|
|
92
|
+
</div>;
|
|
121
93
|
}
|
|
@@ -13,6 +13,8 @@ export interface FrankieWidgetProps extends ChatPanelProps {
|
|
|
13
13
|
/** Distance from the viewport edges, in pixels. */
|
|
14
14
|
offset?: number;
|
|
15
15
|
defaultOpen?: boolean;
|
|
16
|
+
/** Host overlays should sit above the assistant. Override for your layer system. */
|
|
17
|
+
zIndex?: number;
|
|
16
18
|
/** Controlled mode: pass both to drive open state from the host app. */
|
|
17
19
|
open?: boolean;
|
|
18
20
|
onOpenChange?: (open: boolean) => void;
|
|
@@ -30,6 +32,7 @@ export function FrankieWidget({
|
|
|
30
32
|
position = "bottom-right",
|
|
31
33
|
offset = 24,
|
|
32
34
|
defaultOpen = false,
|
|
35
|
+
zIndex = 20,
|
|
33
36
|
open: controlledOpen,
|
|
34
37
|
onOpenChange,
|
|
35
38
|
renderLauncher,
|
|
@@ -63,11 +66,12 @@ export function FrankieWidget({
|
|
|
63
66
|
return (
|
|
64
67
|
<div
|
|
65
68
|
className={cn("frankie-root frankie-widget", mode === "dark" && "dark")}
|
|
66
|
-
style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex:
|
|
69
|
+
style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none" }}
|
|
67
70
|
data-frankie-mode={mode}
|
|
68
71
|
>
|
|
69
72
|
<div className={cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end")}>
|
|
70
|
-
{open ? <ChatPanel {...panelProps} onClose={() => setOpen(false)} autoFocus
|
|
73
|
+
{open ? <div style={{ pointerEvents: "auto", maxHeight: "calc(100dvh - 100px)", overflow: "hidden" }}><ChatPanel {...panelProps} className={cn("frankie-widget-panel", panelProps.className)} onClose={() => setOpen(false)} autoFocus /></div> : null}
|
|
74
|
+
<div style={{ pointerEvents: "auto" }}>
|
|
71
75
|
{renderLauncher ? (
|
|
72
76
|
renderLauncher({ open, toggle: () => setOpen(!open), label })
|
|
73
77
|
) : (
|
|
@@ -81,7 +85,7 @@ export function FrankieWidget({
|
|
|
81
85
|
>
|
|
82
86
|
{open ? <X className="size-5" /> : <MessageCircle className="size-5" />}
|
|
83
87
|
</Button>
|
|
84
|
-
)}
|
|
88
|
+
)}</div>
|
|
85
89
|
</div>
|
|
86
90
|
</div>
|
|
87
91
|
);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
3
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
4
4
|
import type { PublicWidgetConfig, WidgetTheme } from "@frankieai/core";
|
|
5
5
|
import { FrankieClient, type FrankieClientOptions, type PageContext } from "../lib/client.js";
|
|
6
|
+
import { FrankieChatContext, useFrankieChatState } from "./useFrankieChat.js";
|
|
6
7
|
|
|
7
8
|
export interface FrankieProviderProps extends FrankieClientOptions {
|
|
8
9
|
children: ReactNode;
|
|
@@ -29,10 +30,12 @@ export function FrankieProvider({
|
|
|
29
30
|
config: providedConfig,
|
|
30
31
|
...clientOptions
|
|
31
32
|
}: FrankieProviderProps) {
|
|
33
|
+
const tokenCallback = useRef(clientOptions.getUserToken);
|
|
34
|
+
tokenCallback.current = clientOptions.getUserToken;
|
|
32
35
|
const client = useMemo(
|
|
33
|
-
() => new FrankieClient(clientOptions),
|
|
36
|
+
() => new FrankieClient({ ...clientOptions, ...(clientOptions.getUserToken ? { getUserToken: () => tokenCallback.current?.() } : {}) }),
|
|
34
37
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
-
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId],
|
|
38
|
+
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId, clientOptions.persist, clientOptions.fetchImpl, Boolean(clientOptions.getUserToken)],
|
|
36
39
|
);
|
|
37
40
|
|
|
38
41
|
const [config, setConfig] = useState<PublicWidgetConfig | undefined>(providedConfig);
|
|
@@ -71,7 +74,12 @@ export function FrankieProvider({
|
|
|
71
74
|
[client, config, configError, loading, context, theme],
|
|
72
75
|
);
|
|
73
76
|
|
|
74
|
-
return <FrankieContext.Provider value={value}>{children}</FrankieContext.Provider>;
|
|
77
|
+
return <FrankieContext.Provider value={value}><ConversationState key={client.namespace + ":" + (client.conversationId ?? "")}>{children}</ConversationState></FrankieContext.Provider>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ConversationState({ children }: { children: ReactNode }) {
|
|
81
|
+
const chat = useFrankieChatState();
|
|
82
|
+
return <FrankieChatContext.Provider value={chat}>{children}</FrankieChatContext.Provider>;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
export function useFrankie(): FrankieContextValue {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import type { ChatAttachment } from "@frankieai/core";
|
|
3
|
+
import { createContext, useContext, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { ChatAttachment, LoadedSkill } from "@frankieai/core";
|
|
5
5
|
import { FrankieApiError } from "../lib/client.js";
|
|
6
6
|
import {
|
|
7
7
|
applyEvent,
|
|
@@ -19,6 +19,8 @@ export interface SendOptions {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export interface UseFrankieChatResult {
|
|
22
|
+
loadedSkills: LoadedSkill[] | null;
|
|
23
|
+
restoring: boolean;
|
|
22
24
|
messages: ChatMessage[];
|
|
23
25
|
/** True from the moment a message is sent until the turn ends. */
|
|
24
26
|
busy: boolean;
|
|
@@ -34,12 +36,20 @@ export interface UseFrankieChatResult {
|
|
|
34
36
|
let counter = 0;
|
|
35
37
|
const nextId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${(counter += 1)}`;
|
|
36
38
|
|
|
39
|
+
export const FrankieChatContext = createContext<UseFrankieChatResult | null>(null);
|
|
37
40
|
export function useFrankieChat(): UseFrankieChatResult {
|
|
41
|
+
const value = useContext(FrankieChatContext);
|
|
42
|
+
if (!value) throw new Error("useFrankieChat requires FrankieProvider.");
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useFrankieChatState(): UseFrankieChatResult {
|
|
38
47
|
const { client, context } = useFrankie();
|
|
39
48
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
40
49
|
const [busy, setBusy] = useState(false);
|
|
41
50
|
const [error, setError] = useState<string | undefined>(undefined);
|
|
42
51
|
const [restored, setRestored] = useState(false);
|
|
52
|
+
const [loadedSkills, setLoadedSkills] = useState<LoadedSkill[] | null>(null);
|
|
43
53
|
const abortRef = useRef<AbortController | null>(null);
|
|
44
54
|
const mounted = useRef(true);
|
|
45
55
|
// State updates can be batched while a stream is arriving. Keep the latest
|
|
@@ -82,7 +92,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
82
92
|
setMessages(restoredMessages);
|
|
83
93
|
}
|
|
84
94
|
} catch {
|
|
85
|
-
|
|
95
|
+
if (active) setError("Could not restore this conversation. Check your sign-in and connection, then try opening it again.");
|
|
86
96
|
} finally {
|
|
87
97
|
if (active) setRestored(true);
|
|
88
98
|
}
|
|
@@ -117,12 +127,13 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
117
127
|
async (text: string, opts?: SendOptions) => {
|
|
118
128
|
const trimmed = text.trim();
|
|
119
129
|
const attachments = opts?.attachments ?? [];
|
|
120
|
-
if ((!trimmed && attachments.length === 0) ||
|
|
130
|
+
if ((!trimmed && attachments.length === 0) || abortRef.current || !restored) return;
|
|
121
131
|
// The server needs a message; an attachment-only turn gets a stand-in.
|
|
122
132
|
const outgoing = trimmed || "Please take a look at the attached file(s).";
|
|
123
133
|
|
|
124
134
|
const assistantId = nextId("a");
|
|
125
135
|
setError(undefined);
|
|
136
|
+
setLoadedSkills(null);
|
|
126
137
|
setBusy(true);
|
|
127
138
|
const next = [
|
|
128
139
|
...messagesRef.current,
|
|
@@ -151,6 +162,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
151
162
|
signal: abort.signal,
|
|
152
163
|
},
|
|
153
164
|
(event) => {
|
|
165
|
+
if (event.type === "skills_loaded") { setLoadedSkills(event.skills); return; }
|
|
154
166
|
if (event.type === "ping" || event.type === "session") return;
|
|
155
167
|
if (event.type === "text_delta") receivedText = true;
|
|
156
168
|
if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
|
|
@@ -202,7 +214,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
202
214
|
abortRef.current = null;
|
|
203
215
|
}
|
|
204
216
|
},
|
|
205
|
-
[busy, client, context, patchAssistant],
|
|
217
|
+
[busy, client, context, patchAssistant, restored],
|
|
206
218
|
);
|
|
207
219
|
|
|
208
220
|
const stop = useCallback(() => abortRef.current?.abort(), []);
|
|
@@ -215,6 +227,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
215
227
|
setError(undefined);
|
|
216
228
|
setBusy(false);
|
|
217
229
|
setRestored(true);
|
|
230
|
+
setLoadedSkills(null);
|
|
218
231
|
try {
|
|
219
232
|
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
220
233
|
} catch {
|
|
@@ -222,7 +235,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
222
235
|
}
|
|
223
236
|
}, [client]);
|
|
224
237
|
|
|
225
|
-
return { messages, busy, ...(error ? { error } : {}), send, stop, reset };
|
|
238
|
+
return { messages, busy, loadedSkills, restoring: !restored, ...(error ? { error } : {}), send, stop, reset };
|
|
226
239
|
}
|
|
227
240
|
|
|
228
241
|
|
package/src/lib/client.ts
CHANGED
|
@@ -21,15 +21,15 @@ export interface FrankieClientOptions {
|
|
|
21
21
|
publishableKey: string;
|
|
22
22
|
/**
|
|
23
23
|
* Fetches a short-lived JWT for the signed-in user from the host's own
|
|
24
|
-
* backend. Called
|
|
24
|
+
* backend. Called before each turn; return undefined after sign-out.
|
|
25
25
|
* Omit only for tenants that allow anonymous chat.
|
|
26
26
|
*/
|
|
27
27
|
getUserToken?: () => Promise<string | undefined> | string | undefined;
|
|
28
28
|
fetchImpl?: typeof fetch;
|
|
29
29
|
/**
|
|
30
30
|
* Remember the session (and, in the hook, the transcript) in localStorage so a
|
|
31
|
-
* page reload resumes the same conversation.
|
|
32
|
-
*
|
|
31
|
+
* page reload resumes the same anonymous conversation. Authenticated chat
|
|
32
|
+
* never writes tokens or transcripts to localStorage; it uses server history.
|
|
33
33
|
*/
|
|
34
34
|
persist?: boolean;
|
|
35
35
|
/** Override localStorage namespace for session and transcript. */
|
|
@@ -59,11 +59,15 @@ export class FrankieClient {
|
|
|
59
59
|
private readonly baseUrl: string;
|
|
60
60
|
private readonly doFetch: typeof fetch;
|
|
61
61
|
private session?: CreateSessionResponse;
|
|
62
|
+
private sessionUserToken?: string;
|
|
62
63
|
|
|
63
64
|
constructor(private readonly options: FrankieClientOptions) {
|
|
64
65
|
this.baseUrl = (options.baseUrl ?? FRANKIE_CLOUD_URL).replace(/\/+$/, "");
|
|
65
66
|
this.doFetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
66
67
|
this.session = this.readStored();
|
|
68
|
+
if (options.getUserToken && typeof window !== "undefined") {
|
|
69
|
+
try { for (const suffix of ["session", "messages"]) window.localStorage.removeItem(`${this.namespace}.${suffix}`); } catch { /* storage may be unavailable */ }
|
|
70
|
+
}
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
get sessionId(): string | undefined {
|
|
@@ -76,7 +80,9 @@ export class FrankieClient {
|
|
|
76
80
|
|
|
77
81
|
/** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
|
|
78
82
|
get persist(): boolean {
|
|
79
|
-
|
|
83
|
+
// Authenticated history comes from the identity-checked server. Do not leave
|
|
84
|
+
// a previous user's bearer token or transcript in shared browser storage.
|
|
85
|
+
return !this.options.getUserToken && this.options.persist !== false && typeof window !== "undefined";
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
/** localStorage namespace for this tenant/conversation, shared with the chat hook. */
|
|
@@ -115,15 +121,17 @@ export class FrankieClient {
|
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
async ensureSession(context?: PageContext): Promise<CreateSessionResponse> {
|
|
118
|
-
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) return this.session;
|
|
119
124
|
const userToken = await this.options.getUserToken?.();
|
|
120
125
|
if (this.options.getUserToken && !userToken) {
|
|
126
|
+
this.resetSession();
|
|
121
127
|
throw new FrankieApiError(
|
|
122
128
|
"Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
|
|
123
129
|
401,
|
|
124
130
|
"user_token_unavailable",
|
|
125
131
|
);
|
|
126
132
|
}
|
|
133
|
+
if (userToken !== this.sessionUserToken) this.resetSession();
|
|
134
|
+
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) return this.session;
|
|
127
135
|
const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
|
|
128
136
|
method: "POST",
|
|
129
137
|
headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
|
|
@@ -134,6 +142,7 @@ export class FrankieClient {
|
|
|
134
142
|
}),
|
|
135
143
|
});
|
|
136
144
|
this.session = (await this.unwrap(response)) as CreateSessionResponse;
|
|
145
|
+
this.sessionUserToken = userToken;
|
|
137
146
|
this.writeStored();
|
|
138
147
|
return this.session;
|
|
139
148
|
}
|
|
@@ -148,7 +157,7 @@ export class FrankieClient {
|
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
async resumeConversation(id = this.options.conversationId): Promise<{ messages: unknown[]; title: string } | undefined> {
|
|
151
|
-
if (!id) return undefined;
|
|
160
|
+
if (!id || !this.options.getUserToken) return undefined;
|
|
152
161
|
const userToken = await this.requireUserToken();
|
|
153
162
|
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
|
|
154
163
|
method: "POST",
|
|
@@ -157,6 +166,7 @@ export class FrankieClient {
|
|
|
157
166
|
if (response.status === 404) return undefined;
|
|
158
167
|
const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string };
|
|
159
168
|
this.session = body.session;
|
|
169
|
+
this.sessionUserToken = userToken;
|
|
160
170
|
this.writeStored();
|
|
161
171
|
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
|
|
162
172
|
}
|
package/src/styles/frankie.css
CHANGED
|
@@ -45,6 +45,27 @@
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
@layer components {
|
|
48
|
+
.frankie-page-shell { display:flex; flex:1; min-height:0; height:100%; background:var(--frankie-background); color:var(--frankie-foreground); }
|
|
49
|
+
.frankie-page-main { display:flex; flex:1; min-width:0; min-height:0; flex-direction:column; }
|
|
50
|
+
.frankie-history-desktop { width:260px; flex-shrink:0; display:flex; flex-direction:column; gap:16px; padding:20px 14px; border-right:1px solid var(--frankie-border); background:var(--frankie-secondary); overflow:auto; }
|
|
51
|
+
.frankie-history-heading { display:flex; align-items:center; justify-content:space-between; font-size:14px; }
|
|
52
|
+
.frankie-new-chat { display:flex; align-items:center; justify-content:center; gap:8px; border:1px solid var(--frankie-border); border-radius:10px; background:var(--frankie-background); min-height:44px; font-weight:600; cursor:pointer; }
|
|
53
|
+
.frankie-history-search > span { display:block; font-size:12px; color:var(--frankie-muted-foreground); margin-bottom:6px; }
|
|
54
|
+
.frankie-history-search input { width:100%; min-height:40px; padding:8px 10px; border:1px solid var(--frankie-border); border-radius:8px; background:var(--frankie-background); }
|
|
55
|
+
.frankie-history-list { display:flex; flex-direction:column; gap:4px; flex:1; }
|
|
56
|
+
.frankie-history-list button { display:flex; flex-direction:column; align-items:flex-start; gap:5px; border:1px solid transparent; border-radius:8px; padding:10px; min-height:54px; background:transparent; text-align:left; cursor:pointer; }
|
|
57
|
+
.frankie-history-list button span { width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; }
|
|
58
|
+
.frankie-history-list small { font-size:11px; color:var(--frankie-muted-foreground); }.frankie-history-list button:hover,.frankie-history-list button[aria-current] { background:var(--frankie-background); border-color:var(--frankie-border); }
|
|
59
|
+
.frankie-history-desktop > p,.frankie-history-dialog p { font-size:13px; color:var(--frankie-muted-foreground); }
|
|
60
|
+
.frankie-header-caption { color:var(--frankie-muted-foreground); font-size:12px; }.frankie-page-header button { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-width:40px; min-height:40px; cursor:pointer; }
|
|
61
|
+
.frankie-chat-settings { position:relative; }.frankie-chat-settings summary { list-style:none; display:grid; place-items:center; width:40px; height:40px; cursor:pointer; border-radius:8px; }.frankie-chat-settings summary::-webkit-details-marker { display:none; }
|
|
62
|
+
.frankie-chat-settings > div { position:absolute; top:46px; right:0; z-index:5; width:min(280px,80vw); display:grid; gap:12px; padding:16px; border:1px solid var(--frankie-border); border-radius:12px; background:var(--frankie-background); box-shadow:0 10px 30px #0002; font-size:13px; }.frankie-chat-settings p { margin:0; color:var(--frankie-muted-foreground); }
|
|
63
|
+
.frankie-loaded-skills { width:100%; max-width:760px; font-size:12px; color:var(--frankie-muted-foreground); }.frankie-loaded-skills summary { cursor:pointer; padding:6px 0; }.frankie-loaded-skills ul { list-style:none; padding:4px 0; margin:0; }.frankie-loaded-skills li { display:flex; gap:8px; padding:4px 0; }.frankie-loaded-skills small { opacity:.7; }
|
|
64
|
+
.frankie-history-dialog { padding:0; border:0; margin:0; max-height:none; height:100dvh; max-width:none; width:min(320px,86vw); background:var(--frankie-secondary); color:var(--frankie-foreground); }.frankie-history-dialog::backdrop { background:#0008; }.frankie-history-dialog > aside { display:flex; flex-direction:column; gap:16px; height:100%; padding:calc(20px + env(safe-area-inset-top)) 16px calc(20px + env(safe-area-inset-bottom)); overflow:auto; }
|
|
65
|
+
.frankie-mobile-only { display:none!important; }.frankie-widget-panel { max-height:calc(100dvh - 100px); }
|
|
66
|
+
.frankie-root button:focus-visible,.frankie-root summary:focus-visible,.frankie-root input:focus-visible { outline:2px solid var(--frankie-primary); outline-offset:3px; }
|
|
67
|
+
.frankie-root button:disabled { opacity:.5; cursor:not-allowed; }
|
|
68
|
+
@media(max-width:760px) { .frankie-history-desktop { display:none; }.frankie-mobile-only { display:inline-flex!important; }.frankie-header-caption { display:none; }.frankie-page-header { padding:6px 10px; }.frankie-history-heading button { border:0; background:transparent; min-width:44px; min-height:44px; align-items:center; justify-content:center; }.frankie-page-prompts { gap:8px; }.frankie-widget-panel { width:calc(100vw - 32px); } }
|
|
48
69
|
.frankie-root {
|
|
49
70
|
--frankie-background: #ffffff;
|
|
50
71
|
--frankie-foreground: #0a0a0a;
|
|
@@ -294,7 +315,7 @@
|
|
|
294
315
|
.frankie-page-prompts button {
|
|
295
316
|
display: grid;
|
|
296
317
|
min-height: 60px;
|
|
297
|
-
grid-template-columns:
|
|
318
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
298
319
|
align-items: center;
|
|
299
320
|
gap: .65rem;
|
|
300
321
|
padding: .75rem .9rem;
|
package/src/widget.test.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
1
|
+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import type { FrankieServerEvent, PublicWidgetConfig } from "@frankieai/core";
|
|
4
4
|
import { FrankieProvider } from "./hooks/FrankieProvider.jsx";
|
|
@@ -7,6 +7,7 @@ import { Markdown } from "./components/chat/Markdown.js";
|
|
|
7
7
|
import { FrankieClient, readEventStream } from "./lib/client.js";
|
|
8
8
|
import { applyEvent, newAssistantMessage } from "./lib/messages.js";
|
|
9
9
|
import { themeToCssVars } from "./lib/theme.js";
|
|
10
|
+
import { useFrankieChat } from "./hooks/useFrankieChat.js";
|
|
10
11
|
|
|
11
12
|
const config: PublicWidgetConfig = {
|
|
12
13
|
tenantId: "acme",
|
|
@@ -97,6 +98,24 @@ describe("readEventStream", () => {
|
|
|
97
98
|
});
|
|
98
99
|
|
|
99
100
|
describe("FrankieClient", () => {
|
|
101
|
+
it("does not request signed-in history for an anonymous conversation", async () => {
|
|
102
|
+
const fetchImpl = vi.fn();
|
|
103
|
+
const client = new FrankieClient({ publishableKey: "pk_anonymous_fixture", conversationId: "new", fetchImpl, persist: false });
|
|
104
|
+
expect(await client.resumeConversation()).toBeUndefined();
|
|
105
|
+
expect(fetchImpl).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
it("keeps authenticated tokens out of storage and rejects a cached session after sign-out", async () => {
|
|
108
|
+
let token: string | undefined = "fixture-user-token";
|
|
109
|
+
const fetchImpl = vi.fn(async () => Response.json({ sessionId: "one", sessionToken: "fixture-bearer", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: false } }));
|
|
110
|
+
const client = new FrankieClient({ publishableKey: "pk_auth_fixture", getUserToken: () => token, fetchImpl });
|
|
111
|
+
await client.ensureSession();
|
|
112
|
+
expect(client.persist).toBe(false);
|
|
113
|
+
expect(window.localStorage.getItem("frankie.pk_auth_fixture.session")).toBeNull();
|
|
114
|
+
token = undefined;
|
|
115
|
+
await expect(client.ensureSession()).rejects.toThrow("sign in again");
|
|
116
|
+
expect(client.sessionId).toBeUndefined();
|
|
117
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
100
119
|
// The client persists its session to localStorage; isolate tests that reuse
|
|
101
120
|
// the same publishable-key namespace.
|
|
102
121
|
beforeEach(() => {
|
|
@@ -192,6 +211,21 @@ describe("themeToCssVars", () => {
|
|
|
192
211
|
});
|
|
193
212
|
|
|
194
213
|
describe("ChatPanel", () => {
|
|
214
|
+
it("shares prompt controls, transcript and loaded skills across provider consumers", async () => {
|
|
215
|
+
cleanup();
|
|
216
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => String(url).endsWith("/v1/session") ? Response.json({ sessionId: "shared", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: true } }) : new Response(sseStream([{ type: "skills_loaded", skills: [{ id: "review", name: "Review items", revision: 2 }] }, { type: "text_delta", text: "Shared response" }, { type: "turn_end", stopReason: "end_turn" }])));
|
|
217
|
+
function Prompt() { const chat = useFrankieChat(); return <button disabled={chat.restoring} onClick={() => void chat.send("Shared prompt")}>External prompt</button>; }
|
|
218
|
+
render(<FrankieProvider publishableKey="pk_shared_fixture" config={config} fetchImpl={fetchImpl} persist={false}><Prompt /><ChatPanel /></FrankieProvider>);
|
|
219
|
+
await waitFor(() => expect((screen.getByRole("button", { name: "External prompt" }) as HTMLButtonElement).disabled).toBe(false));
|
|
220
|
+
fireEvent.click(screen.getByRole("button", { name: "External prompt" }));
|
|
221
|
+
expect(await screen.findByText("Shared response")).toBeTruthy();
|
|
222
|
+
expect(screen.getByText("1 skill loaded")).toBeTruthy();
|
|
223
|
+
expect(fetchImpl.mock.calls.filter(([url]) => String(url).endsWith("/v1/chat"))).toHaveLength(1);
|
|
224
|
+
fireEvent.click(screen.getByRole("button", { name: "Start a new conversation" }));
|
|
225
|
+
await waitFor(() => expect(screen.queryByText("Shared response")).toBeNull());
|
|
226
|
+
expect(screen.queryByText("1 skill loaded")).toBeNull();
|
|
227
|
+
cleanup();
|
|
228
|
+
});
|
|
195
229
|
it("shows the greeting and suggested prompts before the first message", async () => {
|
|
196
230
|
render(
|
|
197
231
|
<FrankieProvider baseUrl="https://agent.acme.com" publishableKey="pk_1" config={config}>
|