@frankieai/react 0.2.18 → 0.2.19
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 +210 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +31 -3
- package/dist/index.d.ts +31 -3
- package/dist/index.js +205 -32
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/bottom-bars.test.tsx +67 -0
- package/src/components/chat/FrankieChatPage.tsx +12 -2
- package/src/components/chat/FrankieWidget.tsx +67 -4
- package/src/components/chat/MessageBubble.tsx +7 -5
- package/src/generated/css-content.ts +1 -1
- package/src/hooks/useFrankieChat.ts +59 -8
- package/src/lib/bottom-bars.ts +43 -0
- package/src/lib/client.ts +21 -3
- package/src/lib/viewport-fill.ts +39 -0
- package/src/styles/frankie.css +6 -3
- package/src/turn-follow.test.tsx +111 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far a fixed or sticky bar along the bottom of the viewport (a mobile tab
|
|
3
|
+
* bar, a cookie banner, a sticky "Buy" bar) rises over the widget's corner, in
|
|
4
|
+
* pixels. The launcher sits above it instead of hiding beneath it.
|
|
5
|
+
*
|
|
6
|
+
* It samples the corner where the launcher sits, ignoring the widget itself,
|
|
7
|
+
* and counts only elements anchored to the bottom edge and shorter than 40% of
|
|
8
|
+
* the viewport, so a full-height sidebar or a modal never pushes it away.
|
|
9
|
+
*/
|
|
10
|
+
export function bottomObstruction(root: Element | null, position: "bottom-right" | "bottom-left", offset: number): number {
|
|
11
|
+
if (typeof document === "undefined" || typeof window === "undefined" || typeof document.elementsFromPoint !== "function") return 0;
|
|
12
|
+
const width = window.innerWidth;
|
|
13
|
+
const height = window.innerHeight;
|
|
14
|
+
if (!width || !height) return 0;
|
|
15
|
+
const xs = position === "bottom-right" ? [width - offset - 24, width - 8] : [offset + 24, 8];
|
|
16
|
+
const ys = [height - offset - 24, height - 4];
|
|
17
|
+
let lift = 0;
|
|
18
|
+
const seen = new Set<Element>();
|
|
19
|
+
for (const x of xs) for (const y of ys) {
|
|
20
|
+
for (const hit of document.elementsFromPoint(x, y)) {
|
|
21
|
+
if (root?.contains(hit)) continue;
|
|
22
|
+
const bar = anchoredAncestor(hit);
|
|
23
|
+
if (!bar || seen.has(bar)) continue;
|
|
24
|
+
seen.add(bar);
|
|
25
|
+
const rect = bar.getBoundingClientRect();
|
|
26
|
+
const anchoredToBottom = rect.bottom >= height - 4;
|
|
27
|
+
const short = rect.height > 0 && rect.height < height * 0.4;
|
|
28
|
+
if (anchoredToBottom && short) lift = Math.max(lift, height - rect.top);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return Math.round(lift);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The nearest fixed or sticky element at or above `element` (a few levels up at most). */
|
|
35
|
+
function anchoredAncestor(element: Element): Element | null {
|
|
36
|
+
let node: Element | null = element;
|
|
37
|
+
for (let depth = 0; node && depth < 8 && node !== document.body && node !== document.documentElement; depth += 1) {
|
|
38
|
+
const position = getComputedStyle(node).position;
|
|
39
|
+
if (position === "fixed" || position === "sticky") return node;
|
|
40
|
+
node = node.parentElement;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
package/src/lib/client.ts
CHANGED
|
@@ -207,7 +207,25 @@ export class FrankieClient {
|
|
|
207
207
|
return Array.isArray(body.conversations) ? body.conversations : [];
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
/** Whether a conversation can be reloaded from the server (a signed-in user and a conversation id). */
|
|
211
|
+
get canResume(): boolean {
|
|
212
|
+
return Boolean(this.options.conversationId && this.options.getUserToken);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Ask the server to stop the running turn. Closing the stream no longer
|
|
217
|
+
* stops it (a closed tab shouldn't lose the reply), so Stop says so
|
|
218
|
+
* explicitly. Best effort: the stream is closed right after either way.
|
|
219
|
+
*/
|
|
220
|
+
async stopTurn(): Promise<void> {
|
|
221
|
+
if (!this.session) return;
|
|
222
|
+
await this.doFetch(`${this.baseUrl}/v1/chat/stop`, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${this.session.sessionToken}` },
|
|
225
|
+
}).catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async resumeConversation(id = this.options.conversationId): Promise<{ messages: unknown[]; title: string; busy: boolean } | undefined> {
|
|
211
229
|
if (!id || !this.options.getUserToken) return undefined;
|
|
212
230
|
const userToken = await this.requireUserToken();
|
|
213
231
|
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
|
|
@@ -215,11 +233,11 @@ export class FrankieClient {
|
|
|
215
233
|
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
|
|
216
234
|
});
|
|
217
235
|
if (response.status === 404) return undefined;
|
|
218
|
-
const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string };
|
|
236
|
+
const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string; busy?: boolean };
|
|
219
237
|
this.session = body.session;
|
|
220
238
|
this.sessionUserKey = sessionIdentity(userToken);
|
|
221
239
|
this.writeStored();
|
|
222
|
-
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
|
|
240
|
+
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat", busy: body.busy === true };
|
|
223
241
|
}
|
|
224
242
|
|
|
225
243
|
async syncConversation(messages: unknown[], title?: string): Promise<void> {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useState, type RefObject } from "react";
|
|
2
|
+
import { bottomObstruction } from "./bottom-bars.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The height that fills the viewport from where `ref` starts down to the
|
|
6
|
+
* bottom edge, less any fixed bar along the bottom (a mobile tab bar). A
|
|
7
|
+
* full-page chat inside a host layout with no sized parent otherwise stops at
|
|
8
|
+
* its minimum height and leaves a gap above the host's navigation.
|
|
9
|
+
* Re-measured on resize and, throttled, as the page changes.
|
|
10
|
+
*/
|
|
11
|
+
export function useViewportFill(ref: RefObject<HTMLElement | null>, enabled: boolean): number {
|
|
12
|
+
const [fill, setFill] = useState(0);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!enabled || typeof window === "undefined") { setFill(0); return; }
|
|
15
|
+
let timer = 0;
|
|
16
|
+
let last = 0;
|
|
17
|
+
const measure = () => {
|
|
18
|
+
timer = 0;
|
|
19
|
+
last = Date.now();
|
|
20
|
+
const node = ref.current;
|
|
21
|
+
if (!node) return;
|
|
22
|
+
const top = node.getBoundingClientRect().top + window.scrollY;
|
|
23
|
+
const lift = bottomObstruction(node, "bottom-right", 0);
|
|
24
|
+
setFill(Math.max(0, Math.round(window.innerHeight - top - lift)));
|
|
25
|
+
};
|
|
26
|
+
const schedule = () => {
|
|
27
|
+
if (timer) return;
|
|
28
|
+
timer = window.setTimeout(() => requestAnimationFrame(measure), Math.max(0, 300 - (Date.now() - last)));
|
|
29
|
+
};
|
|
30
|
+
measure();
|
|
31
|
+
window.addEventListener("resize", schedule);
|
|
32
|
+
const observer = typeof MutationObserver === "function"
|
|
33
|
+
? new MutationObserver((records) => { if (records.some((record) => !ref.current?.contains(record.target))) schedule(); })
|
|
34
|
+
: undefined;
|
|
35
|
+
observer?.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
|
|
36
|
+
return () => { window.clearTimeout(timer); window.removeEventListener("resize", schedule); observer?.disconnect(); };
|
|
37
|
+
}, [ref, enabled]);
|
|
38
|
+
return fill;
|
|
39
|
+
}
|
package/src/styles/frankie.css
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
.frankie-chat-settings > div { position:absolute; top:46px; right:0; z-index:5; width:min(280px,80vw); display:grid; gap:12px; padding:16px; border:1px solid var(--frankie-border); border-radius:12px; background:var(--frankie-background); box-shadow:0 10px 30px #0002; font-size:13px; }.frankie-chat-settings p { margin:0; color:var(--frankie-muted-foreground); }
|
|
67
67
|
.frankie-loaded-skills { width:100%; max-width:760px; font-size:12px; color:var(--frankie-muted-foreground); }.frankie-loaded-skills summary { cursor:pointer; padding:6px 0; }.frankie-loaded-skills ul { list-style:none; padding:4px 0; margin:0; }.frankie-loaded-skills li { display:flex; gap:8px; padding:4px 0; }.frankie-loaded-skills small { opacity:.7; }
|
|
68
68
|
.frankie-history-dialog { padding:0; border:0; margin:0; max-height:none; height:100dvh; max-width:none; width:min(320px,86vw); background:var(--frankie-secondary); color:var(--frankie-foreground); }.frankie-history-dialog::backdrop { background:#0008; }.frankie-history-dialog > aside { display:flex; flex-direction:column; gap:16px; height:100%; padding:calc(20px + env(safe-area-inset-top)) 16px calc(20px + env(safe-area-inset-bottom)); overflow:auto; }
|
|
69
|
-
.frankie-mobile-only { display:none!important; }.frankie-widget-panel { max-height:calc(100dvh - 100px); }
|
|
69
|
+
.frankie-mobile-only { display:none!important; }.frankie-widget-panel { max-height:calc(100dvh - 100px - var(--frankie-lift, 0px)); }
|
|
70
70
|
.frankie-root button:focus-visible,.frankie-root summary:focus-visible,.frankie-root input:focus-visible { outline:2px solid var(--frankie-primary); outline-offset:3px; }
|
|
71
71
|
.frankie-root button:disabled { opacity:.5; cursor:not-allowed; }
|
|
72
72
|
@media(max-width:760px) { .frankie-history-desktop { display:none; }.frankie-mobile-only { display:inline-flex!important; }.frankie-header-caption { display:none; }.frankie-page-header { padding:6px 10px; }.frankie-history-heading button { border:0; background:transparent; min-width:44px; min-height:44px; align-items:center; justify-content:center; }.frankie-page-prompts { gap:8px; }.frankie-widget-panel { width:calc(100vw - 32px); } }
|
|
@@ -591,11 +591,14 @@
|
|
|
591
591
|
height:12px; border-radius:6px;
|
|
592
592
|
background:linear-gradient(90deg,
|
|
593
593
|
color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 25%,
|
|
594
|
-
color-mix(in srgb, var(--frankie-muted-foreground)
|
|
594
|
+
color-mix(in srgb, var(--frankie-muted-foreground) 19%, transparent) 37%,
|
|
595
595
|
color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 63%);
|
|
596
596
|
background-size:400% 100%;
|
|
597
|
-
animation:frankie-shimmer
|
|
597
|
+
animation:frankie-shimmer 2.2s ease-in-out infinite;
|
|
598
598
|
}
|
|
599
|
+
/* Fades in after a beat, so a fast first word never flashes a skeleton. */
|
|
600
|
+
.frankie-root .frankie-skeleton.is-delayed { animation:frankie-skeleton-in .25s ease .35s both; }
|
|
601
|
+
@keyframes frankie-skeleton-in { from { opacity:0; } to { opacity:1; } }
|
|
599
602
|
@keyframes frankie-shimmer { 0% { background-position:100% 50%; } 100% { background-position:0 50%; } }
|
|
600
603
|
@keyframes frankie-skeleton-pulse { 0%,100% { opacity:.45; } 50% { opacity:.85; } }
|
|
601
604
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen, waitFor } 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 { useFrankieChat } from "./hooks/useFrankieChat.js";
|
|
6
|
+
|
|
7
|
+
const config: PublicWidgetConfig = {
|
|
8
|
+
tenantId: "acme",
|
|
9
|
+
branding: { name: "Acme Guide", tagline: "Setup help", launcherLabel: "Need a hand?" },
|
|
10
|
+
greeting: "Hi!",
|
|
11
|
+
suggestedPrompts: [],
|
|
12
|
+
theme: { mode: "light" },
|
|
13
|
+
allowAnonymous: false,
|
|
14
|
+
showToolActivity: true,
|
|
15
|
+
features: {
|
|
16
|
+
attachments: { enabled: false, accept: ["image/*"], maxMb: 10, maxCount: 4 },
|
|
17
|
+
modelPicker: { enabled: false, models: [] },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const session = { sessionId: "s", sessionToken: "fixture", expiresAt: new Date(Date.now() + 600_000).toISOString(), user: { id: "one", anonymous: false } };
|
|
22
|
+
const token = async () => "h.eyJzdWIiOiJvbmUifQ.s";
|
|
23
|
+
const user = { id: "u1", role: "user", text: "Find the backrooms", createdAt: 1 };
|
|
24
|
+
const done = { id: "a1", role: "assistant", text: "Here they are.", thinking: "", tools: [], status: "done", createdAt: 2 };
|
|
25
|
+
|
|
26
|
+
/** Prints the chat state so tests can read it. */
|
|
27
|
+
function Probe() {
|
|
28
|
+
const chat = useFrankieChat();
|
|
29
|
+
const last = chat.messages[chat.messages.length - 1];
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<output data-testid="state">{`${chat.busy ? "busy" : "idle"}|${last ? `${last.role}:${last.role === "assistant" ? last.status : ""}:${last.text}` : "empty"}`}</output>
|
|
33
|
+
<button disabled={chat.restoring} onClick={() => void chat.send("hello")}>Send</button>
|
|
34
|
+
<button onClick={() => chat.stop()}>Stop</button>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const state = () => screen.getByTestId("state").textContent;
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
cleanup();
|
|
43
|
+
vi.useRealTimers();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("a reply the server is still writing", () => {
|
|
47
|
+
it("shows as working after a reload and lands when the server finishes", async () => {
|
|
48
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
49
|
+
let resumes = 0;
|
|
50
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
|
51
|
+
if (!String(url).endsWith("/resume")) return Response.json({ ok: true });
|
|
52
|
+
resumes += 1;
|
|
53
|
+
// First visit: the tab was closed mid-reply and the turn is still running.
|
|
54
|
+
return resumes === 1
|
|
55
|
+
? Response.json({ session, messages: [user], title: "Backrooms", busy: true })
|
|
56
|
+
: Response.json({ session, messages: [user, done], title: "Backrooms", busy: false });
|
|
57
|
+
});
|
|
58
|
+
render(<FrankieProvider publishableKey="pk_follow" config={config} conversationId="conv-1" fetchImpl={fetchImpl} persist={false} getUserToken={token}><Probe /></FrankieProvider>);
|
|
59
|
+
await waitFor(() => expect(state()).toBe("busy|assistant:streaming:"));
|
|
60
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
61
|
+
await waitFor(() => expect(state()).toBe("idle|assistant:done:Here they are."));
|
|
62
|
+
expect(resumes).toBe(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps a reply working when only the connection drops, then picks it up", async () => {
|
|
66
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
67
|
+
let resumes = 0;
|
|
68
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request) => {
|
|
69
|
+
const href = String(url);
|
|
70
|
+
if (href.endsWith("/resume")) {
|
|
71
|
+
resumes += 1;
|
|
72
|
+
return resumes === 1
|
|
73
|
+
? Response.json({ session, messages: [], title: "New chat", busy: false })
|
|
74
|
+
: Response.json({ session, messages: [{ ...user, text: "hello" }, done], title: "hello", busy: false });
|
|
75
|
+
}
|
|
76
|
+
if (href.endsWith("/v1/chat")) throw new TypeError("network error");
|
|
77
|
+
return Response.json({ ok: true });
|
|
78
|
+
});
|
|
79
|
+
render(<FrankieProvider publishableKey="pk_drop" config={config} conversationId="conv-2" fetchImpl={fetchImpl} persist={false} getUserToken={token}><Probe /></FrankieProvider>);
|
|
80
|
+
await waitFor(() => expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(false));
|
|
81
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
82
|
+
await waitFor(() => expect(state()).toBe("busy|assistant:streaming:"));
|
|
83
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
84
|
+
await waitFor(() => expect(state()).toBe("idle|assistant:done:Here they are."));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("tells the server to stop when the visitor presses Stop", async () => {
|
|
88
|
+
const calls: string[] = [];
|
|
89
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
90
|
+
const href = String(url);
|
|
91
|
+
calls.push(`${init?.method ?? "GET"} ${new URL(href).pathname}`);
|
|
92
|
+
if (href.endsWith("/resume")) return Response.json({ session, messages: [], title: "New chat", busy: false });
|
|
93
|
+
if (href.endsWith("/v1/chat")) {
|
|
94
|
+
// A stream that stays open until the request is aborted.
|
|
95
|
+
return new Response(new ReadableStream({
|
|
96
|
+
start(controller) {
|
|
97
|
+
init?.signal?.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")));
|
|
98
|
+
},
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
return Response.json({ ok: true });
|
|
102
|
+
});
|
|
103
|
+
render(<FrankieProvider publishableKey="pk_stop" config={config} conversationId="conv-3" fetchImpl={fetchImpl} persist={false} getUserToken={token}><Probe /></FrankieProvider>);
|
|
104
|
+
await waitFor(() => expect((screen.getByRole("button", { name: "Send" }) as HTMLButtonElement).disabled).toBe(false));
|
|
105
|
+
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
106
|
+
await waitFor(() => expect(calls).toContain("POST /v1/chat"));
|
|
107
|
+
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
|
108
|
+
await waitFor(() => expect(calls).toContain("POST /v1/chat/stop"));
|
|
109
|
+
await waitFor(() => expect(state()).toBe("idle|assistant:done:Stopped."));
|
|
110
|
+
});
|
|
111
|
+
});
|