@frankieai/react 0.1.1 → 0.1.4
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 +198 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +33 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.cts +31 -18
- package/dist/index.d.ts +31 -18
- package/dist/index.js +196 -37
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/ChatPanel.tsx +6 -2
- package/src/components/chat/FrankieChatPage.tsx +53 -0
- package/src/components/chat/MessageBubble.tsx +22 -5
- package/src/components/chat/ToolActivityList.tsx +10 -6
- package/src/components/chat/message-actions.css +13 -0
- package/src/hooks/FrankieProvider.tsx +1 -9
- package/src/hooks/useFrankieChat.ts +60 -15
- package/src/index.ts +2 -0
- package/src/lib/client.ts +82 -17
- package/src/styles/frankie.css +12 -4
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Paperclip } from "lucide-react";
|
|
4
|
-
import
|
|
3
|
+
import { Check, Copy, Paperclip } from "lucide-react";
|
|
4
|
+
import "./message-actions.css";
|
|
5
|
+
import { useState, type ReactNode } from "react";
|
|
5
6
|
import { cn } from "../../lib/utils.js";
|
|
6
7
|
import type { ChatMessage, ToolActivity } from "../../lib/messages.js";
|
|
7
8
|
import { Avatar } from "../ui/avatar.js";
|
|
@@ -40,8 +41,9 @@ export function MessageBubble({
|
|
|
40
41
|
</div>
|
|
41
42
|
) : null}
|
|
42
43
|
{message.text ? (
|
|
43
|
-
<div className="max-w-[85%]
|
|
44
|
-
{message.text}
|
|
44
|
+
<div className="flex max-w-[85%] items-end gap-1">
|
|
45
|
+
<div className="rounded-[var(--frankie-radius)] bg-primary px-3 py-2 text-sm text-primary-foreground">{message.text}</div>
|
|
46
|
+
<MessageCopyButton text={message.text} />
|
|
45
47
|
</div>
|
|
46
48
|
) : null}
|
|
47
49
|
</div>
|
|
@@ -52,7 +54,10 @@ export function MessageBubble({
|
|
|
52
54
|
|
|
53
55
|
return (
|
|
54
56
|
<div className="flex gap-2">
|
|
55
|
-
<
|
|
57
|
+
<div className="frankie-assistant-actions mt-0.5 flex shrink-0 flex-col items-center gap-1">
|
|
58
|
+
<Avatar {...(avatarUrl ? { src: avatarUrl } : {})} name={assistantName} />
|
|
59
|
+
{message.text ? <MessageCopyButton text={message.text} /> : null}
|
|
60
|
+
</div>
|
|
56
61
|
<div className="min-w-0 max-w-[85%]">
|
|
57
62
|
{message.status === "error" ? (
|
|
58
63
|
<p className="rounded-[var(--frankie-radius)] bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-400">
|
|
@@ -71,6 +76,18 @@ export function MessageBubble({
|
|
|
71
76
|
);
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
function MessageCopyButton({ text }: { text: string }) {
|
|
80
|
+
const [copied, setCopied] = useState(false);
|
|
81
|
+
const copy = async () => {
|
|
82
|
+
try {
|
|
83
|
+
await navigator.clipboard.writeText(text);
|
|
84
|
+
setCopied(true);
|
|
85
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
86
|
+
} catch { /* Clipboard access can be disabled by an embedding host. */ }
|
|
87
|
+
};
|
|
88
|
+
return <button type="button" onClick={() => void copy()} className="frankie-message-copy inline-flex size-8 shrink-0 items-center justify-center rounded-md border border-transparent text-muted-foreground transition hover:border-border hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" aria-label={copied ? "Copied" : "Copy message"} title={copied ? "Copied" : "Copy message"}>{copied ? <Check className="size-3.5" aria-hidden="true" /> : <Copy className="size-3.5" aria-hidden="true" />}</button>;
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
export function TypingDots() {
|
|
75
92
|
return (
|
|
76
93
|
<span className="inline-flex items-center gap-1 py-2" role="status" aria-label="Thinking">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Check, Loader2, ShieldAlert, X } from "lucide-react";
|
|
3
|
+
import { Check, ChevronDown, Loader2, ShieldAlert, X } from "lucide-react";
|
|
4
4
|
import { Badge } from "../ui/badge.js";
|
|
5
5
|
import type { ToolActivity } from "../../lib/messages.js";
|
|
6
6
|
|
|
@@ -13,12 +13,16 @@ export interface ToolActivityListProps {
|
|
|
13
13
|
/** The "what the assistant is doing" strip under a streaming answer. */
|
|
14
14
|
export function ToolActivityList({ tools, renderTool }: ToolActivityListProps) {
|
|
15
15
|
if (tools.length === 0) return null;
|
|
16
|
+
const running = tools.some((tool) => tool.status === "running");
|
|
16
17
|
return (
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
<details className="frankie-tool-activity mt-2" open={running}>
|
|
19
|
+
<summary className="inline-flex min-h-7 cursor-pointer list-none items-center gap-1.5 rounded-md px-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground [&::-webkit-details-marker]:hidden">
|
|
20
|
+
<ChevronDown className="frankie-tool-chevron size-3.5 transition-transform" aria-hidden="true" />
|
|
21
|
+
<span>{running ? "Working" : "Activity"}</span>
|
|
22
|
+
<span className="text-[11px]">· {tools.length} tool call{tools.length === 1 ? "" : "s"}</span>
|
|
23
|
+
</summary>
|
|
24
|
+
<ul className="mt-1 flex flex-col gap-1 border-l border-border pl-2">{tools.map((tool) => <li key={tool.id}>{renderTool ? renderTool(tool) : <ToolActivityRow tool={tool} />}</li>)}</ul>
|
|
25
|
+
</details>
|
|
22
26
|
);
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.frankie-root .frankie-message-copy { opacity: .72; }
|
|
2
|
+
.frankie-root .frankie-message-copy:hover { opacity: 1; }
|
|
3
|
+
.frankie-root .frankie-tool-activity[open] .frankie-tool-chevron { transform: rotate(180deg); }
|
|
4
|
+
|
|
5
|
+
.frankie-root .frankie-assistant-actions .frankie-message-copy {
|
|
6
|
+
width: 28px; height: 28px; min-width: 28px; min-height: 28px;
|
|
7
|
+
border: 0; background: transparent; opacity: .46;
|
|
8
|
+
}
|
|
9
|
+
.frankie-root .frankie-assistant-actions .frankie-message-copy:hover,
|
|
10
|
+
.frankie-root .frankie-assistant-actions .frankie-message-copy:focus-visible { opacity: 1; }
|
|
11
|
+
.frankie-root .frankie-tool-activity { max-width: 100%; }
|
|
12
|
+
.frankie-root .frankie-tool-activity[open] > summary { color: var(--frankie-foreground); }
|
|
13
|
+
.frankie-root .frankie-tool-activity li .frankie-badge { max-width: 100%; }
|
|
@@ -6,14 +6,8 @@ import { FrankieClient, type FrankieClientOptions, type PageContext } from "../l
|
|
|
6
6
|
|
|
7
7
|
export interface FrankieProviderProps extends FrankieClientOptions {
|
|
8
8
|
children: ReactNode;
|
|
9
|
-
/**
|
|
10
|
-
* Page context sent with every turn — current route, plan tier, whatever the
|
|
11
|
-
* agent should know. Treated as data by the server, never as instructions.
|
|
12
|
-
*/
|
|
13
9
|
context?: PageContext;
|
|
14
|
-
/** Overrides for the server-side theme, so a host can restyle without a redeploy. */
|
|
15
10
|
theme?: WidgetTheme;
|
|
16
|
-
/** Skip the `/v1/config` fetch by supplying the config yourself. */
|
|
17
11
|
config?: PublicWidgetConfig;
|
|
18
12
|
}
|
|
19
13
|
|
|
@@ -37,9 +31,8 @@ export function FrankieProvider({
|
|
|
37
31
|
}: FrankieProviderProps) {
|
|
38
32
|
const client = useMemo(
|
|
39
33
|
() => new FrankieClient(clientOptions),
|
|
40
|
-
// The client holds session state; rebuild it only when the endpoint changes.
|
|
41
34
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
42
|
-
[clientOptions.baseUrl, clientOptions.publishableKey],
|
|
35
|
+
[clientOptions.baseUrl, clientOptions.publishableKey, clientOptions.storageNamespace, clientOptions.conversationId],
|
|
43
36
|
);
|
|
44
37
|
|
|
45
38
|
const [config, setConfig] = useState<PublicWidgetConfig | undefined>(providedConfig);
|
|
@@ -87,7 +80,6 @@ export function useFrankie(): FrankieContextValue {
|
|
|
87
80
|
return value;
|
|
88
81
|
}
|
|
89
82
|
|
|
90
|
-
/** The theme actually in effect: provider override wins over server config. */
|
|
91
83
|
export function useFrankieTheme(): WidgetTheme | undefined {
|
|
92
84
|
const { config, theme } = useFrankie();
|
|
93
85
|
return useMemo(() => ({ ...config?.theme, ...theme }), [config?.theme, theme]);
|
|
@@ -39,6 +39,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
39
39
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
40
40
|
const [busy, setBusy] = useState(false);
|
|
41
41
|
const [error, setError] = useState<string | undefined>(undefined);
|
|
42
|
+
const [restored, setRestored] = useState(false);
|
|
42
43
|
const abortRef = useRef<AbortController | null>(null);
|
|
43
44
|
const mounted = useRef(true);
|
|
44
45
|
|
|
@@ -52,30 +53,47 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
52
53
|
|
|
53
54
|
const storageKey = `${client.namespace}.messages`;
|
|
54
55
|
|
|
55
|
-
// Restore
|
|
56
|
-
// The
|
|
56
|
+
// Restore local storage immediately, then hydrate the authoritative server
|
|
57
|
+
// transcript. The server path makes the same signed-in user's chats available
|
|
58
|
+
// on another browser or device.
|
|
57
59
|
useEffect(() => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
let active = true;
|
|
61
|
+
const restore = async () => {
|
|
62
|
+
if (client.persist) {
|
|
63
|
+
try {
|
|
64
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
65
|
+
const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
|
|
66
|
+
if (Array.isArray(stored) && stored.length && active) setMessages(stored);
|
|
67
|
+
} catch {
|
|
68
|
+
/* storage unavailable; server history can still restore */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const remote = await client.resumeConversation();
|
|
73
|
+
if (active && remote?.messages.length) setMessages(remote.messages as ChatMessage[]);
|
|
74
|
+
} catch {
|
|
75
|
+
/* A new conversation does not exist remotely until its first message. */
|
|
76
|
+
} finally {
|
|
77
|
+
if (active) setRestored(true);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
void restore();
|
|
81
|
+
return () => { active = false; };
|
|
82
|
+
}, [client, storageKey]);
|
|
68
83
|
|
|
69
84
|
// Persist once a turn settles — never mid-stream — so the saved copy is clean.
|
|
85
|
+
// Wait until the initial restore has completed so an empty initial state never
|
|
86
|
+
// deletes an existing transcript.
|
|
70
87
|
useEffect(() => {
|
|
71
|
-
if (!client.persist || busy) return;
|
|
88
|
+
if (!client.persist || !restored || busy) return;
|
|
72
89
|
try {
|
|
73
90
|
if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
|
|
74
91
|
else window.localStorage.removeItem(storageKey);
|
|
75
92
|
} catch {
|
|
76
93
|
/* ignore */
|
|
77
94
|
}
|
|
78
|
-
|
|
95
|
+
if (messages.length) void client.syncConversation(messages, conversationTitle(messages)).catch(() => undefined);
|
|
96
|
+
}, [messages, busy, client, storageKey, restored]);
|
|
79
97
|
|
|
80
98
|
const patchAssistant = useCallback((id: string, update: (m: AssistantChatMessage) => AssistantChatMessage) => {
|
|
81
99
|
setMessages((current) =>
|
|
@@ -110,6 +128,8 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
110
128
|
abortRef.current = abort;
|
|
111
129
|
|
|
112
130
|
try {
|
|
131
|
+
let receivedText = false;
|
|
132
|
+
let receivedToolActivity = false;
|
|
113
133
|
await client.send(
|
|
114
134
|
{
|
|
115
135
|
message: outgoing,
|
|
@@ -120,9 +140,23 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
120
140
|
},
|
|
121
141
|
(event) => {
|
|
122
142
|
if (event.type === "ping" || event.type === "session") return;
|
|
143
|
+
if (event.type === "text_delta") receivedText = true;
|
|
144
|
+
if (event.type === "tool_start" || event.type === "tool_end") receivedToolActivity = true;
|
|
123
145
|
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
124
146
|
},
|
|
125
147
|
);
|
|
148
|
+
// A terminated stream with no text previously looked like Frankie did
|
|
149
|
+
// nothing. Preserve visible tool activity, but never leave an empty
|
|
150
|
+
// assistant bubble for the user to interpret.
|
|
151
|
+
if (!receivedText) {
|
|
152
|
+
patchAssistant(assistantId, (message) => ({
|
|
153
|
+
...message,
|
|
154
|
+
status: "done",
|
|
155
|
+
text: receivedToolActivity
|
|
156
|
+
? "Completed. Open Agent activity for the details."
|
|
157
|
+
: "Frankie didn’t return an answer. Please try again.",
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
126
160
|
} catch (caught) {
|
|
127
161
|
if (abort.signal.aborted) {
|
|
128
162
|
patchAssistant(assistantId, (message) => ({
|
|
@@ -134,7 +168,9 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
134
168
|
const message =
|
|
135
169
|
caught instanceof FrankieApiError
|
|
136
170
|
? caught.message
|
|
137
|
-
:
|
|
171
|
+
: caught instanceof Error && caught.message.startsWith("Frankie")
|
|
172
|
+
? caught.message
|
|
173
|
+
: "Could not reach the assistant. Check your connection and try again.";
|
|
138
174
|
if (mounted.current) setError(message);
|
|
139
175
|
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
140
176
|
}
|
|
@@ -160,6 +196,7 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
160
196
|
setMessages([]);
|
|
161
197
|
setError(undefined);
|
|
162
198
|
setBusy(false);
|
|
199
|
+
setRestored(true);
|
|
163
200
|
try {
|
|
164
201
|
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
165
202
|
} catch {
|
|
@@ -169,3 +206,11 @@ export function useFrankieChat(): UseFrankieChatResult {
|
|
|
169
206
|
|
|
170
207
|
return { messages, busy, ...(error ? { error } : {}), send, stop, reset };
|
|
171
208
|
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
function conversationTitle(messages: ChatMessage[]): string | undefined {
|
|
212
|
+
const first = messages.find((message) => message.role === "user" && message.text.trim());
|
|
213
|
+
if (!first) return undefined;
|
|
214
|
+
const title = first.text.trim().replace(/\s+/g, " ");
|
|
215
|
+
return title.length > 42 ? `${title.slice(0, 42)}…` : title;
|
|
216
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { FrankieWidget } from "./components/chat/FrankieWidget.js";
|
|
|
7
7
|
export type { FrankieWidgetProps } from "./components/chat/FrankieWidget.js";
|
|
8
8
|
export { FrankieInline } from "./components/chat/FrankieInline.js";
|
|
9
9
|
export type { FrankieInlineProps } from "./components/chat/FrankieInline.js";
|
|
10
|
+
export { FrankieChatPage } from "./components/chat/FrankieChatPage.js";
|
|
11
|
+
export type { FrankieChatPageProps } from "./components/chat/FrankieChatPage.js";
|
|
10
12
|
export { ChatPanel } from "./components/chat/ChatPanel.js";
|
|
11
13
|
export type { ChatPanelProps } from "./components/chat/ChatPanel.js";
|
|
12
14
|
export { Composer } from "./components/chat/Composer.js";
|
package/src/lib/client.ts
CHANGED
|
@@ -32,6 +32,17 @@ export interface FrankieClientOptions {
|
|
|
32
32
|
* set false for an ephemeral chat that starts fresh every load.
|
|
33
33
|
*/
|
|
34
34
|
persist?: boolean;
|
|
35
|
+
/** Override localStorage namespace for session and transcript. */
|
|
36
|
+
storageNamespace?: string;
|
|
37
|
+
/** Stable id for a server-backed conversation. */
|
|
38
|
+
conversationId?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChatConversation {
|
|
42
|
+
id: string;
|
|
43
|
+
title: string;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
updatedAt: number;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
export class FrankieApiError extends Error {
|
|
@@ -59,14 +70,18 @@ export class FrankieClient {
|
|
|
59
70
|
return this.session?.sessionId;
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
get conversationId(): string | undefined {
|
|
74
|
+
return this.options.conversationId;
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
/** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
|
|
63
78
|
get persist(): boolean {
|
|
64
79
|
return this.options.persist !== false && typeof window !== "undefined";
|
|
65
80
|
}
|
|
66
81
|
|
|
67
|
-
/** localStorage namespace for this tenant, shared with the chat hook. */
|
|
82
|
+
/** localStorage namespace for this tenant/conversation, shared with the chat hook. */
|
|
68
83
|
get namespace(): string {
|
|
69
|
-
return `frankie.${this.options.publishableKey}`;
|
|
84
|
+
return this.options.storageNamespace ?? `frankie.${this.options.publishableKey}`;
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
private readStored(): CreateSessionResponse | undefined {
|
|
@@ -75,7 +90,6 @@ export class FrankieClient {
|
|
|
75
90
|
const raw = window.localStorage.getItem(`${this.namespace}.session`);
|
|
76
91
|
if (!raw) return undefined;
|
|
77
92
|
const session = JSON.parse(raw) as CreateSessionResponse;
|
|
78
|
-
// A stored session past its expiry is useless; drop it.
|
|
79
93
|
return new Date(session.expiresAt).getTime() > Date.now() + 30_000 ? session : undefined;
|
|
80
94
|
} catch {
|
|
81
95
|
return undefined;
|
|
@@ -100,39 +114,92 @@ export class FrankieClient {
|
|
|
100
114
|
return (await this.unwrap(response)) as PublicWidgetConfig;
|
|
101
115
|
}
|
|
102
116
|
|
|
103
|
-
/** Opens (or reuses) a chat session. */
|
|
104
117
|
async ensureSession(context?: PageContext): Promise<CreateSessionResponse> {
|
|
105
|
-
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000)
|
|
106
|
-
return this.session;
|
|
107
|
-
}
|
|
118
|
+
if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) return this.session;
|
|
108
119
|
const userToken = await this.options.getUserToken?.();
|
|
120
|
+
if (this.options.getUserToken && !userToken) {
|
|
121
|
+
throw new FrankieApiError(
|
|
122
|
+
"Your signed-in Frankie session could not be verified. Refresh the page and sign in again.",
|
|
123
|
+
401,
|
|
124
|
+
"user_token_unavailable",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
109
127
|
const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
|
|
110
128
|
method: "POST",
|
|
111
129
|
headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
|
|
112
|
-
body: JSON.stringify({
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
...(userToken ? { userToken } : {}),
|
|
132
|
+
...(context ? { context } : {}),
|
|
133
|
+
...(this.options.conversationId ? { conversationId: this.options.conversationId } : {}),
|
|
134
|
+
}),
|
|
113
135
|
});
|
|
114
136
|
this.session = (await this.unwrap(response)) as CreateSessionResponse;
|
|
115
137
|
this.writeStored();
|
|
116
138
|
return this.session;
|
|
117
139
|
}
|
|
118
140
|
|
|
119
|
-
|
|
141
|
+
async listConversations(): Promise<ChatConversation[]> {
|
|
142
|
+
const userToken = await this.requireUserToken();
|
|
143
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations`, {
|
|
144
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
|
|
145
|
+
});
|
|
146
|
+
const body = (await this.unwrap(response)) as { conversations?: ChatConversation[] };
|
|
147
|
+
return Array.isArray(body.conversations) ? body.conversations : [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async resumeConversation(id = this.options.conversationId): Promise<{ messages: unknown[]; title: string } | undefined> {
|
|
151
|
+
if (!id) return undefined;
|
|
152
|
+
const userToken = await this.requireUserToken();
|
|
153
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}/resume`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
|
|
156
|
+
});
|
|
157
|
+
if (response.status === 404) return undefined;
|
|
158
|
+
const body = (await this.unwrap(response)) as { session: CreateSessionResponse; messages?: unknown[]; title?: string };
|
|
159
|
+
this.session = body.session;
|
|
160
|
+
this.writeStored();
|
|
161
|
+
return { messages: Array.isArray(body.messages) ? body.messages : [], title: body.title ?? "New chat" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async syncConversation(messages: unknown[], title?: string): Promise<void> {
|
|
165
|
+
if (!this.session || !this.options.conversationId) return;
|
|
166
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(this.options.conversationId)}`, {
|
|
167
|
+
method: "PUT",
|
|
168
|
+
headers: {
|
|
169
|
+
"content-type": "application/json",
|
|
170
|
+
"x-frankie-key": this.options.publishableKey,
|
|
171
|
+
authorization: `Bearer ${this.session.sessionToken}`,
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify({ messages, ...(title ? { title } : {}) }),
|
|
174
|
+
});
|
|
175
|
+
await this.unwrap(response);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async deleteConversation(id: string): Promise<void> {
|
|
179
|
+
const userToken = await this.requireUserToken();
|
|
180
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/conversations/${encodeURIComponent(id)}`, {
|
|
181
|
+
method: "DELETE",
|
|
182
|
+
headers: { "x-frankie-key": this.options.publishableKey, authorization: `Bearer ${userToken}` },
|
|
183
|
+
});
|
|
184
|
+
if (response.status !== 404) await this.unwrap(response);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async requireUserToken(): Promise<string> {
|
|
188
|
+
const token = await this.options.getUserToken?.();
|
|
189
|
+
if (!token) throw new FrankieApiError("Sign in to access your chat history.", 401, "unauthorized");
|
|
190
|
+
return token;
|
|
191
|
+
}
|
|
192
|
+
|
|
120
193
|
resetSession(): void {
|
|
121
194
|
this.session = undefined;
|
|
122
195
|
this.writeStored();
|
|
123
196
|
}
|
|
124
197
|
|
|
125
|
-
/**
|
|
126
|
-
* Sends a message and invokes `onEvent` for each server event as it arrives.
|
|
127
|
-
* Resolves when the turn ends.
|
|
128
|
-
*/
|
|
129
198
|
async send(
|
|
130
199
|
input: {
|
|
131
200
|
message: string;
|
|
132
201
|
context?: PageContext;
|
|
133
|
-
/** Model id chosen by the user, honoured only if the workspace allows it. */
|
|
134
202
|
model?: string;
|
|
135
|
-
/** Files attached to this turn. */
|
|
136
203
|
attachments?: ChatAttachment[];
|
|
137
204
|
signal?: AbortSignal;
|
|
138
205
|
},
|
|
@@ -159,7 +226,6 @@ export class FrankieClient {
|
|
|
159
226
|
});
|
|
160
227
|
|
|
161
228
|
if (!response.ok || !response.body) {
|
|
162
|
-
// A dropped session is recoverable: clear it so the next send re-auths.
|
|
163
229
|
if (response.status === 401 || response.status === 404) this.resetSession();
|
|
164
230
|
await this.unwrap(response);
|
|
165
231
|
return;
|
|
@@ -185,7 +251,6 @@ export class FrankieClient {
|
|
|
185
251
|
}
|
|
186
252
|
}
|
|
187
253
|
|
|
188
|
-
/** Parse the server's `data:`-only SSE stream into typed events. */
|
|
189
254
|
export async function* readEventStream(
|
|
190
255
|
body: ReadableStream<Uint8Array>,
|
|
191
256
|
): AsyncGenerator<FrankieServerEvent> {
|
package/src/styles/frankie.css
CHANGED
|
@@ -7,15 +7,23 @@
|
|
|
7
7
|
* a CSS variable scoped to `.frankie-root`, so a host can restyle the widget
|
|
8
8
|
* without touching this file.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* widget then compiles against their design tokens.
|
|
10
|
+
* This stylesheet is self-contained. Widget utilities are compiled ahead of
|
|
11
|
+
* time and prefixed with `.frankie-root`, so they cannot style the host app.
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
@layer theme, components, utilities;
|
|
16
15
|
|
|
17
16
|
@import "tailwindcss/theme.css" layer(theme);
|
|
18
|
-
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
* The widget is embedded inside arbitrary applications. Native CSS scoping
|
|
20
|
+
* keeps Tailwind's generated utilities inside the widget instead of relying on
|
|
21
|
+
* Tailwind's invalid selector-important import syntax (which made the whole
|
|
22
|
+
* utility layer disappear in production).
|
|
23
|
+
*/
|
|
24
|
+
@scope (.frankie-root) {
|
|
25
|
+
@tailwind utilities;
|
|
26
|
+
}
|
|
19
27
|
|
|
20
28
|
@source "../";
|
|
21
29
|
|