@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 +213 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +163 -0
- package/package.json +42 -0
- package/src/index.tsx +390 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|