@frankieai/react 0.2.7 → 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 +394 -190
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -9
- package/dist/index.d.ts +63 -9
- package/dist/index.js +377 -175
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/components/chat/AgentContext.tsx +2 -1
- 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 +65 -3
- 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/src/widget.test.tsx +17 -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",
|
|
@@ -6,7 +6,7 @@ import { useFrankieChat } from "../../hooks/useFrankieChat.js";
|
|
|
6
6
|
/** Reports server-confirmed skills for the current turn; never implies drafts are active. */
|
|
7
7
|
export function AgentContext() {
|
|
8
8
|
const { loading, configError } = useFrankie();
|
|
9
|
-
const { loadedSkills, busy, restoring, error } = useFrankieChat();
|
|
9
|
+
const { loadedSkills, activeApis, busy, restoring, error } = useFrankieChat();
|
|
10
10
|
const status = configError || error ? "Unavailable" : loading || restoring ? "Connecting" : busy && loadedSkills === null ? "Checking" : loadedSkills?.length ? "Active" : loadedSkills ? "Tools only" : "Ready";
|
|
11
11
|
return <div className="frankie-agent-context-wrap">
|
|
12
12
|
<details className="frankie-agent-context">
|
|
@@ -15,6 +15,7 @@ export function AgentContext() {
|
|
|
15
15
|
<strong>{loadedSkills === null ? "Skills are checked when you send a message" : loadedSkills.length ? `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded` : "No skills loaded for this turn"}</strong>
|
|
16
16
|
{loadedSkills?.length ? <ul>{loadedSkills.map(skill => <li key={skill.id}><span>{skill.name}</span><small>v{skill.revision}</small></li>)}</ul> : <p>Frankie can use the tools approved for this workspace. Drafts and skills without available tools are not loaded.</p>}
|
|
17
17
|
<p>Published skills and updates are checked on every message. Only approved, available tools can run.</p>
|
|
18
|
+
{activeApis?.length ? <ul className="frankie-active-apis">{activeApis.map(api => <li key={api.name}><span>{api.name}</span><small>spec {api.specHash} · {api.operationCount} {api.operationCount === 1 ? "operation" : "operations"}</small></li>)}</ul> : null}
|
|
18
19
|
</div>
|
|
19
20
|
</details>
|
|
20
21
|
<span className="frankie-sr-only" role="status">{loadedSkills === null ? status : `${loadedSkills.length} ${loadedSkills.length === 1 ? "skill" : "skills"} loaded. ${status}.`}</span>
|
|
@@ -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);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext, useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import type { ChatAttachment, LoadedSkill } from "@frankieai/core";
|
|
4
|
+
import type { ApiToolInventory, ChatAttachment, LoadedSkill } from "@frankieai/core";
|
|
5
5
|
import { FrankieApiError } from "../lib/client.js";
|
|
6
6
|
import {
|
|
7
7
|
applyEvent,
|
|
@@ -20,6 +20,8 @@ export interface SendOptions {
|
|
|
20
20
|
|
|
21
21
|
export interface UseFrankieChatResult {
|
|
22
22
|
loadedSkills: LoadedSkill[] | null;
|
|
23
|
+
/** Which OpenAPI sources are live for this turn, and a content fingerprint of each. */
|
|
24
|
+
activeApis: ApiToolInventory[] | null;
|
|
23
25
|
restoring: boolean;
|
|
24
26
|
messages: ChatMessage[];
|
|
25
27
|
/** True from the moment a message is sent until the turn ends. */
|
|
@@ -50,6 +52,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
50
52
|
const [error, setError] = useState<string | undefined>(undefined);
|
|
51
53
|
const [restored, setRestored] = useState(false);
|
|
52
54
|
const [loadedSkills, setLoadedSkills] = useState<LoadedSkill[] | null>(null);
|
|
55
|
+
const [activeApis, setActiveApis] = useState<ApiToolInventory[] | null>(null);
|
|
53
56
|
const abortRef = useRef<AbortController | null>(null);
|
|
54
57
|
const mounted = useRef(true);
|
|
55
58
|
// State updates can be batched while a stream is arriving. Keep the latest
|
|
@@ -142,6 +145,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
142
145
|
const assistantId = nextId("a");
|
|
143
146
|
setError(undefined);
|
|
144
147
|
setLoadedSkills(null);
|
|
148
|
+
setActiveApis(null);
|
|
145
149
|
setBusy(true);
|
|
146
150
|
const next = [
|
|
147
151
|
...messagesRef.current,
|
|
@@ -158,6 +162,49 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
158
162
|
const abort = new AbortController();
|
|
159
163
|
abortRef.current = abort;
|
|
160
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
|
+
|
|
161
208
|
try {
|
|
162
209
|
let receivedText = false;
|
|
163
210
|
let receivedToolActivity = false;
|
|
@@ -171,12 +218,20 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
171
218
|
},
|
|
172
219
|
(event) => {
|
|
173
220
|
if (event.type === "skills_loaded") { setLoadedSkills(event.skills); return; }
|
|
221
|
+
if (event.type === "tools_active") { setActiveApis(event.apis); return; }
|
|
174
222
|
if (event.type === "ping" || event.type === "session") return;
|
|
175
|
-
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; }
|
|
176
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();
|
|
177
229
|
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
178
230
|
},
|
|
179
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();
|
|
180
235
|
// A terminated stream with no text previously looked like Frankie did
|
|
181
236
|
// nothing. Preserve visible tool activity, but never leave an empty
|
|
182
237
|
// assistant bubble for the user to interpret.
|
|
@@ -190,6 +245,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
190
245
|
}));
|
|
191
246
|
}
|
|
192
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();
|
|
193
251
|
if (abort.signal.aborted) {
|
|
194
252
|
patchAssistant(assistantId, (message) => ({
|
|
195
253
|
...message,
|
|
@@ -207,6 +265,9 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
207
265
|
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
208
266
|
}
|
|
209
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();
|
|
210
271
|
// A turn that ended without a `turn_end` event still has to settle.
|
|
211
272
|
patchAssistant(assistantId, (message) =>
|
|
212
273
|
message.status === "streaming" ? { ...message, status: "done" } : message,
|
|
@@ -236,6 +297,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
236
297
|
setBusy(false);
|
|
237
298
|
setRestored(true);
|
|
238
299
|
setLoadedSkills(null);
|
|
300
|
+
setActiveApis(null);
|
|
239
301
|
try {
|
|
240
302
|
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
241
303
|
} catch {
|
|
@@ -243,7 +305,7 @@ export function useFrankieChatState(): UseFrankieChatResult {
|
|
|
243
305
|
}
|
|
244
306
|
}, [client]);
|
|
245
307
|
|
|
246
|
-
return { messages, busy, loadedSkills, restoring: !restored, ...(error ? { error } : {}), send, stop, reset };
|
|
308
|
+
return { messages, busy, loadedSkills, activeApis, restoring: !restored, ...(error ? { error } : {}), send, stop, reset };
|
|
247
309
|
}
|
|
248
310
|
|
|
249
311
|
|
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
|
+
}
|