@yak-io/react 0.11.1 → 0.12.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.
@@ -1,195 +0,0 @@
1
- "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
3
- import { INITIAL_VOICE_MACHINE, logger, YakEmbed, } from "@yak-io/javascript";
4
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
- import { YakContext } from "./context.js";
6
- /**
7
- * YakProvider sets up the unified chat + voice runtime. All DOM rendering
8
- * (panel, iframe, optional trigger) is delegated to YakEmbed. Consumers
9
- * access both surfaces via `useYak()`.
10
- */
11
- export function YakProvider({ appId, origin, mode = "chat", getConfig, onToolCall, theme, onRedirect, disableRestartButton, trigger = false, user, children, }) {
12
- const [isOpen, setIsOpen] = useState(false);
13
- const [isReady, setIsReady] = useState(false);
14
- const [voiceMachine, setVoiceMachine] = useState(INITIAL_VOICE_MACHINE);
15
- // Store event subscribers for tool call events
16
- const toolEventSubscribersRef = useRef(new Set());
17
- // Handler that notifies all subscribers when a tool call completes
18
- const handleToolCallComplete = useCallback((event) => {
19
- logger.debug("Tool call completed, notifying subscribers:", {
20
- name: event.name,
21
- ok: event.ok,
22
- subscriberCount: toolEventSubscribersRef.current.size,
23
- });
24
- for (const handler of toolEventSubscribersRef.current) {
25
- try {
26
- handler(event);
27
- }
28
- catch (err) {
29
- logger.warn("Error in tool event subscriber:", err);
30
- }
31
- }
32
- }, []);
33
- // Resolve redirect handler
34
- const resolvedRedirect = useMemo(() => {
35
- if (onRedirect)
36
- return onRedirect;
37
- if (typeof window === "undefined")
38
- return undefined;
39
- return (path) => {
40
- window.location.assign(path);
41
- };
42
- }, [onRedirect]);
43
- // Initialize YakEmbed — created once, never recreated
44
- const embedRef = useRef(null);
45
- if (!embedRef.current) {
46
- embedRef.current = new YakEmbed({
47
- appId,
48
- origin,
49
- mode,
50
- theme,
51
- trigger,
52
- getConfig,
53
- onToolCall,
54
- onRedirect: resolvedRedirect,
55
- options: { disableRestartButton },
56
- onToolCallComplete: handleToolCallComplete,
57
- user,
58
- });
59
- }
60
- const embed = embedRef.current;
61
- // Mount/unmount embed and subscribe to chat + voice state changes
62
- useEffect(() => {
63
- embed.mount();
64
- const unsubscribeChat = embed.onStateChange((state) => {
65
- setIsOpen(state.isOpen);
66
- setIsReady(state.isReady);
67
- });
68
- const unsubscribeVoice = embed.onVoiceStateChange((m) => setVoiceMachine(m));
69
- return () => {
70
- unsubscribeChat();
71
- unsubscribeVoice();
72
- embed.destroy();
73
- };
74
- }, [embed]);
75
- // Update embed config when props change. Chat config goes via the client;
76
- // voice config goes via the voice session.
77
- useEffect(() => {
78
- embed.getClient().updateConfig({
79
- appId,
80
- onToolCall,
81
- theme,
82
- onRedirect: resolvedRedirect,
83
- options: { disableRestartButton },
84
- onToolCallComplete: handleToolCallComplete,
85
- user,
86
- });
87
- embed.getVoiceSession()?.updateConfig({
88
- appId,
89
- getConfig,
90
- onToolCall,
91
- onRedirect: resolvedRedirect,
92
- });
93
- }, [
94
- appId,
95
- getConfig,
96
- onToolCall,
97
- theme,
98
- resolvedRedirect,
99
- disableRestartButton,
100
- embed,
101
- handleToolCallComplete,
102
- user,
103
- ]);
104
- // Fetch chat config when the chat is opened
105
- useEffect(() => {
106
- if (typeof window === "undefined" || !isOpen || !getConfig)
107
- return;
108
- logger.debug("Getting chat config");
109
- let cancelled = false;
110
- (async () => {
111
- try {
112
- const config = await getConfig();
113
- if (!cancelled) {
114
- logger.debug(`Chat config loaded with ${config.tools?.tools.length ?? 0} tools and ${config.routes?.routes.length ?? 0} routes`);
115
- embed.getClient().updateConfig({ chatConfig: config });
116
- }
117
- }
118
- catch (err) {
119
- logger.warn("Error getting chat config:", err);
120
- }
121
- })();
122
- return () => {
123
- cancelled = true;
124
- };
125
- }, [getConfig, isOpen, embed]);
126
- // Chat methods
127
- const open = useCallback(() => embed.open(), [embed]);
128
- const close = useCallback(() => embed.close(), [embed]);
129
- const openWithPrompt = useCallback((prompt) => embed.openWithPrompt(prompt), [embed]);
130
- // Voice methods
131
- const voiceStart = useCallback(async () => {
132
- try {
133
- await embed.voiceStart();
134
- }
135
- catch (err) {
136
- logger.warn("Voice start failed", err);
137
- }
138
- }, [embed]);
139
- const voiceStop = useCallback(() => embed.voiceStop(), [embed]);
140
- const voiceToggle = useCallback(() => embed.voiceToggle(), [embed]);
141
- // Subscribe to tool call completion events
142
- const subscribeToToolEvents = useCallback((handler) => {
143
- toolEventSubscribersRef.current.add(handler);
144
- logger.debug("Tool event subscriber added, total:", toolEventSubscribersRef.current.size);
145
- return () => {
146
- toolEventSubscribersRef.current.delete(handler);
147
- logger.debug("Tool event subscriber removed, total:", toolEventSubscribersRef.current.size);
148
- };
149
- }, []);
150
- // Expose iframe origin for YakWidget
151
- const getIframeOrigin = useCallback(() => embed.getClient().getIframeOrigin(), [embed]);
152
- const voiceState = voiceMachine.state;
153
- const voiceIsActive = voiceState !== "idle" && voiceState !== "error";
154
- const chatLoading = isOpen && !isReady;
155
- const voiceLoading = voiceState === "connecting";
156
- const contextValue = useMemo(() => ({
157
- mode,
158
- isOpen,
159
- isReady,
160
- chatLoading,
161
- open,
162
- close,
163
- openWithPrompt,
164
- subscribeToToolEvents,
165
- voiceMachine,
166
- voiceState,
167
- voiceErrorMessage: voiceMachine.errorMessage,
168
- voiceIsActive,
169
- voiceLoading,
170
- voiceStart,
171
- voiceStop,
172
- voiceToggle,
173
- getIframeOrigin,
174
- theme,
175
- }), [
176
- mode,
177
- isOpen,
178
- isReady,
179
- chatLoading,
180
- open,
181
- close,
182
- openWithPrompt,
183
- subscribeToToolEvents,
184
- voiceMachine,
185
- voiceState,
186
- voiceIsActive,
187
- voiceLoading,
188
- voiceStart,
189
- voiceStop,
190
- voiceToggle,
191
- getIframeOrigin,
192
- theme,
193
- ]);
194
- return _jsx(YakContext.Provider, { value: contextValue, children: children });
195
- }
package/dist/YakWidget.js DELETED
@@ -1,81 +0,0 @@
1
- "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useYakInternal } from "./context.js";
4
- const VOICE_ARIA = {
5
- idle: "Start voice mode",
6
- connecting: "Connecting voice session",
7
- listening: "Voice listening — tap to stop",
8
- thinking: "Voice thinking — tap to stop",
9
- speaking: "Voice speaking — tap to stop",
10
- error: "Voice error — tap to retry",
11
- };
12
- function MessageCircleIcon() {
13
- return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", width: 20, height: 20, "aria-hidden": "true", children: _jsx("path", { d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" }) }));
14
- }
15
- function AudioLinesIcon() {
16
- return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", width: 20, height: 20, "aria-hidden": "true", children: [_jsx("path", { d: "M2 10v3" }), _jsx("path", { d: "M6 6v11" }), _jsx("path", { d: "M10 3v18" }), _jsx("path", { d: "M14 8v7" }), _jsx("path", { d: "M18 5v13" }), _jsx("path", { d: "M22 10v3" })] }));
17
- }
18
- function StopIcon() {
19
- return (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", width: 20, height: 20, "aria-hidden": "true", children: _jsx("rect", { x: "6", y: "6", width: "12", height: "12", rx: "2" }) }));
20
- }
21
- function VoiceIcon({ state }) {
22
- if (state === "connecting") {
23
- return _jsx("span", { className: "yak-widget-spinner", "aria-hidden": "true" });
24
- }
25
- if (state === "listening" || state === "speaking" || state === "thinking") {
26
- return _jsx(StopIcon, {});
27
- }
28
- return _jsx(AudioLinesIcon, {});
29
- }
30
- function buildButtonStyle(lightButton, darkButton) {
31
- const style = {};
32
- if (lightButton?.background)
33
- style["--yak-btn-light-bg"] = lightButton.background;
34
- if (lightButton?.color)
35
- style["--yak-btn-light-color"] = lightButton.color;
36
- if (lightButton?.border)
37
- style["--yak-btn-light-border"] = lightButton.border;
38
- if (darkButton?.background)
39
- style["--yak-btn-dark-bg"] = darkButton.background;
40
- if (darkButton?.color)
41
- style["--yak-btn-dark-color"] = darkButton.color;
42
- if (darkButton?.border)
43
- style["--yak-btn-dark-border"] = darkButton.border;
44
- return style;
45
- }
46
- function buildButtonClasses(colorMode, hasLightCustom, hasDarkCustom) {
47
- const colorModeClass = colorMode === "light" ? "yak-widget-light" : colorMode === "dark" ? "yak-widget-dark" : "";
48
- let customButtonClass = "";
49
- if (colorMode === "light" && hasLightCustom) {
50
- customButtonClass = "yak-widget-custom-light";
51
- }
52
- else if (colorMode === "dark" && hasDarkCustom) {
53
- customButtonClass = "yak-widget-custom-dark";
54
- }
55
- return ["yak-widget-trigger", colorModeClass, customButtonClass].filter(Boolean).join(" ");
56
- }
57
- /**
58
- * YakWidget renders a fixed-position launcher pill. The logo sits on the
59
- * left; one or two icon buttons sit on the right based on `mode`. Trigger
60
- * CSS is injected by YakEmbed (via YakProvider) — this component only
61
- * provides the React markup using those shared class names.
62
- */
63
- export function YakWidget({ mode, lightButton, darkButton, } = {}) {
64
- const ctx = useYakInternal();
65
- const resolvedMode = mode ?? ctx.mode;
66
- const position = ctx.theme?.position ?? "bottom-left";
67
- const colorMode = ctx.theme?.colorMode;
68
- const logoUrl = `${ctx.getIframeOrigin()}/logo.svg`;
69
- const showChat = resolvedMode === "chat" || resolvedMode === "both";
70
- const showVoice = resolvedMode === "voice" || resolvedMode === "both";
71
- // Consume the shipped loading flags rather than re-deriving them here.
72
- const chatLoading = ctx.chatLoading;
73
- const voiceConnecting = ctx.voiceLoading;
74
- const hasLightCustom = lightButton?.background || lightButton?.color || lightButton?.border;
75
- const hasDarkCustom = darkButton?.background || darkButton?.color || darkButton?.border;
76
- const buttonStyle = buildButtonStyle(lightButton, darkButton);
77
- const buttonClasses = buildButtonClasses(colorMode, hasLightCustom, hasDarkCustom);
78
- return (_jsxs("div", { className: buttonClasses, style: Object.keys(buttonStyle).length > 0 ? buttonStyle : undefined, "data-position": position, "data-mode": resolvedMode, "data-has-light-custom": hasLightCustom || undefined, "data-has-dark-custom": hasDarkCustom || undefined, children: [_jsx("div", { className: "yak-widget-icon-bg", children: _jsx("img", { src: logoUrl, alt: "", width: 20, height: 20, className: "yak-widget-icon" }) }), showChat && (_jsx("button", { type: "button", className: "yak-widget-trigger-icon-btn", "data-action": "chat", "aria-label": chatLoading ? "Loading chat" : "Open chat", disabled: chatLoading, onClick: ctx.open, children: chatLoading ? (_jsx("span", { className: "yak-widget-spinner", "aria-hidden": "true" })) : (_jsx(MessageCircleIcon, {})) })), showVoice && (_jsx("button", { type: "button", className: "yak-widget-trigger-icon-btn", "data-action": "voice", "data-state": ctx.voiceState, "aria-label": VOICE_ARIA[ctx.voiceState], disabled: voiceConnecting, onClick: () => {
79
- void ctx.voiceToggle();
80
- }, children: _jsx(VoiceIcon, { state: ctx.voiceState }) }))] }));
81
- }
package/dist/context.js DELETED
@@ -1,103 +0,0 @@
1
- "use client";
2
- import { createContext, useContext, useEffect, useRef } from "react";
3
- export const YakContext = createContext(null);
4
- /**
5
- * Hook to access the Yak widget API — chat and voice.
6
- *
7
- * @example
8
- * ```tsx
9
- * function MyComponent() {
10
- * const { open, voiceToggle, voiceState } = useYak();
11
- * return (
12
- * <div>
13
- * <button onClick={open}>Open chat</button>
14
- * <button onClick={voiceToggle}>{voiceState === "idle" ? "Start voice" : "Stop voice"}</button>
15
- * </div>
16
- * );
17
- * }
18
- * ```
19
- *
20
- * @throws {Error} if used outside YakProvider
21
- */
22
- export function useYak() {
23
- const context = useContext(YakContext);
24
- if (!context) {
25
- throw new Error("useYak must be used within YakProvider");
26
- }
27
- return {
28
- mode: context.mode,
29
- isOpen: context.isOpen,
30
- isReady: context.isReady,
31
- chatLoading: context.chatLoading,
32
- open: context.open,
33
- close: context.close,
34
- openWithPrompt: context.openWithPrompt,
35
- subscribeToToolEvents: context.subscribeToToolEvents,
36
- voiceMachine: context.voiceMachine,
37
- voiceState: context.voiceState,
38
- voiceErrorMessage: context.voiceErrorMessage,
39
- voiceIsActive: context.voiceIsActive,
40
- voiceLoading: context.voiceLoading,
41
- voiceStart: context.voiceStart,
42
- voiceStop: context.voiceStop,
43
- voiceToggle: context.voiceToggle,
44
- };
45
- }
46
- /**
47
- * Internal hook for components that need full context access.
48
- * Not exported publicly.
49
- */
50
- export function useYakInternal() {
51
- const context = useContext(YakContext);
52
- if (!context) {
53
- throw new Error("useYakInternal must be used within YakProvider");
54
- }
55
- return context;
56
- }
57
- /**
58
- * Hook to subscribe to tool call completion events.
59
- * Useful for page-level cache invalidation when chatbot tools modify data.
60
- *
61
- * The handler receives an event with:
62
- * - `name`: The tool name that was called (e.g., "order.cancel")
63
- * - `args`: The arguments passed to the tool
64
- * - `ok`: Whether the call succeeded
65
- * - `result`: The result (if ok is true)
66
- * - `error`: The error message (if ok is false)
67
- *
68
- * @example
69
- * ```tsx
70
- * function PlanPage({ planId }: { planId: string }) {
71
- * const utils = trpc.useUtils();
72
- *
73
- * useYakToolEvent((event) => {
74
- * // Only invalidate if this tool touched plans
75
- * if (event.ok && event.name.startsWith("plan.")) {
76
- * utils.plan.get.invalidate({ id: planId });
77
- * utils.planItem.list.invalidate({ planId });
78
- * }
79
- * });
80
- *
81
- * // ... rest of component
82
- * }
83
- * ```
84
- *
85
- * @param handler - Function called after each tool call completes
86
- * @throws {Error} if used outside YakProvider
87
- */
88
- export function useYakToolEvent(handler) {
89
- const context = useContext(YakContext);
90
- if (!context) {
91
- throw new Error("useYakToolEvent must be used within YakProvider");
92
- }
93
- // Use ref to always have latest handler without re-subscribing
94
- const handlerRef = useRef(handler);
95
- handlerRef.current = handler;
96
- useEffect(() => {
97
- // Wrap handler to use ref (stable reference)
98
- const stableHandler = (event) => {
99
- handlerRef.current(event);
100
- };
101
- return context.subscribeToToolEvents(stableHandler);
102
- }, [context]);
103
- }