@frankieai/react 0.1.0
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 +2 -0
- package/dist/index.cjs +1065 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +310 -0
- package/dist/index.d.ts +310 -0
- package/dist/index.js +1003 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/components/chat/ChatPanel.tsx +147 -0
- package/src/components/chat/Composer.tsx +75 -0
- package/src/components/chat/FrankieInline.tsx +35 -0
- package/src/components/chat/FrankieWidget.tsx +88 -0
- package/src/components/chat/Markdown.tsx +136 -0
- package/src/components/chat/MessageBubble.tsx +70 -0
- package/src/components/chat/SuggestedPrompts.tsx +31 -0
- package/src/components/chat/ToolActivityList.tsx +52 -0
- package/src/components/ui/avatar.tsx +30 -0
- package/src/components/ui/badge.tsx +28 -0
- package/src/components/ui/button.tsx +39 -0
- package/src/components/ui/card.tsx +36 -0
- package/src/components/ui/scroll-area.tsx +15 -0
- package/src/components/ui/separator.tsx +7 -0
- package/src/components/ui/textarea.tsx +20 -0
- package/src/hooks/FrankieProvider.tsx +94 -0
- package/src/hooks/useFrankieChat.ts +148 -0
- package/src/index.ts +34 -0
- package/src/lib/client.ts +217 -0
- package/src/lib/messages.ts +121 -0
- package/src/lib/theme.ts +30 -0
- package/src/lib/utils.ts +7 -0
- package/src/styles/frankie.css +158 -0
- package/src/widget.test.tsx +217 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/hooks/FrankieProvider.tsx
|
|
4
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
5
|
+
|
|
6
|
+
// src/lib/client.ts
|
|
7
|
+
var FRANKIE_CLOUD_URL = "https://frankie-data-gq5o5syq2q-uc.a.run.app";
|
|
8
|
+
var FrankieApiError = class extends Error {
|
|
9
|
+
constructor(message, status, code) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
status;
|
|
15
|
+
code;
|
|
16
|
+
};
|
|
17
|
+
var FrankieClient = class {
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
this.baseUrl = (options.baseUrl ?? FRANKIE_CLOUD_URL).replace(/\/+$/, "");
|
|
21
|
+
this.doFetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
22
|
+
this.session = this.readStored();
|
|
23
|
+
}
|
|
24
|
+
options;
|
|
25
|
+
baseUrl;
|
|
26
|
+
doFetch;
|
|
27
|
+
session;
|
|
28
|
+
get sessionId() {
|
|
29
|
+
return this.session?.sessionId;
|
|
30
|
+
}
|
|
31
|
+
/** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
|
|
32
|
+
get persist() {
|
|
33
|
+
return this.options.persist !== false && typeof window !== "undefined";
|
|
34
|
+
}
|
|
35
|
+
/** localStorage namespace for this tenant, shared with the chat hook. */
|
|
36
|
+
get namespace() {
|
|
37
|
+
return `frankie.${this.options.publishableKey}`;
|
|
38
|
+
}
|
|
39
|
+
readStored() {
|
|
40
|
+
if (!this.persist) return void 0;
|
|
41
|
+
try {
|
|
42
|
+
const raw = window.localStorage.getItem(`${this.namespace}.session`);
|
|
43
|
+
if (!raw) return void 0;
|
|
44
|
+
const session = JSON.parse(raw);
|
|
45
|
+
return new Date(session.expiresAt).getTime() > Date.now() + 3e4 ? session : void 0;
|
|
46
|
+
} catch {
|
|
47
|
+
return void 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
writeStored() {
|
|
51
|
+
if (!this.persist) return;
|
|
52
|
+
try {
|
|
53
|
+
if (this.session) window.localStorage.setItem(`${this.namespace}.session`, JSON.stringify(this.session));
|
|
54
|
+
else window.localStorage.removeItem(`${this.namespace}.session`);
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async getConfig(signal) {
|
|
59
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/config`, {
|
|
60
|
+
headers: { "x-frankie-key": this.options.publishableKey },
|
|
61
|
+
...signal ? { signal } : {}
|
|
62
|
+
});
|
|
63
|
+
return await this.unwrap(response);
|
|
64
|
+
}
|
|
65
|
+
/** Opens (or reuses) a chat session. */
|
|
66
|
+
async ensureSession(context) {
|
|
67
|
+
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 3e4) {
|
|
68
|
+
return this.session;
|
|
69
|
+
}
|
|
70
|
+
const userToken = await this.options.getUserToken?.();
|
|
71
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
|
|
74
|
+
body: JSON.stringify({ ...userToken ? { userToken } : {}, ...context ? { context } : {} })
|
|
75
|
+
});
|
|
76
|
+
this.session = await this.unwrap(response);
|
|
77
|
+
this.writeStored();
|
|
78
|
+
return this.session;
|
|
79
|
+
}
|
|
80
|
+
/** Forget the current session so the next send starts a fresh conversation. */
|
|
81
|
+
resetSession() {
|
|
82
|
+
this.session = void 0;
|
|
83
|
+
this.writeStored();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Sends a message and invokes `onEvent` for each server event as it arrives.
|
|
87
|
+
* Resolves when the turn ends.
|
|
88
|
+
*/
|
|
89
|
+
async send(input, onEvent) {
|
|
90
|
+
const session = await this.ensureSession(input.context);
|
|
91
|
+
const body = {
|
|
92
|
+
sessionId: session.sessionId,
|
|
93
|
+
message: input.message,
|
|
94
|
+
...input.context ? { context: input.context } : {}
|
|
95
|
+
};
|
|
96
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/chat`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
"content-type": "application/json",
|
|
100
|
+
"x-frankie-key": this.options.publishableKey,
|
|
101
|
+
authorization: `Bearer ${session.sessionToken}`
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
...input.signal ? { signal: input.signal } : {}
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok || !response.body) {
|
|
107
|
+
if (response.status === 401 || response.status === 404) this.resetSession();
|
|
108
|
+
await this.unwrap(response);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
for await (const event of readEventStream(response.body)) {
|
|
112
|
+
onEvent(event);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async unwrap(response) {
|
|
116
|
+
if (response.ok) return response.json();
|
|
117
|
+
let code = "internal";
|
|
118
|
+
let message = `Request failed with status ${response.status}.`;
|
|
119
|
+
try {
|
|
120
|
+
const body = await response.json();
|
|
121
|
+
code = body.error?.code ?? code;
|
|
122
|
+
message = body.error?.message ?? message;
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
throw new FrankieApiError(message, response.status, code);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
async function* readEventStream(body) {
|
|
129
|
+
const reader = body.getReader();
|
|
130
|
+
const decoder = new TextDecoder();
|
|
131
|
+
let buffer = "";
|
|
132
|
+
try {
|
|
133
|
+
for (; ; ) {
|
|
134
|
+
const { done, value } = await reader.read();
|
|
135
|
+
if (done) break;
|
|
136
|
+
buffer += decoder.decode(value, { stream: true });
|
|
137
|
+
let split = buffer.indexOf("\n\n");
|
|
138
|
+
while (split !== -1) {
|
|
139
|
+
const frame = buffer.slice(0, split);
|
|
140
|
+
buffer = buffer.slice(split + 2);
|
|
141
|
+
const event = parseFrame(frame);
|
|
142
|
+
if (event) yield event;
|
|
143
|
+
split = buffer.indexOf("\n\n");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
reader.releaseLock();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function parseFrame(frame) {
|
|
151
|
+
const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).replace(/^ /, "")).join("\n");
|
|
152
|
+
if (!data) return null;
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(data);
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/hooks/FrankieProvider.tsx
|
|
161
|
+
import { jsx } from "react/jsx-runtime";
|
|
162
|
+
var FrankieContext = createContext(null);
|
|
163
|
+
function FrankieProvider({
|
|
164
|
+
children,
|
|
165
|
+
context,
|
|
166
|
+
theme,
|
|
167
|
+
config: providedConfig,
|
|
168
|
+
...clientOptions
|
|
169
|
+
}) {
|
|
170
|
+
const client = useMemo(
|
|
171
|
+
() => new FrankieClient(clientOptions),
|
|
172
|
+
// The client holds session state; rebuild it only when the endpoint changes.
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
[clientOptions.baseUrl, clientOptions.publishableKey]
|
|
175
|
+
);
|
|
176
|
+
const [config, setConfig] = useState(providedConfig);
|
|
177
|
+
const [configError, setConfigError] = useState(void 0);
|
|
178
|
+
const [loading, setLoading] = useState(!providedConfig);
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (providedConfig) return;
|
|
181
|
+
const abort = new AbortController();
|
|
182
|
+
setLoading(true);
|
|
183
|
+
client.getConfig(abort.signal).then((next) => {
|
|
184
|
+
setConfig(next);
|
|
185
|
+
setConfigError(void 0);
|
|
186
|
+
}).catch((error) => {
|
|
187
|
+
if (abort.signal.aborted) return;
|
|
188
|
+
setConfigError(error instanceof Error ? error.message : "Could not load the assistant.");
|
|
189
|
+
}).finally(() => {
|
|
190
|
+
if (!abort.signal.aborted) setLoading(false);
|
|
191
|
+
});
|
|
192
|
+
return () => abort.abort();
|
|
193
|
+
}, [client, providedConfig]);
|
|
194
|
+
const value = useMemo(
|
|
195
|
+
() => ({
|
|
196
|
+
client,
|
|
197
|
+
...config ? { config } : {},
|
|
198
|
+
...configError ? { configError } : {},
|
|
199
|
+
loading,
|
|
200
|
+
...context ? { context } : {},
|
|
201
|
+
...theme ? { theme } : {}
|
|
202
|
+
}),
|
|
203
|
+
[client, config, configError, loading, context, theme]
|
|
204
|
+
);
|
|
205
|
+
return /* @__PURE__ */ jsx(FrankieContext.Provider, { value, children });
|
|
206
|
+
}
|
|
207
|
+
function useFrankie() {
|
|
208
|
+
const value = useContext(FrankieContext);
|
|
209
|
+
if (!value) throw new Error("Frankie components must be rendered inside <FrankieProvider>.");
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
function useFrankieTheme() {
|
|
213
|
+
const { config, theme } = useFrankie();
|
|
214
|
+
return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/hooks/useFrankieChat.ts
|
|
218
|
+
import { useCallback, useEffect as useEffect2, useRef, useState as useState2 } from "react";
|
|
219
|
+
|
|
220
|
+
// src/lib/messages.ts
|
|
221
|
+
function applyEvent(message, event) {
|
|
222
|
+
switch (event.type) {
|
|
223
|
+
case "text_delta":
|
|
224
|
+
return { ...message, text: message.text + event.text };
|
|
225
|
+
case "thinking_delta":
|
|
226
|
+
return { ...message, thinking: message.thinking + event.text };
|
|
227
|
+
case "tool_start":
|
|
228
|
+
return {
|
|
229
|
+
...message,
|
|
230
|
+
tools: [
|
|
231
|
+
...message.tools,
|
|
232
|
+
{ id: event.toolUseId, name: event.name, label: event.label, status: "running" }
|
|
233
|
+
]
|
|
234
|
+
};
|
|
235
|
+
case "tool_end":
|
|
236
|
+
return {
|
|
237
|
+
...message,
|
|
238
|
+
tools: message.tools.map(
|
|
239
|
+
(tool) => tool.id === event.toolUseId ? {
|
|
240
|
+
...tool,
|
|
241
|
+
status: event.ok ? "done" : "failed",
|
|
242
|
+
...event.preview ? { detail: event.preview } : {}
|
|
243
|
+
} : tool
|
|
244
|
+
)
|
|
245
|
+
};
|
|
246
|
+
case "tool_denied":
|
|
247
|
+
return {
|
|
248
|
+
...message,
|
|
249
|
+
tools: upsertTool(message.tools, {
|
|
250
|
+
id: event.toolUseId,
|
|
251
|
+
name: event.name,
|
|
252
|
+
label: event.name,
|
|
253
|
+
status: "denied",
|
|
254
|
+
detail: event.reason
|
|
255
|
+
})
|
|
256
|
+
};
|
|
257
|
+
case "turn_end":
|
|
258
|
+
return {
|
|
259
|
+
...message,
|
|
260
|
+
status: message.status === "error" ? "error" : "done",
|
|
261
|
+
// A turn that produced nothing still needs to say something.
|
|
262
|
+
text: message.text || fallbackText(event.stopReason)
|
|
263
|
+
};
|
|
264
|
+
case "error":
|
|
265
|
+
return { ...message, status: "error", error: event.message };
|
|
266
|
+
default:
|
|
267
|
+
return message;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function upsertTool(tools, next) {
|
|
271
|
+
return tools.some((tool) => tool.id === next.id) ? tools.map((tool) => tool.id === next.id ? { ...tool, ...next } : tool) : [...tools, next];
|
|
272
|
+
}
|
|
273
|
+
function fallbackText(stopReason) {
|
|
274
|
+
switch (stopReason) {
|
|
275
|
+
case "max_turns":
|
|
276
|
+
return "I got stuck working on that. Could you narrow it down a little?";
|
|
277
|
+
case "aborted":
|
|
278
|
+
return "Stopped.";
|
|
279
|
+
case "error":
|
|
280
|
+
return "Something went wrong before I could answer. Try again?";
|
|
281
|
+
default:
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function newAssistantMessage(id) {
|
|
286
|
+
return { id, role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() };
|
|
287
|
+
}
|
|
288
|
+
function newUserMessage(id, text) {
|
|
289
|
+
return { id, role: "user", text, createdAt: Date.now() };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/hooks/useFrankieChat.ts
|
|
293
|
+
var counter = 0;
|
|
294
|
+
var nextId = (prefix) => `${prefix}_${Date.now().toString(36)}_${counter += 1}`;
|
|
295
|
+
function useFrankieChat() {
|
|
296
|
+
const { client, context } = useFrankie();
|
|
297
|
+
const [messages, setMessages] = useState2([]);
|
|
298
|
+
const [busy, setBusy] = useState2(false);
|
|
299
|
+
const [error, setError] = useState2(void 0);
|
|
300
|
+
const abortRef = useRef(null);
|
|
301
|
+
const mounted = useRef(true);
|
|
302
|
+
useEffect2(() => {
|
|
303
|
+
mounted.current = true;
|
|
304
|
+
return () => {
|
|
305
|
+
mounted.current = false;
|
|
306
|
+
abortRef.current?.abort();
|
|
307
|
+
};
|
|
308
|
+
}, []);
|
|
309
|
+
const storageKey = `${client.namespace}.messages`;
|
|
310
|
+
useEffect2(() => {
|
|
311
|
+
if (!client.persist) return;
|
|
312
|
+
try {
|
|
313
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
314
|
+
const stored = raw ? JSON.parse(raw) : [];
|
|
315
|
+
if (Array.isArray(stored) && stored.length) setMessages(stored);
|
|
316
|
+
} catch {
|
|
317
|
+
}
|
|
318
|
+
}, [storageKey]);
|
|
319
|
+
useEffect2(() => {
|
|
320
|
+
if (!client.persist || busy) return;
|
|
321
|
+
try {
|
|
322
|
+
if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
|
|
323
|
+
else window.localStorage.removeItem(storageKey);
|
|
324
|
+
} catch {
|
|
325
|
+
}
|
|
326
|
+
}, [messages, busy, client, storageKey]);
|
|
327
|
+
const patchAssistant = useCallback((id, update) => {
|
|
328
|
+
setMessages(
|
|
329
|
+
(current) => current.map(
|
|
330
|
+
(message) => message.id === id && message.role === "assistant" ? update(message) : message
|
|
331
|
+
)
|
|
332
|
+
);
|
|
333
|
+
}, []);
|
|
334
|
+
const send = useCallback(
|
|
335
|
+
async (text) => {
|
|
336
|
+
const trimmed = text.trim();
|
|
337
|
+
if (!trimmed || busy) return;
|
|
338
|
+
const assistantId = nextId("a");
|
|
339
|
+
setError(void 0);
|
|
340
|
+
setBusy(true);
|
|
341
|
+
setMessages((current) => [
|
|
342
|
+
...current,
|
|
343
|
+
newUserMessage(nextId("u"), trimmed),
|
|
344
|
+
newAssistantMessage(assistantId)
|
|
345
|
+
]);
|
|
346
|
+
const abort = new AbortController();
|
|
347
|
+
abortRef.current = abort;
|
|
348
|
+
try {
|
|
349
|
+
await client.send({ message: trimmed, ...context ? { context } : {}, signal: abort.signal }, (event) => {
|
|
350
|
+
if (event.type === "ping" || event.type === "session") return;
|
|
351
|
+
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
352
|
+
});
|
|
353
|
+
} catch (caught) {
|
|
354
|
+
if (abort.signal.aborted) {
|
|
355
|
+
patchAssistant(assistantId, (message) => ({
|
|
356
|
+
...message,
|
|
357
|
+
status: "done",
|
|
358
|
+
text: message.text || "Stopped."
|
|
359
|
+
}));
|
|
360
|
+
} else {
|
|
361
|
+
const message = caught instanceof FrankieApiError ? caught.message : "Could not reach the assistant. Check your connection and try again.";
|
|
362
|
+
if (mounted.current) setError(message);
|
|
363
|
+
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
if (mounted.current) {
|
|
367
|
+
setBusy(false);
|
|
368
|
+
patchAssistant(
|
|
369
|
+
assistantId,
|
|
370
|
+
(message) => message.status === "streaming" ? { ...message, status: "done" } : message
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
abortRef.current = null;
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
[busy, client, context, patchAssistant]
|
|
377
|
+
);
|
|
378
|
+
const stop = useCallback(() => abortRef.current?.abort(), []);
|
|
379
|
+
const reset = useCallback(() => {
|
|
380
|
+
abortRef.current?.abort();
|
|
381
|
+
client.resetSession();
|
|
382
|
+
setMessages([]);
|
|
383
|
+
setError(void 0);
|
|
384
|
+
setBusy(false);
|
|
385
|
+
try {
|
|
386
|
+
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
387
|
+
} catch {
|
|
388
|
+
}
|
|
389
|
+
}, [client]);
|
|
390
|
+
return { messages, busy, ...error ? { error } : {}, send, stop, reset };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// src/components/chat/FrankieWidget.tsx
|
|
394
|
+
import { MessageCircle, X as X3 } from "lucide-react";
|
|
395
|
+
import { useEffect as useEffect5, useState as useState4 } from "react";
|
|
396
|
+
|
|
397
|
+
// src/lib/theme.ts
|
|
398
|
+
function themeToCssVars(theme) {
|
|
399
|
+
if (!theme) return {};
|
|
400
|
+
const vars = {};
|
|
401
|
+
if (theme.primary) vars["--frankie-primary"] = theme.primary;
|
|
402
|
+
if (theme.primaryForeground) vars["--frankie-primary-foreground"] = theme.primaryForeground;
|
|
403
|
+
if (theme.background) vars["--frankie-background"] = theme.background;
|
|
404
|
+
if (theme.foreground) vars["--frankie-foreground"] = theme.foreground;
|
|
405
|
+
if (theme.muted) vars["--frankie-muted"] = theme.muted;
|
|
406
|
+
if (theme.border) vars["--frankie-border"] = theme.border;
|
|
407
|
+
if (theme.radius) vars["--frankie-radius"] = `${theme.radius}rem`;
|
|
408
|
+
if (theme.fontFamily) vars["--frankie-font"] = theme.fontFamily;
|
|
409
|
+
return vars;
|
|
410
|
+
}
|
|
411
|
+
function resolveMode(mode) {
|
|
412
|
+
if (mode === "light" || mode === "dark") return mode;
|
|
413
|
+
if (typeof window === "undefined" || !window.matchMedia) return "light";
|
|
414
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/lib/utils.ts
|
|
418
|
+
import { clsx } from "clsx";
|
|
419
|
+
import { twMerge } from "tailwind-merge";
|
|
420
|
+
function cn(...inputs) {
|
|
421
|
+
return twMerge(clsx(inputs));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/components/ui/button.tsx
|
|
425
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
426
|
+
import { cva } from "class-variance-authority";
|
|
427
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
428
|
+
var buttonVariants = cva(
|
|
429
|
+
"frankie-focus inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--frankie-radius)] text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
430
|
+
{
|
|
431
|
+
variants: {
|
|
432
|
+
variant: {
|
|
433
|
+
default: "bg-primary text-primary-foreground hover:opacity-90",
|
|
434
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-muted",
|
|
435
|
+
outline: "border border-border bg-background hover:bg-muted",
|
|
436
|
+
ghost: "hover:bg-muted",
|
|
437
|
+
link: "text-primary underline-offset-4 hover:underline"
|
|
438
|
+
},
|
|
439
|
+
size: {
|
|
440
|
+
default: "h-9 px-4 py-2",
|
|
441
|
+
sm: "h-8 rounded-[calc(var(--frankie-radius)-2px)] px-3 text-xs",
|
|
442
|
+
lg: "h-10 px-6",
|
|
443
|
+
icon: "size-9"
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
defaultVariants: { variant: "default", size: "default" }
|
|
447
|
+
}
|
|
448
|
+
);
|
|
449
|
+
function Button({ className, variant, size, asChild = false, ...props }) {
|
|
450
|
+
const Comp = asChild ? Slot : "button";
|
|
451
|
+
return /* @__PURE__ */ jsx2(Comp, { className: cn(buttonVariants({ variant, size }), className), ...props });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/components/chat/ChatPanel.tsx
|
|
455
|
+
import { RotateCcw, X as X2 } from "lucide-react";
|
|
456
|
+
import { useEffect as useEffect4, useRef as useRef3 } from "react";
|
|
457
|
+
|
|
458
|
+
// src/components/ui/avatar.tsx
|
|
459
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
460
|
+
function Avatar({ src, name, className }) {
|
|
461
|
+
const initials = name.split(/\s+/).slice(0, 2).map((part) => part[0]?.toUpperCase() ?? "").join("");
|
|
462
|
+
return /* @__PURE__ */ jsx3(
|
|
463
|
+
"span",
|
|
464
|
+
{
|
|
465
|
+
className: cn(
|
|
466
|
+
"inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-primary text-[0.625rem] font-semibold text-primary-foreground",
|
|
467
|
+
className
|
|
468
|
+
),
|
|
469
|
+
"aria-hidden": "true",
|
|
470
|
+
children: src ? /* @__PURE__ */ jsx3("img", { src, alt: "", className: "size-full object-cover" }) : initials
|
|
471
|
+
}
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// src/components/ui/card.tsx
|
|
476
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
477
|
+
function Card({ className, ...props }) {
|
|
478
|
+
return /* @__PURE__ */ jsx4(
|
|
479
|
+
"div",
|
|
480
|
+
{
|
|
481
|
+
className: cn(
|
|
482
|
+
"flex flex-col rounded-[calc(var(--frankie-radius)+4px)] border border-border bg-background text-foreground shadow-lg",
|
|
483
|
+
className
|
|
484
|
+
),
|
|
485
|
+
...props
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
function CardHeader({ className, ...props }) {
|
|
490
|
+
return /* @__PURE__ */ jsx4("div", { className: cn("flex items-center gap-3 border-b border-border px-4 py-3", className), ...props });
|
|
491
|
+
}
|
|
492
|
+
function CardTitle({ className, ...props }) {
|
|
493
|
+
return /* @__PURE__ */ jsx4("div", { className: cn("text-sm font-semibold leading-tight", className), ...props });
|
|
494
|
+
}
|
|
495
|
+
function CardDescription({ className, ...props }) {
|
|
496
|
+
return /* @__PURE__ */ jsx4("div", { className: cn("text-xs text-muted-foreground", className), ...props });
|
|
497
|
+
}
|
|
498
|
+
function CardContent({ className, ...props }) {
|
|
499
|
+
return /* @__PURE__ */ jsx4("div", { className: cn("flex-1 overflow-hidden", className), ...props });
|
|
500
|
+
}
|
|
501
|
+
function CardFooter({ className, ...props }) {
|
|
502
|
+
return /* @__PURE__ */ jsx4("div", { className: cn("border-t border-border p-3", className), ...props });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/components/ui/scroll-area.tsx
|
|
506
|
+
import { forwardRef } from "react";
|
|
507
|
+
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
508
|
+
var ScrollArea = forwardRef(
|
|
509
|
+
function ScrollArea2({ className, ...props }, ref) {
|
|
510
|
+
return /* @__PURE__ */ jsx5("div", { ref, className: cn("frankie-scroll overflow-y-auto overscroll-contain", className), ...props });
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
// src/components/chat/Composer.tsx
|
|
515
|
+
import { ArrowUp, Square } from "lucide-react";
|
|
516
|
+
import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState3 } from "react";
|
|
517
|
+
|
|
518
|
+
// src/components/ui/textarea.tsx
|
|
519
|
+
import { forwardRef as forwardRef2 } from "react";
|
|
520
|
+
import { jsx as jsx6 } from "react/jsx-runtime";
|
|
521
|
+
var Textarea = forwardRef2(
|
|
522
|
+
function Textarea2({ className, ...props }, ref) {
|
|
523
|
+
return /* @__PURE__ */ jsx6(
|
|
524
|
+
"textarea",
|
|
525
|
+
{
|
|
526
|
+
ref,
|
|
527
|
+
className: cn(
|
|
528
|
+
"frankie-focus flex min-h-[2.25rem] w-full resize-none rounded-[var(--frankie-radius)] border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
529
|
+
className
|
|
530
|
+
),
|
|
531
|
+
...props
|
|
532
|
+
}
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
// src/components/chat/Composer.tsx
|
|
538
|
+
import { jsx as jsx7, jsxs } from "react/jsx-runtime";
|
|
539
|
+
var MAX_ROWS_PX = 140;
|
|
540
|
+
function Composer({ onSend, onStop, busy, placeholder, disabled, autoFocus }) {
|
|
541
|
+
const [value, setValue] = useState3("");
|
|
542
|
+
const textareaRef = useRef2(null);
|
|
543
|
+
useEffect3(() => {
|
|
544
|
+
const node = textareaRef.current;
|
|
545
|
+
if (!node) return;
|
|
546
|
+
node.style.height = "auto";
|
|
547
|
+
node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`;
|
|
548
|
+
}, [value]);
|
|
549
|
+
const submit = useCallback2(() => {
|
|
550
|
+
const text = value.trim();
|
|
551
|
+
if (!text || busy || disabled) return;
|
|
552
|
+
setValue("");
|
|
553
|
+
onSend(text);
|
|
554
|
+
}, [busy, disabled, onSend, value]);
|
|
555
|
+
const onKeyDown = (event) => {
|
|
556
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
557
|
+
event.preventDefault();
|
|
558
|
+
submit();
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
return /* @__PURE__ */ jsxs(
|
|
562
|
+
"form",
|
|
563
|
+
{
|
|
564
|
+
className: "flex items-end gap-2",
|
|
565
|
+
onSubmit: (event) => {
|
|
566
|
+
event.preventDefault();
|
|
567
|
+
submit();
|
|
568
|
+
},
|
|
569
|
+
children: [
|
|
570
|
+
/* @__PURE__ */ jsx7(
|
|
571
|
+
Textarea,
|
|
572
|
+
{
|
|
573
|
+
ref: textareaRef,
|
|
574
|
+
rows: 1,
|
|
575
|
+
value,
|
|
576
|
+
disabled,
|
|
577
|
+
autoFocus,
|
|
578
|
+
onChange: (event) => setValue(event.target.value),
|
|
579
|
+
onKeyDown,
|
|
580
|
+
placeholder: placeholder ?? "Ask anything\u2026",
|
|
581
|
+
"aria-label": "Message"
|
|
582
|
+
}
|
|
583
|
+
),
|
|
584
|
+
busy ? /* @__PURE__ */ jsx7(Button, { type: "button", size: "icon", variant: "outline", onClick: onStop, "aria-label": "Stop", children: /* @__PURE__ */ jsx7(Square, {}) }) : /* @__PURE__ */ jsx7(Button, { type: "submit", size: "icon", disabled: disabled || value.trim().length === 0, "aria-label": "Send", children: /* @__PURE__ */ jsx7(ArrowUp, {}) })
|
|
585
|
+
]
|
|
586
|
+
}
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/components/chat/Markdown.tsx
|
|
591
|
+
import { Fragment } from "react";
|
|
592
|
+
import { jsx as jsx8 } from "react/jsx-runtime";
|
|
593
|
+
function Markdown({ text, className }) {
|
|
594
|
+
return /* @__PURE__ */ jsx8("div", { className: cn("frankie-prose", className), children: renderBlocks(text) });
|
|
595
|
+
}
|
|
596
|
+
function renderBlocks(text) {
|
|
597
|
+
const lines = text.split("\n");
|
|
598
|
+
const blocks = [];
|
|
599
|
+
let paragraph = [];
|
|
600
|
+
let list = null;
|
|
601
|
+
let code = null;
|
|
602
|
+
const flushParagraph = () => {
|
|
603
|
+
if (paragraph.length === 0) return;
|
|
604
|
+
blocks.push(/* @__PURE__ */ jsx8("p", { children: renderInline(paragraph.join(" ")) }, `p${blocks.length}`));
|
|
605
|
+
paragraph = [];
|
|
606
|
+
};
|
|
607
|
+
const flushList = () => {
|
|
608
|
+
if (!list) return;
|
|
609
|
+
const items = list.items.map((item, index) => /* @__PURE__ */ jsx8("li", { children: renderInline(item) }, index));
|
|
610
|
+
blocks.push(list.ordered ? /* @__PURE__ */ jsx8("ol", { children: items }, `l${blocks.length}`) : /* @__PURE__ */ jsx8("ul", { children: items }, `l${blocks.length}`));
|
|
611
|
+
list = null;
|
|
612
|
+
};
|
|
613
|
+
for (const line of lines) {
|
|
614
|
+
const fence = /^```(\w*)\s*$/.exec(line);
|
|
615
|
+
if (fence) {
|
|
616
|
+
if (code) {
|
|
617
|
+
blocks.push(
|
|
618
|
+
/* @__PURE__ */ jsx8("pre", { children: /* @__PURE__ */ jsx8("code", { children: code.lines.join("\n") }) }, `c${blocks.length}`)
|
|
619
|
+
);
|
|
620
|
+
code = null;
|
|
621
|
+
} else {
|
|
622
|
+
flushParagraph();
|
|
623
|
+
flushList();
|
|
624
|
+
code = { lang: fence[1] ?? "", lines: [] };
|
|
625
|
+
}
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (code) {
|
|
629
|
+
code.lines.push(line);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
|
|
633
|
+
const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
634
|
+
if (bullet ?? ordered) {
|
|
635
|
+
flushParagraph();
|
|
636
|
+
const isOrdered = Boolean(ordered);
|
|
637
|
+
if (!list || list.ordered !== isOrdered) {
|
|
638
|
+
flushList();
|
|
639
|
+
list = { ordered: isOrdered, items: [] };
|
|
640
|
+
}
|
|
641
|
+
list.items.push(ordered?.[1] ?? bullet?.[1] ?? "");
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (line.trim() === "") {
|
|
645
|
+
flushParagraph();
|
|
646
|
+
flushList();
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
650
|
+
if (heading) {
|
|
651
|
+
flushParagraph();
|
|
652
|
+
flushList();
|
|
653
|
+
blocks.push(/* @__PURE__ */ jsx8("p", { className: "font-semibold", children: renderInline(heading[1] ?? "") }, `h${blocks.length}`));
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
flushList();
|
|
657
|
+
paragraph.push(line.trim());
|
|
658
|
+
}
|
|
659
|
+
if (code) {
|
|
660
|
+
blocks.push(
|
|
661
|
+
/* @__PURE__ */ jsx8("pre", { children: /* @__PURE__ */ jsx8("code", { children: code.lines.join("\n") }) }, `c${blocks.length}`)
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
flushParagraph();
|
|
665
|
+
flushList();
|
|
666
|
+
return blocks;
|
|
667
|
+
}
|
|
668
|
+
var INLINE = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\((?:https?:\/\/|\/)[^)\s]+\)|\bhttps?:\/\/[^\s<>()]+)/g;
|
|
669
|
+
function renderInline(text) {
|
|
670
|
+
const nodes = [];
|
|
671
|
+
let lastIndex = 0;
|
|
672
|
+
for (const match of text.matchAll(INLINE)) {
|
|
673
|
+
const token = match[0];
|
|
674
|
+
const start = match.index;
|
|
675
|
+
if (start > lastIndex) nodes.push(text.slice(lastIndex, start));
|
|
676
|
+
nodes.push(/* @__PURE__ */ jsx8(Fragment, { children: renderToken(token) }, start));
|
|
677
|
+
lastIndex = start + token.length;
|
|
678
|
+
}
|
|
679
|
+
if (lastIndex < text.length) nodes.push(text.slice(lastIndex));
|
|
680
|
+
return nodes;
|
|
681
|
+
}
|
|
682
|
+
function renderToken(token) {
|
|
683
|
+
if (token.startsWith("**")) return /* @__PURE__ */ jsx8("strong", { children: token.slice(2, -2) });
|
|
684
|
+
if (token.startsWith("`")) return /* @__PURE__ */ jsx8("code", { children: token.slice(1, -1) });
|
|
685
|
+
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
|
|
686
|
+
const href = link ? link[2] ?? "" : token;
|
|
687
|
+
const label = link ? link[1] ?? "" : token;
|
|
688
|
+
if (!/^(https?:\/\/|\/)/.test(href)) return token;
|
|
689
|
+
return /* @__PURE__ */ jsx8("a", { href, target: "_blank", rel: "noopener noreferrer nofollow", children: label });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// src/components/chat/ToolActivityList.tsx
|
|
693
|
+
import { Check, Loader2, ShieldAlert, X } from "lucide-react";
|
|
694
|
+
|
|
695
|
+
// src/components/ui/badge.tsx
|
|
696
|
+
import { cva as cva2 } from "class-variance-authority";
|
|
697
|
+
import { jsx as jsx9 } from "react/jsx-runtime";
|
|
698
|
+
var badgeVariants = cva2(
|
|
699
|
+
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.6875rem] font-medium",
|
|
700
|
+
{
|
|
701
|
+
variants: {
|
|
702
|
+
variant: {
|
|
703
|
+
default: "border-transparent bg-muted text-muted-foreground",
|
|
704
|
+
success: "border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
|
705
|
+
danger: "border-transparent bg-red-500/10 text-red-600 dark:text-red-400",
|
|
706
|
+
outline: "border-border text-muted-foreground"
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
defaultVariants: { variant: "default" }
|
|
710
|
+
}
|
|
711
|
+
);
|
|
712
|
+
function Badge({
|
|
713
|
+
className,
|
|
714
|
+
variant,
|
|
715
|
+
...props
|
|
716
|
+
}) {
|
|
717
|
+
return /* @__PURE__ */ jsx9("span", { className: cn(badgeVariants({ variant }), className), ...props });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/components/chat/ToolActivityList.tsx
|
|
721
|
+
import { jsx as jsx10, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
722
|
+
function ToolActivityList({ tools, renderTool }) {
|
|
723
|
+
if (tools.length === 0) return null;
|
|
724
|
+
return /* @__PURE__ */ jsx10("ul", { className: "mt-2 flex flex-col gap-1", children: tools.map((tool) => /* @__PURE__ */ jsx10("li", { children: renderTool ? renderTool(tool) : /* @__PURE__ */ jsx10(ToolActivityRow, { tool }) }, tool.id)) });
|
|
725
|
+
}
|
|
726
|
+
function ToolActivityRow({ tool }) {
|
|
727
|
+
return /* @__PURE__ */ jsxs2(Badge, { variant: badgeVariant(tool.status), title: tool.detail ?? tool.name, children: [
|
|
728
|
+
/* @__PURE__ */ jsx10(ToolIcon, { status: tool.status }),
|
|
729
|
+
/* @__PURE__ */ jsx10("span", { className: "truncate", children: tool.label })
|
|
730
|
+
] });
|
|
731
|
+
}
|
|
732
|
+
function ToolIcon({ status }) {
|
|
733
|
+
switch (status) {
|
|
734
|
+
case "running":
|
|
735
|
+
return /* @__PURE__ */ jsx10(Loader2, { className: "size-3 animate-spin", "aria-hidden": "true" });
|
|
736
|
+
case "done":
|
|
737
|
+
return /* @__PURE__ */ jsx10(Check, { className: "size-3", "aria-hidden": "true" });
|
|
738
|
+
case "denied":
|
|
739
|
+
return /* @__PURE__ */ jsx10(ShieldAlert, { className: "size-3", "aria-hidden": "true" });
|
|
740
|
+
default:
|
|
741
|
+
return /* @__PURE__ */ jsx10(X, { className: "size-3", "aria-hidden": "true" });
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function badgeVariant(status) {
|
|
745
|
+
if (status === "done") return "success";
|
|
746
|
+
if (status === "failed") return "danger";
|
|
747
|
+
if (status === "denied") return "outline";
|
|
748
|
+
return "default";
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// src/components/chat/MessageBubble.tsx
|
|
752
|
+
import { jsx as jsx11, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
753
|
+
function MessageBubble({
|
|
754
|
+
message,
|
|
755
|
+
assistantName,
|
|
756
|
+
avatarUrl,
|
|
757
|
+
showToolActivity,
|
|
758
|
+
renderTool
|
|
759
|
+
}) {
|
|
760
|
+
if (message.role === "user") {
|
|
761
|
+
return /* @__PURE__ */ jsx11("div", { className: "flex justify-end", children: /* @__PURE__ */ jsx11("div", { className: "max-w-[85%] rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground", children: message.text }) });
|
|
762
|
+
}
|
|
763
|
+
const empty = message.text.length === 0 && message.tools.length === 0;
|
|
764
|
+
return /* @__PURE__ */ jsxs3("div", { className: "flex gap-2", children: [
|
|
765
|
+
/* @__PURE__ */ jsx11(Avatar, { ...avatarUrl ? { src: avatarUrl } : {}, name: assistantName, className: "mt-0.5" }),
|
|
766
|
+
/* @__PURE__ */ jsxs3("div", { className: "min-w-0 max-w-[85%]", children: [
|
|
767
|
+
message.status === "error" ? /* @__PURE__ */ jsx11("p", { className: "rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400", children: message.error }) : empty && message.status === "streaming" ? /* @__PURE__ */ jsx11(TypingDots, {}) : /* @__PURE__ */ jsx11(Markdown, { text: message.text, className: "text-sm" }),
|
|
768
|
+
showToolActivity ? /* @__PURE__ */ jsx11(ToolActivityList, { tools: message.tools, ...renderTool ? { renderTool } : {} }) : null
|
|
769
|
+
] })
|
|
770
|
+
] });
|
|
771
|
+
}
|
|
772
|
+
function TypingDots() {
|
|
773
|
+
return /* @__PURE__ */ jsx11("span", { className: "inline-flex items-center gap-1 py-2", role: "status", "aria-label": "Thinking", children: [0, 1, 2].map((index) => /* @__PURE__ */ jsx11(
|
|
774
|
+
"span",
|
|
775
|
+
{
|
|
776
|
+
className: cn("size-1.5 animate-pulse rounded-full bg-muted-foreground"),
|
|
777
|
+
style: { animationDelay: `${index * 150}ms` }
|
|
778
|
+
},
|
|
779
|
+
index
|
|
780
|
+
)) });
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/components/chat/SuggestedPrompts.tsx
|
|
784
|
+
import { jsx as jsx12 } from "react/jsx-runtime";
|
|
785
|
+
function SuggestedPrompts({
|
|
786
|
+
prompts,
|
|
787
|
+
onPick,
|
|
788
|
+
disabled
|
|
789
|
+
}) {
|
|
790
|
+
if (prompts.length === 0) return null;
|
|
791
|
+
return /* @__PURE__ */ jsx12("div", { className: "flex flex-wrap gap-2", children: prompts.map((prompt) => /* @__PURE__ */ jsx12(
|
|
792
|
+
Button,
|
|
793
|
+
{
|
|
794
|
+
type: "button",
|
|
795
|
+
size: "sm",
|
|
796
|
+
variant: "outline",
|
|
797
|
+
disabled,
|
|
798
|
+
onClick: () => onPick(prompt),
|
|
799
|
+
children: prompt
|
|
800
|
+
},
|
|
801
|
+
prompt
|
|
802
|
+
)) });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/components/chat/ChatPanel.tsx
|
|
806
|
+
import { jsx as jsx13, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
807
|
+
function ChatPanel({
|
|
808
|
+
className,
|
|
809
|
+
header,
|
|
810
|
+
footer,
|
|
811
|
+
emptyState,
|
|
812
|
+
renderMessage,
|
|
813
|
+
renderTool,
|
|
814
|
+
placeholder,
|
|
815
|
+
onClose,
|
|
816
|
+
autoFocus,
|
|
817
|
+
fill
|
|
818
|
+
}) {
|
|
819
|
+
const { config, configError, loading } = useFrankie();
|
|
820
|
+
const theme = useFrankieTheme();
|
|
821
|
+
const { messages, busy, error, send, stop, reset } = useFrankieChat();
|
|
822
|
+
const scrollRef = useRef3(null);
|
|
823
|
+
useEffect4(() => {
|
|
824
|
+
const node = scrollRef.current;
|
|
825
|
+
if (!node) return;
|
|
826
|
+
const distanceFromBottom = node.scrollHeight - node.scrollTop - node.clientHeight;
|
|
827
|
+
if (distanceFromBottom < 160) node.scrollTop = node.scrollHeight;
|
|
828
|
+
}, [messages]);
|
|
829
|
+
const name = config?.branding.name ?? "Assistant";
|
|
830
|
+
const mode = resolveMode(theme?.mode);
|
|
831
|
+
return /* @__PURE__ */ jsxs4(
|
|
832
|
+
Card,
|
|
833
|
+
{
|
|
834
|
+
className: cn(
|
|
835
|
+
"frankie-root",
|
|
836
|
+
fill ? "h-full w-full rounded-none border-0 shadow-none" : "h-[32rem] w-[22rem] max-w-[calc(100vw-2rem)]",
|
|
837
|
+
mode === "dark" && "dark",
|
|
838
|
+
className
|
|
839
|
+
),
|
|
840
|
+
style: themeToCssVars(theme),
|
|
841
|
+
"data-frankie-mode": mode,
|
|
842
|
+
children: [
|
|
843
|
+
header === false ? null : header !== void 0 ? header : /* @__PURE__ */ jsxs4(CardHeader, { children: [
|
|
844
|
+
/* @__PURE__ */ jsx13(Avatar, { ...config?.branding.avatarUrl ? { src: config.branding.avatarUrl } : {}, name }),
|
|
845
|
+
/* @__PURE__ */ jsxs4("div", { className: "min-w-0 flex-1", children: [
|
|
846
|
+
/* @__PURE__ */ jsx13(CardTitle, { children: name }),
|
|
847
|
+
config?.branding.tagline ? /* @__PURE__ */ jsx13(CardDescription, { children: config.branding.tagline }) : null
|
|
848
|
+
] }),
|
|
849
|
+
/* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: reset, "aria-label": "Start a new conversation", title: "New conversation", children: /* @__PURE__ */ jsx13(RotateCcw, {}) }),
|
|
850
|
+
onClose ? /* @__PURE__ */ jsx13(Button, { size: "icon", variant: "ghost", onClick: onClose, "aria-label": "Close", children: /* @__PURE__ */ jsx13(X2, {}) }) : null
|
|
851
|
+
] }),
|
|
852
|
+
/* @__PURE__ */ jsx13(CardContent, { children: /* @__PURE__ */ jsx13(ScrollArea, { ref: scrollRef, className: "flex h-full flex-col gap-4 p-4", "aria-live": "polite", children: configError ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: configError }) : loading ? /* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: "Loading\u2026" }) : messages.length === 0 ? emptyState ?? /* @__PURE__ */ jsxs4("div", { className: "flex flex-col gap-4", children: [
|
|
853
|
+
/* @__PURE__ */ jsx13("p", { className: "text-sm text-muted-foreground", children: config?.greeting }),
|
|
854
|
+
/* @__PURE__ */ jsx13(SuggestedPrompts, { prompts: config?.suggestedPrompts ?? [], onPick: send, disabled: busy })
|
|
855
|
+
] }) : /* @__PURE__ */ jsx13("div", { className: "flex flex-col gap-4", children: messages.map(
|
|
856
|
+
(message) => renderMessage ? /* @__PURE__ */ jsx13("div", { children: renderMessage(message) }, message.id) : /* @__PURE__ */ jsx13(
|
|
857
|
+
MessageBubble,
|
|
858
|
+
{
|
|
859
|
+
message,
|
|
860
|
+
assistantName: name,
|
|
861
|
+
...config?.branding.avatarUrl ? { avatarUrl: config.branding.avatarUrl } : {},
|
|
862
|
+
showToolActivity: config?.showToolActivity ?? true,
|
|
863
|
+
...renderTool ? { renderTool } : {}
|
|
864
|
+
},
|
|
865
|
+
message.id
|
|
866
|
+
)
|
|
867
|
+
) }) }) }),
|
|
868
|
+
/* @__PURE__ */ jsxs4(CardFooter, { className: "flex flex-col gap-2", children: [
|
|
869
|
+
error ? /* @__PURE__ */ jsx13("p", { className: "w-full text-xs text-red-600 dark:text-red-400", children: error }) : null,
|
|
870
|
+
/* @__PURE__ */ jsx13("div", { className: "w-full", children: /* @__PURE__ */ jsx13(
|
|
871
|
+
Composer,
|
|
872
|
+
{
|
|
873
|
+
onSend: send,
|
|
874
|
+
onStop: stop,
|
|
875
|
+
busy,
|
|
876
|
+
disabled: Boolean(configError),
|
|
877
|
+
...placeholder ? { placeholder } : {},
|
|
878
|
+
...autoFocus ? { autoFocus } : {}
|
|
879
|
+
}
|
|
880
|
+
) }),
|
|
881
|
+
footer
|
|
882
|
+
] })
|
|
883
|
+
]
|
|
884
|
+
}
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// src/components/chat/FrankieWidget.tsx
|
|
889
|
+
import { jsx as jsx14, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
890
|
+
function FrankieWidget({
|
|
891
|
+
position = "bottom-right",
|
|
892
|
+
offset = 24,
|
|
893
|
+
defaultOpen = false,
|
|
894
|
+
open: controlledOpen,
|
|
895
|
+
onOpenChange,
|
|
896
|
+
renderLauncher,
|
|
897
|
+
...panelProps
|
|
898
|
+
}) {
|
|
899
|
+
const { config } = useFrankie();
|
|
900
|
+
const theme = useFrankieTheme();
|
|
901
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState4(defaultOpen);
|
|
902
|
+
const open = controlledOpen ?? uncontrolledOpen;
|
|
903
|
+
const setOpen = (next) => {
|
|
904
|
+
if (controlledOpen === void 0) setUncontrolledOpen(next);
|
|
905
|
+
onOpenChange?.(next);
|
|
906
|
+
};
|
|
907
|
+
useEffect5(() => {
|
|
908
|
+
if (!open) return;
|
|
909
|
+
const onKeyDown = (event) => {
|
|
910
|
+
if (event.key === "Escape") setOpen(false);
|
|
911
|
+
};
|
|
912
|
+
window.addEventListener("keydown", onKeyDown);
|
|
913
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
914
|
+
}, [open]);
|
|
915
|
+
const label = config?.branding.launcherLabel ?? "Need a hand?";
|
|
916
|
+
const mode = resolveMode(theme?.mode);
|
|
917
|
+
const side = position === "bottom-right" ? { right: offset } : { left: offset };
|
|
918
|
+
return /* @__PURE__ */ jsx14(
|
|
919
|
+
"div",
|
|
920
|
+
{
|
|
921
|
+
className: cn("frankie-root frankie-widget", mode === "dark" && "dark"),
|
|
922
|
+
style: { ...themeToCssVars(theme), position: "fixed", bottom: offset, ...side, zIndex: 2147483e3 },
|
|
923
|
+
"data-frankie-mode": mode,
|
|
924
|
+
children: /* @__PURE__ */ jsxs5("div", { className: cn("flex flex-col gap-3", position === "bottom-left" ? "items-start" : "items-end"), children: [
|
|
925
|
+
open ? /* @__PURE__ */ jsx14(ChatPanel, { ...panelProps, onClose: () => setOpen(false), autoFocus: true }) : null,
|
|
926
|
+
renderLauncher ? renderLauncher({ open, toggle: () => setOpen(!open), label }) : /* @__PURE__ */ jsx14(
|
|
927
|
+
Button,
|
|
928
|
+
{
|
|
929
|
+
size: "icon",
|
|
930
|
+
className: "size-12 rounded-full shadow-lg",
|
|
931
|
+
onClick: () => setOpen(!open),
|
|
932
|
+
"aria-expanded": open,
|
|
933
|
+
"aria-label": open ? "Close the assistant" : label,
|
|
934
|
+
title: label,
|
|
935
|
+
children: open ? /* @__PURE__ */ jsx14(X3, { className: "size-5" }) : /* @__PURE__ */ jsx14(MessageCircle, { className: "size-5" })
|
|
936
|
+
}
|
|
937
|
+
)
|
|
938
|
+
] })
|
|
939
|
+
}
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// src/components/chat/FrankieInline.tsx
|
|
944
|
+
import { jsx as jsx15 } from "react/jsx-runtime";
|
|
945
|
+
function FrankieInline({ className, style, ...panelProps }) {
|
|
946
|
+
const theme = useFrankieTheme();
|
|
947
|
+
const mode = resolveMode(theme?.mode);
|
|
948
|
+
return /* @__PURE__ */ jsx15(
|
|
949
|
+
"div",
|
|
950
|
+
{
|
|
951
|
+
className: cn("frankie-root frankie-inline", mode === "dark" && "dark", className),
|
|
952
|
+
style: { ...themeToCssVars(theme), display: "flex", height: "100%", width: "100%", ...style },
|
|
953
|
+
"data-frankie-mode": mode,
|
|
954
|
+
children: /* @__PURE__ */ jsx15(ChatPanel, { ...panelProps, fill: true })
|
|
955
|
+
}
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// src/components/ui/separator.tsx
|
|
960
|
+
import { jsx as jsx16 } from "react/jsx-runtime";
|
|
961
|
+
function Separator({ className }) {
|
|
962
|
+
return /* @__PURE__ */ jsx16("div", { role: "separator", className: cn("h-px w-full bg-border", className) });
|
|
963
|
+
}
|
|
964
|
+
export {
|
|
965
|
+
Avatar,
|
|
966
|
+
Badge,
|
|
967
|
+
Button,
|
|
968
|
+
Card,
|
|
969
|
+
CardContent,
|
|
970
|
+
CardDescription,
|
|
971
|
+
CardFooter,
|
|
972
|
+
CardHeader,
|
|
973
|
+
CardTitle,
|
|
974
|
+
ChatPanel,
|
|
975
|
+
Composer,
|
|
976
|
+
FRANKIE_CLOUD_URL,
|
|
977
|
+
FrankieApiError,
|
|
978
|
+
FrankieClient,
|
|
979
|
+
FrankieInline,
|
|
980
|
+
FrankieProvider,
|
|
981
|
+
FrankieWidget,
|
|
982
|
+
Markdown,
|
|
983
|
+
MessageBubble,
|
|
984
|
+
ScrollArea,
|
|
985
|
+
Separator,
|
|
986
|
+
SuggestedPrompts,
|
|
987
|
+
Textarea,
|
|
988
|
+
ToolActivityList,
|
|
989
|
+
ToolActivityRow,
|
|
990
|
+
TypingDots,
|
|
991
|
+
applyEvent,
|
|
992
|
+
buttonVariants,
|
|
993
|
+
cn,
|
|
994
|
+
newAssistantMessage,
|
|
995
|
+
newUserMessage,
|
|
996
|
+
readEventStream,
|
|
997
|
+
resolveMode,
|
|
998
|
+
themeToCssVars,
|
|
999
|
+
useFrankie,
|
|
1000
|
+
useFrankieChat,
|
|
1001
|
+
useFrankieTheme
|
|
1002
|
+
};
|
|
1003
|
+
//# sourceMappingURL=index.js.map
|