@frankieai/react 0.2.19 → 0.2.21
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 +282 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +51 -6
- package/dist/index.d.ts +51 -6
- package/dist/index.js +281 -158
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ActionConfirm.tsx +85 -0
- package/src/components/chat/ChatPanel.tsx +3 -2
- package/src/components/chat/FrankieWidget.tsx +23 -0
- package/src/components/chat/Markdown.tsx +2 -1
- package/src/components/chat/MessageBubble.tsx +12 -0
- package/src/generated/css-content.ts +1 -1
- package/src/hooks/FrankieProvider.tsx +19 -2
- package/src/hooks/useFrankieChat.ts +26 -2
- package/src/index.ts +2 -1
- package/src/lib/client.ts +14 -4
- package/src/lib/messages.ts +21 -1
- package/src/lib/theme-inherit.test.ts +10 -0
- package/src/lib/theme-inherit.ts +7 -1
- package/src/outside-click.test.tsx +42 -0
- package/src/styles/frankie.css +45 -0
- package/src/theme-ready.test.tsx +19 -0
- package/src/widget.test.tsx +52 -3
package/src/lib/messages.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FrankieServerEvent } from "@frankieai/core";
|
|
1
|
+
import type { ActionPreview, FrankieServerEvent } from "@frankieai/core";
|
|
2
2
|
|
|
3
3
|
export interface ToolActivity {
|
|
4
4
|
id: string;
|
|
@@ -22,6 +22,18 @@ export interface UserChatMessage {
|
|
|
22
22
|
createdAt: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* An action that changes data, paused until the user approves it. `pending`
|
|
27
|
+
* shows Approve and Cancel; the other states record the answer.
|
|
28
|
+
*/
|
|
29
|
+
export interface ActionRequest {
|
|
30
|
+
toolUseId: string;
|
|
31
|
+
name: string;
|
|
32
|
+
label: string;
|
|
33
|
+
preview: ActionPreview;
|
|
34
|
+
state: "pending" | "approved" | "cancelled";
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
export interface AssistantChatMessage {
|
|
26
38
|
id: string;
|
|
27
39
|
role: "assistant";
|
|
@@ -31,6 +43,8 @@ export interface AssistantChatMessage {
|
|
|
31
43
|
status: "streaming" | "done" | "error";
|
|
32
44
|
error?: string;
|
|
33
45
|
createdAt: number;
|
|
46
|
+
/** An action this reply is waiting on the user to approve. */
|
|
47
|
+
action?: ActionRequest;
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
export type ChatMessage = UserChatMessage | AssistantChatMessage;
|
|
@@ -72,6 +86,12 @@ export function applyEvent(message: AssistantChatMessage, event: FrankieServerEv
|
|
|
72
86
|
),
|
|
73
87
|
};
|
|
74
88
|
|
|
89
|
+
case "action_confirm":
|
|
90
|
+
return {
|
|
91
|
+
...message,
|
|
92
|
+
action: { toolUseId: event.toolUseId, name: event.name, label: event.label, preview: event.preview, state: "pending" },
|
|
93
|
+
};
|
|
94
|
+
|
|
75
95
|
case "tool_denied":
|
|
76
96
|
return {
|
|
77
97
|
...message,
|
|
@@ -72,6 +72,16 @@ describe("inferHostTheme", () => {
|
|
|
72
72
|
expect(theme?.primary).toBe("rgb(220, 38, 38)");
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it("skips a grey link's text and takes the next colorful element", () => {
|
|
76
|
+
document.body.innerHTML = '<a href="/lessons" data-stub="grey-link">Lessons</a><button data-stub="green">Start</button>';
|
|
77
|
+
stubComputedStyle({
|
|
78
|
+
body: { backgroundColor: "rgb(255, 255, 255)", color: "rgb(29, 27, 34)" },
|
|
79
|
+
"grey-link": { backgroundColor: "rgba(0, 0, 0, 0)", color: "rgb(90, 88, 96)" },
|
|
80
|
+
green: { backgroundColor: "rgb(18, 161, 80)", color: "rgb(255, 255, 255)" },
|
|
81
|
+
});
|
|
82
|
+
expect(inferHostTheme()?.primary).toBe("rgb(18, 161, 80)");
|
|
83
|
+
});
|
|
84
|
+
|
|
75
85
|
it("returns null for a transparent body background — nothing real to read", () => {
|
|
76
86
|
stubComputedStyle({ body: { backgroundColor: "rgba(0, 0, 0, 0)", color: "rgb(10, 10, 10)" } });
|
|
77
87
|
expect(inferHostTheme()).toBeNull();
|
package/src/lib/theme-inherit.ts
CHANGED
|
@@ -27,6 +27,12 @@ function luminance([r, g, b]: Rgba): number {
|
|
|
27
27
|
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Whether a color has a hue of its own: a grey or near-black link is text, not a brand color. */
|
|
31
|
+
function colorful([r, g, b]: Rgba): boolean {
|
|
32
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
33
|
+
return max - min >= 40;
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
function contrastRatio(a: Rgba, b: Rgba): number {
|
|
31
37
|
const la = luminance(a), lb = luminance(b);
|
|
32
38
|
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
@@ -193,7 +199,7 @@ export function inferHostTheme(): Partial<WidgetTheme> | null {
|
|
|
193
199
|
const surface = parseRgb(style.backgroundColor);
|
|
194
200
|
if (surface && surface[3] > 0.5 && !sameColor(surface, background)) { accent = surface; break; }
|
|
195
201
|
const text = parseRgb(style.color);
|
|
196
|
-
if (text && text[3] > 0.5 && !sameColor(text, foreground)) { accent = text; break; }
|
|
202
|
+
if (text && text[3] > 0.5 && !sameColor(text, foreground) && colorful(text)) { accent = text; break; }
|
|
197
203
|
}
|
|
198
204
|
|
|
199
205
|
// The page's light/dark decides the widget's, not the visitor's OS: a dark
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { PublicWidgetConfig } from "@frankieai/core";
|
|
4
|
+
import { FrankieProvider } from "./hooks/FrankieProvider.jsx";
|
|
5
|
+
import { FrankieWidget } from "./components/chat/FrankieWidget.js";
|
|
6
|
+
|
|
7
|
+
const config: PublicWidgetConfig = {
|
|
8
|
+
tenantId: "acme", branding: { name: "Acme", tagline: "", launcherLabel: "Need a hand?" }, greeting: "Hi", suggestedPrompts: [],
|
|
9
|
+
theme: { mode: "light" }, allowAnonymous: true, showToolActivity: true,
|
|
10
|
+
features: { attachments: { enabled: false, accept: [], maxMb: 10, maxCount: 4 }, modelPicker: { enabled: false, models: [] } },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup);
|
|
14
|
+
const panel = () => document.querySelector(".frankie-widget-panel");
|
|
15
|
+
const renderWidget = (props: { closeOnOutsideClick?: boolean } = {}) => render(
|
|
16
|
+
<div>
|
|
17
|
+
<button type="button">Elsewhere</button>
|
|
18
|
+
<button type="button" data-frankie-open>Ask</button>
|
|
19
|
+
<FrankieProvider publishableKey="pk_outside" config={config} fetchImpl={vi.fn()} persist={false}><FrankieWidget defaultOpen {...props} /></FrankieProvider>
|
|
20
|
+
</div>,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
describe("clicking outside the widget", () => {
|
|
24
|
+
it("closes the panel", () => {
|
|
25
|
+
renderWidget();
|
|
26
|
+
expect(panel()).toBeTruthy();
|
|
27
|
+
act(() => { fireEvent.pointerDown(screen.getByRole("button", { name: "Elsewhere" })); });
|
|
28
|
+
expect(panel()).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it("keeps it open for clicks inside it and on the host's own Ask button", () => {
|
|
31
|
+
renderWidget();
|
|
32
|
+
act(() => { fireEvent.pointerDown(panel()!); });
|
|
33
|
+
expect(panel()).toBeTruthy();
|
|
34
|
+
act(() => { fireEvent.pointerDown(screen.getByRole("button", { name: "Ask" })); });
|
|
35
|
+
expect(panel()).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
it("can be turned off", () => {
|
|
38
|
+
renderWidget({ closeOnOutsideClick: false });
|
|
39
|
+
act(() => { fireEvent.pointerDown(screen.getByRole("button", { name: "Elsewhere" })); });
|
|
40
|
+
expect(panel()).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/styles/frankie.css
CHANGED
|
@@ -607,3 +607,48 @@
|
|
|
607
607
|
animation:frankie-skeleton-pulse 1.6s ease-in-out infinite;
|
|
608
608
|
}
|
|
609
609
|
}
|
|
610
|
+
/* The compact composer (widget, inline) with an attach button or model picker:
|
|
611
|
+
the message box gets its own row and the tools + send sit below it, so the
|
|
612
|
+
row can't run past a narrow panel's edge. */
|
|
613
|
+
.frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) { flex-wrap:wrap; align-items:center; row-gap:8px; }
|
|
614
|
+
.frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) > textarea { order:-1; flex:1 0 100%; width:100%; }
|
|
615
|
+
.frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) > .frankie-composer-actions { flex:1 1 auto; }
|
|
616
|
+
.frankie-root:not(.frankie-chat-panel-fill) .frankie-composer-controls:has(.frankie-composer-actions > :not(input[hidden])) > button { margin-left:auto; }
|
|
617
|
+
|
|
618
|
+
/* An action that changes data, waiting for the user's approval. */
|
|
619
|
+
.frankie-root .frankie-action {
|
|
620
|
+
display:flex; flex-direction:column; gap:10px; margin-top:12px; padding:12px 14px;
|
|
621
|
+
border:1px solid var(--frankie-border); border-radius:var(--frankie-radius);
|
|
622
|
+
background:var(--frankie-background); color:var(--frankie-foreground); font-size:14px; line-height:1.4;
|
|
623
|
+
}
|
|
624
|
+
.frankie-root .frankie-action.is-pending {
|
|
625
|
+
border-color:color-mix(in srgb, var(--frankie-primary) 45%, var(--frankie-border));
|
|
626
|
+
background:color-mix(in srgb, var(--frankie-primary) 5%, var(--frankie-background));
|
|
627
|
+
}
|
|
628
|
+
.frankie-root .frankie-action-head { display:flex; align-items:flex-start; gap:10px; }
|
|
629
|
+
.frankie-root .frankie-action-icon { margin-top:2px; flex-shrink:0; color:var(--frankie-primary); }
|
|
630
|
+
.frankie-root .frankie-action-title { display:flex; flex-direction:column; gap:2px; min-width:0; }
|
|
631
|
+
.frankie-root .frankie-action-title strong { font-weight:600; overflow-wrap:anywhere; }
|
|
632
|
+
.frankie-root .frankie-action-title span { font-size:12px; color:var(--frankie-muted-foreground); }
|
|
633
|
+
.frankie-root .frankie-action-route {
|
|
634
|
+
display:block; padding:6px 8px; border-radius:calc(var(--frankie-radius) * .6);
|
|
635
|
+
background:var(--frankie-muted); font-size:12px; overflow-wrap:anywhere;
|
|
636
|
+
}
|
|
637
|
+
.frankie-root .frankie-action-route span { font-weight:600; }
|
|
638
|
+
.frankie-root .frankie-action-fields { display:grid; gap:6px; margin:0; }
|
|
639
|
+
.frankie-root .frankie-action-fields > div { display:grid; grid-template-columns:minmax(0,35%) minmax(0,1fr); gap:10px; }
|
|
640
|
+
.frankie-root .frankie-action-fields dt { color:var(--frankie-muted-foreground); font-size:12px; overflow-wrap:anywhere; }
|
|
641
|
+
.frankie-root .frankie-action-fields dd {
|
|
642
|
+
margin:0; font-size:13px; white-space:pre-wrap; overflow-wrap:anywhere; max-height:8.5em; overflow:auto;
|
|
643
|
+
}
|
|
644
|
+
.frankie-root .frankie-action-buttons { display:flex; gap:8px; flex-wrap:wrap; }
|
|
645
|
+
.frankie-root .frankie-action-buttons button {
|
|
646
|
+
display:inline-flex; align-items:center; gap:6px; padding:7px 14px; cursor:pointer; font:inherit; font-size:14px; font-weight:500;
|
|
647
|
+
border-radius:var(--frankie-radius); border:1px solid var(--frankie-border); transition:background-color .15s ease, border-color .15s ease;
|
|
648
|
+
}
|
|
649
|
+
.frankie-root .frankie-action-approve { background:var(--frankie-primary); color:var(--frankie-primary-foreground); border-color:var(--frankie-primary); }
|
|
650
|
+
.frankie-root .frankie-action-approve:hover:not(:disabled) { background:color-mix(in srgb, var(--frankie-primary) 88%, black); }
|
|
651
|
+
.frankie-root .frankie-action-cancel { background:var(--frankie-background); color:var(--frankie-foreground); }
|
|
652
|
+
.frankie-root .frankie-action-cancel:hover:not(:disabled) { background:var(--frankie-muted); }
|
|
653
|
+
.frankie-root .frankie-action-buttons button:disabled { opacity:.55; cursor:not-allowed; }
|
|
654
|
+
.frankie-root .frankie-action-buttons button:focus-visible { outline:none; box-shadow:0 0 0 2px color-mix(in srgb, var(--frankie-primary) 40%, transparent); }
|
package/src/theme-ready.test.tsx
CHANGED
|
@@ -33,4 +33,23 @@ describe("first paint", () => {
|
|
|
33
33
|
await waitFor(() => expect(root.style.opacity).toBe(""));
|
|
34
34
|
expect(root.dataset.frankieMode).toBe("light");
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it("shows the widget anyway when the config never answers, instead of hiding it for as long as the browser waits", async () => {
|
|
38
|
+
stylesLoaded();
|
|
39
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
40
|
+
try {
|
|
41
|
+
// A hung request: no response, no error, and the client sets no timeout of its own.
|
|
42
|
+
const fetchImpl = vi.fn(() => new Promise<Response>(() => {}));
|
|
43
|
+
const { container } = render(<FrankieProvider publishableKey="pk_test" baseUrl="https://frankie.test" fetchImpl={fetchImpl as unknown as typeof fetch}><FrankieWidget /></FrankieProvider>);
|
|
44
|
+
const root = container.querySelector(".frankie-widget") as HTMLElement;
|
|
45
|
+
await waitFor(() => expect(root.dataset.frankieStyles).toBe("ready"));
|
|
46
|
+
expect(root.style.opacity).toBe("0");
|
|
47
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(4_000); });
|
|
48
|
+
expect(root.style.opacity).toBe("0");
|
|
49
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(1_500); });
|
|
50
|
+
expect(root.style.opacity).toBe("");
|
|
51
|
+
} finally {
|
|
52
|
+
vi.useRealTimers();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
36
55
|
});
|
package/src/widget.test.tsx
CHANGED
|
@@ -279,6 +279,11 @@ describe("Markdown", () => {
|
|
|
279
279
|
expect(screen.queryByRole("link")).toBeNull();
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
it("refuses protocol-relative links that would leave the site", () => {
|
|
283
|
+
render(<Markdown text={"[a](//evil.example/x) [b](/\\evil.example/y) [ok](/settings)"} />);
|
|
284
|
+
expect(screen.getAllByRole("link").map((link) => link.getAttribute("href"))).toEqual(["/settings"]);
|
|
285
|
+
});
|
|
286
|
+
|
|
282
287
|
it("renders bullet lists", () => {
|
|
283
288
|
const { container } = render(<Markdown text={"Steps:\n\n- one\n- two"} />);
|
|
284
289
|
expect(container.querySelectorAll("li")).toHaveLength(2);
|
|
@@ -374,6 +379,49 @@ describe("ChatPanel", () => {
|
|
|
374
379
|
expect(screen.getByText("spec abc123def456 · 4 operations")).toBeTruthy();
|
|
375
380
|
});
|
|
376
381
|
|
|
382
|
+
it("asks before an action runs and answers with exactly that action", async () => {
|
|
383
|
+
cleanup();
|
|
384
|
+
const bodies: Array<Record<string, unknown>> = [];
|
|
385
|
+
let turn = 0;
|
|
386
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
387
|
+
if (String(url).endsWith("/v1/session")) return Response.json({ sessionId: "act", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: true } });
|
|
388
|
+
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
|
|
389
|
+
turn += 1;
|
|
390
|
+
return new Response(sseStream(turn === 1
|
|
391
|
+
? [
|
|
392
|
+
{ type: "action_confirm", toolUseId: "toolu_1", name: "api__acme__deleteProject", label: "Delete project", preview: { method: "DELETE", path: "/projects/{id}", input: { id: "p_7" } } },
|
|
393
|
+
{ type: "text_delta", text: "Approve it and I'll delete it." },
|
|
394
|
+
{ type: "turn_end", stopReason: "end_turn" },
|
|
395
|
+
]
|
|
396
|
+
: [{ type: "text_delta", text: "Deleted p_7." }, { type: "turn_end", stopReason: "end_turn" }]));
|
|
397
|
+
});
|
|
398
|
+
render(<FrankieProvider publishableKey="pk_action_fixture" config={config} fetchImpl={fetchImpl} persist={false}><ChatPanel /></FrankieProvider>);
|
|
399
|
+
await waitFor(() => expect((screen.getByLabelText("Message") as HTMLTextAreaElement).disabled).toBe(false));
|
|
400
|
+
fireEvent.change(screen.getByLabelText("Message"), { target: { value: "Delete p_7" } });
|
|
401
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
402
|
+
const card = await screen.findByRole("group", { name: "Approve: Delete project" });
|
|
403
|
+
expect(within(card).getByText("p_7")).toBeTruthy();
|
|
404
|
+
expect(bodies[0]).toMatchObject({ confirmActions: true });
|
|
405
|
+
await waitFor(() => expect((within(card).getByRole("button", { name: "Approve" }) as HTMLButtonElement).disabled).toBe(false));
|
|
406
|
+
fireEvent.click(within(card).getByRole("button", { name: "Approve" }));
|
|
407
|
+
expect(await screen.findByText("Deleted p_7.")).toBeTruthy();
|
|
408
|
+
expect(bodies[1]).toMatchObject({ message: "Go ahead", confirm: { toolUseId: "toolu_1", approved: true } });
|
|
409
|
+
// Answered: the buttons are gone and the card says what was decided.
|
|
410
|
+
expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
|
|
411
|
+
expect(screen.getByText("Approved")).toBeTruthy();
|
|
412
|
+
cleanup();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("leaves out the agent context bar when the agent says so (Support, Docs and other page agents)", async () => {
|
|
416
|
+
cleanup();
|
|
417
|
+
const fetchImpl = vi.fn(async () => Response.json({}));
|
|
418
|
+
render(<FrankieProvider publishableKey="pk_support_fixture" config={{ ...config, agentContext: false }} fetchImpl={fetchImpl} persist={false}><ChatPanel /></FrankieProvider>);
|
|
419
|
+
expect(screen.queryByLabelText("Agent context")).toBeNull();
|
|
420
|
+
cleanup();
|
|
421
|
+
render(<FrankieProvider publishableKey="pk_product_fixture" config={{ ...config, agentContext: true }} fetchImpl={fetchImpl} persist={false}><ChatPanel /></FrankieProvider>);
|
|
422
|
+
expect(screen.getByLabelText("Agent context")).toBeTruthy();
|
|
423
|
+
});
|
|
424
|
+
|
|
377
425
|
it("refreshes the context count and revisions from each server turn", async () => {
|
|
378
426
|
cleanup();
|
|
379
427
|
let turn = 0;
|
|
@@ -644,7 +692,7 @@ describe("client-side tool execution", () => {
|
|
|
644
692
|
expect(body.result.body).toContain("Bistro");
|
|
645
693
|
});
|
|
646
694
|
|
|
647
|
-
it("
|
|
695
|
+
it("calls the site the user is on, with the page's own sign-in, when no api option is set", async () => {
|
|
648
696
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
649
697
|
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
650
698
|
const href = String(url);
|
|
@@ -669,9 +717,10 @@ describe("client-side tool execution", () => {
|
|
|
669
717
|
await client.send({ message: "hi" }, () => {});
|
|
670
718
|
|
|
671
719
|
await waitFor(() => expect(calls.some((c) => c.url.endsWith("/v1/chat/tool-result"))).toBe(true));
|
|
720
|
+
const hostCall = calls.find((c) => c.url === `${window.location.origin}/x`)!;
|
|
721
|
+
expect(hostCall.init?.credentials).toBe("same-origin");
|
|
672
722
|
const post = calls.find((c) => c.url.endsWith("/v1/chat/tool-result"))!;
|
|
673
723
|
const body = JSON.parse(String(post.init?.body)) as { result: { status: number; error?: string } };
|
|
674
|
-
expect(body.result.status).toBe(
|
|
675
|
-
expect(body.result.error).toMatch(/no host API client/i);
|
|
724
|
+
expect(body.result.status).toBe(200);
|
|
676
725
|
});
|
|
677
726
|
});
|