@frankieai/react 0.1.0 → 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/src/index.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  export { FrankieProvider, useFrankie, useFrankieTheme } from "./hooks/FrankieProvider.jsx";
2
2
  export type { FrankieProviderProps } from "./hooks/FrankieProvider.jsx";
3
3
  export { useFrankieChat } from "./hooks/useFrankieChat.js";
4
- export type { UseFrankieChatResult } from "./hooks/useFrankieChat.js";
4
+ export type { UseFrankieChatResult, SendOptions } from "./hooks/useFrankieChat.js";
5
5
 
6
6
  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";
@@ -27,7 +29,7 @@ export { Textarea } from "./components/ui/textarea.js";
27
29
  export { FrankieClient, FrankieApiError, readEventStream, FRANKIE_CLOUD_URL } from "./lib/client.js";
28
30
  export type { FrankieClientOptions, PageContext } from "./lib/client.js";
29
31
  export { applyEvent, newAssistantMessage, newUserMessage } from "./lib/messages.js";
30
- export type { AssistantChatMessage, ChatMessage, ToolActivity, UserChatMessage } from "./lib/messages.js";
32
+ export type { AssistantChatMessage, ChatMessage, MessageAttachment, ToolActivity, UserChatMessage } from "./lib/messages.js";
31
33
  export { resolveMode, themeToCssVars } from "./lib/theme.js";
32
34
  export { cn } from "./lib/utils.js";
33
35
 
package/src/lib/client.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ChatAttachment,
2
3
  ChatRequestBody,
3
4
  CreateSessionResponse,
4
5
  FrankieServerEvent,
@@ -31,6 +32,17 @@ export interface FrankieClientOptions {
31
32
  * set false for an ephemeral chat that starts fresh every load.
32
33
  */
33
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;
34
46
  }
35
47
 
36
48
  export class FrankieApiError extends Error {
@@ -58,14 +70,18 @@ export class FrankieClient {
58
70
  return this.session?.sessionId;
59
71
  }
60
72
 
73
+ get conversationId(): string | undefined {
74
+ return this.options.conversationId;
75
+ }
76
+
61
77
  /** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
62
78
  get persist(): boolean {
63
79
  return this.options.persist !== false && typeof window !== "undefined";
64
80
  }
65
81
 
66
- /** localStorage namespace for this tenant, shared with the chat hook. */
82
+ /** localStorage namespace for this tenant/conversation, shared with the chat hook. */
67
83
  get namespace(): string {
68
- return `frankie.${this.options.publishableKey}`;
84
+ return this.options.storageNamespace ?? `frankie.${this.options.publishableKey}`;
69
85
  }
70
86
 
71
87
  private readStored(): CreateSessionResponse | undefined {
@@ -74,7 +90,6 @@ export class FrankieClient {
74
90
  const raw = window.localStorage.getItem(`${this.namespace}.session`);
75
91
  if (!raw) return undefined;
76
92
  const session = JSON.parse(raw) as CreateSessionResponse;
77
- // A stored session past its expiry is useless; drop it.
78
93
  return new Date(session.expiresAt).getTime() > Date.now() + 30_000 ? session : undefined;
79
94
  } catch {
80
95
  return undefined;
@@ -99,34 +114,95 @@ export class FrankieClient {
99
114
  return (await this.unwrap(response)) as PublicWidgetConfig;
100
115
  }
101
116
 
102
- /** Opens (or reuses) a chat session. */
103
117
  async ensureSession(context?: PageContext): Promise<CreateSessionResponse> {
104
- if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) {
105
- return this.session;
106
- }
118
+ if (this.session && new Date(this.session.expiresAt).getTime() > Date.now() + 30_000) return this.session;
107
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
+ }
108
127
  const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
109
128
  method: "POST",
110
129
  headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
111
- body: JSON.stringify({ ...(userToken ? { userToken } : {}), ...(context ? { context } : {}) }),
130
+ body: JSON.stringify({
131
+ ...(userToken ? { userToken } : {}),
132
+ ...(context ? { context } : {}),
133
+ ...(this.options.conversationId ? { conversationId: this.options.conversationId } : {}),
134
+ }),
112
135
  });
