@frankieai/react 0.2.8 → 0.2.10
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 +596 -203
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -10
- package/dist/index.d.ts +114 -10
- package/dist/index.js +579 -189
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/src/components/chat/ChatPanel.tsx +9 -1
- package/src/components/chat/Choices.tsx +52 -0
- package/src/components/chat/Composer.tsx +33 -18
- package/src/components/chat/FrankieChatPage.tsx +4 -1
- package/src/components/chat/FrankieInline.tsx +9 -3
- package/src/components/chat/FrankieWidget.tsx +12 -3
- package/src/components/chat/Markdown.tsx +15 -8
- package/src/components/chat/MessageBubble.tsx +53 -10
- package/src/components/chat/ToolActivityList.tsx +2 -1
- package/src/generated/css-content.ts +2 -0
- package/src/hooks/FrankieProvider.tsx +30 -3
- package/src/hooks/useFrankieChat.ts +57 -1
- package/src/index.ts +4 -1
- package/src/lib/choices.ts +49 -0
- package/src/lib/client.ts +136 -5
- package/src/lib/ensure-frankie-styles.ts +32 -0
- package/src/lib/identity.ts +39 -0
- package/src/lib/styles-ready.ts +83 -0
- package/src/lib/theme-inherit.test.ts +68 -0
- package/src/lib/theme-inherit.ts +89 -0
- package/src/styles/frankie.css +136 -3
- package/src/styles-ready.test.tsx +98 -0
- package/src/widget.test.tsx +218 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
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 { inferHostTheme } from "../lib/theme-inherit.js";
|
|
6
7
|
import { FrankieChatContext, useFrankieChatState } from "./useFrankieChat.js";
|
|
7
8
|
|
|
8
9
|
export interface FrankieProviderProps extends FrankieClientOptions {
|
|
@@ -10,6 +11,15 @@ export interface FrankieProviderProps extends FrankieClientOptions {
|
|
|
10
11
|
context?: PageContext;
|
|
11
12
|
theme?: WidgetTheme;
|
|
12
13
|
config?: PublicWidgetConfig;
|
|
14
|
+
/**
|
|
15
|
+
* Set to `false` if the host compiles Frankie's classes itself instead of
|
|
16
|
+
* loading `@frankieai/react/styles.css` — e.g. a Tailwind v4 host that
|
|
17
|
+
* `@source`s this package — so it isn't given a second, independently-themed
|
|
18
|
+
* copy of the stylesheet when its own build never sets the styles-ready
|
|
19
|
+
* sentinel. Defaults to `true`: a host that simply forgot the import, or
|
|
20
|
+
* whose CSS chunk hasn't landed yet, still gets a correctly-styled widget.
|
|
21
|
+
*/
|
|
22
|
+
injectStyles?: boolean;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
interface FrankieContextValue {
|
|
@@ -19,6 +29,7 @@ interface FrankieContextValue {
|
|
|
19
29
|
loading: boolean;
|
|
20
30
|
context?: PageContext;
|
|
21
31
|
theme?: WidgetTheme;
|
|
32
|
+
injectStyles: boolean;
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
const FrankieContext = createContext<FrankieContextValue | null>(null);
|
|
@@ -28,6 +39,7 @@ export function FrankieProvider({
|
|
|
28
39
|
context,
|
|
29
40
|
theme,
|
|
30
41
|
config: providedConfig,
|
|
42
|
+
injectStyles = true,
|
|
31
43
|
...clientOptions
|
|
32
44
|
}: FrankieProviderProps) {
|
|
33
45
|
const tokenCallback = useRef(clientOptions.getUserToken);
|
|
@@ -35,7 +47,7 @@ export function FrankieProvider({
|
|
|
35
47
|
const client = useMemo(
|
|
36
48
|
() => new FrankieClient({ ...clientOptions, ...(clientOptions.getUserToken ? { getUserToken: () => tokenCallback.current?.() } : {}) }),
|
|
37
49
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
38
|
-
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId, clientOptions.persist, clientOptions.fetchImpl, Boolean(clientOptions.getUserToken)],
|
|
50
|
+
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId, clientOptions.persist, clientOptions.fetchImpl, clientOptions.api?.baseUrl, clientOptions.api?.credentials, Boolean(clientOptions.getUserToken)],
|
|
39
51
|
);
|
|
40
52
|
|
|
41
53
|
const [config, setConfig] = useState<PublicWidgetConfig | undefined>(providedConfig);
|
|
@@ -70,8 +82,9 @@ export function FrankieProvider({
|
|
|
70
82
|
loading,
|
|
71
83
|
...(context ? { context } : {}),
|
|
72
84
|
...(theme ? { theme } : {}),
|
|
85
|
+
injectStyles,
|
|
73
86
|
}),
|
|
74
|
-
[client, config, configError, loading, context, theme],
|
|
87
|
+
[client, config, configError, loading, context, theme, injectStyles],
|
|
75
88
|
);
|
|
76
89
|
|
|
77
90
|
return <FrankieContext.Provider value={value}><ConversationState key={client.namespace + ":" + (client.conversationId ?? "")}>{children}</ConversationState></FrankieContext.Provider>;
|
|
@@ -90,5 +103,19 @@ export function useFrankie(): FrankieContextValue {
|
|
|
90
103
|
|
|
91
104
|
export function useFrankieTheme(): WidgetTheme | undefined {
|
|
92
105
|
const { config, theme } = useFrankie();
|
|
93
|
-
|
|
106
|
+
const merged = useMemo<WidgetTheme | undefined>(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
|
|
107
|
+
const inheriting = merged?.source === "inherit";
|
|
108
|
+
// Sampling reads the live DOM, so it can only run once mounted — not during
|
|
109
|
+
// the render that decides `inheriting`, and never on the server.
|
|
110
|
+
const [inferred, setInferred] = useState<Partial<WidgetTheme> | null>(null);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
setInferred(inheriting ? inferHostTheme() : null);
|
|
113
|
+
}, [inheriting]);
|
|
114
|
+
return useMemo(() => {
|
|
115
|
+
if (!inheriting || !inferred) return merged;
|
|
116
|
+
// Detection succeeded: the host's own colors replace the configured
|
|
117
|
+
// palette, which stays as the fallback already folded into `merged`
|
|
118
|
+
// wherever a specific inferred value is missing.
|
|
119
|
+
return { ...merged, ...inferred };
|
|
120
|
+
}, [merged, inheriting, inferred]);
|
|
94
121
|
}
|
|
@@ -162,6 +162,49 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
162
162
|
const abort = new AbortController();
|
|
163
163
|
abortRef.current = abort;
|
|
164
164
|
|
|
165
|
+
// Models stream text in uneven bursts — a few characters, then a lull,
|
|
166
|
+
// then a whole paragraph at once — which reads as a stutter no matter how
|
|
167
|
+
// efficiently React renders it. So we don't paint what arrives; we buffer
|
|
168
|
+
// it and *reveal* it at a steady cadence, a slice per animation frame, so
|
|
169
|
+
// the answer types out smoothly regardless of how lumpy the network is.
|
|
170
|
+
// The slice is a fraction of what's waiting, so a big burst drains fast
|
|
171
|
+
// (and never lags far behind) while a trickle still advances a few chars a
|
|
172
|
+
// frame. Thinking isn't rendered, so it commits in full without pacing.
|
|
173
|
+
// Any structural event, the stream's end, or an abort flushes the whole
|
|
174
|
+
// remaining buffer at once, so ordering holds and no token is dropped.
|
|
175
|
+
let bufferedText = "";
|
|
176
|
+
let pendingThinking = "";
|
|
177
|
+
let frame: number | null = null;
|
|
178
|
+
const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 16) as unknown as number;
|
|
179
|
+
const cancelRaf = typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : (clearTimeout as (id: number) => void);
|
|
180
|
+
const revealTick = () => {
|
|
181
|
+
frame = null;
|
|
182
|
+
let addText = "";
|
|
183
|
+
if (bufferedText) {
|
|
184
|
+
// Reveal ~1/6 of what's waiting (at least a few chars) so the pace
|
|
185
|
+
// eases with the backlog: snappy on a burst, gentle on a trickle.
|
|
186
|
+
const step = Math.max(3, Math.ceil(bufferedText.length / 6));
|
|
187
|
+
addText = bufferedText.slice(0, step);
|
|
188
|
+
bufferedText = bufferedText.slice(step);
|
|
189
|
+
}
|
|
190
|
+
const addThinking = pendingThinking;
|
|
191
|
+
pendingThinking = "";
|
|
192
|
+
if (addText || addThinking) {
|
|
193
|
+
patchAssistant(assistantId, (message) => ({ ...message, text: message.text + addText, thinking: message.thinking + addThinking }));
|
|
194
|
+
}
|
|
195
|
+
if (bufferedText) scheduleReveal();
|
|
196
|
+
};
|
|
197
|
+
const scheduleReveal = () => { if (frame === null) frame = raf(revealTick); };
|
|
198
|
+
const flushDeltas = () => {
|
|
199
|
+
if (frame !== null) { cancelRaf(frame); frame = null; }
|
|
200
|
+
if (!bufferedText && !pendingThinking) return;
|
|
201
|
+
const addText = bufferedText;
|
|
202
|
+
const addThinking = pendingThinking;
|
|
203
|
+
bufferedText = "";
|
|
204
|
+
pendingThinking = "";
|
|
205
|
+
patchAssistant(assistantId, (message) => ({ ...message, text: message.text + addText, thinking: message.thinking + addThinking }));
|
|
206
|
+
};
|
|
207
|
+
|
|
165
208
|
try {
|
|
166
209
|
let receivedText = false;
|
|
167
210
|
let receivedToolActivity = false;
|
|
@@ -177,11 +220,18 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
177
220
|
if (event.type === "skills_loaded") { setLoadedSkills(event.skills); return; }
|
|
178
221
|
if (event.type === "tools_active") { setActiveApis(event.apis); return; }
|
|
179
222
|
if (event.type === "ping" || event.type === "session") return;
|
|
180
|
-
if (event.type === "text_delta") receivedText = true;
|
|
223
|
+
if (event.type === "text_delta") { receivedText = true; bufferedText += event.text; scheduleReveal(); return; }
|
|
224
|
+
if (event.type === "thinking_delta") { pendingThinking += event.text; scheduleReveal(); return; }
|
|
181
225
|
if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
|
|
226
|
+
// A structural event: land the buffered text before it so the tool
|
|
227
|
+
// row or terminal status sits in the right place in the transcript.
|
|
228
|
+
flushDeltas();
|
|
182
229
|
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
183
230
|
},
|
|
184
231
|
);
|
|
232
|
+
// Commit anything still buffered when the stream ends, so the closing
|
|
233
|
+
// words appear at once instead of trickling after the reply is done.
|
|
234
|
+
flushDeltas();
|
|
185
235
|
// A terminated stream with no text previously looked like Frankie did
|
|
186
236
|
// nothing. Preserve visible tool activity, but never leave an empty
|
|
187
237
|
// assistant bubble for the user to interpret.
|
|
@@ -195,6 +245,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
195
245
|
}));
|
|
196
246
|
}
|
|
197
247
|
} catch (caught) {
|
|
248
|
+
// Land any tokens buffered when the stream broke off, so a stopped or
|
|
249
|
+
// failed turn still shows everything that had arrived.
|
|
250
|
+
flushDeltas();
|
|
198
251
|
if (abort.signal.aborted) {
|
|
199
252
|
patchAssistant(assistantId, (message) => ({
|
|
200
253
|
...message,
|
|
@@ -212,6 +265,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
212
265
|
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
213
266
|
}
|
|
214
267
|
} finally {
|
|
268
|
+
// Ensure no frame is left pending and every buffered token is committed
|
|
269
|
+
// before the turn settles and the transcript is persisted.
|
|
270
|
+
flushDeltas();
|
|
215
271
|
// A turn that ended without a `turn_end` event still has to settle.
|
|
216
272
|
patchAssistant(assistantId, (message) =>
|
|
217
273
|
message.status === "streaming" ? { ...message, status: "done" } : message,
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,9 @@ export type { FrankieChatPageProps } from "./components/chat/FrankieChatPage.js"
|
|
|
12
12
|
export { ChatPanel } from "./components/chat/ChatPanel.js";
|
|
13
13
|
export type { ChatPanelProps } from "./components/chat/ChatPanel.js";
|
|
14
14
|
export { Composer } from "./components/chat/Composer.js";
|
|
15
|
-
export { MessageBubble, TypingDots } from "./components/chat/MessageBubble.js";
|
|
15
|
+
export { MessageBubble, TypingDots, Thinking } from "./components/chat/MessageBubble.js";
|
|
16
16
|
export { Markdown } from "./components/chat/Markdown.js";
|
|
17
|
+
export { Choices } from "./components/chat/Choices.js";
|
|
17
18
|
export { SuggestedPrompts } from "./components/chat/SuggestedPrompts.js";
|
|
18
19
|
export { ToolActivityList, ToolActivityRow } from "./components/chat/ToolActivityList.js";
|
|
19
20
|
|
|
@@ -30,6 +31,8 @@ export { FrankieClient, FrankieApiError, readEventStream, FRANKIE_CLOUD_URL } fr
|
|
|
30
31
|
export type { FrankieClientOptions, PageContext } from "./lib/client.js";
|
|
31
32
|
export { applyEvent, newAssistantMessage, newUserMessage } from "./lib/messages.js";
|
|
32
33
|
export type { AssistantChatMessage, ChatMessage, MessageAttachment, ToolActivity, UserChatMessage } from "./lib/messages.js";
|
|
34
|
+
export { extractChoices } from "./lib/choices.js";
|
|
35
|
+
export type { ExtractedChoices } from "./lib/choices.js";
|
|
33
36
|
export { resolveMode, themeToCssVars } from "./lib/theme.js";
|
|
34
37
|
export { cn } from "./lib/utils.js";
|
|
35
38
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive choice blocks.
|
|
3
|
+
*
|
|
4
|
+
* The agent can end a reply with a fenced block to offer the user a small set
|
|
5
|
+
* of discrete answers:
|
|
6
|
+
*
|
|
7
|
+
* ```frankie:choices
|
|
8
|
+
* British
|
|
9
|
+
* Indian
|
|
10
|
+
* Australian
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The SDK lifts that block out of the prose and renders it as clickable options
|
|
14
|
+
* (the composer stays open, so the user can always type their own answer). The
|
|
15
|
+
* block is a plain fenced code block, so a host that doesn't understand it just
|
|
16
|
+
* shows a harmless code listing instead — never broken markup.
|
|
17
|
+
*/
|
|
18
|
+
export interface ExtractedChoices {
|
|
19
|
+
/** The reply text with the choices block removed. */
|
|
20
|
+
body: string;
|
|
21
|
+
/** The offered options, or null when there is no complete block. */
|
|
22
|
+
options: string[] | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CLOSED = /```frankie:choices[ \t]*\r?\n([\s\S]*?)```/;
|
|
26
|
+
const OPEN = /```frankie:choices[ \t]*\r?\n?[\s\S]*$/;
|
|
27
|
+
|
|
28
|
+
export function extractChoices(text: string): ExtractedChoices {
|
|
29
|
+
const closed = CLOSED.exec(text);
|
|
30
|
+
if (closed) {
|
|
31
|
+
const options = parseOptionLines(closed[1] ?? "");
|
|
32
|
+
const body = (text.slice(0, closed.index) + text.slice(closed.index + closed[0].length))
|
|
33
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
34
|
+
.trimEnd();
|
|
35
|
+
return { body, options: options.length ? options : null };
|
|
36
|
+
}
|
|
37
|
+
// An unterminated block means the reply is still streaming the options in.
|
|
38
|
+
// Hide the half-written fence so the user never sees the raw ``` marker.
|
|
39
|
+
const open = OPEN.exec(text);
|
|
40
|
+
if (open) return { body: text.slice(0, open.index).trimEnd(), options: null };
|
|
41
|
+
return { body: text, options: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseOptionLines(block: string): string[] {
|
|
45
|
+
return block
|
|
46
|
+
.split("\n")
|
|
47
|
+
.map((line) => line.trim().replace(/^([-*+]|\d+[.)])\s+/, "").trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
package/src/lib/client.ts
CHANGED
|
@@ -3,11 +3,35 @@ import type {
|
|
|
3
3
|
ChatRequestBody,
|
|
4
4
|
CreateSessionResponse,
|
|
5
5
|
FrankieServerEvent,
|
|
6
|
+
HostToolRequest,
|
|
7
|
+
HostToolResult,
|
|
6
8
|
PublicWidgetConfig,
|
|
7
9
|
} from "@frankieai/core";
|
|
10
|
+
import { sessionIdentity } from "./identity.js";
|
|
8
11
|
|
|
9
12
|
export type PageContext = Record<string, string | number | boolean | null>;
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* How the widget reaches the host's own API for client-side tool execution.
|
|
16
|
+
*
|
|
17
|
+
* When a workspace marks an API source `execution: "client"`, Frankie asks the
|
|
18
|
+
* browser to make the call itself — from the user's existing session — instead
|
|
19
|
+
* of calling the API from Frankie's servers. This says where that API lives and
|
|
20
|
+
* how the browser authenticates to it: normally the app's own cookies
|
|
21
|
+
* (`credentials: "include"` on a same-origin or CORS-allowed API), or a bearer
|
|
22
|
+
* token the app already holds, added via `headers`. Frankie sends no credential
|
|
23
|
+
* of its own; the host API authorizes the call exactly as it would any request
|
|
24
|
+
* from the host's frontend.
|
|
25
|
+
*/
|
|
26
|
+
export interface HostApiClientOptions {
|
|
27
|
+
/** Base URL that operation paths resolve against, e.g. "/api" or "https://api.acme.com". */
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
/** fetch credentials mode. "include" sends the app's cookies. Defaults to "same-origin". */
|
|
30
|
+
credentials?: RequestCredentials;
|
|
31
|
+
/** Extra headers for every host call (e.g. an app access token). Awaited per call. */
|
|
32
|
+
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
/**
|
|
12
36
|
* The hosted Frankie data plane. Embedding needs only a publishable key, so
|
|
13
37
|
* `baseUrl` defaults here — override it only when self-hosting the server.
|
|
@@ -26,6 +50,13 @@ export interface FrankieClientOptions {
|
|
|
26
50
|
*/
|
|
27
51
|
getUserToken?: () => Promise<string | undefined> | string | undefined;
|
|
28
52
|
fetchImpl?: typeof fetch;
|
|
53
|
+
/**
|
|
54
|
+
* Reaches the host's own API for `execution: "client"` tool calls (see
|
|
55
|
+
* `HostApiClientOptions`). Required only for workspaces that use client-side
|
|
56
|
+
* execution; omit it and a client-mode tool reports that no host API is
|
|
57
|
+
* configured rather than calling anything.
|
|
58
|
+
*/
|
|
59
|
+
api?: HostApiClientOptions;
|
|
29
60
|
/**
|
|
30
61
|
* Remember the session (and, in the hook, the transcript) in localStorage so a
|
|
31
62
|
* page reload resumes the same anonymous conversation. Authenticated chat
|
|
@@ -59,7 +90,8 @@ export class FrankieClient {
|
|
|
59
90
|
private readonly baseUrl: string;
|
|
60
91
|
private readonly doFetch: typeof fetch;
|
|
61
92
|
private session?: CreateSessionResponse;
|
|
62
|
-
|
|
93
|
+
/** Identity (JWT `sub`, else the raw token) the cached session was issued for. */
|
|
94
|
+
private sessionUserKey?: string;
|
|
63
95
|
|
|
64
96
|
constructor(private readonly options: FrankieClientOptions) {
|
|
65
97
|
this.baseUrl = (options.baseUrl ?? FRANKIE_CLOUD_URL).replace(/\/+$/, "");
|
|
@@ -121,7 +153,20 @@ export class FrankieClient {
|
|
|
121
153
|
}
|
|
122
154
|
|
|
123
155
|
async ensureSession(context?: PageContext): Promise<CreateSessionResponse> {
|
|
124
|
-
|
|
156
|
+
// The host's token endpoint knows why it failed (not signed in, secret
|
|
157
|
+
// missing, lookup not wired); a thrown Error carries that reason to the user.
|
|
158
|
+
let userToken: string | undefined;
|
|
159
|
+
try {
|
|
160
|
+
userToken = await this.options.getUserToken?.();
|
|
161
|
+
} catch (error) {
|
|
162
|
+
this.resetSession();
|
|
163
|
+
const reason = error instanceof Error && error.message.trim() ? error.message.trim() : undefined;
|
|
164
|
+
throw new FrankieApiError(
|
|
165
|
+
reason ?? "Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
|
|
166
|
+
401,
|
|
167
|
+
"user_token_unavailable",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
125
170
|
if (this.options.getUserToken && !userToken) {
|
|
126
171
|
this.resetSession();
|
|
127
172
|
throw new FrankieApiError(
|
|
@@ -130,7 +175,13 @@ export class FrankieClient {
|
|
|
130
175
|
"user_token_unavailable",
|
|
131
176
|
);
|
|
132
177
|
}
|
|
133
|
-
|
|
178
|
+
// Reset only when the *person* changed, not when the same person's token
|
|
179
|
+
// was re-minted. Hosts mint a fresh JWT (with `iat`) before every turn, so
|
|
180
|
+
// comparing raw bytes threw the session away on every message and started
|
|
181
|
+
// a new, empty transcript each time — the agent never saw the last turn.
|
|
182
|
+
// Sign-out is handled above; a different user resets here as before.
|
|
183
|
+
const userKey = sessionIdentity(userToken);
|
|
184
|
+
if (userKey !== this.sessionUserKey) this.resetSession();
|
|
134
185
|
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) return this.session;
|
|
135
186
|
const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
|
|
136
187
|
method: "POST",
|
|
@@ -142,7 +193,7 @@ export class FrankieClient {
|
|
|
142
193
|
}),
|
|
143
194
|
});
|
|
144
195
|
this.session = (await this.unwrap(response)) as CreateSessionResponse;
|
|
145
|
-
this.
|
|
196
|
+
this.sessionUserKey = userKey;
|
|
146
197
|
this.writeStored();
|
|
147
198
|
return this.session;
|
|
148
199
|
}
|
|
@@ -166,7 +217,7 @@ export class FrankieClient {
|
|
|
166
217
|
if (response.status === 404) return undefined;
|
|
167
218
|
const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string };
|
|
168
219
|
this.session = body.session;
|
|
169
|
-
this.
|
|
220
|
+
this.sessionUserKey = sessionIdentity(userToken);
|
|
170
221
|
this.writeStored();
|
|
171
222
|
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
|
|
172
223
|
}
|
|
@@ -242,10 +293,77 @@ export class FrankieClient {
|
|
|
242
293
|
}
|
|
243
294
|
|
|
244
295
|
for await (const event of readEventStream(response.body)) {
|
|
296
|
+
// A client-side tool call is handled here, not surfaced to the UI: run it
|
|
297
|
+
// against the host API on the user's own session and POST the outcome
|
|
298
|
+
// back. It runs detached so reading the stream never blocks on a host call
|
|
299
|
+
// (the server's agent loop is what waits, via the tool-result endpoint).
|
|
300
|
+
if (event.type === "tool_call_request") {
|
|
301
|
+
void this.runClientTool(session, event.callId, event.request);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
245
304
|
onEvent(event);
|
|
246
305
|
}
|
|
247
306
|
}
|
|
248
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Execute one host API call in the browser and report the outcome back to the
|
|
310
|
+
* server. Never throws: a transport failure or a missing `api` client becomes
|
|
311
|
+
* a result the model can see and explain, exactly like a non-2xx status.
|
|
312
|
+
*/
|
|
313
|
+
private async runClientTool(
|
|
314
|
+
session: CreateSessionResponse,
|
|
315
|
+
callId: string,
|
|
316
|
+
request: HostToolRequest,
|
|
317
|
+
): Promise<void> {
|
|
318
|
+
const result = await this.callHostApi(request);
|
|
319
|
+
await this.postToolResult(session, callId, result).catch(() => undefined);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private async callHostApi(request: HostToolRequest): Promise<HostToolResult> {
|
|
323
|
+
const api = this.options.api;
|
|
324
|
+
if (!api) {
|
|
325
|
+
return {
|
|
326
|
+
status: 0,
|
|
327
|
+
error:
|
|
328
|
+
"This assistant tried to call your API from the browser, but no host API client is configured " +
|
|
329
|
+
"in the Frankie SDK (the `api` option).",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const extra = api.headers ? await api.headers() : {};
|
|
334
|
+
const response = await this.doFetch(joinHostUrl(api.baseUrl, request.path), {
|
|
335
|
+
method: request.method,
|
|
336
|
+
credentials: api.credentials ?? "same-origin",
|
|
337
|
+
headers: {
|
|
338
|
+
accept: "application/json, text/plain;q=0.9, */*;q=0.8",
|
|
339
|
+
...(request.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
340
|
+
...extra,
|
|
341
|
+
},
|
|
342
|
+
...(request.body !== undefined ? { body: JSON.stringify(request.body) } : {}),
|
|
343
|
+
});
|
|
344
|
+
const body = await response.text().catch(() => "");
|
|
345
|
+
return { status: response.status, body };
|
|
346
|
+
} catch (error) {
|
|
347
|
+
return { status: 0, error: error instanceof Error ? error.message : String(error) };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private async postToolResult(
|
|
352
|
+
session: CreateSessionResponse,
|
|
353
|
+
callId: string,
|
|
354
|
+
result: HostToolResult,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
await this.doFetch(`${this.baseUrl}/v1/chat/tool-result`, {
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: {
|
|
359
|
+
"content-type": "application/json",
|
|
360
|
+
"x-frankie-key": this.options.publishableKey,
|
|
361
|
+
authorization: `Bearer ${session.sessionToken}`,
|
|
362
|
+
},
|
|
363
|
+
body: JSON.stringify({ callId, result }),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
249
367
|
private async unwrap(response: Response): Promise<unknown> {
|
|
250
368
|
if (response.ok) return response.json();
|
|
251
369
|
let code = "internal";
|
|
@@ -261,6 +379,19 @@ export class FrankieClient {
|
|
|
261
379
|
}
|
|
262
380
|
}
|
|
263
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Join the host's configured base URL with a server-supplied relative path. The
|
|
384
|
+
* server always sends a relative path (params interpolated, no scheme or host),
|
|
385
|
+
* and this refuses anything that looks absolute or protocol-relative — so a tool
|
|
386
|
+
* call can never be steered to a different origin than the one the host set.
|
|
387
|
+
*/
|
|
388
|
+
export function joinHostUrl(baseUrl: string, path: string): string {
|
|
389
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith("//")) {
|
|
390
|
+
throw new Error("A client tool path must be relative to the configured host API base URL.");
|
|
391
|
+
}
|
|
392
|
+
return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
393
|
+
}
|
|
394
|
+
|
|
264
395
|
export async function* readEventStream(
|
|
265
396
|
body: ReadableStream<Uint8Array>,
|
|
266
397
|
): AsyncGenerator<FrankieServerEvent> {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { FRANKIE_CSS_CONTENT } from "../generated/css-content.js";
|
|
2
|
+
|
|
3
|
+
const STYLE_ELEMENT_ID = "frankie-fallback-styles";
|
|
4
|
+
let attempted = false;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Last resort for a host that never loaded `@frankieai/react/styles.css` —
|
|
8
|
+
* forgot the import, or its CSS chunk hasn't landed by the time
|
|
9
|
+
* `useStylesReady`'s bounded wait gives up. Injects the same stylesheet the
|
|
10
|
+
* package ships as `./styles.css`, so the widget still renders correctly
|
|
11
|
+
* instead of as raw, unstyled HTML.
|
|
12
|
+
*
|
|
13
|
+
* Only called when a host hasn't opted out via `injectStyles={false}` — the
|
|
14
|
+
* documented escape hatch for a host that compiles Frankie's classes itself
|
|
15
|
+
* (e.g. a Tailwind v4 host `@source`-ing this package) and would not want a
|
|
16
|
+
* second, independently-themed copy of the stylesheet appended.
|
|
17
|
+
*/
|
|
18
|
+
export function ensureFrankieStyles(): void {
|
|
19
|
+
if (attempted || typeof document === "undefined") return;
|
|
20
|
+
attempted = true;
|
|
21
|
+
if (document.getElementById(STYLE_ELEMENT_ID)) return;
|
|
22
|
+
const style = document.createElement("style");
|
|
23
|
+
style.id = STYLE_ELEMENT_ID;
|
|
24
|
+
style.textContent = FRANKIE_CSS_CONTENT;
|
|
25
|
+
document.head.appendChild(style);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Test-only: clears the injected-once guard so each test starts clean. */
|
|
29
|
+
export function __resetFrankieStylesForTests(): void {
|
|
30
|
+
attempted = false;
|
|
31
|
+
document.getElementById(STYLE_ELEMENT_ID)?.remove();
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stable identity a host-signed user token carries, used only to decide
|
|
3
|
+
* whether a cached chat session still belongs to the same person.
|
|
4
|
+
*
|
|
5
|
+
* The client never verifies the token — it has no secret, and the server
|
|
6
|
+
* re-verifies on every `/v1/session` anyway. It only needs a key that stays
|
|
7
|
+
* constant while the same user's token is re-minted. A JWT's `sub` is that
|
|
8
|
+
* key: hosts mint with `setIssuedAt()`, so the raw string changes every
|
|
9
|
+
* second even though nothing about the user did.
|
|
10
|
+
*
|
|
11
|
+
* Anything that isn't a readable JWT falls back to the raw token, which is
|
|
12
|
+
* exactly the previous behaviour — an opaque token that changes still resets
|
|
13
|
+
* the session, so this can only ever reuse a session, never leak one.
|
|
14
|
+
*/
|
|
15
|
+
export function sessionIdentity(token: string | undefined): string | undefined {
|
|
16
|
+
if (!token) return undefined;
|
|
17
|
+
return jwtSubject(token) ?? token;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function jwtSubject(token: string): string | undefined {
|
|
21
|
+
const parts = token.split(".");
|
|
22
|
+
if (parts.length !== 3 || !parts[1]) return undefined;
|
|
23
|
+
try {
|
|
24
|
+
const payload = JSON.parse(base64UrlDecode(parts[1])) as { sub?: unknown };
|
|
25
|
+
return typeof payload.sub === "string" && payload.sub ? payload.sub : undefined;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function base64UrlDecode(segment: string): string {
|
|
32
|
+
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
33
|
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
34
|
+
if (typeof atob === "function") {
|
|
35
|
+
const bytes = atob(padded);
|
|
36
|
+
return decodeURIComponent(Array.from(bytes, (c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0")).join(""));
|
|
37
|
+
}
|
|
38
|
+
return Buffer.from(padded, "base64").toString("utf8");
|
|
39
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
|
|
2
|
+
import { ensureFrankieStyles } from "./ensure-frankie-styles.js";
|
|
3
|
+
|
|
4
|
+
/** `useLayoutEffect` warns during SSR, where there is nothing to measure anyway. */
|
|
5
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
6
|
+
|
|
7
|
+
/** The property `frankie.css` sets on `.frankie-root`, and the value it sets. */
|
|
8
|
+
const SENTINEL = "--frankie-styles-ready";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether this widget's stylesheet has actually been applied to `node`.
|
|
12
|
+
*
|
|
13
|
+
* The widget is mounted inside someone else's application and bundler. A host
|
|
14
|
+
* that imports `@frankieai/react/styles.css` from a client component ships it
|
|
15
|
+
* in that component's chunk, and on a client-side navigation React can render
|
|
16
|
+
* the panel before the CSS arrives — the panel then paints as raw HTML: native
|
|
17
|
+
* `<details>` markers, no pills, browser-default type. A full reload hides this
|
|
18
|
+
* because the stylesheet is in `<head>` before first paint.
|
|
19
|
+
*
|
|
20
|
+
* Probing a custom property costs nothing and asks the only question that
|
|
21
|
+
* matters — are our rules in effect for this element — rather than guessing at
|
|
22
|
+
* `<link>` elements the host may have inlined, bundled or renamed.
|
|
23
|
+
*
|
|
24
|
+
* Callers hide the root with `opacity` for the wait rather than `visibility` or
|
|
25
|
+
* unmounting: the sentinel must stay measurable on that element, and both of the
|
|
26
|
+
* alternatives would also drop the widget out of the accessibility tree.
|
|
27
|
+
*
|
|
28
|
+
* The wait is deliberately bounded. After `timeoutMs`, with no sentinel found,
|
|
29
|
+
* this injects the stylesheet itself (`ensureFrankieStyles`) as a fallback for
|
|
30
|
+
* a host that never loaded `styles.css` — the far more common way this shows
|
|
31
|
+
* up in practice than the one case it must not clobber: a Tailwind v4 host
|
|
32
|
+
* told it may skip our stylesheet and compile the same classes itself, which
|
|
33
|
+
* also never sets the sentinel. That host opts out with `injectStyles={false}`
|
|
34
|
+
* so it doesn't get a second, independently-themed copy of the stylesheet.
|
|
35
|
+
*/
|
|
36
|
+
export function useStylesReady(ref: RefObject<HTMLElement | null>, timeoutMs = 600, injectFallback = true): boolean {
|
|
37
|
+
const [ready, setReady] = useState(false);
|
|
38
|
+
|
|
39
|
+
useIsomorphicLayoutEffect(() => {
|
|
40
|
+
if (ready) return;
|
|
41
|
+
const node = ref.current;
|
|
42
|
+
if (!node || typeof window === "undefined" || typeof window.getComputedStyle !== "function") {
|
|
43
|
+
setReady(true);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const applied = () => window.getComputedStyle(node).getPropertyValue(SENTINEL).trim() === "1";
|
|
48
|
+
// The common case — stylesheet already in `<head>` — resolves here, inside a
|
|
49
|
+
// layout effect, so nothing unstyled is ever painted and nothing flickers.
|
|
50
|
+
if (applied()) {
|
|
51
|
+
setReady(true);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const deadline = Date.now() + timeoutMs;
|
|
56
|
+
let frame = 0;
|
|
57
|
+
const poll = () => {
|
|
58
|
+
if (applied()) {
|
|
59
|
+
setReady(true);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (Date.now() >= deadline) {
|
|
63
|
+
// Inline `<style>` insertion applies synchronously, so re-check right
|
|
64
|
+
// after — but set `ready` regardless, matching the pre-existing
|
|
65
|
+
// "never hide the widget forever" guarantee.
|
|
66
|
+
if (injectFallback) ensureFrankieStyles();
|
|
67
|
+
setReady(true);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
frame = window.requestAnimationFrame(poll);
|
|
71
|
+
};
|
|
72
|
+
// `requestAnimationFrame` is missing in some test environments; fall back to
|
|
73
|
+
// rendering rather than hiding the widget forever.
|
|
74
|
+
if (typeof window.requestAnimationFrame !== "function") {
|
|
75
|
+
setReady(true);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
frame = window.requestAnimationFrame(poll);
|
|
79
|
+
return () => window.cancelAnimationFrame(frame);
|
|
80
|
+
}, [ref, ready, timeoutMs, injectFallback]);
|
|
81
|
+
|
|
82
|
+
return ready;
|
|
83
|
+
}
|