@frankieai/react 0.1.1 → 0.1.5
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 +280 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +33 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +32 -18
- package/dist/index.d.ts +32 -18
- package/dist/index.js +278 -50
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ChatPanel.tsx +6 -2
- package/src/components/chat/FrankieChatPage.tsx +121 -0
- package/src/components/chat/MessageBubble.tsx +22 -5
- package/src/components/chat/ToolActivityList.tsx +10 -6
- package/src/components/chat/message-actions.css +13 -0
- package/src/hooks/FrankieProvider.tsx +1 -9
- package/src/hooks/useFrankieChat.ts +91 -28
- package/src/index.ts +2 -0
- package/src/lib/client.ts +82 -17
- package/src/styles/frankie.css +173 -4
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties } from "react";
|
|
4
|
+
import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
|
|
5
|
+
import { FrankieProvider, useFrankie } from "../../hooks/FrankieProvider.jsx";
|
|
6
|
+
import { useFrankieChat } from "../../hooks/useFrankieChat.js";
|
|
7
|
+
import { FrankieInline } from "./FrankieInline.js";
|
|
8
|
+
import type { ChatPanelProps } from "./ChatPanel.js";
|
|
9
|
+
|
|
10
|
+
export interface FrankieChatPageProps
|
|
11
|
+
extends Omit<FrankieProviderProps, "children">,
|
|
12
|
+
Pick<
|
|
13
|
+
ChatPanelProps,
|
|
14
|
+
"header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus"
|
|
15
|
+
> {
|
|
16
|
+
className?: string;
|
|
17
|
+
style?: CSSProperties;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function PageEmptyState() {
|
|
21
|
+
const { config } = useFrankie();
|
|
22
|
+
const { send, busy } = useFrankieChat();
|
|
23
|
+
const name = config?.branding.name ?? "Frankie";
|
|
24
|
+
const prompts = config?.suggestedPrompts?.slice(0, 3) ?? [];
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<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>
|
|
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
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function PageChrome({
|
|
50
|
+
header,
|
|
51
|
+
footer,
|
|
52
|
+
emptyState,
|
|
53
|
+
renderMessage,
|
|
54
|
+
renderTool,
|
|
55
|
+
placeholder,
|
|
56
|
+
autoFocus,
|
|
57
|
+
}: Pick<FrankieChatPageProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus">) {
|
|
58
|
+
const { config } = useFrankie();
|
|
59
|
+
const { reset } = useFrankieChat();
|
|
60
|
+
const name = config?.branding.name ?? "Frankie";
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<header className="frankie-page-header">
|
|
65
|
+
<div>
|
|
66
|
+
<span className="frankie-page-status" aria-hidden />
|
|
67
|
+
<strong>{name}</strong>
|
|
68
|
+
</div>
|
|
69
|
+
<button type="button" onClick={reset} aria-label="Start a new conversation">New chat</button>
|
|
70
|
+
</header>
|
|
71
|
+
<div className="frankie-page-canvas">
|
|
72
|
+
<FrankieInline
|
|
73
|
+
header={header ?? false}
|
|
74
|
+
footer={footer}
|
|
75
|
+
emptyState={emptyState ?? <PageEmptyState />}
|
|
76
|
+
renderMessage={renderMessage}
|
|
77
|
+
renderTool={renderTool}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
autoFocus={autoFocus}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A complete, polished chat route matching Frankie's own conversation canvas.
|
|
88
|
+
* The shell, header, empty state and composer are all scoped under
|
|
89
|
+
* `.frankie-root`, so host application styles remain untouched.
|
|
90
|
+
*/
|
|
91
|
+
export function FrankieChatPage({
|
|
92
|
+
className,
|
|
93
|
+
style,
|
|
94
|
+
header,
|
|
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
|
+
);
|
|
121
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Paperclip } from "lucide-react";
|
|
4
|
-
import
|
|
3
|
+
import { Check, Copy, Paperclip } from "lucide-react";
|
|
4
|
+
import "./message-actions.css";
|
|
5
|
+
import { useState, type ReactNode } from "react";
|
|
5
6
|
import { cn } from "../../lib/utils.js";
|
|
6
7
|
import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
|
|
7
8
|
import { Avatar } from "../ui/avatar.js";
|
|
@@ -40,8 +41,9 @@ export function MessageBubble({
|
|
|
40
41
|
</div>
|
|
41
42
|
) : null}
|
|
42
43
|
{message.text ? (
|
|
43
|
-
<div className="max-w-[85%]
|
|
44
|
-
{message.text}
|
|
44
|
+
<div className="flex max-w-[85%] items-end gap-1">
|
|
45
|
+
<div className="rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">{message.text}</div>
|
|
46
|
+
<MessageCopyButton text={message.text} />
|
|
45
47
|
</div>
|
|
46
48
|
) : null}
|
|
47
49
|
</div>
|
|
@@ -52,7 +54,10 @@ export function MessageBubble({
|
|
|
52
54
|
|
|
53
55
|
return (
|
|
54
56
|
<div className="flex gap-2">
|
|
55
|
-
<
|
|
57
|
+
<div className="frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1">
|
|
58
|
+
<Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} />
|
|
59
|
+
{message.text ? <MessageCopyButton text={message.text} /> : null}
|
|
60
|
+
</div>
|
|
56
61
|
<div className="min-w-0 max-w-[85%]">
|
|
57
62
|
{message.status === "error" ? (
|
|
58
63
|
<p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
|
@@ -71,6 +76,18 @@ export function MessageBubble({
|
|
|
71
76
|
);
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
function MessageCopyButton({ text }: { text: string }) {
|
|
80
|
+
const [copied, setCopied] = useState(false);
|
|
81
|
+
const copy = async () => {
|
|
82
|
+
try {
|
|
83
|
+
await navigator.clipboard.writeText(text);
|
|
84
|
+
setCopied(true);
|
|
85
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
86
|
+
} catch { /* Clipboard access can be disabled by an embedding host. */ }
|
|
87
|
+
};
|
|
88
|
+
return <button type="button" onClick={() => void copy()} className="frankie-message-copy inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-transparent text-muted-foreground transition hover:border-border hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" aria-label={copied ? "Copied" : "Copy message"} title={copied ? "Copied" : "Copy message"}>{copied ? <Check className="size-3.5" aria-hidden="true" /> : <Copy className="size-3.5" aria-hidden="true" />}</button>;
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
export function TypingDots() {
|
|
75
92
|
return (
|
|
76
93
|
<span className="inline-flex items-center gap-1 py-2" role="status" aria-label="Thinking">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Check, Loader2, ShieldAlert, X } from "lucide-react";
|
|
3
|
+
import { Check, ChevronDown, Loader2, ShieldAlert, X } from "lucide-react";
|
|
4
4
|
import { Badge } from "../ui/badge.js";
|
|
5
5
|
import type { ToolActivity } from "../../lib/messages.js";
|
|
6
6
|
|
|
@@ -13,12 +13,16 @@ export interface ToolActivityListProps {
|
|
|
13
13
|
/** The "what the assistant is doing" strip under a streaming answer. */
|
|
14
14
|
export function ToolActivityList({ tools, renderTool }: ToolActivityListProps) {
|
|
15
15
|
if (tools.length === 0) return null;
|
|
16
|
+
const running = tools.some((tool) => tool.status === "running");
|
|
16
17
|
return (
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
<details className="frankie-tool-activity mt-2" open={running}>
|
|
19
|
+
<summary className="inline-flex min-h-7 cursor-pointer list-none items-center gap-1.5 rounded-md px-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden">
|
|
20
|
+
<ChevronDown className="frankie-tool-chevron size-3.5 transition-transform" aria-hidden="true" />
|
|
21
|
+
<span>{running ? "Working" : "Activity"}</span>
|
|
22
|
+
<span className="text-[11px]">· {tools.length} tool call{tools.length === 1 ? "" : "s"}</span>
|
|
23
|
+
</summary>
|
|
24
|
+
<ul className="mt-1 flex flex-col gap-1 border-l border-border pl-2">{tools.map((tool) => <li key={tool.id}>{renderTool ? renderTool(tool) : <ToolActivityRow tool={tool} />}</li>)}</ul>
|
|
25
|
+
</details>
|
|
22
26
|
);
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.frankie-root .frankie-message-copy { opacity: .72; }
|
|
2
|
+
.frankie-root .frankie-message-copy:hover { opacity: 1; }
|
|
3
|
+
.frankie-root .frankie-tool-activity[open] .frankie-tool-chevron { transform: rotate(180deg); }
|
|
4
|
+
|
|
5
|
+
.frankie-root .frankie-assistant-actions .frankie-message-copy {
|
|
6
|
+
width: 28px; height: 28px; min-width: 28px; min-height: 28px;
|
|
7
|
+
border: 0; background: transparent; opacity: .46;
|
|
8
|
+
}
|
|
9
|
+
.frankie-root .frankie-assistant-actions .frankie-message-copy:hover,
|
|
10
|
+
.frankie-root .frankie-assistant-actions .frankie-message-copy:focus-visible { opacity: 1; }
|
|
11
|
+
.frankie-root .frankie-tool-activity { max-width: 100%; }
|
|
12
|
+
.frankie-root .frankie-tool-activity[open] > summary { color: var(--frankie-foreground); }
|
|
13
|
+
.frankie-root .frankie-tool-activity li .frankie-badge { max-width: 100%; }
|
|
@@ -6,14 +6,8 @@ import { FrankieClient, type FrankieClientOptions, type PageContext } from "../l
|
|
|
6
6
|
|
|
7
7
|
export interface FrankieProviderProps extends FrankieClientOptions {
|
|
8
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
9
|
context?: PageContext;
|
|
14
|
-
/** Overrides for the server-side theme, so a host can restyle without a redeploy. */
|
|
15
10
|
theme?: WidgetTheme;
|
|
16
|
-
/** Skip the `/v1/config` fetch by supplying the config yourself. */
|
|
17
11
|
config?: PublicWidgetConfig;
|
|
18
12
|
}
|
|
19
13
|
|
|
@@ -37,9 +31,8 @@ export function FrankieProvider({
|
|
|
37
31
|
}: FrankieProviderProps) {
|
|
38
32
|
const client = useMemo(
|
|
39
33
|
() => new FrankieClient(clientOptions),
|
|
40
|
-
// The client holds session state; rebuild it only when the endpoint changes.
|
|
41
34
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
42
|
-
[clientOptions.baseUrl, clientOptions.publishableKey],
|
|
35
|
+
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId],
|
|
43
36
|
);
|
|
44
37
|
|
|
45
38
|
const [config, setConfig] = useState<PublicWidgetConfig | undefined>(providedConfig);
|
|
@@ -87,7 +80,6 @@ export function useFrankie(): FrankieContextValue {
|
|
|
87
80
|
return value;
|
|
88
81
|
}
|
|
89
82
|
|
|
90
|
-
/** The theme actually in effect: provider override wins over server config. */
|
|
91
83
|
export function useFrankieTheme(): WidgetTheme | undefined {
|
|
92
84
|
const { config, theme } = useFrankie();
|
|
93
85
|
return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
|
|
@@ -39,8 +39,12 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
39
39
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
40
40
|
const [busy, setBusy] = useState(false);
|
|
41
41
|
const [error, setError] = useState<string | undefined>(undefined);
|
|
42
|
+
const [restored, setRestored] = useState(false);
|
|
42
43
|
const abortRef = useRef<AbortController | null>(null);
|
|
43
44
|
const mounted = useRef(true);
|
|
45
|
+
// State updates can be batched while a stream is arriving. Keep the latest
|
|
46
|
+
// transcript synchronously so the final server sync cannot save a stale copy.
|
|
47
|
+
const messagesRef = useRef<ChatMessage[]>([]);
|
|
44
48
|
|
|
45
49
|
useEffect(() => {
|
|
46
50
|
mounted.current = true;
|
|
@@ -52,37 +56,61 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
52
56
|
|
|
53
57
|
const storageKey = `${client.namespace}.messages`;
|
|
54
58
|
|
|
55
|
-
// Restore
|
|
56
|
-
// The
|
|
59
|
+
// Restore local storage immediately, then hydrate the authoritative server
|
|
60
|
+
// transcript. The server path makes the same signed-in user's chats available
|
|
61
|
+
// on another browser or device.
|
|
57
62
|
useEffect(() => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
let active = true;
|
|
64
|
+
const restore = async () => {
|
|
65
|
+
if (client.persist) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
68
|
+
const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
|
|
69
|
+
if (Array.isArray(stored) && stored.length && active) {
|
|
70
|
+
messagesRef.current = stored;
|
|
71
|
+
setMessages(stored);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
/* storage unavailable; server history can still restore */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const remote = await client.resumeConversation();
|
|
79
|
+
if (active && remote?.messages.length) {
|
|
80
|
+
const restoredMessages = remote.messages as ChatMessage[];
|
|
81
|
+
messagesRef.current = restoredMessages;
|
|
82
|
+
setMessages(restoredMessages);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* A new conversation does not exist remotely until its first message. */
|
|
86
|
+
} finally {
|
|
87
|
+
if (active) setRestored(true);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
void restore();
|
|
91
|
+
return () => { active = false; };
|
|
92
|
+
}, [client, storageKey]);
|
|
68
93
|
|
|
69
94
|
// Persist once a turn settles — never mid-stream — so the saved copy is clean.
|
|
95
|
+
// Wait until the initial restore has completed so an empty initial state never
|
|
96
|
+
// deletes an existing transcript.
|
|
70
97
|
useEffect(() => {
|
|
71
|
-
if (!client.persist || busy) return;
|
|
98
|
+
if (!client.persist || !restored || busy) return;
|
|
72
99
|
try {
|
|
73
100
|
if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
|
|
74
101
|
else window.localStorage.removeItem(storageKey);
|
|
75
102
|
} catch {
|
|
76
103
|
/* ignore */
|
|
77
104
|
}
|
|
78
|
-
|
|
105
|
+
if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => undefined);
|
|
106
|
+
}, [messages, busy, client, storageKey, restored]);
|
|
79
107
|
|
|
80
108
|
const patchAssistant = useCallback((id: string, update: (m: AssistantChatMessage) => AssistantChatMessage) => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
message.id === id && message.role === "assistant" ? update(message) : message,
|
|
84
|
-
),
|
|
109
|
+
const next = messagesRef.current.map((message) =>
|
|
110
|
+
message.id === id && message.role === "assistant" ? update(message) : message,
|
|
85
111
|
);
|
|
112
|
+
messagesRef.current = next;
|
|
113
|
+
setMessages(next);
|
|
86
114
|
}, []);
|
|
87
115
|
|
|
88
116
|
const send = useCallback(
|
|
@@ -96,20 +124,24 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
96
124
|
const assistantId = nextId("a");
|
|
97
125
|
setError(undefined);
|
|
98
126
|
setBusy(true);
|
|
99
|
-
|
|
100
|
-
...current,
|
|
127
|
+
const next = [
|
|
128
|
+
...messagesRef.current,
|
|
101
129
|
newUserMessage(
|
|
102
130
|
nextId("u"),
|
|
103
131
|
trimmed,
|
|
104
132
|
attachments.map((file) => ({ name: file.name, mimeType: file.mimeType })),
|
|
105
133
|
),
|
|
106
134
|
newAssistantMessage(assistantId),
|
|
107
|
-
]
|
|
135
|
+
];
|
|
136
|
+
messagesRef.current = next;
|
|
137
|
+
setMessages(next);
|
|
108
138
|
|
|
109
139
|
const abort = new AbortController();
|
|
110
140
|
abortRef.current = abort;
|
|
111
141
|
|
|
112
142
|
try {
|
|
143
|
+
let receivedText = false;
|
|
144
|
+
let receivedToolActivity = false;
|
|
113
145
|
await client.send(
|
|
114
146
|
{
|
|
115
147
|
message: outgoing,
|
|
@@ -120,9 +152,23 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
120
152
|
},
|
|
121
153
|
(event) => {
|
|
122
154
|
if (event.type === "ping" || event.type === "session") return;
|
|
155
|
+
if (event.type === "text_delta") receivedText = true;
|
|
156
|
+
if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
|
|
123
157
|
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
124
158
|
},
|
|
125
159
|
);
|
|
160
|
+
// A terminated stream with no text previously looked like Frankie did
|
|
161
|
+
// nothing. Preserve visible tool activity, but never leave an empty
|
|
162
|
+
// assistant bubble for the user to interpret.
|
|
163
|
+
if (!receivedText) {
|
|
164
|
+
patchAssistant(assistantId, (message) => ({
|
|
165
|
+
...message,
|
|
166
|
+
status: "done",
|
|
167
|
+
text: receivedToolActivity
|
|
168
|
+
? "Completed. Open Agent activity for the details."
|
|
169
|
+
: "Frankie didn’t return an answer. Please try again.",
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
126
172
|
} catch (caught) {
|
|
127
173
|
if (abort.signal.aborted) {
|
|
128
174
|
patchAssistant(assistantId, (message) => ({
|
|
@@ -134,18 +180,25 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
134
180
|
const message =
|
|
135
181
|
caught instanceof FrankieApiError
|
|
136
182
|
? caught.message
|
|
137
|
-
:
|
|
183
|
+
: caught instanceof Error && caught.message.startsWith("Frankie")
|
|
184
|
+
? caught.message
|
|
185
|
+
: "Could not reach the assistant. Check your connection and try again.";
|
|
138
186
|
if (mounted.current) setError(message);
|
|
139
187
|
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
140
188
|
}
|
|
141
189
|
} finally {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
190
|
+
// A turn that ended without a `turn_end` event still has to settle.
|
|
191
|
+
patchAssistant(assistantId, (message) =>
|
|
192
|
+
message.status === "streaming" ? { ...message, status: "done" } : message,
|
|
193
|
+
);
|
|
194
|
+
// Persist before send() resolves. This closes the previous gap where a
|
|
195
|
+
// route change or tab close could leave a server conversation with no
|
|
196
|
+
// rendered messages even though the agent transcript existed.
|
|
197
|
+
const transcript = messagesRef.current;
|
|
198
|
+
if (transcript.length) {
|
|
199
|
+
await client.syncConversation(transcript, conversationTitle(transcript)).catch(() => undefined);
|
|
148
200
|
}
|
|
201
|
+
if (mounted.current) setBusy(false);
|
|
149
202
|
abortRef.current = null;
|
|
150
203
|
}
|
|
151
204
|
},
|
|
@@ -157,9 +210,11 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
157
210
|
const reset = useCallback(() => {
|
|
158
211
|
abortRef.current?.abort();
|
|
159
212
|
client.resetSession();
|
|
213
|
+
messagesRef.current = [];
|
|
160
214
|
setMessages([]);
|
|
161
215
|
setError(undefined);
|
|
162
216
|
setBusy(false);
|
|
217
|
+
setRestored(true);
|
|
163
218
|
try {
|
|
164
219
|
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
165
220
|
} catch {
|
|
@@ -169,3 +224,11 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
169
224
|
|
|
170
225
|
return { messages, busy, ...(error ? { error } : {}), send, stop, reset };
|
|
171
226
|
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
function conversationTitle(messages: ChatMessage[]): string | undefined {
|
|
230
|
+
const first = messages.find((message) => message.role === "user" && message.text.trim());
|
|
231
|
+
if (!first) return undefined;
|
|
232
|
+
const title = first.text.trim().replace(/\s+/g, " ");
|
|
233
|
+
return title.length > 42 ? `${title.slice(0, 42)}…` : title;
|
|
234
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { FrankieWidget } from "./components/chat/FrankieWidget.js";
|
|
|
7
7
|
export type { FrankieWidgetProps } from "./components/chat/FrankieWidget.js";
|
|
8
8
|
export { FrankieInline } from "./components/chat/FrankieInline.js";
|
|
9
9
|
export type { FrankieInlineProps } from "./components/chat/FrankieInline.js";
|
|
10
|
+
export { FrankieChatPage } from "./components/chat/FrankieChatPage.js";
|
|
11
|
+
export type { FrankieChatPageProps } from "./components/chat/FrankieChatPage.js";
|
|
10
12
|
export { ChatPanel } from "./components/chat/ChatPanel.js";
|
|
11
13
|
export type { ChatPanelProps } from "./components/chat/ChatPanel.js";
|
|
12
14
|
export { Composer } from "./components/chat/Composer.js";
|
package/src/lib/client.ts
CHANGED
|
@@ -32,6 +32,17 @@ export interface FrankieClientOptions {
|
|
|
32
32
|
* set false for an ephemeral chat that starts fresh every load.
|
|
33
33
|
*/
|
|
34
34
|
persist?: boolean;
|
|
35
|
+
/** Override localStorage namespace for session and transcript. */
|
|
36
|
+
storageNamespace?: string;
|
|
37
|
+
/** Stable id for a server-backed conversation. */
|
|
38
|
+
conversationId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChatConversation {
|
|
42
|
+
id: string;
|
|
43
|
+
title: string;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
updatedAt: number;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
export class FrankieApiError extends Error {
|
|
@@ -59,14 +70,18 @@ export class FrankieClient {
|
|
|
59
70
|
return this.session?.sessionId;
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
get conversationId(): string | undefined {
|
|
74
|
+
return this.options.conversationId;
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
/** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
|
|
63
78
|
get persist(): boolean {
|
|
64
79
|
return this.options.persist !== false && typeof window !== "undefined";
|
|
65
80
|
}
|
|
66
81
|
|
|
67
|
-
/** localStorage namespace for this tenant, shared with the chat hook. */
|
|
82
|
+
/** localStorage namespace for this tenant/conversation, shared with the chat hook. */
|
|
68
83
|
get namespace(): string {
|
|
69
|
-
return `frankie.${this.options.publishableKey}`;
|
|
84
|
+
return this.options.storageNamespace ?? `frankie.${this.options.publishableKey}`;
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
private readStored(): CreateSessionResponse | undefined {
|
|
@@ -75,7 +90,6 @@ export class FrankieClient {
|
|
|
75
90
|
const raw = window.localStorage.getItem(`${this.namespace}.session`);
|
|
76
91
|
if (!raw) return undefined;
|
|
77
92
|
const session = JSON.parse(raw) as CreateSessionResponse;
|
|
78
|
-
// A stored session past its expiry is useless; drop it.
|
|
79
93
|
return new Date(session.expiresAt).getTime() > Date.now() + 30_000 ? session : undefined;
|
|
80
94
|
} catch {
|
|
81
95
|
return undefined;
|
|
@@ -100,39 +114,92 @@ export class FrankieClient {
|
|
|
100
114
|
return (await this.unwrap(response)) as PublicWidgetConfig;
|
|
101
115
|
}
|
|
102
116
|
|
|
103
|
-
/** Opens (or reuses) a chat session. */
|
|
104
117
|
async ensureSession(context?: PageContext): Promise<CreateSessionResponse> {
|
|
105
|
-
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000)
|
|
106
|
-
return this.session;
|
|
107
|
-
}
|
|
118
|
+
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) return this.session;
|
|
108
119
|
const userToken = await this.options.getUserToken?.();
|
|
120
|
+
if (this.options.getUserToken && !userToken) {
|
|
121
|
+
throw new FrankieApiError(
|
|
122
|
+
"Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
|
|
123
|
+
401,
|
|
124
|
+
"user_token_unavailable",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
109
127
|
const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
|
|
110
128
|
method: "POST",
|
|
111
129
|
headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
|
|
112
|
-
body: JSON.stringify({
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
...(userToken ? { userToken } : {}),
|
|
132
|
+
...(context ? { context } : {}),
|
|
133
|
+
...(this.options.conversationId ? { conversationId: this.options.conversationId } : {}),
|
|
134
|
+
}),
|
|
113
135
|
});
|
|
114
136
|
this.session = (await this.unwrap(response)) as CreateSessionResponse;
|
|
115
137
|
this.writeStored();
|
|
116
138
|
return this.session;
|
|
117
139
|
}
|
|
118
140
|
|
|
119
|
-
|
|
141
|
+
async listConversations(): Promise<ChatConversation[]> {
|
|
142
|
+
const userToken = await this.requireUserToken();
|
|
143
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations`, {
|
|
144
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
|
|
145
|
+
});
|
|
146
|
+
const body = (await this.unwrap(response)) as { conversations?: ChatConversation[] };
|
|
147
|
+
return Array.isArray(body.conversations) ? body.conversations : [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async resumeConversation(id = this.options.conversationId): Promise<{ messages: unknown[]; title: string } | undefined> {
|
|
151
|
+
if (!id) return undefined;
|
|
152
|
+
const userToken = await this.requireUserToken();
|
|
153
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
|
|
156
|
+
});
|
|
157
|
+
if (response.status === 404) return undefined;
|
|
158
|
+
const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string };
|
|
159
|
+
this.session = body.session;
|
|
160
|
+
this.writeStored();
|
|
161
|
+
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async syncConversation(messages: unknown[], title?: string): Promise<void> {
|
|
165
|
+
if (!this.session || !this.options.conversationId) return;
|
|
166
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(this.options.conversationId)}`, {
|
|
167
|
+
method: "PUT",
|
|
168
|
+
headers: {
|
|
169
|
+
"content-type": "application/json",
|
|
170
|
+
"x-frankie-key": this.options.publishableKey,
|
|
171
|
+
authorization: `Bearer ${this.session.sessionToken}`,
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({ messages, ...(title ? { title } : {}) }),
|
|
174
|
+
});
|
|
175
|
+
await this.unwrap(response);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async deleteConversation(id: string): Promise<void> {
|
|
179
|
+
const userToken = await this.requireUserToken();
|
|
180
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}`, {
|
|
181
|
+
method: "DELETE",
|
|
182
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
|
|
183
|
+
});
|
|
184
|
+
if (response.status !== 404) await this.unwrap(response);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async requireUserToken(): Promise<string> {
|
|
188
|
+
const token = await this.options.getUserToken?.();
|
|
189
|
+
if (!token) throw new FrankieApiError("Sign in to access your chat history.", 401, "unauthorized");
|
|
190
|
+
return token;
|
|
191
|
+
}
|
|
192
|
+
|
|
120
193
|
resetSession(): void {
|
|
121
194
|
this.session = undefined;
|
|
122
195
|
this.writeStored();
|
|
123
196
|
}
|
|
124
197
|
|
|
125
|
-
/**
|
|
126
|
-
* Sends a message and invokes `onEvent` for each server event as it arrives.
|
|
127
|
-
* Resolves when the turn ends.
|
|
128
|
-
*/
|
|
129
198
|
async send(
|
|
130
199
|
input: {
|
|
131
200
|
message: string;
|
|
132
201
|
context?: PageContext;
|
|
133
|
-
/** Model id chosen by the user, honoured only if the workspace allows it. */
|
|
134
202
|
model?: string;
|
|
135
|
-
/** Files attached to this turn. */
|
|
136
203
|
attachments?: ChatAttachment[];
|
|
137
204
|
signal?: AbortSignal;
|
|
138
205
|
},
|
|
@@ -159,7 +226,6 @@ export class FrankieClient {
|
|
|
159
226
|
});
|
|
160
227
|
|
|
161
228
|
if (!response.ok || !response.body) {
|
|
162
|
-
// A dropped session is recoverable: clear it so the next send re-auths.
|
|
163
229
|
if (response.status === 401 || response.status === 404) this.resetSession();
|
|
164
230
|
await this.unwrap(response);
|
|
165
231
|
return;
|
|
@@ -185,7 +251,6 @@ export class FrankieClient {
|
|
|
185
251
|
}
|
|
186
252
|
}
|
|
187
253
|
|
|
188
|
-
/** Parse the server's `data:`-only SSE stream into typed events. */
|
|
189
254
|
export async function* readEventStream(
|
|
190
255
|
body: ReadableStream<Uint8Array>,
|
|
191
256
|
): AsyncGenerator<FrankieServerEvent> {
|