@frankieai/react 0.2.8 → 0.2.9
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 +373 -187
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +60 -8
- package/dist/index.d.ts +60 -8
- package/dist/index.js +356 -172
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/components/chat/ChatPanel.tsx +9 -1
- package/src/components/chat/Choices.tsx +52 -0
- package/src/components/chat/Composer.tsx +12 -2
- package/src/components/chat/FrankieChatPage.tsx +4 -1
- package/src/components/chat/FrankieInline.tsx +7 -2
- package/src/components/chat/FrankieWidget.tsx +11 -2
- package/src/components/chat/Markdown.tsx +15 -8
- package/src/components/chat/MessageBubble.tsx +29 -5
- package/src/hooks/useFrankieChat.ts +57 -1
- package/src/index.ts +3 -0
- package/src/lib/choices.ts +49 -0
- package/src/lib/styles-ready.ts +71 -0
- package/src/styles/frankie.css +106 -3
- package/src/styles-ready.test.tsx +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frankieai/react",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
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.2"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@tailwindcss/cli": "^4.0.0",
|
|
@@ -46,6 +46,9 @@
|
|
|
46
46
|
"typescript": "^5.7.0",
|
|
47
47
|
"vitest": "^3.0.0"
|
|
48
48
|
},
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20"
|
|
51
|
+
},
|
|
49
52
|
"scripts": {
|
|
50
53
|
"build": "tsup && pnpm build:css",
|
|
51
54
|
"build:css": "tailwindcss -i src/styles/frankie.css -o dist/frankie.css --minify",
|
|
@@ -65,6 +65,9 @@ export function ChatPanel({
|
|
|
65
65
|
const features = config?.features;
|
|
66
66
|
const pickerModels = features?.modelPicker.enabled ? features.modelPicker.models : [];
|
|
67
67
|
const [model, setModel] = useState<string | undefined>(undefined);
|
|
68
|
+
// A "Something else" choice focuses the composer through this handle.
|
|
69
|
+
const composerFocus = useRef<(() => void) | null>(null);
|
|
70
|
+
const focusComposer = () => composerFocus.current?.();
|
|
68
71
|
|
|
69
72
|
useEffect(() => {
|
|
70
73
|
const node = scrollRef.current;
|
|
@@ -138,7 +141,7 @@ export function ChatPanel({
|
|
|
138
141
|
))
|
|
139
142
|
) : (
|
|
140
143
|
<div className="frankie-message-list flex flex-col gap-4">
|
|
141
|
-
{messages.map((message) =>
|
|
144
|
+
{messages.map((message, index) =>
|
|
142
145
|
renderMessage ? (
|
|
143
146
|
<div key={message.id}>{renderMessage(message)}</div>
|
|
144
147
|
) : (
|
|
@@ -149,6 +152,10 @@ export function ChatPanel({
|
|
|
149
152
|
{...(config?.branding.avatarUrl ? { avatarUrl: config.branding.avatarUrl } : {})}
|
|
150
153
|
showToolActivity={config?.showToolActivity ?? true}
|
|
151
154
|
{...(renderTool ? { renderTool } : {})}
|
|
155
|
+
onChoose={send}
|
|
156
|
+
onRequestInput={focusComposer}
|
|
157
|
+
active={index === messages.length - 1}
|
|
158
|
+
busy={busy}
|
|
152
159
|
/>
|
|
153
160
|
),
|
|
154
161
|
)}
|
|
@@ -164,6 +171,7 @@ export function ChatPanel({
|
|
|
164
171
|
onSend={send}
|
|
165
172
|
onStop={stop}
|
|
166
173
|
busy={busy}
|
|
174
|
+
focusHandleRef={composerFocus}
|
|
167
175
|
disabled={loading || restoring || Boolean(configError)}
|
|
168
176
|
{...(placeholder ? { placeholder } : {})}
|
|
169
177
|
{...(autoFocus ? { autoFocus } : {})}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowRight, PencilLine } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Clickable answer options the agent offered via a `frankie:choices` block.
|
|
7
|
+
* Picking one sends it as the next message. A trailing "Something else" row
|
|
8
|
+
* doesn't send — it moves the caret to the composer so the user can type their
|
|
9
|
+
* own answer, while the offered options stay put in case they change their mind.
|
|
10
|
+
*/
|
|
11
|
+
export function Choices({
|
|
12
|
+
options,
|
|
13
|
+
onPick,
|
|
14
|
+
onSomethingElse,
|
|
15
|
+
disabled,
|
|
16
|
+
}: {
|
|
17
|
+
options: string[];
|
|
18
|
+
onPick: (choice: string) => void;
|
|
19
|
+
onSomethingElse?: () => void;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
}) {
|
|
22
|
+
if (options.length === 0) return null;
|
|
23
|
+
return (
|
|
24
|
+
<div className="frankie-choices" role="group" aria-label="Choose an option">
|
|
25
|
+
{options.map((option, index) => (
|
|
26
|
+
<button
|
|
27
|
+
key={`${index}-${option}`}
|
|
28
|
+
type="button"
|
|
29
|
+
className="frankie-choice"
|
|
30
|
+
disabled={disabled}
|
|
31
|
+
onClick={() => onPick(option)}
|
|
32
|
+
>
|
|
33
|
+
<span className="frankie-choice-index" aria-hidden>{index + 1}</span>
|
|
34
|
+
<span className="frankie-choice-label">{option}</span>
|
|
35
|
+
<ArrowRight className="frankie-choice-go size-4" aria-hidden />
|
|
36
|
+
</button>
|
|
37
|
+
))}
|
|
38
|
+
{onSomethingElse ? (
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
className="frankie-choice frankie-choice-other"
|
|
42
|
+
disabled={disabled}
|
|
43
|
+
onClick={onSomethingElse}
|
|
44
|
+
>
|
|
45
|
+
<span className="frankie-choice-index" aria-hidden><PencilLine className="size-3.5" /></span>
|
|
46
|
+
<span className="frankie-choice-label">Something else…</span>
|
|
47
|
+
<span className="frankie-choice-hint" aria-hidden>Type your own</span>
|
|
48
|
+
</button>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { ArrowUp, Paperclip, Plus, Square, X } from "lucide-react";
|
|
4
|
-
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type MutableRefObject } from "react";
|
|
5
5
|
import type { ChatAttachment } from "@frankieai/core";
|
|
6
6
|
import { Button } from "../ui/button.js";
|
|
7
7
|
import { Textarea } from "../ui/textarea.js";
|
|
@@ -31,6 +31,8 @@ export interface ComposerProps {
|
|
|
31
31
|
attachments?: ComposerAttachmentsConfig;
|
|
32
32
|
/** Enables a model dropdown when provided with at least one model. */
|
|
33
33
|
modelPicker?: ComposerModelPicker;
|
|
34
|
+
/** Populated with a function that focuses the input, so a "type your own" choice can jump here. */
|
|
35
|
+
focusHandleRef?: MutableRefObject<(() => void) | null>;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface PickedFile extends ChatAttachment {
|
|
@@ -39,13 +41,21 @@ interface PickedFile extends ChatAttachment {
|
|
|
39
41
|
|
|
40
42
|
const MAX_ROWS_PX = 140;
|
|
41
43
|
|
|
42
|
-
export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker }: ComposerProps) {
|
|
44
|
+
export function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus, attachments, modelPicker, focusHandleRef }: ComposerProps) {
|
|
43
45
|
const [value, setValue] = useState("");
|
|
44
46
|
const [files, setFiles] = useState<PickedFile[]>([]);
|
|
45
47
|
const [fileError, setFileError] = useState<string | undefined>(undefined);
|
|
46
48
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
47
49
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
48
50
|
|
|
51
|
+
// Publish a focus handle so a "Something else" choice elsewhere in the panel
|
|
52
|
+
// can move the caret here without reaching into the DOM.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!focusHandleRef) return;
|
|
55
|
+
focusHandleRef.current = () => textareaRef.current?.focus();
|
|
56
|
+
return () => { focusHandleRef.current = null; };
|
|
57
|
+
}, [focusHandleRef]);
|
|
58
|
+
|
|
49
59
|
const canAttach = Boolean(attachments?.enabled);
|
|
50
60
|
const models = modelPicker?.models ?? [];
|
|
51
61
|
|
|
@@ -5,6 +5,7 @@ import { ChevronsLeft, ChevronsRight, History, Menu, Plus, Settings, X } from "l
|
|
|
5
5
|
import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
|
|
6
6
|
import { FrankieProvider, useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
7
7
|
import { useFrankieChat } from "../../hooks/useFrankieChat.js";
|
|
8
|
+
import { useStylesReady } from "../../lib/styles-ready.js";
|
|
8
9
|
import { themeToCssVars, resolveMode } from "../../lib/theme.js";
|
|
9
10
|
import { FrankieApiError, type ChatConversation } from "../../lib/client.js";
|
|
10
11
|
import { FrankieInline } from "./FrankieInline.js";
|
|
@@ -104,7 +105,9 @@ function PageChrome({ showHistory, navigation, currentId, onSelect, onNew, ...pa
|
|
|
104
105
|
export function FrankieChatPage({ className, style, header, footer, emptyState, renderMessage, renderTool, placeholder, autoFocus, showHistory = true, navigation, ...provider }: FrankieChatPageProps) {
|
|
105
106
|
const [id, setId] = useState(() => provider.conversationId ?? crypto.randomUUID());
|
|
106
107
|
const onNew = () => setId(crypto.randomUUID());
|
|
107
|
-
|
|
108
|
+
const pageRef = useRef<HTMLDivElement>(null);
|
|
109
|
+
const stylesReady = useStylesReady(pageRef);
|
|
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"}>
|
|
108
111
|
<FrankieProvider {...provider} conversationId={id} storageNamespace={(provider.storageNamespace ?? "frankie." + provider.publishableKey) + "." + id}>
|
|
109
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} />
|
|
110
113
|
</FrankieProvider>
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { useRef, type CSSProperties } from "react";
|
|
4
4
|
import { useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
5
|
+
import { useStylesReady } from "../../lib/styles-ready.js";
|
|
5
6
|
import { resolveMode, themeToCssVars } from "../../lib/theme.js";
|
|
6
7
|
import { cn } from "../../lib/utils.js";
|
|
7
8
|
import { ChatPanel, type ChatPanelProps } from "./ChatPanel.js";
|
|
@@ -23,11 +24,15 @@ export interface FrankieInlineProps extends ChatPanelProps {
|
|
|
23
24
|
export function FrankieInline({ className, style, ...panelProps }: FrankieInlineProps) {
|
|
24
25
|
const theme = useFrankieTheme();
|
|
25
26
|
const mode = resolveMode(theme?.mode);
|
|
27
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const stylesReady = useStylesReady(rootRef);
|
|
26
29
|
return (
|
|
27
30
|
<div
|
|
31
|
+
ref={rootRef}
|
|
28
32
|
className={cn("frankie-root frankie-inline", mode === "dark" && "dark", className)}
|
|
29
|
-
style={{ ...themeToCssVars(theme), display: "flex", height: "100%", width: "100%", ...style }}
|
|
33
|
+
style={{ ...themeToCssVars(theme), display: "flex", height: "100%", width: "100%", ...style, ...(stylesReady ? null : { opacity: 0 as const }) }}
|
|
30
34
|
data-frankie-mode={mode}
|
|
35
|
+
data-frankie-styles={stylesReady ? "ready" : "pending"}
|
|
31
36
|
>
|
|
32
37
|
<ChatPanel {...panelProps} fill />
|
|
33
38
|
</div>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { MessageCircle, X } from "lucide-react";
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import { useFrankie, useFrankieTheme } from "../../hooks/FrankieProvider.jsx";
|
|
6
|
+
import { useStylesReady } from "../../lib/styles-ready.js";
|
|
6
7
|
import { resolveMode, themeToCssVars } from "../../lib/theme.js";
|
|
7
8
|
import { cn } from "../../lib/utils.js";
|
|
8
9
|
import { Button } from "../ui/button.js";
|
|
@@ -40,6 +41,9 @@ export function FrankieWidget({
|
|
|
40
41
|
}: FrankieWidgetProps) {
|
|
41
42
|
const { config } = useFrankie();
|
|
42
43
|
const theme = useFrankieTheme();
|
|
44
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
45
|
+
// Never paint the panel as raw HTML while the host's CSS chunk is still in flight.
|
|
46
|
+
const stylesReady = useStylesReady(rootRef);
|
|
43
47
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
44
48
|
const open = controlledOpen ?? uncontrolledOpen;
|
|
45
49
|
|
|
@@ -65,9 +69,14 @@ export function FrankieWidget({
|
|
|
65
69
|
|
|
66
70
|
return (
|
|
67
71
|
<div
|
|
72
|
+
ref={rootRef}
|
|
68
73
|
className={cn("frankie-root frankie-widget", mode === "dark" && "dark")}
|
|
69
|
-
|
|
74
|
+
// `opacity` rather than `visibility` or unmounting: the sentinel has to stay
|
|
75
|
+
// measurable on this element, and the two alternatives both drop the widget
|
|
76
|
+
// out of the accessibility tree for the wait.
|
|
77
|
+
style={{ ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex, pointerEvents: "none", ...(stylesReady ? null : { opacity: 0 as const }) }}
|
|
70
78
|
data-frankie-mode={mode}
|
|
79
|
+
data-frankie-styles={stylesReady ? "ready" : "pending"}
|
|
71
80
|
>
|
|
72
81
|
<div className={cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end")}>
|
|
73
82
|
{open ? <div style={{ pointerEvents: "auto", maxHeight: "calc(100dvh - 100px)", overflow: "hidden" }}><ChatPanel {...panelProps} className={cn("frankie-widget-panel", panelProps.className)} onClose={() => setOpen(false)} autoFocus /></div> : null}
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Fragment, type ReactNode } from "react";
|
|
3
|
+
import { Fragment, memo, useMemo, type ReactNode } from "react";
|
|
4
4
|
import { cn } from "../../lib/utils.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* A deliberately small Markdown subset — paragraphs, lists, fenced
|
|
8
|
-
* italic, inline code and links.
|
|
7
|
+
* A deliberately small Markdown subset — headings, paragraphs, lists, fenced
|
|
8
|
+
* code, bold, italic, inline code and links.
|
|
9
9
|
*
|
|
10
10
|
* Everything is rendered as React elements; nothing goes through
|
|
11
11
|
* `dangerouslySetInnerHTML`. Model output lands in someone else's production
|
|
12
12
|
* page, so it is never trusted as markup.
|
|
13
|
+
*
|
|
14
|
+
* Memoized on `text`: while a reply streams, the parent re-renders every frame,
|
|
15
|
+
* but the block tree is only recomputed when the text actually grows — the
|
|
16
|
+
* settled messages above never re-parse at all.
|
|
13
17
|
*/
|
|
14
|
-
export function Markdown({ text, className }: { text: string; className?: string }) {
|
|
15
|
-
|
|
16
|
-
}
|
|
18
|
+
export const Markdown = memo(function Markdown({ text, className }: { text: string; className?: string }) {
|
|
19
|
+
const blocks = useMemo(() => renderBlocks(text), [text]);
|
|
20
|
+
return <div className={cn("frankie-prose", className)}>{blocks}</div>;
|
|
21
|
+
});
|
|
17
22
|
|
|
18
23
|
function renderBlocks(text: string): ReactNode[] {
|
|
19
24
|
const lines = text.split("\n");
|
|
@@ -75,11 +80,13 @@ function renderBlocks(text: string): ReactNode[] {
|
|
|
75
80
|
continue;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
const heading =
|
|
83
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
79
84
|
if (heading) {
|
|
80
85
|
flushParagraph();
|
|
81
86
|
flushList();
|
|
82
|
-
|
|
87
|
+
const level = (heading[1] ?? "#").length;
|
|
88
|
+
const Tag = level <= 1 ? "h3" : level === 2 ? "h4" : "h5";
|
|
89
|
+
blocks.push(<Tag key={`h${blocks.length}`} className="frankie-h">{renderInline(heading[2] ?? "")}</Tag>);
|
|
83
90
|
continue;
|
|
84
91
|
}
|
|
85
92
|
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { Check, Copy, Paperclip } from "lucide-react";
|
|
4
4
|
import "./message-actions.css";
|
|
5
|
-
import { useState, type ReactNode } from "react";
|
|
5
|
+
import { memo, useState, type ReactNode } from "react";
|
|
6
6
|
import { cn } from "../../lib/utils.js";
|
|
7
|
+
import { extractChoices } from "../../lib/choices.js";
|
|
7
8
|
import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
|
|
8
9
|
import { Avatar } from "../ui/avatar.js";
|
|
10
|
+
import { Choices } from "./Choices.js";
|
|
9
11
|
import { Markdown } from "./Markdown.js";
|
|
10
12
|
import { ToolActivityList } from "./ToolActivityList.js";
|
|
11
13
|
|
|
@@ -15,14 +17,29 @@ export interface MessageBubbleProps {
|
|
|
15
17
|
avatarUrl?: string;
|
|
16
18
|
showToolActivity: boolean;
|
|
17
19
|
renderTool?: (tool: ToolActivity) => ReactNode;
|
|
20
|
+
/** Send a picked choice as the next message. Omitted when the host renders its own. */
|
|
21
|
+
onChoose?: (choice: string) => void;
|
|
22
|
+
/** Focus the composer so the user can type their own answer instead of picking. */
|
|
23
|
+
onRequestInput?: () => void;
|
|
24
|
+
/** True only for the newest message, so stale choices from earlier turns aren't clickable. */
|
|
25
|
+
active?: boolean;
|
|
26
|
+
/** A turn is in flight, so choices should be disabled. */
|
|
27
|
+
busy?: boolean;
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
|
|
30
|
+
// Memoized: during a stream only the message whose object identity changed (the
|
|
31
|
+
// one being written) re-renders. Every earlier bubble keeps its parsed markdown,
|
|
32
|
+
// which is what keeps a long transcript from re-laying-out on every token.
|
|
33
|
+
export const MessageBubble = memo(function MessageBubble({
|
|
21
34
|
message,
|
|
22
35
|
assistantName,
|
|
23
36
|
avatarUrl,
|
|
24
37
|
showToolActivity,
|
|
25
38
|
renderTool,
|
|
39
|
+
onChoose,
|
|
40
|
+
onRequestInput,
|
|
41
|
+
active,
|
|
42
|
+
busy,
|
|
26
43
|
}: MessageBubbleProps) {
|
|
27
44
|
if (message.role === "user") {
|
|
28
45
|
return (
|
|
@@ -51,12 +68,18 @@ export function MessageBubble({
|
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
const empty = message.text.length === 0 && message.tools.length === 0;
|
|
71
|
+
// Lift any offered options out of the prose. While the reply is still
|
|
72
|
+
// streaming, a half-written block is hidden (options stays null) so the raw
|
|
73
|
+
// fence never flashes. Options become clickable only on the newest, settled
|
|
74
|
+
// message so answering an earlier turn's question can't jump the transcript.
|
|
75
|
+
const { body, options } = extractChoices(message.text);
|
|
76
|
+
const showChoices = Boolean(active && onChoose && options && message.status === "done");
|
|
54
77
|
|
|
55
78
|
return (
|
|
56
79
|
<div className="frankie-assistant-message flex gap-2">
|
|
57
80
|
<div className="frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1">
|
|
58
81
|
<Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} />
|
|
59
|
-
{
|
|
82
|
+
{body ? <MessageCopyButton text={body} /> : null}
|
|
60
83
|
</div>
|
|
61
84
|
<div className="frankie-assistant-content min-w-0 max-w-[85%]">
|
|
62
85
|
{message.status === "error" ? (
|
|
@@ -66,15 +89,16 @@ export function MessageBubble({
|
|
|
66
89
|
) : empty && message.status === "streaming" ? (
|
|
67
90
|
<TypingDots />
|
|
68
91
|
) : (
|
|
69
|
-
<Markdown text={
|
|
92
|
+
<Markdown text={body} className="text-sm" />
|
|
70
93
|
)}
|
|
94
|
+
{showChoices ? <Choices options={options ?? []} onPick={onChoose!} {...(onRequestInput ? { onSomethingElse: onRequestInput } : {})} disabled={busy} /> : null}
|
|
71
95
|
{showToolActivity ? (
|
|
72
96
|
<ToolActivityList tools={message.tools} {...(renderTool ? { renderTool } : {})} />
|
|
73
97
|
) : null}
|
|
74
98
|
</div>
|
|
75
99
|
</div>
|
|
76
100
|
);
|
|
77
|
-
}
|
|
101
|
+
});
|
|
78
102
|
|
|
79
103
|
function MessageCopyButton({ text }: { text: string }) {
|
|
80
104
|
const [copied, setCopied] = useState(false);
|
|
@@ -162,6 +162,49 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
162
162
|
const abort = new AbortController();
|
|
163
163
|
abortRef.current = abort;
|
|
164
164
|
|
|
165
|
+
// Models stream text in uneven bursts — a few characters, then a lull,
|
|
166
|
+
// then a whole paragraph at once — which reads as a stutter no matter how
|
|
167
|
+
// efficiently React renders it. So we don't paint what arrives; we buffer
|
|
168
|
+
// it and *reveal* it at a steady cadence, a slice per animation frame, so
|
|
169
|
+
// the answer types out smoothly regardless of how lumpy the network is.
|
|
170
|
+
// The slice is a fraction of what's waiting, so a big burst drains fast
|
|
171
|
+
// (and never lags far behind) while a trickle still advances a few chars a
|
|
172
|
+
// frame. Thinking isn't rendered, so it commits in full without pacing.
|
|
173
|
+
// Any structural event, the stream's end, or an abort flushes the whole
|
|
174
|
+
// remaining buffer at once, so ordering holds and no token is dropped.
|
|
175
|
+
let bufferedText = "";
|
|
176
|
+
let pendingThinking = "";
|
|
177
|
+
let frame: number | null = null;
|
|
178
|
+
const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (cb: FrameRequestCallback) => setTimeout(() => cb(performance.now()), 16) as unknown as number;
|
|
179
|
+
const cancelRaf = typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : (clearTimeout as (id: number) => void);
|
|
180
|
+
const revealTick = () => {
|
|
181
|
+
frame = null;
|
|
182
|
+
let addText = "";
|
|
183
|
+
if (bufferedText) {
|
|
184
|
+
// Reveal ~1/6 of what's waiting (at least a few chars) so the pace
|
|
185
|
+
// eases with the backlog: snappy on a burst, gentle on a trickle.
|
|
186
|
+
const step = Math.max(3, Math.ceil(bufferedText.length / 6));
|
|
187
|
+
addText = bufferedText.slice(0, step);
|
|
188
|
+
bufferedText = bufferedText.slice(step);
|
|
189
|
+
}
|
|
190
|
+
const addThinking = pendingThinking;
|
|
191
|
+
pendingThinking = "";
|
|
192
|
+
if (addText || addThinking) {
|
|
193
|
+
patchAssistant(assistantId, (message) => ({ ...message, text: message.text + addText, thinking: message.thinking + addThinking }));
|
|
194
|
+
}
|
|
195
|
+
if (bufferedText) scheduleReveal();
|
|
196
|
+
};
|
|
197
|
+
const scheduleReveal = () => { if (frame === null) frame = raf(revealTick); };
|
|
198
|
+
const flushDeltas = () => {
|
|
199
|
+
if (frame !== null) { cancelRaf(frame); frame = null; }
|
|
200
|
+
if (!bufferedText && !pendingThinking) return;
|
|
201
|
+
const addText = bufferedText;
|
|
202
|
+
const addThinking = pendingThinking;
|
|
203
|
+
bufferedText = "";
|
|
204
|
+
pendingThinking = "";
|
|
205
|
+
patchAssistant(assistantId, (message) => ({ ...message, text: message.text + addText, thinking: message.thinking + addThinking }));
|
|
206
|
+
};
|
|
207
|
+
|
|
165
208
|
try {
|
|
166
209
|
let receivedText = false;
|
|
167
210
|
let receivedToolActivity = false;
|
|
@@ -177,11 +220,18 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
177
220
|
if (event.type === "skills_loaded") { setLoadedSkills(event.skills); return; }
|
|
178
221
|
if (event.type === "tools_active") { setActiveApis(event.apis); return; }
|
|
179
222
|
if (event.type === "ping" || event.type === "session") return;
|
|
180
|
-
if (event.type === "text_delta") receivedText = true;
|
|
223
|
+
if (event.type === "text_delta") { receivedText = true; bufferedText += event.text; scheduleReveal(); return; }
|
|
224
|
+
if (event.type === "thinking_delta") { pendingThinking += event.text; scheduleReveal(); return; }
|
|
181
225
|
if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
|
|
226
|
+
// A structural event: land the buffered text before it so the tool
|
|
227
|
+
// row or terminal status sits in the right place in the transcript.
|
|
228
|
+
flushDeltas();
|
|
182
229
|
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
183
230
|
},
|
|
184
231
|
);
|
|
232
|
+
// Commit anything still buffered when the stream ends, so the closing
|
|
233
|
+
// words appear at once instead of trickling after the reply is done.
|
|
234
|
+
flushDeltas();
|
|
185
235
|
// A terminated stream with no text previously looked like Frankie did
|
|
186
236
|
// nothing. Preserve visible tool activity, but never leave an empty
|
|
187
237
|
// assistant bubble for the user to interpret.
|
|
@@ -195,6 +245,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
195
245
|
}));
|
|
196
246
|
}
|
|
197
247
|
} catch (caught) {
|
|
248
|
+
// Land any tokens buffered when the stream broke off, so a stopped or
|
|
249
|
+
// failed turn still shows everything that had arrived.
|
|
250
|
+
flushDeltas();
|
|
198
251
|
if (abort.signal.aborted) {
|
|
199
252
|
patchAssistant(assistantId, (message) => ({
|
|
200
253
|
...message,
|
|
@@ -212,6 +265,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
212
265
|
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
213
266
|
}
|
|
214
267
|
} finally {
|
|
268
|
+
// Ensure no frame is left pending and every buffered token is committed
|
|
269
|
+
// before the turn settles and the transcript is persisted.
|
|
270
|
+
flushDeltas();
|
|
215
271
|
// A turn that ended without a `turn_end` event still has to settle.
|
|
216
272
|
patchAssistant(assistantId, (message) =>
|
|
217
273
|
message.status === "streaming" ? { ...message, status: "done" } : message,
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type { ChatPanelProps } from "./components/chat/ChatPanel.js";
|
|
|
14
14
|
export { Composer } from "./components/chat/Composer.js";
|
|
15
15
|
export { MessageBubble, TypingDots } from "./components/chat/MessageBubble.js";
|
|
16
16
|
export { Markdown } from "./components/chat/Markdown.js";
|
|
17
|
+
export { Choices } from "./components/chat/Choices.js";
|
|
17
18
|
export { SuggestedPrompts } from "./components/chat/SuggestedPrompts.js";
|
|
18
19
|
export { ToolActivityList, ToolActivityRow } from "./components/chat/ToolActivityList.js";
|
|
19
20
|
|
|
@@ -30,6 +31,8 @@ export { FrankieClient, FrankieApiError, readEventStream, FRANKIE_CLOUD_URL } fr
|
|
|
30
31
|
export type { FrankieClientOptions, PageContext } from "./lib/client.js";
|
|
31
32
|
export { applyEvent, newAssistantMessage, newUserMessage } from "./lib/messages.js";
|
|
32
33
|
export type { AssistantChatMessage, ChatMessage, MessageAttachment, ToolActivity, UserChatMessage } from "./lib/messages.js";
|
|
34
|
+
export { extractChoices } from "./lib/choices.js";
|
|
35
|
+
export type { ExtractedChoices } from "./lib/choices.js";
|
|
33
36
|
export { resolveMode, themeToCssVars } from "./lib/theme.js";
|
|
34
37
|
export { cn } from "./lib/utils.js";
|
|
35
38
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive choice blocks.
|
|
3
|
+
*
|
|
4
|
+
* The agent can end a reply with a fenced block to offer the user a small set
|
|
5
|
+
* of discrete answers:
|
|
6
|
+
*
|
|
7
|
+
* ```frankie:choices
|
|
8
|
+
* British
|
|
9
|
+
* Indian
|
|
10
|
+
* Australian
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* The SDK lifts that block out of the prose and renders it as clickable options
|
|
14
|
+
* (the composer stays open, so the user can always type their own answer). The
|
|
15
|
+
* block is a plain fenced code block, so a host that doesn't understand it just
|
|
16
|
+
* shows a harmless code listing instead — never broken markup.
|
|
17
|
+
*/
|
|
18
|
+
export interface ExtractedChoices {
|
|
19
|
+
/** The reply text with the choices block removed. */
|
|
20
|
+
body: string;
|
|
21
|
+
/** The offered options, or null when there is no complete block. */
|
|
22
|
+
options: string[] | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CLOSED = /```frankie:choices[ \t]*\r?\n([\s\S]*?)```/;
|
|
26
|
+
const OPEN = /```frankie:choices[ \t]*\r?\n?[\s\S]*$/;
|
|
27
|
+
|
|
28
|
+
export function extractChoices(text: string): ExtractedChoices {
|
|
29
|
+
const closed = CLOSED.exec(text);
|
|
30
|
+
if (closed) {
|
|
31
|
+
const options = parseOptionLines(closed[1] ?? "");
|
|
32
|
+
const body = (text.slice(0, closed.index) + text.slice(closed.index + closed[0].length))
|
|
33
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
34
|
+
.trimEnd();
|
|
35
|
+
return { body, options: options.length ? options : null };
|
|
36
|
+
}
|
|
37
|
+
// An unterminated block means the reply is still streaming the options in.
|
|
38
|
+
// Hide the half-written fence so the user never sees the raw ``` marker.
|
|
39
|
+
const open = OPEN.exec(text);
|
|
40
|
+
if (open) return { body: text.slice(0, open.index).trimEnd(), options: null };
|
|
41
|
+
return { body: text, options: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseOptionLines(block: string): string[] {
|
|
45
|
+
return block
|
|
46
|
+
.split("\n")
|
|
47
|
+
.map((line) => line.trim().replace(/^([-*+]|\d+[.)])\s+/, "").trim())
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useState, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/** `useLayoutEffect` warns during SSR, where there is nothing to measure anyway. */
|
|
4
|
+
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
5
|
+
|
|
6
|
+
/** The property `frankie.css` sets on `.frankie-root`, and the value it sets. */
|
|
7
|
+
const SENTINEL = "--frankie-styles-ready";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether this widget's stylesheet has actually been applied to `node`.
|
|
11
|
+
*
|
|
12
|
+
* The widget is mounted inside someone else's application and bundler. A host
|
|
13
|
+
* that imports `@frankieai/react/styles.css` from a client component ships it
|
|
14
|
+
* in that component's chunk, and on a client-side navigation React can render
|
|
15
|
+
* the panel before the CSS arrives — the panel then paints as raw HTML: native
|
|
16
|
+
* `<details>` markers, no pills, browser-default type. A full reload hides this
|
|
17
|
+
* because the stylesheet is in `<head>` before first paint.
|
|
18
|
+
*
|
|
19
|
+
* Probing a custom property costs nothing and asks the only question that
|
|
20
|
+
* matters — are our rules in effect for this element — rather than guessing at
|
|
21
|
+
* `<link>` elements the host may have inlined, bundled or renamed.
|
|
22
|
+
*
|
|
23
|
+
* Callers hide the root with `opacity` for the wait rather than `visibility` or
|
|
24
|
+
* unmounting: the sentinel must stay measurable on that element, and both of the
|
|
25
|
+
* alternatives would also drop the widget out of the accessibility tree.
|
|
26
|
+
*
|
|
27
|
+
* The wait is deliberately bounded. A Tailwind v4 host is told it may skip our
|
|
28
|
+
* stylesheet and compile the utilities itself, and such a page never sets the
|
|
29
|
+
* sentinel; it must still get a widget. After `timeoutMs` we render regardless,
|
|
30
|
+
* which is exactly today's behaviour.
|
|
31
|
+
*/
|
|
32
|
+
export function useStylesReady(ref: RefObject<HTMLElement | null>, timeoutMs = 600): boolean {
|
|
33
|
+
const [ready, setReady] = useState(false);
|
|
34
|
+
|
|
35
|
+
useIsomorphicLayoutEffect(() => {
|
|
36
|
+
if (ready) return;
|
|
37
|
+
const node = ref.current;
|
|
38
|
+
if (!node || typeof window === "undefined" || typeof window.getComputedStyle !== "function") {
|
|
39
|
+
setReady(true);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const applied = () => window.getComputedStyle(node).getPropertyValue(SENTINEL).trim() === "1";
|
|
44
|
+
// The common case — stylesheet already in `<head>` — resolves here, inside a
|
|
45
|
+
// layout effect, so nothing unstyled is ever painted and nothing flickers.
|
|
46
|
+
if (applied()) {
|
|
47
|
+
setReady(true);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
let frame = 0;
|
|
53
|
+
const poll = () => {
|
|
54
|
+
if (applied() || Date.now() >= deadline) {
|
|
55
|
+
setReady(true);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
frame = window.requestAnimationFrame(poll);
|
|
59
|
+
};
|
|
60
|
+
// `requestAnimationFrame` is missing in some test environments; fall back to
|
|
61
|
+
// rendering rather than hiding the widget forever.
|
|
62
|
+
if (typeof window.requestAnimationFrame !== "function") {
|
|
63
|
+
setReady(true);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
frame = window.requestAnimationFrame(poll);
|
|
67
|
+
return () => window.cancelAnimationFrame(frame);
|
|
68
|
+
}, [ref, ready, timeoutMs]);
|
|
69
|
+
|
|
70
|
+
return ready;
|
|
71
|
+
}
|