113
136
  this.session = (await this.unwrap(response)) as CreateSessionResponse;
114
137
  this.writeStored();
115
138
  return this.session;
116
139
  }
117
140
 
118
- /** Forget the current session so the next send starts a fresh conversation. */
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
+
119
193
  resetSession(): void {
120
194
  this.session = undefined;
121
195
  this.writeStored();
122
196
  }
123
197
 
124
- /**
125
- * Sends a message and invokes `onEvent` for each server event as it arrives.
126
- * Resolves when the turn ends.
127
- */
128
198
  async send(
129
- input: { message: string; context?: PageContext; signal?: AbortSignal },
199
+ input: {
200
+ message: string;
201
+ context?: PageContext;
202
+ model?: string;
203
+ attachments?: ChatAttachment[];
204
+ signal?: AbortSignal;
205
+ },
130
206
  onEvent: (event: FrankieServerEvent) => void,
131
207
  ): Promise<void> {
132
208
  const session = await this.ensureSession(input.context);
@@ -134,6 +210,8 @@ export class FrankieClient {
134
210
  sessionId: session.sessionId,
135
211
  message: input.message,
136
212
  ...(input.context ? { context: input.context } : {}),
213
+ ...(input.model ? { model: input.model } : {}),
214
+ ...(input.attachments?.length ? { attachments: input.attachments } : {}),
137
215
  };
138
216
 
139
217
  const response = await this.doFetch(`${this.baseUrl}/v1/chat`, {
@@ -148,7 +226,6 @@ export class FrankieClient {
148
226
  });
149
227
 
150
228
  if (!response.ok || !response.body) {
151
- // A dropped session is recoverable: clear it so the next send re-auths.
152
229
  if (response.status === 401 || response.status === 404) this.resetSession();
153
230
  await this.unwrap(response);
154
231
  return;
@@ -174,7 +251,6 @@ export class FrankieClient {
174
251
  }
175
252
  }
176
253
 
177
- /** Parse the server's `data:`-only SSE stream into typed events. */
178
254
  export async function* readEventStream(
179
255
  body: ReadableStream<Uint8Array>,
180
256
  ): AsyncGenerator<FrankieServerEvent> {
@@ -8,10 +8,17 @@ export interface ToolActivity {
8
8
  detail?: string;
9
9
  }
10
10
 
11
+ /** A file shown on a user message (name/type only — the bytes aren't kept in the transcript). */
12
+ export interface MessageAttachment {
13
+ name: string;
14
+ mimeType: string;
15
+ }
16
+
11
17
  export interface UserChatMessage {
12
18
  id: string;
13
19
  role: "user";
14
20
  text: string;
21
+ attachments?: MessageAttachment[];
15
22
  createdAt: number;
16
23
  }
17
24
 
@@ -116,6 +123,6 @@ export function newAssistantMessage(id: string): AssistantChatMessage {
116
123
  return { id, role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() };
117
124
  }
118
125
 
119
- export function newUserMessage(id: string, text: string): UserChatMessage {
120
- return { id, role: "user", text, createdAt: Date.now() };
126
+ export function newUserMessage(id: string, text: string, attachments?: MessageAttachment[]): UserChatMessage {
127
+ return { id, role: "user", text, ...(attachments?.length ? { attachments } : {}), createdAt: Date.now() };
121
128
  }
@@ -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
- * Hosts that already run Tailwind v4 can skip this file entirely and instead
11
- * add `@source "../node_modules/@frankieai/react/dist";` to their own CSS: the
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
- @import "tailwindcss/utilities.css" layer(utilities) source(none);
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
 
@@ -16,6 +16,10 @@ const config: PublicWidgetConfig = {
16
16
  theme: { mode: "light" },
17
17
  allowAnonymous: true,
18
18
  showToolActivity: true,
19
+ features: {
20
+ attachments: { enabled: false, accept: ["image/*"], maxMb: 10, maxCount: 4 },
21
+ modelPicker: { enabled: false, models: [] },
22
+ },
19
23
  };
20
24
 
21
25
  function sseStream(events: FrankieServerEvent[]): ReadableStream<Uint8Array> {