@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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { FrankieApiError } from "../lib/client.js";
|
|
5
|
+
import {
|
|
6
|
+
applyEvent,
|
|
7
|
+
newAssistantMessage,
|
|
8
|
+
newUserMessage,
|
|
9
|
+
type AssistantChatMessage,
|
|
10
|
+
type ChatMessage,
|
|
11
|
+
} from "../lib/messages.js";
|
|
12
|
+
import { useFrankie } from "./FrankieProvider.jsx";
|
|
13
|
+
|
|
14
|
+
export interface UseFrankieChatResult {
|
|
15
|
+
messages: ChatMessage[];
|
|
16
|
+
/** True from the moment a message is sent until the turn ends. */
|
|
17
|
+
busy: boolean;
|
|
18
|
+
/** Set when the turn failed outright (network, auth, rate limit). */
|
|
19
|
+
error?: string;
|
|
20
|
+
send: (text: string) => Promise<void>;
|
|
21
|
+
/** Abort the in-flight turn. The partial answer stays in the transcript. */
|
|
22
|
+
stop: () => void;
|
|
23
|
+
/** Clear the transcript and start a brand-new server session. */
|
|
24
|
+
reset: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let counter = 0;
|
|
28
|
+
const nextId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${(counter += 1)}`;
|
|
29
|
+
|
|
30
|
+
export function useFrankieChat(): UseFrankieChatResult {
|
|
31
|
+
const { client, context } = useFrankie();
|
|
32
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
33
|
+
const [busy, setBusy] = useState(false);
|
|
34
|
+
const [error, setError] = useState<string | undefined>(undefined);
|
|
35
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
36
|
+
const mounted = useRef(true);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
mounted.current = true;
|
|
40
|
+
return () => {
|
|
41
|
+
mounted.current = false;
|
|
42
|
+
abortRef.current?.abort();
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const storageKey = `${client.namespace}.messages`;
|
|
47
|
+
|
|
48
|
+
// Restore the transcript on mount, so a page reload keeps the conversation.
|
|
49
|
+
// The client resumes the matching server session, so context is kept too.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!client.persist) return;
|
|
52
|
+
try {
|
|
53
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
54
|
+
const stored = raw ? (JSON.parse(raw) as ChatMessage[]) : [];
|
|
55
|
+
if (Array.isArray(stored) && stored.length) setMessages(stored);
|
|
56
|
+
} catch {
|
|
57
|
+
/* storage unavailable; start empty */
|
|
58
|
+
}
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
}, [storageKey]);
|
|
61
|
+
|
|
62
|
+
// Persist once a turn settles — never mid-stream — so the saved copy is clean.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!client.persist || busy) return;
|
|
65
|
+
try {
|
|
66
|
+
if (messages.length) window.localStorage.setItem(storageKey, JSON.stringify(messages));
|
|
67
|
+
else window.localStorage.removeItem(storageKey);
|
|
68
|
+
} catch {
|
|
69
|
+
/* ignore */
|
|
70
|
+
}
|
|
71
|
+
}, [messages, busy, client, storageKey]);
|
|
72
|
+
|
|
73
|
+
const patchAssistant = useCallback((id: string, update: (m: AssistantChatMessage) => AssistantChatMessage) => {
|
|
74
|
+
setMessages((current) =>
|
|
75
|
+
current.map((message) =>
|
|
76
|
+
message.id === id && message.role === "assistant" ? update(message) : message,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const send = useCallback(
|
|
82
|
+
async (text: string) => {
|
|
83
|
+
const trimmed = text.trim();
|
|
84
|
+
if (!trimmed || busy) return;
|
|
85
|
+
|
|
86
|
+
const assistantId = nextId("a");
|
|
87
|
+
setError(undefined);
|
|
88
|
+
setBusy(true);
|
|
89
|
+
setMessages((current) => [
|
|
90
|
+
...current,
|
|
91
|
+
newUserMessage(nextId("u"), trimmed),
|
|
92
|
+
newAssistantMessage(assistantId),
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const abort = new AbortController();
|
|
96
|
+
abortRef.current = abort;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await client.send({ message: trimmed, ...(context ? { context } : {}), signal: abort.signal }, (event) => {
|
|
100
|
+
if (event.type === "ping" || event.type === "session") return;
|
|
101
|
+
patchAssistant(assistantId, (message) => applyEvent(message, event));
|
|
102
|
+
});
|
|
103
|
+
} catch (caught) {
|
|
104
|
+
if (abort.signal.aborted) {
|
|
105
|
+
patchAssistant(assistantId, (message) => ({
|
|
106
|
+
...message,
|
|
107
|
+
status: "done",
|
|
108
|
+
text: message.text || "Stopped.",
|
|
109
|
+
}));
|
|
110
|
+
} else {
|
|
111
|
+
const message =
|
|
112
|
+
caught instanceof FrankieApiError
|
|
113
|
+
? caught.message
|
|
114
|
+
: "Could not reach the assistant. Check your connection and try again.";
|
|
115
|
+
if (mounted.current) setError(message);
|
|
116
|
+
patchAssistant(assistantId, (current) => ({ ...current, status: "error", error: message }));
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
if (mounted.current) {
|
|
120
|
+
setBusy(false);
|
|
121
|
+
// A turn that ended without a `turn_end` event still has to settle.
|
|
122
|
+
patchAssistant(assistantId, (message) =>
|
|
123
|
+
message.status === "streaming" ? { ...message, status: "done" } : message,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
abortRef.current = null;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
[busy, client, context, patchAssistant],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const stop = useCallback(() => abortRef.current?.abort(), []);
|
|
133
|
+
|
|
134
|
+
const reset = useCallback(() => {
|
|
135
|
+
abortRef.current?.abort();
|
|
136
|
+
client.resetSession();
|
|
137
|
+
setMessages([]);
|
|
138
|
+
setError(undefined);
|
|
139
|
+
setBusy(false);
|
|
140
|
+
try {
|
|
141
|
+
if (client.persist) window.localStorage.removeItem(`${client.namespace}.messages`);
|
|
142
|
+
} catch {
|
|
143
|
+
/* ignore */
|
|
144
|
+
}
|
|
145
|
+
}, [client]);
|
|
146
|
+
|
|
147
|
+
return { messages, busy, ...(error ? { error } : {}), send, stop, reset };
|
|
148
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export { FrankieProvider, useFrankie, useFrankieTheme } from "./hooks/FrankieProvider.jsx";
|
|
2
|
+
export type { FrankieProviderProps } from "./hooks/FrankieProvider.jsx";
|
|
3
|
+
export { useFrankieChat } from "./hooks/useFrankieChat.js";
|
|
4
|
+
export type { UseFrankieChatResult } from "./hooks/useFrankieChat.js";
|
|
5
|
+
|
|
6
|
+
export { FrankieWidget } from "./components/chat/FrankieWidget.js";
|
|
7
|
+
export type { FrankieWidgetProps } from "./components/chat/FrankieWidget.js";
|
|
8
|
+
export { FrankieInline } from "./components/chat/FrankieInline.js";
|
|
9
|
+
export type { FrankieInlineProps } from "./components/chat/FrankieInline.js";
|
|
10
|
+
export { ChatPanel } from "./components/chat/ChatPanel.js";
|
|
11
|
+
export type { ChatPanelProps } from "./components/chat/ChatPanel.js";
|
|
12
|
+
export { Composer } from "./components/chat/Composer.js";
|
|
13
|
+
export { MessageBubble, TypingDots } from "./components/chat/MessageBubble.js";
|
|
14
|
+
export { Markdown } from "./components/chat/Markdown.js";
|
|
15
|
+
export { SuggestedPrompts } from "./components/chat/SuggestedPrompts.js";
|
|
16
|
+
export { ToolActivityList, ToolActivityRow } from "./components/chat/ToolActivityList.js";
|
|
17
|
+
|
|
18
|
+
export { Button, buttonVariants } from "./components/ui/button.js";
|
|
19
|
+
export type { ButtonProps } from "./components/ui/button.js";
|
|
20
|
+
export { Badge } from "./components/ui/badge.js";
|
|
21
|
+
export { Avatar } from "./components/ui/avatar.js";
|
|
22
|
+
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./components/ui/card.js";
|
|
23
|
+
export { ScrollArea } from "./components/ui/scroll-area.js";
|
|
24
|
+
export { Separator } from "./components/ui/separator.js";
|
|
25
|
+
export { Textarea } from "./components/ui/textarea.js";
|
|
26
|
+
|
|
27
|
+
export { FrankieClient, FrankieApiError, readEventStream, FRANKIE_CLOUD_URL } from "./lib/client.js";
|
|
28
|
+
export type { FrankieClientOptions, PageContext } from "./lib/client.js";
|
|
29
|
+
export { applyEvent, newAssistantMessage, newUserMessage } from "./lib/messages.js";
|
|
30
|
+
export type { AssistantChatMessage, ChatMessage, ToolActivity, UserChatMessage } from "./lib/messages.js";
|
|
31
|
+
export { resolveMode, themeToCssVars } from "./lib/theme.js";
|
|
32
|
+
export { cn } from "./lib/utils.js";
|
|
33
|
+
|
|
34
|
+
export type { PublicWidgetConfig, WidgetTheme, FrankieServerEvent } from "@frankieai/core";
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChatRequestBody,
|
|
3
|
+
CreateSessionResponse,
|
|
4
|
+
FrankieServerEvent,
|
|
5
|
+
PublicWidgetConfig,
|
|
6
|
+
} from "@frankieai/core";
|
|
7
|
+
|
|
8
|
+
export type PageContext = Record<string, string | number | boolean | null>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The hosted Frankie data plane. Embedding needs only a publishable key, so
|
|
12
|
+
* `baseUrl` defaults here — override it only when self-hosting the server.
|
|
13
|
+
*/
|
|
14
|
+
export const FRANKIE_CLOUD_URL = "https://frankie-data-gq5o5syq2q-uc.a.run.app";
|
|
15
|
+
|
|
16
|
+
export interface FrankieClientOptions {
|
|
17
|
+
/** Where the Frankie server is mounted. Defaults to the hosted cloud. */
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
/** Safe to ship to the browser; the origin allowlist is what authorises it. */
|
|
20
|
+
publishableKey: string;
|
|
21
|
+
/**
|
|
22
|
+
* Fetches a short-lived JWT for the signed-in user from the host's own
|
|
23
|
+
* backend. Called once per session, and again when a session expires.
|
|
24
|
+
* Omit only for tenants that allow anonymous chat.
|
|
25
|
+
*/
|
|
26
|
+
getUserToken?: () => Promise<string | undefined> | string | undefined;
|
|
27
|
+
fetchImpl?: typeof fetch;
|
|
28
|
+
/**
|
|
29
|
+
* Remember the session (and, in the hook, the transcript) in localStorage so a
|
|
30
|
+
* page reload resumes the same conversation. On by default in the browser;
|
|
31
|
+
* set false for an ephemeral chat that starts fresh every load.
|
|
32
|
+
*/
|
|
33
|
+
persist?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class FrankieApiError extends Error {
|
|
37
|
+
constructor(
|
|
38
|
+
message: string,
|
|
39
|
+
readonly status: number,
|
|
40
|
+
readonly code: string,
|
|
41
|
+
) {
|
|
42
|
+
super(message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class FrankieClient {
|
|
47
|
+
private readonly baseUrl: string;
|
|
48
|
+
private readonly doFetch: typeof fetch;
|
|
49
|
+
private session?: CreateSessionResponse;
|
|
50
|
+
|
|
51
|
+
constructor(private readonly options: FrankieClientOptions) {
|
|
52
|
+
this.baseUrl = (options.baseUrl ?? FRANKIE_CLOUD_URL).replace(/\/+$/, "");
|
|
53
|
+
this.doFetch = options.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
54
|
+
this.session = this.readStored();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get sessionId(): string | undefined {
|
|
58
|
+
return this.session?.sessionId;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether this client persists across reloads (browser only, opt-out via `persist:false`). */
|
|
62
|
+
get persist(): boolean {
|
|
63
|
+
return this.options.persist !== false && typeof window !== "undefined";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** localStorage namespace for this tenant, shared with the chat hook. */
|
|
67
|
+
get namespace(): string {
|
|
68
|
+
return `frankie.${this.options.publishableKey}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private readStored(): CreateSessionResponse | undefined {
|
|
72
|
+
if (!this.persist) return undefined;
|
|
73
|
+
try {
|
|
74
|
+
const raw = window.localStorage.getItem(`${this.namespace}.session`);
|
|
75
|
+
if (!raw) return undefined;
|
|
76
|
+
const session = JSON.parse(raw) as CreateSessionResponse;
|
|
77
|
+
// A stored session past its expiry is useless; drop it.
|
|
78
|
+
return new Date(session.expiresAt).getTime() > Date.now() + 30_000 ? session : undefined;
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private writeStored(): void {
|
|
85
|
+
if (!this.persist) return;
|
|
86
|
+
try {
|
|
87
|
+
if (this.session) window.localStorage.setItem(`${this.namespace}.session`, JSON.stringify(this.session));
|
|
88
|
+
else window.localStorage.removeItem(`${this.namespace}.session`);
|
|
89
|
+
} catch {
|
|
90
|
+
/* storage may be unavailable (private mode); the chat still works in memory */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async getConfig(signal?: AbortSignal): Promise<PublicWidgetConfig> {
|
|
95
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/config`, {
|
|
96
|
+
headers: { "x-frankie-key": this.options.publishableKey },
|
|
97
|
+
...(signal ? { signal } : {}),
|
|
98
|
+
});
|
|
99
|
+
return (await this.unwrap(response)) as PublicWidgetConfig;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Opens (or reuses) a chat session. */
|
|
103
|
+
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
|
+
}
|
|
107
|
+
const userToken = await this.options.getUserToken?.();
|
|
108
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/session`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "content-type": "application/json", "x-frankie-key": this.options.publishableKey },
|
|
111
|
+
body: JSON.stringify({ ...(userToken ? { userToken } : {}), ...(context ? { context } : {}) }),
|
|
112
|
+
});
|
|
113
|
+
this.session = (await this.unwrap(response)) as CreateSessionResponse;
|
|
114
|
+
this.writeStored();
|
|
115
|
+
return this.session;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Forget the current session so the next send starts a fresh conversation. */
|
|
119
|
+
resetSession(): void {
|
|
120
|
+
this.session = undefined;
|
|
121
|
+
this.writeStored();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Sends a message and invokes `onEvent` for each server event as it arrives.
|
|
126
|
+
* Resolves when the turn ends.
|
|
127
|
+
*/
|
|
128
|
+
async send(
|
|
129
|
+
input: { message: string; context?: PageContext; signal?: AbortSignal },
|
|
130
|
+
onEvent: (event: FrankieServerEvent) => void,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const session = await this.ensureSession(input.context);
|
|
133
|
+
const body: ChatRequestBody = {
|
|
134
|
+
sessionId: session.sessionId,
|
|
135
|
+
message: input.message,
|
|
136
|
+
...(input.context ? { context: input.context } : {}),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const response = await this.doFetch(`${this.baseUrl}/v1/chat`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
"content-type": "application/json",
|
|
143
|
+
"x-frankie-key": this.options.publishableKey,
|
|
144
|
+
authorization: `Bearer ${session.sessionToken}`,
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify(body),
|
|
147
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (!response.ok || !response.body) {
|
|
151
|
+
// A dropped session is recoverable: clear it so the next send re-auths.
|
|
152
|
+
if (response.status === 401 || response.status === 404) this.resetSession();
|
|
153
|
+
await this.unwrap(response);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for await (const event of readEventStream(response.body)) {
|
|
158
|
+
onEvent(event);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async unwrap(response: Response): Promise<unknown> {
|
|
163
|
+
if (response.ok) return response.json();
|
|
164
|
+
let code = "internal";
|
|
165
|
+
let message = `Request failed with status ${response.status}.`;
|
|
166
|
+
try {
|
|
167
|
+
const body = (await response.json()) as { error?: { code?: string; message?: string } };
|
|
168
|
+
code = body.error?.code ?? code;
|
|
169
|
+
message = body.error?.message ?? message;
|
|
170
|
+
} catch {
|
|
171
|
+
/* keep the default message */
|
|
172
|
+
}
|
|
173
|
+
throw new FrankieApiError(message, response.status, code);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Parse the server's `data:`-only SSE stream into typed events. */
|
|
178
|
+
export async function* readEventStream(
|
|
179
|
+
body: ReadableStream<Uint8Array>,
|
|
180
|
+
): AsyncGenerator<FrankieServerEvent> {
|
|
181
|
+
const reader = body.getReader();
|
|
182
|
+
const decoder = new TextDecoder();
|
|
183
|
+
let buffer = "";
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
for (;;) {
|
|
187
|
+
const { done, value } = await reader.read();
|
|
188
|
+
if (done) break;
|
|
189
|
+
buffer += decoder.decode(value, { stream: true });
|
|
190
|
+
|
|
191
|
+
let split = buffer.indexOf("\n\n");
|
|
192
|
+
while (split !== -1) {
|
|
193
|
+
const frame = buffer.slice(0, split);
|
|
194
|
+
buffer = buffer.slice(split + 2);
|
|
195
|
+
const event = parseFrame(frame);
|
|
196
|
+
if (event) yield event;
|
|
197
|
+
split = buffer.indexOf("\n\n");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} finally {
|
|
201
|
+
reader.releaseLock();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function parseFrame(frame: string): FrankieServerEvent | null {
|
|
206
|
+
const data = frame
|
|
207
|
+
.split("\n")
|
|
208
|
+
.filter((line) => line.startsWith("data:"))
|
|
209
|
+
.map((line) => line.slice(5).replace(/^ /, ""))
|
|
210
|
+
.join("\n");
|
|
211
|
+
if (!data) return null;
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(data) as FrankieServerEvent;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { FrankieServerEvent } from "@frankieai/core";
|
|
2
|
+
|
|
3
|
+
export interface ToolActivity {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
label: string;
|
|
7
|
+
status: "running" | "done" | "failed" | "denied";
|
|
8
|
+
detail?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UserChatMessage {
|
|
12
|
+
id: string;
|
|
13
|
+
role: "user";
|
|
14
|
+
text: string;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AssistantChatMessage {
|
|
19
|
+
id: string;
|
|
20
|
+
role: "assistant";
|
|
21
|
+
text: string;
|
|
22
|
+
thinking: string;
|
|
23
|
+
tools: ToolActivity[];
|
|
24
|
+
status: "streaming" | "done" | "error";
|
|
25
|
+
error?: string;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ChatMessage = UserChatMessage | AssistantChatMessage;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fold one server event into the assistant message being built.
|
|
33
|
+
*
|
|
34
|
+
* Kept pure and separate from React so the streaming logic is testable without
|
|
35
|
+
* a DOM, and so a host writing its own UI can reuse it.
|
|
36
|
+
*/
|
|
37
|
+
export function applyEvent(message: AssistantChatMessage, event: FrankieServerEvent): AssistantChatMessage {
|
|
38
|
+
switch (event.type) {
|
|
39
|
+
case "text_delta":
|
|
40
|
+
return { ...message, text: message.text + event.text };
|
|
41
|
+
|
|
42
|
+
case "thinking_delta":
|
|
43
|
+
return { ...message, thinking: message.thinking + event.text };
|
|
44
|
+
|
|
45
|
+
case "tool_start":
|
|
46
|
+
return {
|
|
47
|
+
...message,
|
|
48
|
+
tools: [
|
|
49
|
+
...message.tools,
|
|
50
|
+
{ id: event.toolUseId, name: event.name, label: event.label, status: "running" },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
case "tool_end":
|
|
55
|
+
return {
|
|
56
|
+
...message,
|
|
57
|
+
tools: message.tools.map((tool) =>
|
|
58
|
+
tool.id === event.toolUseId
|
|
59
|
+
? {
|
|
60
|
+
...tool,
|
|
61
|
+
status: event.ok ? "done" : "failed",
|
|
62
|
+
...(event.preview ? { detail: event.preview } : {}),
|
|
63
|
+
}
|
|
64
|
+
: tool,
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
case "tool_denied":
|
|
69
|
+
return {
|
|
70
|
+
...message,
|
|
71
|
+
tools: upsertTool(message.tools, {
|
|
72
|
+
id: event.toolUseId,
|
|
73
|
+
name: event.name,
|
|
74
|
+
label: event.name,
|
|
75
|
+
status: "denied",
|
|
76
|
+
detail: event.reason,
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
case "turn_end":
|
|
81
|
+
return {
|
|
82
|
+
...message,
|
|
83
|
+
status: message.status === "error" ? "error" : "done",
|
|
84
|
+
// A turn that produced nothing still needs to say something.
|
|
85
|
+
text: message.text || fallbackText(event.stopReason),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
case "error":
|
|
89
|
+
return { ...message, status: "error", error: event.message };
|
|
90
|
+
|
|
91
|
+
default:
|
|
92
|
+
return message;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function upsertTool(tools: ToolActivity[], next: ToolActivity): ToolActivity[] {
|
|
97
|
+
return tools.some((tool) => tool.id === next.id)
|
|
98
|
+
? tools.map((tool) => (tool.id === next.id ? { ...tool, ...next } : tool))
|
|
99
|
+
: [...tools, next];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fallbackText(stopReason: string): string {
|
|
103
|
+
switch (stopReason) {
|
|
104
|
+
case "max_turns":
|
|
105
|
+
return "I got stuck working on that. Could you narrow it down a little?";
|
|
106
|
+
case "aborted":
|
|
107
|
+
return "Stopped.";
|
|
108
|
+
case "error":
|
|
109
|
+
return "Something went wrong before I could answer. Try again?";
|
|
110
|
+
default:
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function newAssistantMessage(id: string): AssistantChatMessage {
|
|
116
|
+
return { id, role: "assistant", text: "", thinking: "", tools: [], status: "streaming", createdAt: Date.now() };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function newUserMessage(id: string, text: string): UserChatMessage {
|
|
120
|
+
return { id, role: "user", text, createdAt: Date.now() };
|
|
121
|
+
}
|
package/src/lib/theme.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { WidgetTheme } from "@frankieai/core";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Map a theme to the CSS custom properties the widget's styles read.
|
|
6
|
+
*
|
|
7
|
+
* Anything not set falls through to the defaults in `frankie.css`, which are
|
|
8
|
+
* themselves shadcn/ui token names — so a host that already runs shadcn can
|
|
9
|
+
* drop the stylesheet and the widget inherits their design system instead.
|
|
10
|
+
*/
|
|
11
|
+
export function themeToCssVars(theme: WidgetTheme | undefined): CSSProperties {
|
|
12
|
+
if (!theme) return {};
|
|
13
|
+
const vars: Record<string, string> = {};
|
|
14
|
+
if (theme.primary) vars["--frankie-primary"] = theme.primary;
|
|
15
|
+
if (theme.primaryForeground) vars["--frankie-primary-foreground"] = theme.primaryForeground;
|
|
16
|
+
if (theme.background) vars["--frankie-background"] = theme.background;
|
|
17
|
+
if (theme.foreground) vars["--frankie-foreground"] = theme.foreground;
|
|
18
|
+
if (theme.muted) vars["--frankie-muted"] = theme.muted;
|
|
19
|
+
if (theme.border) vars["--frankie-border"] = theme.border;
|
|
20
|
+
if (theme.radius) vars["--frankie-radius"] = `${theme.radius}rem`;
|
|
21
|
+
if (theme.fontFamily) vars["--frankie-font"] = theme.fontFamily;
|
|
22
|
+
return vars as CSSProperties;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve `mode: "system"` against the browser, so the widget can hard-set a class. */
|
|
26
|
+
export function resolveMode(mode: WidgetTheme["mode"]): "light" | "dark" {
|
|
27
|
+
if (mode === "light" || mode === "dark") return mode;
|
|
28
|
+
if (typeof window === "undefined" || !window.matchMedia) return "light";
|
|
29
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
30
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
/** shadcn/ui's class helper: conditional classes with Tailwind conflict resolution. */
|
|
5
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
6
|
+
return twMerge(clsx(inputs));
|
|
7
|
+
}
|