@frankieai/react 0.2.9 → 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 +231 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +55 -3
- package/dist/index.d.ts +55 -3
- package/dist/index.js +231 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/chat/Composer.tsx +21 -16
- package/src/components/chat/FrankieChatPage.tsx +1 -1
- package/src/components/chat/FrankieInline.tsx +3 -2
- package/src/components/chat/FrankieWidget.tsx +2 -2
- package/src/components/chat/MessageBubble.tsx +24 -5
- 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/index.ts +1 -1
- 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 +19 -7
- package/src/lib/theme-inherit.test.ts +68 -0
- package/src/lib/theme-inherit.ts +89 -0
- package/src/styles/frankie.css +30 -0
- package/src/styles-ready.test.tsx +30 -3
- package/src/widget.test.tsx +218 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frankieai/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
4
4
|
"description": "Embeddable, themeable chat widget for the Frankie onboarding agent — shadcn/ui components, works with or without Tailwind in the host app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"clsx": "^2.1.1",
|
|
32
32
|
"lucide-react": "^0.460.0",
|
|
33
33
|
"tailwind-merge": "^2.5.0",
|
|
34
|
-
"@frankieai/core": "0.2.
|
|
34
|
+
"@frankieai/core": "0.2.3"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@tailwindcss/cli": "^4.0.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"node": ">=20"
|
|
51
51
|
},
|
|
52
52
|
"scripts": {
|
|
53
|
-
"build": "tsup && pnpm build:css",
|
|
53
|
+
"build": "pnpm build:css && node scripts/embed-css.mjs && tsup && pnpm build:css",
|
|
54
54
|
"build:css": "tailwindcss -i src/styles/frankie.css -o dist/frankie.css --minify",
|
|
55
55
|
"dev": "tsup --watch",
|
|
56
56
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -197,22 +197,6 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
|
|
|
197
197
|
aria-label="Message"
|
|
198
198
|
/>
|
|
199
199
|
|
|
200
|
-
{models.length > 0 ? (
|
|
201
|
-
<select
|
|
202
|
-
value={modelPicker?.value ?? models[0]?.id}
|
|
203
|
-
onChange={(event) => modelPicker?.onChange(event.target.value)}
|
|
204
|
-
disabled={disabled}
|
|
205
|
-
aria-label="Model"
|
|
206
|
-
className="frankie-focus h-9 max-w-[8rem] rounded-[var(--frankie-radius)] border border-border bg-background px-2 text-sm"
|
|
207
|
-
>
|
|
208
|
-
{models.map((model) => (
|
|
209
|
-
<option key={model.id} value={model.id}>
|
|
210
|
-
{model.label}
|
|
211
|
-
</option>
|
|
212
|
-
))}
|
|
213
|
-
</select>
|
|
214
|
-
) : null}
|
|
215
|
-
|
|
216
200
|
{busy ? (
|
|
217
201
|
<Button type="button" size="icon" variant="outline" onClick={onStop} aria-label="Stop">
|
|
218
202
|
<Square />
|
|
@@ -223,6 +207,27 @@ export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocu
|
|
|
223
207
|
</Button>
|
|
224
208
|
)}
|
|
225
209
|
</div>
|
|
210
|
+
|
|
211
|
+
{models.length > 0 ? (
|
|
212
|
+
<div className="frankie-composer-meta">
|
|
213
|
+
<label className="frankie-model-picker" title="Model">
|
|
214
|
+
<span className="frankie-sr-only">Model</span>
|
|
215
|
+
<select
|
|
216
|
+
value={modelPicker?.value ?? models[0]?.id}
|
|
217
|
+
onChange={(event) => modelPicker?.onChange(event.target.value)}
|
|
218
|
+
disabled={disabled}
|
|
219
|
+
aria-label="Model"
|
|
220
|
+
className="frankie-model-select"
|
|
221
|
+
>
|
|
222
|
+
{models.map((model) => (
|
|
223
|
+
<option key={model.id} value={model.id}>
|
|
224
|
+
{model.label}
|
|
225
|
+
</option>
|
|
226
|
+
))}
|
|
227
|
+
</select>
|
|
228
|
+
</label>
|
|
229
|
+
</div>
|
|
230
|
+
) : null}
|
|
226
231
|
</form>
|
|
227
232
|
);
|
|
228
233
|
}
|
|
@@ -106,7 +106,7 @@ export function FrankieChatPage({ className, style, header, footer, emptyState,
|
|
|
106
106
|
const [id, setId] = useState(() => provider.conversationId ?? crypto.randomUUID());
|
|
107
107
|
const onNew = () => setId(crypto.randomUUID());
|
|
108
108
|
const pageRef = useRef<HTMLDivElement>(null);
|
|
109
|
-
const stylesReady = useStylesReady(pageRef);
|
|
109
|
+
const stylesReady = useStylesReady(pageRef, undefined, provider.injectStyles);
|
|
110
110
|
return <div ref={pageRef} className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")} style={{ height: "100%", minHeight: "min(36rem, 100dvh)", width: "100%", ...style, ...(stylesReady ? null : { opacity: 0 as const }) }} data-frankie-styles={stylesReady ? "ready" : "pending"}>
|
|
111
111
|
<FrankieProvider {...provider} conversationId={id} storageNamespace={(provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id}>
|
|
112
112
|
<PageChrome showHistory={showHistory} navigation={navigation} currentId={id} onSelect={setId} onNew={onNew} header={header} footer={footer} emptyState={emptyState} renderMessage={renderMessage} renderTool={renderTool} placeholder={placeholder} autoFocus={autoFocus} />
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useRef, type CSSProperties } from "react";
|
|
4
|
-
import { useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
4
|
+
import { useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
5
5
|
import { useStylesReady } from "../../lib/styles-ready.js";
|
|
6
6
|
import { resolveMode, themeToCssVars } from "../../lib/theme.js";
|
|
7
7
|
import { cn } from "../../lib/utils.js";
|
|
@@ -22,10 +22,11 @@ export interface FrankieInlineProps extends ChatPanelProps {
|
|
|
22
22
|
* </div>
|
|
23
23
|
*/
|
|
24
24
|
export function FrankieInline({ className, style, ...panelProps }: FrankieInlineProps) {
|
|
25
|
+
const { injectStyles } = useFrankie();
|
|
25
26
|
const theme = useFrankieTheme();
|
|
26
27
|
const mode = resolveMode(theme?.mode);
|
|
27
28
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
28
|
-
const stylesReady = useStylesReady(rootRef);
|
|
29
|
+
const stylesReady = useStylesReady(rootRef, undefined, injectStyles);
|
|
29
30
|
return (
|
|
30
31
|
<div
|
|
31
32
|
ref={rootRef}
|
|
@@ -39,11 +39,11 @@ export function FrankieWidget({
|
|
|
39
39
|
renderLauncher,
|
|
40
40
|
...panelProps
|
|
41
41
|
}: FrankieWidgetProps) {
|
|
42
|
-
const { config } = useFrankie();
|
|
42
|
+
const { config, injectStyles } = useFrankie();
|
|
43
43
|
const theme = useFrankieTheme();
|
|
44
44
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
45
45
|
// Never paint the panel as raw HTML while the host's CSS chunk is still in flight.
|
|
46
|
-
const stylesReady = useStylesReady(rootRef);
|
|
46
|
+
const stylesReady = useStylesReady(rootRef, undefined, injectStyles);
|
|
47
47
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
48
48
|
const open = controlledOpen ?? uncontrolledOpen;
|
|
49
49
|
|
|
@@ -67,13 +67,18 @@ export const MessageBubble = memo(function MessageBubble({
|
|
|
67
67
|
);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
const empty = message.text.length === 0 && message.tools.length === 0;
|
|
71
70
|
// Lift any offered options out of the prose. While the reply is still
|
|
72
71
|
// streaming, a half-written block is hidden (options stays null) so the raw
|
|
73
72
|
// fence never flashes. Options become clickable only on the newest, settled
|
|
74
73
|
// message so answering an earlier turn's question can't jump the transcript.
|
|
75
74
|
const { body, options } = extractChoices(message.text);
|
|
76
75
|
const showChoices = Boolean(active && onChoose && options && message.status === "done");
|
|
76
|
+
// A running tool row is its own progress indicator; otherwise, while the turn
|
|
77
|
+
// is still streaming with no visible text yet, the model is thinking (before
|
|
78
|
+
// its first words, or in the pause after a tool result while it composes the
|
|
79
|
+
// answer). Show a skeleton there so those gaps never look frozen.
|
|
80
|
+
const toolsRunning = message.tools.some((tool) => tool.status === "running");
|
|
81
|
+
const waiting = message.status === "streaming" && body.length === 0 && !toolsRunning;
|
|
77
82
|
|
|
78
83
|
return (
|
|
79
84
|
<div className="frankie-assistant-message flex gap-2">
|
|
@@ -86,15 +91,14 @@ export const MessageBubble = memo(function MessageBubble({
|
|
|
86
91
|
<p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
|
87
92
|
{message.error}
|
|
88
93
|
</p>
|
|
89
|
-
) :
|
|
90
|
-
<TypingDots />
|
|
91
|
-
) : (
|
|
94
|
+
) : body ? (
|
|
92
95
|
<Markdown text={body} className="text-sm" />
|
|
93
|
-
)}
|
|
96
|
+
) : null}
|
|
94
97
|
{showChoices ? <Choices options={options ?? []} onPick={onChoose!} {...(onRequestInput ? { onSomethingElse: onRequestInput } : {})} disabled={busy} /> : null}
|
|
95
98
|
{showToolActivity ? (
|
|
96
99
|
<ToolActivityList tools={message.tools} {...(renderTool ? { renderTool } : {})} />
|
|
97
100
|
) : null}
|
|
101
|
+
{waiting ? <Thinking /> : null}
|
|
98
102
|
</div>
|
|
99
103
|
</div>
|
|
100
104
|
);
|
|
@@ -127,3 +131,18 @@ export function TypingDots() {
|
|
|
127
131
|
);
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A shimmering skeleton shown while the reply is still being produced — before
|
|
136
|
+
* the first words, or during the pause after a tool result while the model
|
|
137
|
+
* composes its answer — so those gaps read as "loading" rather than frozen.
|
|
138
|
+
*/
|
|
139
|
+
export function Thinking() {
|
|
140
|
+
return (
|
|
141
|
+
<div className="frankie-skeleton" role="status" aria-label="Thinking">
|
|
142
|
+
<span className="frankie-skeleton-line" style={{ width: "62%" }} />
|
|
143
|
+
<span className="frankie-skeleton-line" style={{ width: "92%" }} />
|
|
144
|
+
<span className="frankie-skeleton-line" style={{ width: "48%" }} />
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Check, ChevronDown, Loader2, ShieldAlert, X } from "lucide-react";
|
|
3
|
+
import { Check, ChevronDown, Loader2, ShieldAlert, Wrench, X } from "lucide-react";
|
|
4
4
|
import { Badge } from "../ui/badge.js";
|
|
5
5
|
import type { ToolActivity } from "../../lib/messages.js";
|
|
6
6
|
|
|
@@ -18,6 +18,7 @@ export function ToolActivityList({ tools, renderTool }: ToolActivityListProps) {
|
|
|
18
18
|
<details className="frankie-tool-activity mt-2" open={running}>
|
|
19
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
20
|
<ChevronDown className="frankie-tool-chevron size-3.5 transition-transform" aria-hidden="true" />
|
|
21
|
+
{running ? <Loader2 className="size-3.5 animate-spin" aria-hidden="true" /> : <Wrench className="size-3.5" aria-hidden="true" />}
|
|
21
22
|
<span>{running ? "Working" : "Activity"}</span>
|
|
22
23
|
<span className="text-[11px]">· {tools.length} tool call{tools.length === 1 ? "" : "s"}</span>
|
|
23
24
|
</summary>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generated by scripts/embed-css.mjs from dist/frankie.css — do not edit by hand.
|
|
2
|
+
export const FRANKIE_CSS_CONTENT: string = "/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-ease:initial}}}@layer theme{:root,:host{--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--font-weight-medium:500;--font-weight-semibold:600;--leading-tight:1.25;--radius-md:.375rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1)}}@layer components{.frankie-page-shell{background:var(--frankie-background);height:100%;min-height:0;color:var(--frankie-foreground);flex:1;display:flex}.frankie-page-main{flex-direction:column;flex:1;min-width:0;min-height:0;display:flex}.frankie-history-desktop{border-right:1px solid var(--frankie-border);background:var(--frankie-secondary);flex-direction:column;flex-shrink:0;gap:16px;width:260px;padding:20px 14px;transition:width .16s,padding .16s;display:flex;overflow:auto}.frankie-history-toggle{border:1px solid var(--frankie-border);background:var(--frankie-background);width:32px;height:32px;color:var(--frankie-muted-foreground);cursor:pointer;border-radius:8px;flex-shrink:0;justify-content:center;align-self:flex-end;align-items:center;display:flex}.frankie-history-toggle:hover{color:var(--frankie-foreground)}.frankie-history-collapsed{width:56px;padding:20px 8px;overflow:visible}.frankie-history-collapsed .frankie-history-toggle{align-self:center}.frankie-history-heading{justify-content:space-between;align-items:center;font-size:14px;display:flex}.frankie-new-chat{border:1px solid var(--frankie-border);background:var(--frankie-background);cursor:pointer;border-radius:10px;justify-content:center;align-items:center;gap:8px;min-height:44px;font-weight:600;display:flex}.frankie-history-search>span{color:var(--frankie-muted-foreground);margin-bottom:6px;font-size:12px;display:block}.frankie-history-search input{border:1px solid var(--frankie-border);background:var(--frankie-background);border-radius:8px;width:100%;min-height:40px;padding:8px 10px}.frankie-history-list{flex-direction:column;flex:1;gap:4px;display:flex}.frankie-history-list button{text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:8px;flex-direction:column;align-items:flex-start;gap:5px;min-height:54px;padding:10px;display:flex}.frankie-history-list button span{text-overflow:ellipsis;white-space:nowrap;width:100%;font-size:13px;overflow:hidden}.frankie-history-list small{color:var(--frankie-muted-foreground);font-size:11px}.frankie-history-list button:hover,.frankie-history-list button[aria-current]{background:var(--frankie-background);border-color:var(--frankie-border)}.frankie-history-desktop>p,.frankie-history-dialog p{color:var(--frankie-muted-foreground);font-size:13px}.frankie-header-caption{color:var(--frankie-muted-foreground);font-size:12px}.frankie-page-header button{cursor:pointer;justify-content:center;align-items:center;gap:6px;min-width:40px;min-height:40px;display:inline-flex}.frankie-chat-settings{position:relative}.frankie-chat-settings summary{cursor:pointer;border-radius:8px;place-items:center;width:40px;height:40px;list-style:none;display:grid}.frankie-chat-settings summary::-webkit-details-marker{display:none}.frankie-chat-settings>div{z-index:5;border:1px solid var(--frankie-border);background:var(--frankie-background);border-radius:12px;gap:12px;width:min(280px,80vw);padding:16px;font-size:13px;display:grid;position:absolute;top:46px;right:0;box-shadow:0 10px 30px #0002}.frankie-chat-settings p{color:var(--frankie-muted-foreground);margin:0}.frankie-loaded-skills{width:100%;max-width:760px;color:var(--frankie-muted-foreground);font-size:12px}.frankie-loaded-skills summary{cursor:pointer;padding:6px 0}.frankie-loaded-skills ul{margin:0;padding:4px 0;list-style:none}.frankie-loaded-skills li{gap:8px;padding:4px 0;display:flex}.frankie-loaded-skills small{opacity:.7}.frankie-history-dialog{background:var(--frankie-secondary);width:min(320px,86vw);max-width:none;height:100dvh;max-height:none;color:var(--frankie-foreground);border:0;margin:0;padding:0}.frankie-history-dialog::backdrop{background:#0008}.frankie-history-dialog>aside{height:100%;padding:calc(20px + env(safe-area-inset-top)) 16px calc(20px + env(safe-area-inset-bottom));flex-direction:column;gap:16px;display:flex;overflow:auto}.frankie-mobile-only{display:none!important}.frankie-widget-panel{max-height:calc(100dvh - 100px)}.frankie-root button:focus-visible,.frankie-root summary:focus-visible,.frankie-root input:focus-visible{outline:2px solid var(--frankie-primary);outline-offset:3px}.frankie-root button:disabled{opacity:.5;cursor:not-allowed}@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{background:0 0;border:0;justify-content:center;align-items:center;min-width:44px;min-height:44px}.frankie-page-prompts{gap:8px}.frankie-widget-panel{width:calc(100vw - 32px)}}.frankie-root{--frankie-styles-ready:1;--frankie-background:#fff;--frankie-foreground:#0a0a0a;--frankie-primary:#171717;--frankie-primary-foreground:#fafafa;--frankie-secondary:#f5f5f5;--frankie-secondary-foreground:#171717;--frankie-muted:#f5f5f5;--frankie-muted-foreground:#737373;--frankie-border:#e5e5e5;--frankie-radius:.625rem;--frankie-font:ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;font-family:var(--frankie-font);color:var(--frankie-foreground);-webkit-font-smoothing:antialiased;line-height:1.5}.frankie-root.dark{--frankie-background:#0a0a0a;--frankie-foreground:#fafafa;--frankie-primary:#fafafa;--frankie-primary-foreground:#171717;--frankie-secondary:#262626;--frankie-secondary-foreground:#fafafa;--frankie-muted:#262626;--frankie-muted-foreground:#a3a3a3;--frankie-border:#262626}.frankie-root,.frankie-root *,.frankie-root :before,.frankie-root :after{box-sizing:border-box}.frankie-root button,.frankie-root textarea,.frankie-root input{font:inherit;color:inherit;margin:0}.frankie-root textarea{background-color:var(--frankie-background)}.frankie-focus:focus-visible{outline:2px solid var(--frankie-primary);outline-offset:2px}.frankie-scroll{scrollbar-width:thin;scrollbar-color:var(--frankie-border) transparent}.frankie-prose>*+*{margin-top:.6rem}.frankie-prose p{white-space:pre-wrap;overflow-wrap:anywhere;margin:0;line-height:1.6}.frankie-prose>.frankie-h{letter-spacing:-.01em;margin-top:1.15rem;line-height:1.3}.frankie-prose>.frankie-h:first-child{margin-top:0}.frankie-prose h3.frankie-h{font-size:1.06em;font-weight:700}.frankie-prose h4.frankie-h{font-size:1em;font-weight:600}.frankie-prose h5.frankie-h{color:var(--frankie-muted-foreground);font-size:.92em;font-weight:600}.frankie-prose strong{font-weight:600}.frankie-prose ul,.frankie-prose ol{flex-direction:column;gap:.35rem;margin:0;padding-left:1.4rem;display:flex}.frankie-prose li{padding-left:.15rem;line-height:1.55}.frankie-prose li::marker{color:var(--frankie-muted-foreground)}.frankie-prose ul{list-style:outside}.frankie-prose ol{list-style:decimal}.frankie-prose a{color:var(--frankie-primary);text-underline-offset:2px;text-decoration:underline}.frankie-prose code{background-color:var(--frankie-muted);border-radius:.25rem;padding:.1em .3em;font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,monospace;font-size:.85em}.frankie-prose pre{border-radius:var(--frankie-radius);background-color:var(--frankie-muted);margin:0;padding:.625rem .75rem;overflow-x:auto}.frankie-prose pre code{background:0 0;padding:0}.frankie-chat-page{background:radial-gradient(60rem 24rem at 50% -12rem, var(--frankie-primary), transparent 65%), var(--frankie-background);flex-direction:column;min-width:0;display:flex;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.frankie-chat-page{background:radial-gradient(60rem 24rem at 50% -12rem, color-mix(in srgb, var(--frankie-primary) 8%, transparent), transparent 65%), var(--frankie-background)}}.frankie-chat-page{color:var(--frankie-foreground)}.frankie-page-header{border-bottom:1px solid var(--frankie-border);background:var(--frankie-background);flex:0 0 56px;justify-content:space-between;align-items:center;gap:16px;min-height:56px;padding:0 24px;display:flex}@supports (color:color-mix(in lab, red, red)){.frankie-page-header{background:color-mix(in srgb, var(--frankie-background) 92%, transparent)}}.frankie-page-header{-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.frankie-page-header>div{align-items:center;gap:10px;display:flex}.frankie-page-header strong{font-size:.925rem;font-weight:650}.frankie-page-status{background:var(--frankie-primary);width:10px;height:10px;box-shadow:0 0 0 4px var(--frankie-primary);border-radius:999px}@supports (color:color-mix(in lab, red, red)){.frankie-page-status{box-shadow:0 0 0 4px color-mix(in srgb, var(--frankie-primary) 16%, transparent)}}.frankie-page-header button{border:1px solid var(--frankie-border);border-radius:calc(var(--frankie-radius) * .9);background:var(--frankie-secondary);cursor:pointer;min-height:36px;padding:0 13px}.frankie-page-header button:hover{border-color:var(--frankie-primary)}.frankie-page-canvas{flex:1;min-width:0;min-height:0;display:flex}.frankie-page-canvas>.frankie-inline{width:100%;max-width:56rem;margin:0 auto}.frankie-page-canvas .frankie-scroll{padding:2rem clamp(1rem,4vw,4rem) 1.25rem}.frankie-page-canvas .frankie-root>.border-t{background:linear-gradient(180deg, transparent, var(--frankie-background) 30%);border-top:0;padding:.75rem clamp(1rem,4vw,4rem) 1.5rem}.frankie-page-empty{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:1rem;width:min(100%,38rem);min-height:100%;margin:auto;padding:clamp(2rem,7vh,4.5rem) 1rem 2rem;display:flex}.frankie-page-avatar{border-radius:calc(var(--frankie-radius) * 1.4);background:var(--frankie-primary);width:48px;height:48px;color:var(--frankie-primary-foreground);place-items:center;font-weight:750;display:grid}.frankie-page-kicker{color:var(--frankie-primary);letter-spacing:.1em;text-transform:uppercase;margin:0 0 .4rem;font-size:.7rem;font-weight:750}.frankie-page-empty h1{letter-spacing:-.045em;margin:0;font-size:clamp(1.65rem,4vw,2.15rem);line-height:1.08}.frankie-page-empty h1+p{max-width:30rem;color:var(--frankie-muted-foreground);margin:.6rem auto 0}.frankie-page-prompts{gap:.55rem;width:100%;margin-top:.4rem;display:grid}.frankie-page-prompts button{border:1px solid var(--frankie-border);border-radius:calc(var(--frankie-radius) * 1.25);background:var(--frankie-secondary);text-align:left;cursor:pointer;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:.65rem;min-height:60px;padding:.75rem .9rem;display:grid}.frankie-page-prompts button:hover{border-color:var(--frankie-primary)}@supports (color:color-mix(in lab, red, red)){.frankie-page-prompts button:hover{border-color:color-mix(in srgb, var(--frankie-primary) 58%, var(--frankie-border))}}.frankie-page-prompts button:hover{transform:translateY(-1px)}.frankie-page-prompts button>span{color:var(--frankie-muted-foreground);font-size:.68rem;font-weight:700}.frankie-page-prompts button>strong{font-size:.875rem}.frankie-page-prompts button>b{color:var(--frankie-primary);font-size:1rem}@media (max-width:640px){.frankie-page-header{flex-basis:52px;min-height:52px;padding:0 12px}.frankie-page-canvas .frankie-scroll{padding:1.25rem .75rem .5rem}.frankie-page-canvas .frankie-root>.border-t{padding:.65rem .75rem calc(.65rem + env(safe-area-inset-bottom))}}}@layer utilities;@scope(.frankie-root){.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.isolate{isolation:isolate}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mt-0{margin-top:0}.mt-0\\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.block{display:block}.contents{display:contents}.flex{display:flex}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.size-1{width:var(--spacing);height:var(--spacing)}.size-1\\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-full{width:100%;height:100%}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-\\[32rem\\]{height:32rem}.h-full{height:100%}.h-px{height:1px}.min-h-7{min-height:calc(var(--spacing) * 7)}.min-h-\\[2\\.25rem\\]{min-height:2.25rem}.w-\\[22rem\\]{width:22rem}.w-full{width:100%}.max-w-\\[12rem\\]{max-width:12rem}.max-w-\\[14rem\\]{max-width:14rem}.max-w-\\[85\\%\\]{max-width:85%}.max-w-\\[calc\\(100vw-2rem\\)\\]{max-width:calc(100vw - 2rem)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.list-none{list-style-type:none}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-1\\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.overscroll-contain{overscroll-behavior:contain}.rounded{border-radius:.25rem}.rounded-\\[calc\\(var\\(--frankie-radius\\)\\+4px\\)\\]{border-radius:calc(var(--frankie-radius) + 4px)}.rounded-\\[calc\\(var\\(--frankie-radius\\)-2px\\)\\]{border-radius:calc(var(--frankie-radius) - 2px)}.rounded-\\[var\\(--frankie-radius\\)\\]{border-radius:var(--frankie-radius)}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-border{border-color:var(--frankie-border)}.border-transparent{border-color:#0000}.bg-background{background-color:var(--frankie-background)}.bg-border{background-color:var(--frankie-border)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-muted{background-color:var(--frankie-muted)}.bg-muted-foreground{background-color:var(--frankie-muted-foreground)}.bg-primary{background-color:var(--frankie-primary)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-secondary{background-color:var(--frankie-secondary)}.object-cover{object-fit:cover}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-1{padding-inline:var(--spacing)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:0}.py-0\\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.pl-2{padding-left:calc(var(--spacing) * 2)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\\[0\\.625rem\\]{font-size:.625rem}.text-\\[0\\.6875rem\\]{font-size:.6875rem}.text-\\[11px\\]{font-size:11px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--frankie-foreground)}.text-muted-foreground{color:var(--frankie-muted-foreground)}.text-primary{color:var(--frankie-primary)}.text-primary-foreground{color:var(--frankie-primary-foreground)}.text-red-600{color:var(--color-red-600)}.text-secondary-foreground{color:var(--frankie-secondary-foreground)}.underline-offset-4{text-underline-offset:4px}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.placeholder\\:text-muted-foreground::placeholder{color:var(--frankie-muted-foreground)}@media (hover:hover){.hover\\:border-border:hover{border-color:var(--frankie-border)}.hover\\:bg-muted:hover{background-color:var(--frankie-muted)}.hover\\:text-foreground:hover{color:var(--frankie-foreground)}.hover\\:underline:hover{text-decoration-line:underline}.hover\\:opacity-90:hover{opacity:.9}.hover\\:opacity-100:hover{opacity:1}}.focus-visible\\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\\:ring-primary:focus-visible{--tw-ring-color:var(--frankie-primary)}.focus-visible\\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\\:pointer-events-none:disabled{pointer-events:none}.disabled\\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\\:opacity-50:disabled{opacity:.5}.dark\\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.\\[\\&_svg\\]\\:pointer-events-none svg{pointer-events:none}.\\[\\&_svg\\]\\:size-4 svg{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\\[\\&_svg\\]\\:shrink-0 svg{flex-shrink:0}.\\[\\&\\:\\:-webkit-details-marker\\]\\:hidden::-webkit-details-marker{display:none}}.frankie-root.frankie-chat-panel{min-height:0;position:relative}.frankie-root.frankie-widget-panel{box-shadow:0 32px 72px -18px var(--frankie-primary), 0 12px 28px -12px #0f172a47, 0 0 0 1px var(--frankie-foreground)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-widget-panel{box-shadow:0 32px 72px -18px color-mix(in srgb, var(--frankie-primary) 22%, #0f172a6b), 0 12px 28px -12px #0f172a47, 0 0 0 1px color-mix(in srgb, var(--frankie-foreground) 8%, transparent)}}.frankie-root.frankie-widget-panel.dark{box-shadow:0 34px 80px -16px #000000b8, 0 14px 32px -12px #0009, 0 0 0 1px var(--frankie-foreground)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-widget-panel.dark{box-shadow:0 34px 80px -16px #000000b8, 0 14px 32px -12px #0009, 0 0 0 1px color-mix(in srgb, var(--frankie-foreground) 12%, transparent)}}.frankie-root .frankie-agent-context-wrap{flex:none;justify-content:center;padding:12px 10px;display:flex}.frankie-root .frankie-agent-context{max-width:100%;color:var(--frankie-foreground);font-size:13px;position:relative}.frankie-root .frankie-agent-context>summary{border:1px solid var(--frankie-border);background:var(--frankie-background);cursor:pointer;white-space:nowrap;border-radius:999px;justify-content:center;align-items:center;gap:10px;min-height:44px;padding:7px 13px;list-style:none;display:flex;box-shadow:0 1px 3px #0000000d}.frankie-root .frankie-agent-context>summary::-webkit-details-marker{display:none}.frankie-root .frankie-agent-context>summary>svg{color:var(--frankie-muted-foreground);flex:none}.frankie-root .frankie-context-count{background:var(--frankie-muted);color:var(--frankie-muted-foreground);border-radius:999px;padding:2px 10px;font-size:12px}.frankie-root .frankie-context-status{color:var(--frankie-muted-foreground);align-items:center;gap:6px;font-size:12px;display:flex}.frankie-root .frankie-context-status i{background:currentColor;border-radius:50%;width:7px;height:7px}.frankie-root .frankie-context-status[data-active=true]{color:#00976b}.frankie-root .frankie-context-detail{z-index:10;border:1px solid var(--frankie-border);background:var(--frankie-background);border-radius:16px;width:340px;max-width:calc(100vw - 32px);padding:18px;position:absolute;top:calc(100% + 10px);left:50%;transform:translate(-50%);box-shadow:0 12px 32px #00000016}.frankie-root .frankie-context-detail p{color:var(--frankie-muted-foreground);margin:10px 0 0;font-size:12px;line-height:1.6}.frankie-root .frankie-context-detail ul{max-height:220px;margin:12px 0;padding:0;list-style:none;overflow:auto}.frankie-root .frankie-context-detail li{border-bottom:1px solid var(--frankie-border);overflow-wrap:anywhere;justify-content:space-between;gap:12px;padding:8px 0;display:flex}.frankie-root .frankie-context-detail small{color:var(--frankie-muted-foreground);flex:none}.frankie-root .frankie-sr-only{clip:rect(0,0,0,0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.frankie-root.frankie-chat-panel-fill{--frankie-canvas:var(--frankie-background)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-chat-panel-fill{--frankie-canvas:color-mix(in srgb,var(--frankie-background) 60%,var(--frankie-muted))}}.frankie-root.frankie-chat-panel-fill{background:var(--frankie-canvas)}.frankie-root.frankie-chat-panel-fill .frankie-agent-context-wrap{padding:44px 16px 26px}.frankie-root.frankie-chat-panel-fill .frankie-transcript{padding:14px max(24px,50% - 480px) 24px}.frankie-root.frankie-chat-panel-fill .frankie-message-list{gap:38px}.frankie-root.frankie-chat-panel-fill .frankie-user-bubble{background:var(--frankie-muted);color:var(--frankie-foreground);white-space:pre-wrap;overflow-wrap:anywhere;border-radius:24px;padding:12px 20px;font-size:15px}.frankie-root.frankie-chat-panel-fill .frankie-assistant-content{flex:1;max-width:100%;font-size:15px;line-height:1.7}.frankie-root.frankie-chat-panel-fill .frankie-assistant-message{gap:14px}.frankie-root .frankie-thinking{color:var(--frankie-muted-foreground);gap:5px}.frankie-root .frankie-thinking-label{margin-right:6px;font-size:14px}.frankie-root.frankie-chat-panel-fill .frankie-composer-footer{background:0 0;border:0;flex:none;padding:16px max(24px,50% - 500px) 24px}.frankie-root.frankie-chat-panel-fill .frankie-composer{border:1px solid var(--frankie-border);background:var(--frankie-background);border-radius:28px;padding:14px;box-shadow:0 2px 4px #0000000a}.frankie-root.frankie-chat-panel-fill .frankie-composer:focus-within{border-color:var(--frankie-foreground)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-chat-panel-fill .frankie-composer:focus-within{border-color:color-mix(in srgb,var(--frankie-foreground) 32%,var(--frankie-border))}}.frankie-root.frankie-chat-panel-fill .frankie-composer:focus-within{box-shadow:0 0 0 2px var(--frankie-primary)}@supports (color:color-mix(in lab, red, red)){.frankie-root.frankie-chat-panel-fill .frankie-composer:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--frankie-primary) 8%,transparent)}}.frankie-root.frankie-chat-panel-fill .frankie-composer-controls{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.frankie-root.frankie-chat-panel-fill .frankie-composer textarea{resize:none;width:100%;min-height:32px;max-height:140px;color:var(--frankie-foreground);box-shadow:none;background:0 0;border:0;border-radius:0;outline:none;flex:0 0 100%;order:-1;margin-bottom:4px;padding:2px 6px;font-size:16px}.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label=Send],.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label=Stop]{background:var(--frankie-primary);width:42px;height:42px;color:var(--frankie-primary-foreground);border:0;border-radius:50%;margin-left:auto}.frankie-root.frankie-chat-panel-fill .frankie-composer button[aria-label=Attach\\ files]{border-radius:50%;width:42px;height:42px}.frankie-root .frankie-composer-meta{align-items:center;gap:8px;margin-top:6px;padding:0 2px;display:flex}.frankie-root .frankie-model-picker{align-items:center;min-width:0;display:inline-flex}.frankie-root .frankie-model-select{appearance:none;max-width:12rem;color:var(--frankie-muted-foreground);font:inherit;cursor:pointer;text-overflow:ellipsis;background-color:#0000;background-image:url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b8b8b' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>\");background-position:right 6px center;background-repeat:no-repeat;background-size:11px;border:0;border-radius:8px;padding:5px 22px 5px 8px;font-size:12px;font-weight:500;line-height:1;transition:background-color .12s,color .12s}.frankie-root .frankie-model-select:hover{background-color:var(--frankie-muted);color:var(--frankie-foreground)}.frankie-root .frankie-model-select:disabled{opacity:.5;cursor:not-allowed}.frankie-root .frankie-model-select option{color:var(--frankie-foreground);background:var(--frankie-background);font-weight:400}.frankie-root .frankie-history-desktop{background:var(--frankie-background);width:290px;padding:20px 12px}.frankie-root .frankie-history-collapsed{width:56px;padding:20px 8px}.frankie-root .frankie-new-chat{background:0 0;border:0;justify-content:flex-start;padding:8px 12px;font-size:15px}.frankie-root .frankie-history-heading{color:var(--frankie-muted-foreground);padding:0 12px;font-size:12px;font-weight:400}.frankie-root .frankie-history-list button{min-height:44px;padding:10px 12px}.frankie-root .frankie-history-list button span{font-size:14px}.frankie-root .frankie-history-list button[aria-current],.frankie-root .frankie-history-list button:hover{background:var(--frankie-muted);border-color:#0000}.frankie-root .frankie-history-list small{display:none}.frankie-root .frankie-page-header{background:var(--frankie-background);height:56px;min-height:56px}.frankie-root .frankie-page-header>div{min-width:0}.frankie-root .frankie-thread-title{text-overflow:ellipsis;white-space:nowrap;max-width:55vw;font-weight:500;overflow:hidden}.frankie-root .frankie-page-header button{background:0 0;border-color:#0000}.frankie-root .frankie-page-canvas>.frankie-inline{max-width:none}@media (max-width:760px){.frankie-root.frankie-chat-panel-fill .frankie-agent-context-wrap{padding:20px 8px 14px}.frankie-root.frankie-chat-panel-fill .frankie-transcript{padding:12px 16px 16px}.frankie-root.frankie-chat-panel-fill .frankie-composer-footer{padding:10px 12px calc(12px + env(safe-area-inset-bottom))}.frankie-root .frankie-agent-context>summary{gap:6px;padding:7px 9px;font-size:12px}.frankie-root .frankie-context-count{padding:2px 7px;font-size:11px}.frankie-root .frankie-context-status{gap:4px;font-size:11px}.frankie-root .frankie-agent-context>summary>svg{width:14px}.frankie-root .frankie-thread-title{max-width:42vw}}.frankie-root .frankie-agent-context>summary>svg:first-child{box-sizing:content-box;background:var(--frankie-muted);border-radius:50%;padding:4px}.frankie-root.frankie-chat-panel-fill .frankie-agent-context>summary{font-size:14px}.frankie-root .frankie-agent-context>summary>span:first-of-type{border-right:1px solid var(--frankie-border);padding-right:10px}@media (max-width:760px){.frankie-root .frankie-page-header button>span{display:none}}.frankie-root .frankie-choices{flex-direction:column;gap:8px;margin-top:12px;display:flex}.frankie-root .frankie-choice{text-align:left;cursor:pointer;border:1px solid var(--frankie-border);border-radius:var(--frankie-radius);background:var(--frankie-background);width:100%;color:var(--frankie-foreground);font:inherit;align-items:center;gap:12px;padding:11px 14px;font-size:14px;line-height:1.4;transition:border-color .15s,background-color .15s,transform .12s;display:flex}.frankie-root .frankie-choice:hover:not(:disabled){border-color:var(--frankie-primary)}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-choice:hover:not(:disabled){border-color:color-mix(in srgb, var(--frankie-primary) 55%, var(--frankie-border))}}.frankie-root .frankie-choice:hover:not(:disabled){background:var(--frankie-primary)}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-choice:hover:not(:disabled){background:color-mix(in srgb, var(--frankie-primary) 6%, var(--frankie-background))}}.frankie-root .frankie-choice:active:not(:disabled){transform:scale(.99)}.frankie-root .frankie-choice:focus-visible{box-shadow:0 0 0 2px var(--frankie-primary);outline:none}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-choice:focus-visible{box-shadow:0 0 0 2px color-mix(in srgb, var(--frankie-primary) 40%, transparent)}}.frankie-root .frankie-choice:disabled{opacity:.6;cursor:default}.frankie-root .frankie-choice-index{border:1px solid var(--frankie-border);background:var(--frankie-muted);width:24px;height:24px;color:var(--frankie-muted-foreground);border-radius:50%;flex:none;place-items:center;font-size:12px;font-weight:600;transition:background-color .15s,color .15s,border-color .15s;display:grid}.frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-index{background:var(--frankie-primary);border-color:var(--frankie-primary);color:var(--frankie-primary-foreground)}.frankie-root .frankie-choice-label{flex:1;min-width:0}.frankie-root .frankie-choice-go{color:var(--frankie-muted-foreground);opacity:0;flex:none;transition:opacity .15s,transform .15s;transform:translate(-4px)}.frankie-root .frankie-choice:hover:not(:disabled) .frankie-choice-go{opacity:1;color:var(--frankie-primary);transform:none}@media (prefers-reduced-motion:reduce){.frankie-root .frankie-choice,.frankie-root .frankie-choice-go,.frankie-root .frankie-choice-index{transition:none}}.frankie-root .frankie-choice-other{color:var(--frankie-muted-foreground);border-style:dashed}.frankie-root .frankie-choice-other .frankie-choice-index{background:0 0}.frankie-root .frankie-choice-other:hover:not(:disabled){color:var(--frankie-foreground)}.frankie-root .frankie-choice-other:hover:not(:disabled) .frankie-choice-index{background:var(--frankie-muted);border-color:var(--frankie-border);color:var(--frankie-foreground)}.frankie-root .frankie-choice-hint{color:var(--frankie-muted-foreground);flex:none;font-size:12px}.frankie-root .frankie-skeleton{flex-direction:column;gap:8px;max-width:min(420px,100%);padding:4px 0 6px;display:flex}.frankie-root .frankie-skeleton-line{background:linear-gradient(90deg, var(--frankie-muted-foreground) 25%, var(--frankie-muted-foreground) 37%, var(--frankie-muted-foreground) 63%);border-radius:6px;height:12px}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-skeleton-line{background:linear-gradient(90deg, color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 25%, color-mix(in srgb, var(--frankie-muted-foreground) 26%, transparent) 37%, color-mix(in srgb, var(--frankie-muted-foreground) 11%, transparent) 63%)}}.frankie-root .frankie-skeleton-line{background-size:400% 100%;animation:1.4s infinite frankie-shimmer}@keyframes frankie-shimmer{0%{background-position:100%}to{background-position:0}}@keyframes frankie-skeleton-pulse{0%,to{opacity:.45}50%{opacity:.85}}@media (prefers-reduced-motion:reduce){.frankie-root .frankie-skeleton-line{background:var(--frankie-muted-foreground)}@supports (color:color-mix(in lab, red, red)){.frankie-root .frankie-skeleton-line{background:color-mix(in srgb, var(--frankie-muted-foreground) 16%, transparent)}}.frankie-root .frankie-skeleton-line{animation:1.6s ease-in-out infinite frankie-skeleton-pulse}}@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-leading{syntax:\"*\";inherits:false}@property --tw-font-weight{syntax:\"*\";inherits:false}@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:\"*\";inherits:false}@property --tw-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:\"*\";inherits:false}@property --tw-inset-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:\"*\";inherits:false}@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:\"*\";inherits:false}@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:\"*\";inherits:false}@property --tw-ring-offset-width{syntax:\"<length>\";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:\"*\";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-ease{syntax:\"*\";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}";
|
|
@@ -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
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ 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
17
|
export { Choices } from "./components/chat/Choices.js";
|
|
18
18
|
export { SuggestedPrompts } from "./components/chat/SuggestedPrompts.js";
|
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
|
+
}
|