@ai-chans/sdk-react 0.2.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/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # chans-sdk-react
2
+
3
+ React components and hooks for [chans.ai](https://chans.ai) voice AI.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install chans-sdk-react
9
+ ```
10
+
11
+ Requires React 18 or 19.
12
+
13
+ ## Quick Start
14
+
15
+ ### Default UI
16
+
17
+ Drop in a ready-to-use voice button:
18
+
19
+ ```tsx
20
+ import { ChansVoice } from "chans-sdk-react"
21
+
22
+ function App() {
23
+ return (
24
+ <ChansVoice
25
+ agentToken="agt_your_token"
26
+ onTranscript={(text) => console.log("User:", text)}
27
+ onResponse={(text) => console.log("Agent:", text)}
28
+ />
29
+ )
30
+ }
31
+ ```
32
+
33
+ ### Custom UI
34
+
35
+ Build your own interface with render props:
36
+
37
+ ```tsx
38
+ import { ChansVoice } from "chans-sdk-react"
39
+
40
+ function App() {
41
+ return (
42
+ <ChansVoice agentToken="agt_your_token" autoConnect={false}>
43
+ {({ state, isConnected, connect, disconnect, error }) => (
44
+ <div>
45
+ <p>Status: {state}</p>
46
+
47
+ <button
48
+ onClick={isConnected ? disconnect : connect}
49
+ disabled={state === "connecting"}
50
+ >
51
+ {isConnected ? "Stop" : "Start"}
52
+ </button>
53
+
54
+ {error && <p className="error">{error.message}</p>}
55
+ </div>
56
+ )}
57
+ </ChansVoice>
58
+ )
59
+ }
60
+ ```
61
+
62
+ ### useChans Hook
63
+
64
+ Access voice state from nested components:
65
+
66
+ ```tsx
67
+ import { ChansVoice, useChans } from "chans-sdk-react"
68
+
69
+ function VoiceButton() {
70
+ const { state, connect, disconnect, isConnected } = useChans()
71
+
72
+ return (
73
+ <button onClick={isConnected ? disconnect : connect}>
74
+ {state === "listening" ? "Listening..." :
75
+ state === "speaking" ? "Speaking..." : "Start"}
76
+ </button>
77
+ )
78
+ }
79
+
80
+ function App() {
81
+ return (
82
+ <ChansVoice agentToken="agt_your_token" autoConnect={false}>
83
+ {() => <VoiceButton />}
84
+ </ChansVoice>
85
+ )
86
+ }
87
+ ```
88
+
89
+ ## API Reference
90
+
91
+ ### ChansVoice Props
92
+
93
+ | Prop | Type | Default | Description |
94
+ |------|------|---------|-------------|
95
+ | `agentToken` | `string` | *required* | Agent token from dashboard |
96
+ | `userId` | `string` | — | End-user ID for conversation segmentation |
97
+ | `apiUrl` | `string` | `https://api.chans.ai` | API endpoint |
98
+ | `autoConnect` | `boolean` | `true` | Auto-connect on mount |
99
+ | `onTranscript` | `(text: string) => void` | — | User speech transcribed |
100
+ | `onResponse` | `(text: string) => void` | — | Agent response received |
101
+ | `onStateChange` | `(state: ChansState) => void` | — | State changed |
102
+ | `onError` | `(error: Error) => void` | — | Error occurred |
103
+ | `onConnected` | `() => void` | — | Connected to agent |
104
+ | `onDisconnected` | `() => void` | — | Disconnected |
105
+ | `children` | `(props: RenderProps) => ReactNode` | — | Custom render function |
106
+ | `className` | `string` | — | CSS class for wrapper |
107
+
108
+ ### Render Props
109
+
110
+ When using `children` as a function:
111
+
112
+ ```typescript
113
+ interface ChansVoiceRenderProps {
114
+ state: ChansState
115
+ isConnected: boolean
116
+ connect: () => Promise<void>
117
+ disconnect: () => Promise<void>
118
+ error: Error | null
119
+ }
120
+ ```
121
+
122
+ ### useChans Hook
123
+
124
+ Returns the same props as render props. Must be used inside a `ChansVoice` component.
125
+
126
+ ```typescript
127
+ const { state, isConnected, connect, disconnect, error } = useChans()
128
+ ```
129
+
130
+ ### ChansState
131
+
132
+ ```typescript
133
+ type ChansState =
134
+ | "idle" // Not connected
135
+ | "connecting" // Connecting
136
+ | "connected" // Connected, initializing
137
+ | "listening" // Listening for speech
138
+ | "speaking" // Agent speaking
139
+ | "error" // Error occurred
140
+ ```
141
+
142
+ ## Examples
143
+
144
+ ### Chat with Transcript
145
+
146
+ ```tsx
147
+ import { useState } from "react"
148
+ import { ChansVoice } from "chans-sdk-react"
149
+
150
+ function Chat() {
151
+ const [messages, setMessages] = useState<Array<{role: string, text: string}>>([])
152
+
153
+ return (
154
+ <div>
155
+ <div className="messages">
156
+ {messages.map((m, i) => (
157
+ <p key={i}><b>{m.role}:</b> {m.text}</p>
158
+ ))}
159
+ </div>
160
+
161
+ <ChansVoice
162
+ agentToken="agt_your_token"
163
+ onTranscript={(text) =>
164
+ setMessages(prev => [...prev, { role: "You", text }])
165
+ }
166
+ onResponse={(text) =>
167
+ setMessages(prev => [...prev, { role: "Agent", text }])
168
+ }
169
+ />
170
+ </div>
171
+ )
172
+ }
173
+ ```
174
+
175
+ ### Manual Connect/Disconnect
176
+
177
+ ```tsx
178
+ import { ChansVoice } from "chans-sdk-react"
179
+
180
+ function App() {
181
+ return (
182
+ <ChansVoice agentToken="agt_your_token" autoConnect={false}>
183
+ {({ state, connect, disconnect }) => (
184
+ <div>
185
+ {state === "idle" ? (
186
+ <button onClick={connect}>Start Conversation</button>
187
+ ) : (
188
+ <>
189
+ <p>State: {state}</p>
190
+ <button onClick={disconnect}>End Conversation</button>
191
+ </>
192
+ )}
193
+ </div>
194
+ )}
195
+ </ChansVoice>
196
+ )
197
+ }
198
+ ```
199
+
200
+ ## Self-Hosted
201
+
202
+ Point to your own chans.ai instance:
203
+
204
+ ```tsx
205
+ <ChansVoice
206
+ agentToken="agt_your_token"
207
+ apiUrl="https://your-instance.com"
208
+ />
209
+ ```
210
+
211
+ ## License
212
+
213
+ Apache 2.0
@@ -0,0 +1,91 @@
1
+ import { ChansState } from "@chozzz/chans-sdk-js";
2
+ export type { ChansState } from "@chozzz/chans-sdk-js";
3
+ export interface ChansVoiceProps {
4
+ /**
5
+ * Agent token from chans.ai dashboard
6
+ */
7
+ agentToken: string;
8
+ /**
9
+ * Optional end-user ID for conversation segmentation
10
+ */
11
+ userId?: string;
12
+ /**
13
+ * API URL (defaults to https://api.chans.ai)
14
+ */
15
+ apiUrl?: string;
16
+ /**
17
+ * Auto-connect on mount (default: true)
18
+ */
19
+ autoConnect?: boolean;
20
+ /**
21
+ * Called when user's speech is transcribed
22
+ */
23
+ onTranscript?: (text: string) => void;
24
+ /**
25
+ * Called when agent responds
26
+ */
27
+ onResponse?: (text: string) => void;
28
+ /**
29
+ * Called when state changes
30
+ */
31
+ onStateChange?: (state: ChansState) => void;
32
+ /**
33
+ * Called on error
34
+ */
35
+ onError?: (error: Error) => void;
36
+ /**
37
+ * Called when connected
38
+ */
39
+ onConnected?: () => void;
40
+ /**
41
+ * Called when disconnected
42
+ */
43
+ onDisconnected?: () => void;
44
+ /**
45
+ * Custom render function for the voice UI
46
+ */
47
+ children?: (props: ChansVoiceRenderProps) => React.ReactNode;
48
+ /**
49
+ * CSS class name
50
+ */
51
+ className?: string;
52
+ }
53
+ export interface ChansVoiceRenderProps {
54
+ state: ChansState;
55
+ isConnected: boolean;
56
+ connect: () => Promise<void>;
57
+ disconnect: () => Promise<void>;
58
+ error: Error | null;
59
+ }
60
+ type ChansContextValue = ChansVoiceRenderProps;
61
+ /**
62
+ * Hook to access ChansVoice state from child components
63
+ */
64
+ export declare function useChans(): ChansContextValue;
65
+ /**
66
+ * ChansVoice - React component for chans.ai voice AI
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * <ChansVoice
71
+ * agentToken="agt_xxx"
72
+ * userId="user-123"
73
+ * onTranscript={(text) => console.log("User:", text)}
74
+ * onResponse={(text) => console.log("Agent:", text)}
75
+ * />
76
+ * ```
77
+ *
78
+ * @example Custom UI
79
+ * ```tsx
80
+ * <ChansVoice agentToken="agt_xxx">
81
+ * {({ state, connect, disconnect }) => (
82
+ * <button onClick={state === "idle" ? connect : disconnect}>
83
+ * {state === "idle" ? "Start" : "Stop"}
84
+ * </button>
85
+ * )}
86
+ * </ChansVoice>
87
+ * ```
88
+ */
89
+ export declare function ChansVoice({ agentToken, userId, apiUrl, autoConnect, onTranscript, onResponse, onStateChange, onError, onConnected, onDisconnected, children, className, }: ChansVoiceProps): import("react/jsx-runtime").JSX.Element;
90
+ export default ChansVoice;
91
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAWA,OAAO,EAAe,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAG9D,YAAY,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAEtD,MAAM,WAAW,eAAe;IAC9B;;OAEG;IACH,UAAU,EAAE,MAAM,CAAA;IAElB;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf;;OAEG;IACH,WAAW,CAAC,EAAE,OAAO,CAAA;IAErB;;OAEG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAErC;;OAEG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAEnC;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAA;IAE3C;;OAEG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;IAEhC;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,IAAI,CAAA;IAExB;;OAEG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAE3B;;OAEG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,KAAK,CAAC,SAAS,CAAA;IAE5D;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,UAAU,CAAA;IACjB,WAAW,EAAE,OAAO,CAAA;IACpB,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5B,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/B,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;CACpB;AAGD,KAAK,iBAAiB,GAAG,qBAAqB,CAAA;AAI9C;;GAEG;AACH,wBAAgB,QAAQ,IAAI,iBAAiB,CAM5C;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,UAAU,CAAC,EACzB,UAAU,EACV,MAAM,EACN,MAAM,EACN,WAAkB,EAClB,YAAY,EACZ,UAAU,EACV,aAAa,EACb,OAAO,EACP,WAAW,EACX,cAAc,EACd,QAAQ,EACR,SAAS,GACV,EAAE,eAAe,2CA4GjB;AA8ID,eAAe,UAAU,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,163 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState, useEffect, useCallback, useRef, useMemo, createContext, useContext, } from "react";
4
+ import { ChansClient } from "@chozzz/chans-sdk-js";
5
+ const ChansContext = createContext(null);
6
+ /**
7
+ * Hook to access ChansVoice state from child components
8
+ */
9
+ export function useChans() {
10
+ const context = useContext(ChansContext);
11
+ if (!context) {
12
+ throw new Error("useChans must be used within a ChansVoice component");
13
+ }
14
+ return context;
15
+ }
16
+ /**
17
+ * ChansVoice - React component for chans.ai voice AI
18
+ *
19
+ * @example
20
+ * ```tsx
21
+ * <ChansVoice
22
+ * agentToken="agt_xxx"
23
+ * userId="user-123"
24
+ * onTranscript={(text) => console.log("User:", text)}
25
+ * onResponse={(text) => console.log("Agent:", text)}
26
+ * />
27
+ * ```
28
+ *
29
+ * @example Custom UI
30
+ * ```tsx
31
+ * <ChansVoice agentToken="agt_xxx">
32
+ * {({ state, connect, disconnect }) => (
33
+ * <button onClick={state === "idle" ? connect : disconnect}>
34
+ * {state === "idle" ? "Start" : "Stop"}
35
+ * </button>
36
+ * )}
37
+ * </ChansVoice>
38
+ * ```
39
+ */
40
+ export function ChansVoice({ agentToken, userId, apiUrl, autoConnect = true, onTranscript, onResponse, onStateChange, onError, onConnected, onDisconnected, children, className, }) {
41
+ const [state, setState] = useState("idle");
42
+ const [error, setError] = useState(null);
43
+ const clientRef = useRef(null);
44
+ // Create client on mount
45
+ useEffect(() => {
46
+ clientRef.current = new ChansClient({ agentToken, apiUrl });
47
+ const client = clientRef.current;
48
+ // Set up event listeners
49
+ const unsubState = client.on("stateChange", (newState) => {
50
+ setState(newState);
51
+ onStateChange?.(newState);
52
+ });
53
+ const unsubTranscript = client.on("transcript", (text) => {
54
+ onTranscript?.(text);
55
+ });
56
+ const unsubResponse = client.on("response", (text) => {
57
+ onResponse?.(text);
58
+ });
59
+ const unsubError = client.on("error", (err) => {
60
+ setError(err);
61
+ onError?.(err);
62
+ });
63
+ const unsubConnected = client.on("connected", () => {
64
+ setError(null);
65
+ onConnected?.();
66
+ });
67
+ const unsubDisconnected = client.on("disconnected", () => {
68
+ onDisconnected?.();
69
+ });
70
+ return () => {
71
+ unsubState();
72
+ unsubTranscript();
73
+ unsubResponse();
74
+ unsubError();
75
+ unsubConnected();
76
+ unsubDisconnected();
77
+ client.disconnect();
78
+ };
79
+ }, [agentToken, apiUrl, onStateChange, onTranscript, onResponse, onError, onConnected, onDisconnected]);
80
+ // Auto-connect
81
+ useEffect(() => {
82
+ if (autoConnect && clientRef.current && state === "idle") {
83
+ clientRef.current.connect({ userId }).catch(() => {
84
+ // Error handled by event listener
85
+ });
86
+ }
87
+ }, [autoConnect, userId, state]);
88
+ const connect = useCallback(async () => {
89
+ if (clientRef.current) {
90
+ setError(null);
91
+ await clientRef.current.connect({ userId });
92
+ }
93
+ }, [userId]);
94
+ const disconnect = useCallback(async () => {
95
+ if (clientRef.current) {
96
+ await clientRef.current.disconnect();
97
+ }
98
+ }, []);
99
+ const isConnected = state !== "idle" && state !== "error";
100
+ const contextValue = useMemo(() => ({
101
+ state,
102
+ isConnected,
103
+ connect,
104
+ disconnect,
105
+ error,
106
+ }), [state, isConnected, connect, disconnect, error]);
107
+ // Custom render function
108
+ if (children) {
109
+ return (_jsx(ChansContext.Provider, { value: contextValue, children: children(contextValue) }));
110
+ }
111
+ // Default UI
112
+ return (_jsx(ChansContext.Provider, { value: contextValue, children: _jsx("div", { className: className, children: _jsx(DefaultVoiceUI, { state: state, isConnected: isConnected, connect: connect, disconnect: disconnect, error: error }) }) }));
113
+ }
114
+ /**
115
+ * Default voice UI component
116
+ */
117
+ function DefaultVoiceUI({ state, isConnected, connect, disconnect, error, }) {
118
+ const handleClick = async () => {
119
+ if (isConnected) {
120
+ await disconnect();
121
+ }
122
+ else {
123
+ await connect();
124
+ }
125
+ };
126
+ return (_jsxs("div", { style: { textAlign: "center" }, children: [error && (_jsx("div", { style: {
127
+ color: "#ef4444",
128
+ marginBottom: "1rem",
129
+ fontSize: "0.875rem",
130
+ }, children: error.message })), _jsx("button", { onClick: handleClick, disabled: state === "connecting", style: {
131
+ width: "4rem",
132
+ height: "4rem",
133
+ borderRadius: "50%",
134
+ border: "none",
135
+ background: state === "idle"
136
+ ? "linear-gradient(135deg, #8b5cf6, #7c3aed)"
137
+ : state === "speaking"
138
+ ? "#22c55e"
139
+ : state === "error"
140
+ ? "#ef4444"
141
+ : "#6366f1",
142
+ color: "white",
143
+ cursor: state === "connecting" ? "wait" : "pointer",
144
+ transition: "all 0.2s",
145
+ display: "flex",
146
+ alignItems: "center",
147
+ justifyContent: "center",
148
+ }, "aria-label": isConnected ? "Stop voice" : "Start voice", children: state === "connecting" ? (_jsx(LoadingSpinner, {})) : state === "speaking" ? (_jsx(SpeakerIcon, {})) : (_jsx(MicIcon, {})) }), _jsxs("div", { style: {
149
+ marginTop: "0.5rem",
150
+ fontSize: "0.75rem",
151
+ color: "#9ca3af",
152
+ }, children: [state === "idle" && "Click to start", state === "connecting" && "Connecting...", state === "connected" && "Connected", state === "listening" && "Listening...", state === "speaking" && "Agent speaking", state === "error" && "Error"] })] }));
153
+ }
154
+ function MicIcon() {
155
+ return (_jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("path", { d: "M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", y1: "19", x2: "12", y2: "23" }), _jsx("line", { x1: "8", y1: "23", x2: "16", y2: "23" })] }));
156
+ }
157
+ function SpeakerIcon() {
158
+ return (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" }) }));
159
+ }
160
+ function LoadingSpinner() {
161
+ return (_jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", style: { animation: "spin 1s linear infinite" }, children: [_jsx("style", { children: `@keyframes spin { to { transform: rotate(360deg); } }` }), _jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "3", fill: "none", strokeDasharray: "31.4 31.4", strokeLinecap: "round" })] }));
162
+ }
163
+ export default ChansVoice;
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@ai-chans/sdk-react",
3
+ "version": "0.2.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/ai-chans/sdk.git",
7
+ "directory": "react"
8
+ },
9
+ "description": "React component for chans.ai voice AI",
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "dev": "tsc --watch",
27
+ "lint": "echo 'lint not configured yet'"
28
+ },
29
+ "dependencies": {
30
+ "@ai-chans/sdk-js": "workspace:*"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^19",
34
+ "typescript": "^5.9.3"
35
+ },
36
+ "peerDependencies": {
37
+ "react": "^18.0.0 || ^19.0.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,390 @@
1
+ "use client"
2
+
3
+ import {
4
+ useState,
5
+ useEffect,
6
+ useCallback,
7
+ useRef,
8
+ useMemo,
9
+ createContext,
10
+ useContext,
11
+ } from "react"
12
+ import { ChansClient, ChansState } from "@chozzz/chans-sdk-js"
13
+
14
+ // Re-export client types
15
+ export type { ChansState } from "@chozzz/chans-sdk-js"
16
+
17
+ export interface ChansVoiceProps {
18
+ /**
19
+ * Agent token from chans.ai dashboard
20
+ */
21
+ agentToken: string
22
+
23
+ /**
24
+ * Optional end-user ID for conversation segmentation
25
+ */
26
+ userId?: string
27
+
28
+ /**
29
+ * API URL (defaults to https://api.chans.ai)
30
+ */
31
+ apiUrl?: string
32
+
33
+ /**
34
+ * Auto-connect on mount (default: true)
35
+ */
36
+ autoConnect?: boolean
37
+
38
+ /**
39
+ * Called when user's speech is transcribed
40
+ */
41
+ onTranscript?: (text: string) => void
42
+
43
+ /**
44
+ * Called when agent responds
45
+ */
46
+ onResponse?: (text: string) => void
47
+
48
+ /**
49
+ * Called when state changes
50
+ */
51
+ onStateChange?: (state: ChansState) => void
52
+
53
+ /**
54
+ * Called on error
55
+ */
56
+ onError?: (error: Error) => void
57
+
58
+ /**
59
+ * Called when connected
60
+ */
61
+ onConnected?: () => void
62
+
63
+ /**
64
+ * Called when disconnected
65
+ */
66
+ onDisconnected?: () => void
67
+
68
+ /**
69
+ * Custom render function for the voice UI
70
+ */
71
+ children?: (props: ChansVoiceRenderProps) => React.ReactNode
72
+
73
+ /**
74
+ * CSS class name
75
+ */
76
+ className?: string
77
+ }
78
+
79
+ export interface ChansVoiceRenderProps {
80
+ state: ChansState
81
+ isConnected: boolean
82
+ connect: () => Promise<void>
83
+ disconnect: () => Promise<void>
84
+ error: Error | null
85
+ }
86
+
87
+ // Context for accessing chans state from child components
88
+ type ChansContextValue = ChansVoiceRenderProps
89
+
90
+ const ChansContext = createContext<ChansContextValue | null>(null)
91
+
92
+ /**
93
+ * Hook to access ChansVoice state from child components
94
+ */
95
+ export function useChans(): ChansContextValue {
96
+ const context = useContext(ChansContext)
97
+ if (!context) {
98
+ throw new Error("useChans must be used within a ChansVoice component")
99
+ }
100
+ return context
101
+ }
102
+
103
+ /**
104
+ * ChansVoice - React component for chans.ai voice AI
105
+ *
106
+ * @example
107
+ * ```tsx
108
+ * <ChansVoice
109
+ * agentToken="agt_xxx"
110
+ * userId="user-123"
111
+ * onTranscript={(text) => console.log("User:", text)}
112
+ * onResponse={(text) => console.log("Agent:", text)}
113
+ * />
114
+ * ```
115
+ *
116
+ * @example Custom UI
117
+ * ```tsx
118
+ * <ChansVoice agentToken="agt_xxx">
119
+ * {({ state, connect, disconnect }) => (
120
+ * <button onClick={state === "idle" ? connect : disconnect}>
121
+ * {state === "idle" ? "Start" : "Stop"}
122
+ * </button>
123
+ * )}
124
+ * </ChansVoice>
125
+ * ```
126
+ */
127
+ export function ChansVoice({
128
+ agentToken,
129
+ userId,
130
+ apiUrl,
131
+ autoConnect = true,
132
+ onTranscript,
133
+ onResponse,
134
+ onStateChange,
135
+ onError,
136
+ onConnected,
137
+ onDisconnected,
138
+ children,
139
+ className,
140
+ }: ChansVoiceProps) {
141
+ const [state, setState] = useState<ChansState>("idle")
142
+ const [error, setError] = useState<Error | null>(null)
143
+ const clientRef = useRef<ChansClient | null>(null)
144
+
145
+ // Create client on mount
146
+ useEffect(() => {
147
+ clientRef.current = new ChansClient({ agentToken, apiUrl })
148
+
149
+ const client = clientRef.current
150
+
151
+ // Set up event listeners
152
+ const unsubState = client.on("stateChange", (newState) => {
153
+ setState(newState)
154
+ onStateChange?.(newState)
155
+ })
156
+
157
+ const unsubTranscript = client.on("transcript", (text) => {
158
+ onTranscript?.(text)
159
+ })
160
+
161
+ const unsubResponse = client.on("response", (text) => {
162
+ onResponse?.(text)
163
+ })
164
+
165
+ const unsubError = client.on("error", (err) => {
166
+ setError(err)
167
+ onError?.(err)
168
+ })
169
+
170
+ const unsubConnected = client.on("connected", () => {
171
+ setError(null)
172
+ onConnected?.()
173
+ })
174
+
175
+ const unsubDisconnected = client.on("disconnected", () => {
176
+ onDisconnected?.()
177
+ })
178
+
179
+ return () => {
180
+ unsubState()
181
+ unsubTranscript()
182
+ unsubResponse()
183
+ unsubError()
184
+ unsubConnected()
185
+ unsubDisconnected()
186
+ client.disconnect()
187
+ }
188
+ }, [agentToken, apiUrl, onStateChange, onTranscript, onResponse, onError, onConnected, onDisconnected])
189
+
190
+ // Auto-connect
191
+ useEffect(() => {
192
+ if (autoConnect && clientRef.current && state === "idle") {
193
+ clientRef.current.connect({ userId }).catch(() => {
194
+ // Error handled by event listener
195
+ })
196
+ }
197
+ }, [autoConnect, userId, state])
198
+
199
+ const connect = useCallback(async () => {
200
+ if (clientRef.current) {
201
+ setError(null)
202
+ await clientRef.current.connect({ userId })
203
+ }
204
+ }, [userId])
205
+
206
+ const disconnect = useCallback(async () => {
207
+ if (clientRef.current) {
208
+ await clientRef.current.disconnect()
209
+ }
210
+ }, [])
211
+
212
+ const isConnected = state !== "idle" && state !== "error"
213
+
214
+ const contextValue: ChansContextValue = useMemo(
215
+ () => ({
216
+ state,
217
+ isConnected,
218
+ connect,
219
+ disconnect,
220
+ error,
221
+ }),
222
+ [state, isConnected, connect, disconnect, error]
223
+ )
224
+
225
+ // Custom render function
226
+ if (children) {
227
+ return (
228
+ <ChansContext.Provider value={contextValue}>
229
+ {children(contextValue)}
230
+ </ChansContext.Provider>
231
+ )
232
+ }
233
+
234
+ // Default UI
235
+ return (
236
+ <ChansContext.Provider value={contextValue}>
237
+ <div className={className}>
238
+ <DefaultVoiceUI
239
+ state={state}
240
+ isConnected={isConnected}
241
+ connect={connect}
242
+ disconnect={disconnect}
243
+ error={error}
244
+ />
245
+ </div>
246
+ </ChansContext.Provider>
247
+ )
248
+ }
249
+
250
+ /**
251
+ * Default voice UI component
252
+ */
253
+ function DefaultVoiceUI({
254
+ state,
255
+ isConnected,
256
+ connect,
257
+ disconnect,
258
+ error,
259
+ }: ChansVoiceRenderProps) {
260
+ const handleClick = async () => {
261
+ if (isConnected) {
262
+ await disconnect()
263
+ } else {
264
+ await connect()
265
+ }
266
+ }
267
+
268
+ return (
269
+ <div style={{ textAlign: "center" }}>
270
+ {error && (
271
+ <div
272
+ style={{
273
+ color: "#ef4444",
274
+ marginBottom: "1rem",
275
+ fontSize: "0.875rem",
276
+ }}
277
+ >
278
+ {error.message}
279
+ </div>
280
+ )}
281
+
282
+ <button
283
+ onClick={handleClick}
284
+ disabled={state === "connecting"}
285
+ style={{
286
+ width: "4rem",
287
+ height: "4rem",
288
+ borderRadius: "50%",
289
+ border: "none",
290
+ background:
291
+ state === "idle"
292
+ ? "linear-gradient(135deg, #8b5cf6, #7c3aed)"
293
+ : state === "speaking"
294
+ ? "#22c55e"
295
+ : state === "error"
296
+ ? "#ef4444"
297
+ : "#6366f1",
298
+ color: "white",
299
+ cursor: state === "connecting" ? "wait" : "pointer",
300
+ transition: "all 0.2s",
301
+ display: "flex",
302
+ alignItems: "center",
303
+ justifyContent: "center",
304
+ }}
305
+ aria-label={isConnected ? "Stop voice" : "Start voice"}
306
+ >
307
+ {state === "connecting" ? (
308
+ <LoadingSpinner />
309
+ ) : state === "speaking" ? (
310
+ <SpeakerIcon />
311
+ ) : (
312
+ <MicIcon />
313
+ )}
314
+ </button>
315
+
316
+ <div
317
+ style={{
318
+ marginTop: "0.5rem",
319
+ fontSize: "0.75rem",
320
+ color: "#9ca3af",
321
+ }}
322
+ >
323
+ {state === "idle" && "Click to start"}
324
+ {state === "connecting" && "Connecting..."}
325
+ {state === "connected" && "Connected"}
326
+ {state === "listening" && "Listening..."}
327
+ {state === "speaking" && "Agent speaking"}
328
+ {state === "error" && "Error"}
329
+ </div>
330
+ </div>
331
+ )
332
+ }
333
+
334
+ function MicIcon() {
335
+ return (
336
+ <svg
337
+ width="24"
338
+ height="24"
339
+ viewBox="0 0 24 24"
340
+ fill="none"
341
+ stroke="currentColor"
342
+ strokeWidth="2"
343
+ strokeLinecap="round"
344
+ strokeLinejoin="round"
345
+ >
346
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
347
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
348
+ <line x1="12" y1="19" x2="12" y2="23" />
349
+ <line x1="8" y1="23" x2="16" y2="23" />
350
+ </svg>
351
+ )
352
+ }
353
+
354
+ function SpeakerIcon() {
355
+ return (
356
+ <svg
357
+ width="24"
358
+ height="24"
359
+ viewBox="0 0 24 24"
360
+ fill="currentColor"
361
+ >
362
+ <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
363
+ </svg>
364
+ )
365
+ }
366
+
367
+ function LoadingSpinner() {
368
+ return (
369
+ <svg
370
+ width="24"
371
+ height="24"
372
+ viewBox="0 0 24 24"
373
+ style={{ animation: "spin 1s linear infinite" }}
374
+ >
375
+ <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
376
+ <circle
377
+ cx="12"
378
+ cy="12"
379
+ r="10"
380
+ stroke="currentColor"
381
+ strokeWidth="3"
382
+ fill="none"
383
+ strokeDasharray="31.4 31.4"
384
+ strokeLinecap="round"
385
+ />
386
+ </svg>
387
+ )
388
+ }
389
+
390
+ export default ChansVoice