@frankieai/react 0.1.4 → 0.1.5
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 +86 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +87 -18
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/FrankieChatPage.tsx +75 -7
- package/src/hooks/useFrankieChat.ts +33 -15
- package/src/styles/frankie.css +161 -0
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import type { CSSProperties } from "react";
|
|
4
4
|
import type { FrankieProviderProps } from "../../hooks/FrankieProvider.jsx";
|
|
5
|
-
import { FrankieProvider } from "../../hooks/FrankieProvider.jsx";
|
|
5
|
+
import { FrankieProvider, useFrankie } from "../../hooks/FrankieProvider.jsx";
|
|
6
|
+
import { useFrankieChat } from "../../hooks/useFrankieChat.js";
|
|
6
7
|
import { FrankieInline } from "./FrankieInline.js";
|
|
7
8
|
import type { ChatPanelProps } from "./ChatPanel.js";
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
* A complete chat route in one component. This is the simplest way to add a
|
|
11
|
-
* dedicated /chat page to an app while keeping the existing floating widget.
|
|
12
|
-
*/
|
|
13
10
|
export interface FrankieChatPageProps
|
|
14
11
|
extends Omit<FrankieProviderProps, "children">,
|
|
15
12
|
Pick<
|
|
@@ -20,6 +17,77 @@ export interface FrankieChatPageProps
|
|
|
20
17
|
style?: CSSProperties;
|
|
21
18
|
}
|
|
22
19
|
|
|
20
|
+
function PageEmptyState() {
|
|
21
|
+
const { config } = useFrankie();
|
|
22
|
+
const { send, busy } = useFrankieChat();
|
|
23
|
+
const name = config?.branding.name ?? "Frankie";
|
|
24
|
+
const prompts = config?.suggestedPrompts?.slice(0, 3) ?? [];
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<section className="frankie-page-empty" aria-label="Start a conversation">
|
|
28
|
+
<span className="frankie-page-avatar" aria-hidden>{name.trim().slice(0, 1).toUpperCase()}</span>
|
|
29
|
+
<div>
|
|
30
|
+
<p className="frankie-page-kicker">{name} assistant</p>
|
|
31
|
+
<h1>What would you like to do?</h1>
|
|
32
|
+
<p>{config?.greeting ?? "Ask a question or choose a task to get started."}</p>
|
|
33
|
+
</div>
|
|
34
|
+
{prompts.length ? (
|
|
35
|
+
<div className="frankie-page-prompts">
|
|
36
|
+
{prompts.map((prompt, index) => (
|
|
37
|
+
<button key={prompt} type="button" disabled={busy} onClick={() => send(prompt)}>
|
|
38
|
+
<span>{String(index + 1).padStart(2, "0")}</span>
|
|
39
|
+
<strong>{prompt}</strong>
|
|
40
|
+
<b aria-hidden>↗</b>
|
|
41
|
+
</button>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
) : null}
|
|
45
|
+
</section>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function PageChrome({
|
|
50
|
+
header,
|
|
51
|
+
footer,
|
|
52
|
+
emptyState,
|
|
53
|
+
renderMessage,
|
|
54
|
+
renderTool,
|
|
55
|
+
placeholder,
|
|
56
|
+
autoFocus,
|
|
57
|
+
}: Pick<FrankieChatPageProps, "header" | "footer" | "emptyState" | "renderMessage" | "renderTool" | "placeholder" | "autoFocus">) {
|
|
58
|
+
const { config } = useFrankie();
|
|
59
|
+
const { reset } = useFrankieChat();
|
|
60
|
+
const name = config?.branding.name ?? "Frankie";
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<header className="frankie-page-header">
|
|
65
|
+
<div>
|
|
66
|
+
<span className="frankie-page-status" aria-hidden />
|
|
67
|
+
<strong>{name}</strong>
|
|
68
|
+
</div>
|
|
69
|
+
<button type="button" onClick={reset} aria-label="Start a new conversation">New chat</button>
|
|
70
|
+
</header>
|
|
71
|
+
<div className="frankie-page-canvas">
|
|
72
|
+
<FrankieInline
|
|
73
|
+
header={header ?? false}
|
|
74
|
+
footer={footer}
|
|
75
|
+
emptyState={emptyState ?? <PageEmptyState />}
|
|
76
|
+
renderMessage={renderMessage}
|
|
77
|
+
renderTool={renderTool}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
autoFocus={autoFocus}
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
</>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A complete, polished chat route matching Frankie's own conversation canvas.
|
|
88
|
+
* The shell, header, empty state and composer are all scoped under
|
|
89
|
+
* `.frankie-root`, so host application styles remain untouched.
|
|
90
|
+
*/
|
|
23
91
|
export function FrankieChatPage({
|
|
24
92
|
className,
|
|
25
93
|
style,
|
|
@@ -34,11 +102,11 @@ export function FrankieChatPage({
|
|
|
34
102
|
}: FrankieChatPageProps) {
|
|
35
103
|
return (
|
|
36
104
|
<main
|
|
37
|
-
className={className}
|
|
105
|
+
className={["frankie-root", "frankie-chat-page", className].filter(Boolean).join(" ")}
|
|
38
106
|
style={{ height: "100dvh", minHeight: "100dvh", width: "100%", ...style }}
|
|
39
107
|
>
|
|
40
108
|
<FrankieProvider {...providerProps}>
|
|
41
|
-
<
|
|
109
|
+
<PageChrome
|
|
42
110
|
header={header}
|
|
43
111
|
footer={footer}
|
|
44
112
|
emptyState={emptyState}
|
|
@@ -42,6 +42,9 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
42
42
|
const [restored, setRestored] = useState(false);
|
|
43
43
|
const abortRef = useRef<AbortController | null>(null);
|
|
44
44
|
const mounted = useRef(true);
|
|
45
|
+
// State updates can be batched while a stream is arriving. Keep the latest
|
|
46
|
+
// transcript synchronously so the final server sync cannot save a stale copy.
|
|
47
|
+
const messagesRef = useRef<ChatMessage[]>([]);
|
|
45
48
|
|
|
46
49
|
useEffect(() => {
|
|
47
50
|
mounted.current = true;
|
|
@@ -63,14 +66,21 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
63
66
|
try {
|
|
64
67
|
const raw = window.localStorage.getItem(storageKey);
|
|
65
68
|
const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
|
|
66
|
-
if (Array.isArray(stored) && stored.length && active)
|
|
69
|
+
if (Array.isArray(stored) && stored.length && active) {
|
|
70
|
+
messagesRef.current = stored;
|
|
71
|
+
setMessages(stored);
|
|
72
|
+
}
|
|
67
73
|
} catch {
|
|
68
74
|
/* storage unavailable; server history can still restore */
|
|
69
75
|
}
|
|
70
76
|
}
|
|
71
77
|
try {
|
|
72
78
|
const remote = await client.resumeConversation();
|
|
73
|
-
if (active && remote?.messages.length)
|
|
79
|
+
if (active && remote?.messages.length) {
|
|
80
|
+
const restoredMessages = remote.messages as ChatMessage[];
|
|
81
|
+
messagesRef.current = restoredMessages;
|
|
82
|
+
setMessages(restoredMessages);
|
|
83
|
+
}
|
|
74
84
|
} catch {
|
|
75
85
|
/* A new conversation does not exist remotely until its first message. */
|
|
76
86
|
} finally {
|
|
@@ -96,11 +106,11 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
96
106
|
}, [messages, busy, client, storageKey, restored]);
|
|
97
107
|
|
|
98
108
|
const patchAssistant = useCallback((id: string, update: (m: AssistantChatMessage) => AssistantChatMessage) => {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
message.id === id && message.role === "assistant" ? update(message) : message,
|
|
102
|
-
),
|
|
109
|
+
const next = messagesRef.current.map((message) =>
|
|
110
|
+
message.id === id && message.role === "assistant" ? update(message) : message,
|
|
103
111
|
);
|
|
112
|
+
messagesRef.current = next;
|
|
113
|
+
setMessages(next);
|
|
104
114
|
}, []);
|
|
105
115
|
|
|
106
116
|
const send = useCallback(
|
|
@@ -114,15 +124,17 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
114
124
|
const assistantId = nextId("a");
|
|
115
125
|
setError(undefined);
|
|
116
126
|
setBusy(true);
|
|
117
|
-
|
|
118
|
-
...current,
|
|
127
|
+
const next = [
|
|
128
|
+
...messagesRef.current,
|
|
119
129
|
newUserMessage(
|
|
120
130
|
nextId("u"),
|
|
121
131
|
trimmed,
|
|
122
132
|
attachments.map((file) => ({ name: file.name, mimeType: file.mimeType })),
|
|
123
133
|
),
|
|
124
134
|
newAssistantMessage(assistantId),
|
|
125
|
-
]
|
|
135
|
+
];
|
|
136
|
+
messagesRef.current = next;
|
|
137
|
+
setMessages(next);
|
|
126
138
|
|
|
127
139
|
const abort = new AbortController();
|
|
128
140
|
abortRef.current = abort;
|
|
@@ -175,13 +187,18 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
175
187
|
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
176
188
|
}
|
|
177
189
|
} finally {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
// A turn that ended without a `turn_end` event still has to settle.
|
|
191
|
+
patchAssistant(assistantId, (message) =>
|
|
192
|
+
message.status === "streaming" ? { ...message, status: "done" } : message,
|
|
193
|
+
);
|
|
194
|
+
// Persist before send() resolves. This closes the previous gap where a
|
|
195
|
+
// route change or tab close could leave a server conversation with no
|
|
196
|
+
// rendered messages even though the agent transcript existed.
|
|
197
|
+
const transcript = messagesRef.current;
|
|
198
|
+
if (transcript.length) {
|
|
199
|
+
await client.syncConversation(transcript, conversationTitle(transcript)).catch(() => undefined);
|
|
184
200
|
}
|
|
201
|
+
if (mounted.current) setBusy(false);
|
|
185
202
|
abortRef.current = null;
|
|
186
203
|
}
|
|
187
204
|
},
|
|
@@ -193,6 +210,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
193
210
|
const reset = useCallback(() => {
|
|
194
211
|
abortRef.current?.abort();
|
|
195
212
|
client.resetSession();
|
|
213
|
+
messagesRef.current = [];
|
|
196
214
|
setMessages([]);
|
|
197
215
|
setError(undefined);
|
|
198
216
|
setBusy(false);
|
package/src/styles/frankie.css
CHANGED
|
@@ -164,3 +164,164 @@
|
|
|
164
164
|
padding: 0;
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@layer components {
|
|
170
|
+
.frankie-chat-page {
|
|
171
|
+
display: flex;
|
|
172
|
+
min-width: 0;
|
|
173
|
+
flex-direction: column;
|
|
174
|
+
overflow: hidden;
|
|
175
|
+
background:
|
|
176
|
+
radial-gradient(60rem 24rem at 50% -12rem, color-mix(in srgb, var(--frankie-primary) 8%, transparent), transparent 65%),
|
|
177
|
+
var(--frankie-background);
|
|
178
|
+
color: var(--frankie-foreground);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.frankie-page-header {
|
|
182
|
+
display: flex;
|
|
183
|
+
min-height: 56px;
|
|
184
|
+
flex: 0 0 56px;
|
|
185
|
+
align-items: center;
|
|
186
|
+
justify-content: space-between;
|
|
187
|
+
gap: 16px;
|
|
188
|
+
padding: 0 24px;
|
|
189
|
+
border-bottom: 1px solid var(--frankie-border);
|
|
190
|
+
background: color-mix(in srgb, var(--frankie-background) 92%, transparent);
|
|
191
|
+
backdrop-filter: blur(18px);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.frankie-page-header > div {
|
|
195
|
+
display: flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
gap: 10px;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.frankie-page-header strong { font-size: 0.925rem; font-weight: 650; }
|
|
201
|
+
|
|
202
|
+
.frankie-page-status {
|
|
203
|
+
width: 10px;
|
|
204
|
+
height: 10px;
|
|
205
|
+
border-radius: 999px;
|
|
206
|
+
background: var(--frankie-primary);
|
|
207
|
+
box-shadow: 0 0 0 4px color-mix(in srgb, var(--frankie-primary) 16%, transparent);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.frankie-page-header button {
|
|
211
|
+
min-height: 36px;
|
|
212
|
+
padding: 0 13px;
|
|
213
|
+
border: 1px solid var(--frankie-border);
|
|
214
|
+
border-radius: calc(var(--frankie-radius) * .9);
|
|
215
|
+
background: var(--frankie-secondary);
|
|
216
|
+
cursor: pointer;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.frankie-page-header button:hover { border-color: var(--frankie-primary); }
|
|
220
|
+
|
|
221
|
+
.frankie-page-canvas {
|
|
222
|
+
display: flex;
|
|
223
|
+
min-width: 0;
|
|
224
|
+
min-height: 0;
|
|
225
|
+
flex: 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.frankie-page-canvas > .frankie-inline {
|
|
229
|
+
width: 100%;
|
|
230
|
+
max-width: 56rem;
|
|
231
|
+
margin: 0 auto;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.frankie-page-canvas .frankie-scroll { padding: 2rem clamp(1rem, 4vw, 4rem) 1.25rem; }
|
|
235
|
+
.frankie-page-canvas .frankie-root > .border-t {
|
|
236
|
+
padding: .75rem clamp(1rem, 4vw, 4rem) 1.5rem;
|
|
237
|
+
border-top: 0;
|
|
238
|
+
background: linear-gradient(180deg, transparent, var(--frankie-background) 30%);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.frankie-page-empty {
|
|
242
|
+
display: flex;
|
|
243
|
+
width: min(100%, 38rem);
|
|
244
|
+
min-height: 100%;
|
|
245
|
+
margin: auto;
|
|
246
|
+
padding: clamp(2rem, 7vh, 4.5rem) 1rem 2rem;
|
|
247
|
+
flex-direction: column;
|
|
248
|
+
align-items: center;
|
|
249
|
+
justify-content: center;
|
|
250
|
+
gap: 1rem;
|
|
251
|
+
text-align: center;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.frankie-page-avatar {
|
|
255
|
+
display: grid;
|
|
256
|
+
width: 48px;
|
|
257
|
+
height: 48px;
|
|
258
|
+
place-items: center;
|
|
259
|
+
border-radius: calc(var(--frankie-radius) * 1.4);
|
|
260
|
+
background: var(--frankie-primary);
|
|
261
|
+
color: var(--frankie-primary-foreground);
|
|
262
|
+
font-weight: 750;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.frankie-page-kicker {
|
|
266
|
+
margin: 0 0 .4rem;
|
|
267
|
+
color: var(--frankie-primary);
|
|
268
|
+
font-size: .7rem;
|
|
269
|
+
font-weight: 750;
|
|
270
|
+
letter-spacing: .1em;
|
|
271
|
+
text-transform: uppercase;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.frankie-page-empty h1 {
|
|
275
|
+
margin: 0;
|
|
276
|
+
font-size: clamp(1.65rem, 4vw, 2.15rem);
|
|
277
|
+
line-height: 1.08;
|
|
278
|
+
letter-spacing: -.045em;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.frankie-page-empty h1 + p {
|
|
282
|
+
margin: .6rem auto 0;
|
|
283
|
+
max-width: 30rem;
|
|
284
|
+
color: var(--frankie-muted-foreground);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.frankie-page-prompts {
|
|
288
|
+
display: grid;
|
|
289
|
+
width: 100%;
|
|
290
|
+
gap: .55rem;
|
|
291
|
+
margin-top: .4rem;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.frankie-page-prompts button {
|
|
295
|
+
display: grid;
|
|
296
|
+
min-height: 60px;
|
|
297
|
+
grid-template-columns: 2rem minmax(0, 1fr) auto;
|
|
298
|
+
align-items: center;
|
|
299
|
+
gap: .65rem;
|
|
300
|
+
padding: .75rem .9rem;
|
|
301
|
+
border: 1px solid var(--frankie-border);
|
|
302
|
+
border-radius: calc(var(--frankie-radius) * 1.25);
|
|
303
|
+
background: var(--frankie-secondary);
|
|
304
|
+
text-align: left;
|
|
305
|
+
cursor: pointer;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.frankie-page-prompts button:hover {
|
|
309
|
+
border-color: color-mix(in srgb, var(--frankie-primary) 58%, var(--frankie-border));
|
|
310
|
+
transform: translateY(-1px);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.frankie-page-prompts button > span {
|
|
314
|
+
color: var(--frankie-muted-foreground);
|
|
315
|
+
font-size: .68rem;
|
|
316
|
+
font-weight: 700;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.frankie-page-prompts button > strong { font-size: .875rem; }
|
|
320
|
+
.frankie-page-prompts button > b { color: var(--frankie-primary); font-size: 1rem; }
|
|
321
|
+
|
|
322
|
+
@media (max-width: 640px) {
|
|
323
|
+
.frankie-page-header { min-height: 52px; flex-basis: 52px; padding: 0 12px; }
|
|
324
|
+
.frankie-page-canvas .frankie-scroll { padding: 1.25rem .75rem .5rem; }
|
|
325
|
+
.frankie-page-canvas .frankie-root > .border-t { padding: .65rem .75rem calc(.65rem + env(safe-area-inset-bottom)); }
|
|
326
|
+
}
|
|
327
|
+
}
|