@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
package/src/widget.test.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { FrankieProvider } from "./hooks/FrankieProvider.jsx";
|
|
|
5
5
|
import { ChatPanel } from "./components/chat/ChatPanel.js";
|
|
6
6
|
import { FrankieChatPage } from "./components/chat/FrankieChatPage.js";
|
|
7
7
|
import { Markdown } from "./components/chat/Markdown.js";
|
|
8
|
+
import { sessionIdentity } from "./lib/identity.js";
|
|
8
9
|
import { FrankieClient, readEventStream } from "./lib/client.js";
|
|
9
10
|
import { applyEvent, newAssistantMessage } from "./lib/messages.js";
|
|
10
11
|
import { themeToCssVars } from "./lib/theme.js";
|
|
@@ -157,6 +158,75 @@ describe("FrankieClient", () => {
|
|
|
157
158
|
expect(headers["x-frankie-key"]).toBe("pk_1");
|
|
158
159
|
});
|
|
159
160
|
|
|
161
|
+
/** A JWT with the given claims; the signature is irrelevant to the client. */
|
|
162
|
+
const jwt = (claims: Record<string, unknown>) =>
|
|
163
|
+
`h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
|
|
164
|
+
|
|
165
|
+
it("keeps the same session when the same user's token is re-minted between turns", async () => {
|
|
166
|
+
// Hosts mint with setIssuedAt(), so every getUserToken() call returns a
|
|
167
|
+
// different string for the same person. That must not start a new,
|
|
168
|
+
// empty conversation — the agent would never see the previous turn.
|
|
169
|
+
let iat = 1_700_000_000;
|
|
170
|
+
const getUserToken = () => jwt({ sub: "user_42", iat: iat++ });
|
|
171
|
+
let sessionCalls = 0;
|
|
172
|
+
const bearers: string[] = [];
|
|
173
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
174
|
+
const href = String(url);
|
|
175
|
+
if (href.endsWith("/v1/session")) {
|
|
176
|
+
sessionCalls += 1;
|
|
177
|
+
return Response.json({ sessionId: `s${sessionCalls}`, sessionToken: `bearer-${sessionCalls}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "user_42", anonymous: false } });
|
|
178
|
+
}
|
|
179
|
+
bearers.push((init?.headers as Record<string, string>)["authorization"] ?? "");
|
|
180
|
+
return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
|
|
181
|
+
}) as unknown as typeof fetch;
|
|
182
|
+
|
|
183
|
+
const client = new FrankieClient({ publishableKey: "pk_rotate", getUserToken, fetchImpl });
|
|
184
|
+
await client.send({ message: "first" }, () => {});
|
|
185
|
+
await client.send({ message: "second" }, () => {});
|
|
186
|
+
|
|
187
|
+
expect(sessionCalls).toBe(1);
|
|
188
|
+
expect(bearers).toEqual(["Bearer bearer-1", "Bearer bearer-1"]);
|
|
189
|
+
expect(client.sessionId).toBe("s1");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("still starts a new session when a different user signs in", async () => {
|
|
193
|
+
let sub = "user_42";
|
|
194
|
+
const getUserToken = () => jwt({ sub, iat: 1 });
|
|
195
|
+
let sessionCalls = 0;
|
|
196
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
|
197
|
+
if (String(url).endsWith("/v1/session")) {
|
|
198
|
+
sessionCalls += 1;
|
|
199
|
+
return Response.json({ sessionId: `s${sessionCalls}`, sessionToken: `bearer-${sessionCalls}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: sub, anonymous: false } });
|
|
200
|
+
}
|
|
201
|
+
return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
|
|
202
|
+
}) as unknown as typeof fetch;
|
|
203
|
+
|
|
204
|
+
const client = new FrankieClient({ publishableKey: "pk_switch", getUserToken, fetchImpl });
|
|
205
|
+
await client.send({ message: "as 42" }, () => {});
|
|
206
|
+
sub = "user_99"; // account change on the host
|
|
207
|
+
await client.send({ message: "as 99" }, () => {});
|
|
208
|
+
|
|
209
|
+
expect(sessionCalls).toBe(2);
|
|
210
|
+
expect(client.sessionId).toBe("s2");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("falls back to comparing an opaque token, so a changed one still resets", async () => {
|
|
214
|
+
let token = "opaque-token-a";
|
|
215
|
+
let sessionCalls = 0;
|
|
216
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
|
217
|
+
if (String(url).endsWith("/v1/session")) {
|
|
218
|
+
sessionCalls += 1;
|
|
219
|
+
return Response.json({ sessionId: `s${sessionCalls}`, sessionToken: `b${sessionCalls}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "u", anonymous: false } });
|
|
220
|
+
}
|
|
221
|
+
return new Response(sseStream([{ type: "text_delta", text: "ok" }]), { status: 200 });
|
|
222
|
+
}) as unknown as typeof fetch;
|
|
223
|
+
const client = new FrankieClient({ publishableKey: "pk_opaque", getUserToken: () => token, fetchImpl });
|
|
224
|
+
await client.send({ message: "a" }, () => {});
|
|
225
|
+
token = "opaque-token-b";
|
|
226
|
+
await client.send({ message: "b" }, () => {});
|
|
227
|
+
expect(sessionCalls).toBe(2);
|
|
228
|
+
});
|
|
229
|
+
|
|
160
230
|
it("drops a rejected session so the next send re-authenticates", async () => {
|
|
161
231
|
let sessionCalls = 0;
|
|
162
232
|
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
|
@@ -333,6 +403,49 @@ describe("ChatPanel", () => {
|
|
|
333
403
|
expect(root.style.getPropertyValue("--frankie-primary")).toBe("rgb(255, 0, 128)");
|
|
334
404
|
expect(root.classList.contains("dark")).toBe(true);
|
|
335
405
|
});
|
|
406
|
+
|
|
407
|
+
it("inherits the host page's own colors once sampling succeeds", async () => {
|
|
408
|
+
document.body.innerHTML = '<button data-stub="host-accent">Buy</button>';
|
|
409
|
+
const real = window.getComputedStyle.bind(window);
|
|
410
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
|
|
411
|
+
if (el === document.body) return { backgroundColor: "rgb(250, 250, 250)", color: "rgb(20, 20, 20)" } as CSSStyleDeclaration;
|
|
412
|
+
if ((el as HTMLElement).dataset?.stub === "host-accent") return { backgroundColor: "rgb(16, 185, 129)", color: "rgb(255, 255, 255)" } as CSSStyleDeclaration;
|
|
413
|
+
return real(el);
|
|
414
|
+
});
|
|
415
|
+
const { container } = render(
|
|
416
|
+
<FrankieProvider
|
|
417
|
+
baseUrl="https://agent.acme.com"
|
|
418
|
+
publishableKey="pk_1"
|
|
419
|
+
config={{ ...config, theme: { mode: "light", source: "inherit", primary: "#configured-fallback" } }}
|
|
420
|
+
>
|
|
421
|
+
<ChatPanel />
|
|
422
|
+
</FrankieProvider>,
|
|
423
|
+
);
|
|
424
|
+
const root = () => container.querySelector(".frankie-root") as HTMLElement;
|
|
425
|
+
// Sampling runs post-mount, so the first paint still shows the configured
|
|
426
|
+
// fallback — never a blank/undefined value — until inference resolves.
|
|
427
|
+
await waitFor(() => expect(root().style.getPropertyValue("--frankie-primary")).toBe("rgb(16, 185, 129)"));
|
|
428
|
+
expect(root().style.getPropertyValue("--frankie-background")).toBe("rgb(250, 250, 250)");
|
|
429
|
+
vi.restoreAllMocks();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("keeps the configured fallback when the host page can't be read reliably", async () => {
|
|
433
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(() => ({ backgroundColor: "rgba(0, 0, 0, 0)", color: "rgba(0, 0, 0, 0)" }) as CSSStyleDeclaration);
|
|
434
|
+
const { container } = render(
|
|
435
|
+
<FrankieProvider
|
|
436
|
+
baseUrl="https://agent.acme.com"
|
|
437
|
+
publishableKey="pk_1"
|
|
438
|
+
config={{ ...config, theme: { mode: "light", source: "inherit", primary: "rgb(1, 2, 3)" } }}
|
|
439
|
+
>
|
|
440
|
+
<ChatPanel />
|
|
441
|
+
</FrankieProvider>,
|
|
442
|
+
);
|
|
443
|
+
const root = container.querySelector(".frankie-root") as HTMLElement;
|
|
444
|
+
// A transparent body background means inference declines to guess, so
|
|
445
|
+
// the configured value is what the widget shows — not left blank.
|
|
446
|
+
await waitFor(() => expect(root.style.getPropertyValue("--frankie-primary")).toBe("rgb(1, 2, 3)"));
|
|
447
|
+
vi.restoreAllMocks();
|
|
448
|
+
});
|
|
336
449
|
});
|
|
337
450
|
|
|
338
451
|
describe("FrankieChatPage", () => {
|
|
@@ -366,3 +479,108 @@ describe("FrankieChatPage", () => {
|
|
|
366
479
|
cleanup();
|
|
367
480
|
});
|
|
368
481
|
});
|
|
482
|
+
|
|
483
|
+
describe("sessionIdentity", () => {
|
|
484
|
+
const jwt = (claims: Record<string, unknown>) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
|
|
485
|
+
it("reads the JWT subject", () => expect(sessionIdentity(jwt({ sub: "user_42", iat: 1 }))).toBe("user_42"));
|
|
486
|
+
it("is stable across re-minted tokens for the same subject", () =>
|
|
487
|
+
expect(sessionIdentity(jwt({ sub: "u", iat: 1 }))).toBe(sessionIdentity(jwt({ sub: "u", iat: 2 }))));
|
|
488
|
+
it("falls back to the raw token when there is no readable subject", () => {
|
|
489
|
+
expect(sessionIdentity("opaque")).toBe("opaque");
|
|
490
|
+
expect(sessionIdentity("a.b")).toBe("a.b");
|
|
491
|
+
expect(sessionIdentity(jwt({ iat: 1 }))).toBe(jwt({ iat: 1 }));
|
|
492
|
+
expect(sessionIdentity("h.%%%notbase64%%%.s")).toBe("h.%%%notbase64%%%.s");
|
|
493
|
+
});
|
|
494
|
+
it("maps a missing token to undefined", () => expect(sessionIdentity(undefined)).toBeUndefined());
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe("client-side tool execution", () => {
|
|
498
|
+
it("joinHostUrl joins base and relative path, and refuses an absolute one", async () => {
|
|
499
|
+
const { joinHostUrl } = await import("./lib/client.js");
|
|
500
|
+
expect(joinHostUrl("https://app.acme.com/api", "restaurants/1?x=2")).toBe("https://app.acme.com/api/restaurants/1?x=2");
|
|
501
|
+
expect(joinHostUrl("https://app.acme.com/api/", "/restaurants")).toBe("https://app.acme.com/api/restaurants");
|
|
502
|
+
expect(() => joinHostUrl("https://app.acme.com", "https://evil.com/steal")).toThrow(/relative/);
|
|
503
|
+
expect(() => joinHostUrl("https://app.acme.com", "//evil.com/steal")).toThrow(/relative/);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("runs a tool_call_request against the host API and posts the outcome back", async () => {
|
|
507
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
508
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
509
|
+
const href = String(url);
|
|
510
|
+
calls.push({ url: href, ...(init ? { init } : {}) });
|
|
511
|
+
if (href.endsWith("/v1/session")) {
|
|
512
|
+
return Response.json({ sessionId: "s1", sessionToken: "bearer-1", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "u", anonymous: true } });
|
|
513
|
+
}
|
|
514
|
+
if (href.endsWith("/v1/chat")) {
|
|
515
|
+
return new Response(
|
|
516
|
+
sseStream([
|
|
517
|
+
{ type: "tool_call_request", callId: "c-1", toolUseId: "c-1", name: "api__acme__getRestaurant", request: { source: "acme", method: "GET", path: "restaurants/9" } },
|
|
518
|
+
{ type: "text_delta", text: "done" },
|
|
519
|
+
{ type: "turn_end", stopReason: "end_turn" },
|
|
520
|
+
]),
|
|
521
|
+
{ status: 200 },
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (href.endsWith("/v1/chat/tool-result")) return Response.json({ ok: true });
|
|
525
|
+
// The host API call itself.
|
|
526
|
+
return new Response(JSON.stringify({ name: "Bistro" }), { status: 200 });
|
|
527
|
+
}) as unknown as typeof fetch;
|
|
528
|
+
|
|
529
|
+
const client = new FrankieClient({
|
|
530
|
+
baseUrl: "https://agent.acme.com",
|
|
531
|
+
publishableKey: "pk_client_exec",
|
|
532
|
+
api: { baseUrl: "https://app.acme.com/api", credentials: "include" },
|
|
533
|
+
fetchImpl,
|
|
534
|
+
persist: false,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
await client.send({ message: "show me restaurant 9" }, () => {});
|
|
538
|
+
|
|
539
|
+
// The host API was called from the browser, at the configured base, with the
|
|
540
|
+
// app's own credentials — Frankie added no Authorization of its own.
|
|
541
|
+
await waitFor(() => expect(calls.some((c) => c.url === "https://app.acme.com/api/restaurants/9")).toBe(true));
|
|
542
|
+
const hostCall = calls.find((c) => c.url === "https://app.acme.com/api/restaurants/9")!;
|
|
543
|
+
expect(hostCall.init?.method).toBe("GET");
|
|
544
|
+
expect(hostCall.init?.credentials).toBe("include");
|
|
545
|
+
expect((hostCall.init?.headers as Record<string, string> | undefined)?.["authorization"]).toBeUndefined();
|
|
546
|
+
|
|
547
|
+
// The outcome was posted back with the matching callId and the host status.
|
|
548
|
+
await waitFor(() => expect(calls.some((c) => c.url.endsWith("/v1/chat/tool-result"))).toBe(true));
|
|
549
|
+
const post = calls.find((c) => c.url.endsWith("/v1/chat/tool-result"))!;
|
|
550
|
+
const body = JSON.parse(String(post.init?.body)) as { callId: string; result: { status: number; body: string } };
|
|
551
|
+
expect(body.callId).toBe("c-1");
|
|
552
|
+
expect(body.result.status).toBe(200);
|
|
553
|
+
expect(body.result.body).toContain("Bistro");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("reports a transport failure when no host api client is configured", async () => {
|
|
557
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
558
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
559
|
+
const href = String(url);
|
|
560
|
+
calls.push({ url: href, ...(init ? { init } : {}) });
|
|
561
|
+
if (href.endsWith("/v1/session")) {
|
|
562
|
+
return Response.json({ sessionId: "s1", sessionToken: "b1", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "u", anonymous: true } });
|
|
563
|
+
}
|
|
564
|
+
if (href.endsWith("/v1/chat")) {
|
|
565
|
+
return new Response(
|
|
566
|
+
sseStream([
|
|
567
|
+
{ type: "tool_call_request", callId: "c-2", toolUseId: "c-2", name: "api__acme__x", request: { source: "acme", method: "GET", path: "x" } },
|
|
568
|
+
{ type: "turn_end", stopReason: "end_turn" },
|
|
569
|
+
]),
|
|
570
|
+
{ status: 200 },
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return Response.json({ ok: true });
|
|
574
|
+
}) as unknown as typeof fetch;
|
|
575
|
+
|
|
576
|
+
// No `api` option configured.
|
|
577
|
+
const client = new FrankieClient({ baseUrl: "https://agent.acme.com", publishableKey: "pk_no_api", fetchImpl, persist: false });
|
|
578
|
+
await client.send({ message: "hi" }, () => {});
|
|
579
|
+
|
|
580
|
+
await waitFor(() => expect(calls.some((c) => c.url.endsWith("/v1/chat/tool-result"))).toBe(true));
|
|
581
|
+
const post = calls.find((c) => c.url.endsWith("/v1/chat/tool-result"))!;
|
|
582
|
+
const body = JSON.parse(String(post.init?.body)) as { result: { status: number; error?: string } };
|
|
583
|
+
expect(body.result.status).toBe(0);
|
|
584
|
+
expect(body.result.error).toMatch(/no host API client/i);
|
|
585
|
+
});
|
|
586
|
+
});
|