@ai-chans/sdk-react 0.2.1 → 0.3.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/index.d.ts +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -3
- package/package.json +7 -1
- package/src/index.test.tsx +529 -0
- package/src/index.tsx +49 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ChansState } from "@ai-chans/sdk-js";
|
|
2
|
-
export type { ChansState } from "@ai-chans/sdk-js";
|
|
2
|
+
export type { ChansState, AgentInfo, SessionInfo } from "@ai-chans/sdk-js";
|
|
3
3
|
export interface ChansVoiceProps {
|
|
4
4
|
/**
|
|
5
5
|
* Agent token from chans.ai dashboard
|
|
@@ -41,6 +41,22 @@ export interface ChansVoiceProps {
|
|
|
41
41
|
* Called when disconnected
|
|
42
42
|
*/
|
|
43
43
|
onDisconnected?: () => void;
|
|
44
|
+
/**
|
|
45
|
+
* Called when agent joins the room
|
|
46
|
+
*/
|
|
47
|
+
onAgentConnected?: (agent: import("@ai-chans/sdk-js").AgentInfo) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Called when agent leaves the room
|
|
50
|
+
*/
|
|
51
|
+
onAgentDisconnected?: () => void;
|
|
52
|
+
/**
|
|
53
|
+
* Called when user finishes speaking (final transcript)
|
|
54
|
+
*/
|
|
55
|
+
onUserTurnComplete?: (transcript: string) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Called when session is created
|
|
58
|
+
*/
|
|
59
|
+
onSessionCreated?: (session: import("@ai-chans/sdk-js").SessionInfo) => void;
|
|
44
60
|
/**
|
|
45
61
|
* Custom render function for the voice UI
|
|
46
62
|
*/
|
|
@@ -86,6 +102,6 @@ export declare function useChans(): ChansContextValue;
|
|
|
86
102
|
* </ChansVoice>
|
|
87
103
|
* ```
|
|
88
104
|
*/
|
|
89
|
-
export declare function ChansVoice({ agentToken, userId, apiUrl, autoConnect, onTranscript, onResponse, onStateChange, onError, onConnected, onDisconnected, children, className, }: ChansVoiceProps): import("react/jsx-runtime").JSX.Element;
|
|
105
|
+
export declare function ChansVoice({ agentToken, userId, apiUrl, autoConnect, onTranscript, onResponse, onStateChange, onError, onConnected, onDisconnected, onAgentConnected, onAgentDisconnected, onUserTurnComplete, onSessionCreated, children, className, }: ChansVoiceProps): import("react/jsx-runtime").JSX.Element;
|
|
90
106
|
export default ChansVoice;
|
|
91
107
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAWA,OAAO,EAAe,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAG1D,YAAY,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAWA,OAAO,EAAe,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAG1D,YAAY,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAE1E,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,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,kBAAkB,EAAE,SAAS,KAAK,IAAI,CAAA;IAExE;;OAEG;IACH,mBAAmB,CAAC,EAAE,MAAM,IAAI,CAAA;IAEhC;;OAEG;IACH,kBAAkB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAA;IAEjD;;OAEG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,kBAAkB,EAAE,WAAW,KAAK,IAAI,CAAA;IAE5E;;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,gBAAgB,EAChB,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,QAAQ,EACR,SAAS,GACV,EAAE,eAAe,2CAgIjB;AA+ID,eAAe,UAAU,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -37,7 +37,7 @@ export function useChans() {
|
|
|
37
37
|
* </ChansVoice>
|
|
38
38
|
* ```
|
|
39
39
|
*/
|
|
40
|
-
export function ChansVoice({ agentToken, userId, apiUrl, autoConnect = true, onTranscript, onResponse, onStateChange, onError, onConnected, onDisconnected, children, className, }) {
|
|
40
|
+
export function ChansVoice({ agentToken, userId, apiUrl, autoConnect = true, onTranscript, onResponse, onStateChange, onError, onConnected, onDisconnected, onAgentConnected, onAgentDisconnected, onUserTurnComplete, onSessionCreated, children, className, }) {
|
|
41
41
|
const [state, setState] = useState("idle");
|
|
42
42
|
const [error, setError] = useState(null);
|
|
43
43
|
const clientRef = useRef(null);
|
|
@@ -67,6 +67,18 @@ export function ChansVoice({ agentToken, userId, apiUrl, autoConnect = true, onT
|
|
|
67
67
|
const unsubDisconnected = client.on("disconnected", () => {
|
|
68
68
|
onDisconnected?.();
|
|
69
69
|
});
|
|
70
|
+
const unsubAgentConnected = client.on("agentConnected", (agent) => {
|
|
71
|
+
onAgentConnected?.(agent);
|
|
72
|
+
});
|
|
73
|
+
const unsubAgentDisconnected = client.on("agentDisconnected", () => {
|
|
74
|
+
onAgentDisconnected?.();
|
|
75
|
+
});
|
|
76
|
+
const unsubUserTurnComplete = client.on("userTurnComplete", (transcript) => {
|
|
77
|
+
onUserTurnComplete?.(transcript);
|
|
78
|
+
});
|
|
79
|
+
const unsubSessionCreated = client.on("sessionCreated", (session) => {
|
|
80
|
+
onSessionCreated?.(session);
|
|
81
|
+
});
|
|
70
82
|
return () => {
|
|
71
83
|
unsubState();
|
|
72
84
|
unsubTranscript();
|
|
@@ -74,9 +86,13 @@ export function ChansVoice({ agentToken, userId, apiUrl, autoConnect = true, onT
|
|
|
74
86
|
unsubError();
|
|
75
87
|
unsubConnected();
|
|
76
88
|
unsubDisconnected();
|
|
89
|
+
unsubAgentConnected();
|
|
90
|
+
unsubAgentDisconnected();
|
|
91
|
+
unsubUserTurnComplete();
|
|
92
|
+
unsubSessionCreated();
|
|
77
93
|
client.disconnect();
|
|
78
94
|
};
|
|
79
|
-
}, [agentToken, apiUrl, onStateChange, onTranscript, onResponse, onError, onConnected, onDisconnected]);
|
|
95
|
+
}, [agentToken, apiUrl, onStateChange, onTranscript, onResponse, onError, onConnected, onDisconnected, onAgentConnected, onAgentDisconnected, onUserTurnComplete, onSessionCreated]);
|
|
80
96
|
// Auto-connect
|
|
81
97
|
useEffect(() => {
|
|
82
98
|
if (autoConnect && clientRef.current && state === "idle") {
|
|
@@ -149,7 +165,7 @@ function DefaultVoiceUI({ state, isConnected, connect, disconnect, error, }) {
|
|
|
149
165
|
marginTop: "0.5rem",
|
|
150
166
|
fontSize: "0.75rem",
|
|
151
167
|
color: "#9ca3af",
|
|
152
|
-
}, children: [state === "idle" && "Click to start", state === "connecting" && "Connecting...", state === "
|
|
168
|
+
}, children: [state === "idle" && "Click to start", state === "connecting" && "Connecting...", state === "waiting" && "Waiting for agent...", state === "ready" && "Listening...", state === "processing" && "Processing...", state === "speaking" && "Agent speaking", state === "error" && "Error"] })] }));
|
|
153
169
|
}
|
|
154
170
|
function MicIcon() {
|
|
155
171
|
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" })] }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-chans/sdk-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,13 +25,19 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "tsc",
|
|
27
27
|
"dev": "tsc --watch",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"test:coverage": "vitest run --coverage",
|
|
28
31
|
"lint": "echo 'lint not configured yet'"
|
|
29
32
|
},
|
|
30
33
|
"dependencies": {
|
|
31
34
|
"@ai-chans/sdk-js": "workspace:*"
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
37
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
38
|
+
"@testing-library/react": "^16.3.1",
|
|
34
39
|
"@types/react": "^19",
|
|
40
|
+
"happy-dom": "^20.0.11",
|
|
35
41
|
"typescript": "^5.9.3"
|
|
36
42
|
},
|
|
37
43
|
"peerDependencies": {
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
|
2
|
+
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
|
|
3
|
+
import { ChansVoice, useChans } from "./index.js"
|
|
4
|
+
|
|
5
|
+
// Helper to create a mock ChansClient instance
|
|
6
|
+
function createMockClient() {
|
|
7
|
+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
|
|
8
|
+
|
|
9
|
+
const mockClient = {
|
|
10
|
+
on: vi.fn((event: string, callback: (...args: unknown[]) => void) => {
|
|
11
|
+
if (!listeners.has(event)) {
|
|
12
|
+
listeners.set(event, new Set())
|
|
13
|
+
}
|
|
14
|
+
listeners.get(event)!.add(callback)
|
|
15
|
+
return () => listeners.get(event)?.delete(callback)
|
|
16
|
+
}),
|
|
17
|
+
off: vi.fn(),
|
|
18
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
getState: vi.fn().mockReturnValue("idle"),
|
|
21
|
+
isConnected: vi.fn().mockReturnValue(false),
|
|
22
|
+
// Helper to emit events in tests
|
|
23
|
+
_emit: (event: string, ...args: unknown[]) => {
|
|
24
|
+
listeners.get(event)?.forEach((cb) => cb(...args))
|
|
25
|
+
},
|
|
26
|
+
_listeners: listeners,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return mockClient
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Store the mock client for test access
|
|
33
|
+
let currentMockClient: ReturnType<typeof createMockClient>
|
|
34
|
+
// Track constructor calls for verification
|
|
35
|
+
let lastConstructorArgs: unknown[] = []
|
|
36
|
+
|
|
37
|
+
// Mock the sdk-js module with a proper class constructor
|
|
38
|
+
vi.mock("@ai-chans/sdk-js", async () => {
|
|
39
|
+
const actual = await vi.importActual("@ai-chans/sdk-js")
|
|
40
|
+
return {
|
|
41
|
+
...actual,
|
|
42
|
+
ChansClient: class MockChansClient {
|
|
43
|
+
constructor(...args: unknown[]) {
|
|
44
|
+
lastConstructorArgs = args
|
|
45
|
+
// Copy all methods from currentMockClient to this instance
|
|
46
|
+
Object.assign(this, currentMockClient)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("ChansVoice", () => {
|
|
53
|
+
let mockClient: ReturnType<typeof createMockClient>
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
mockClient = createMockClient()
|
|
57
|
+
currentMockClient = mockClient
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.clearAllMocks()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe("rendering", () => {
|
|
65
|
+
it("should render default UI", () => {
|
|
66
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
67
|
+
|
|
68
|
+
expect(screen.getByRole("button")).toBeInTheDocument()
|
|
69
|
+
expect(screen.getByText("Click to start")).toBeInTheDocument()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("should render custom children", () => {
|
|
73
|
+
render(
|
|
74
|
+
<ChansVoice agentToken="agt_test" autoConnect={false}>
|
|
75
|
+
{({ state }) => <div data-testid="custom">State: {state}</div>}
|
|
76
|
+
</ChansVoice>
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
expect(screen.getByTestId("custom")).toHaveTextContent("State: idle")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("should apply className to wrapper", () => {
|
|
83
|
+
const { container } = render(
|
|
84
|
+
<ChansVoice agentToken="agt_test" autoConnect={false} className="custom-class" />
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
expect(container.querySelector(".custom-class")).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe("client initialization", () => {
|
|
92
|
+
it("should create ChansClient with agentToken", () => {
|
|
93
|
+
render(<ChansVoice agentToken="agt_test_token" autoConnect={false} />)
|
|
94
|
+
|
|
95
|
+
expect(lastConstructorArgs[0]).toEqual({
|
|
96
|
+
agentToken: "agt_test_token",
|
|
97
|
+
apiUrl: undefined,
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("should create ChansClient with custom apiUrl", () => {
|
|
102
|
+
render(
|
|
103
|
+
<ChansVoice
|
|
104
|
+
agentToken="agt_test"
|
|
105
|
+
apiUrl="https://custom.api.com"
|
|
106
|
+
autoConnect={false}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(lastConstructorArgs[0]).toEqual({
|
|
111
|
+
agentToken: "agt_test",
|
|
112
|
+
apiUrl: "https://custom.api.com",
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("should set up event listeners on mount", () => {
|
|
117
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
118
|
+
|
|
119
|
+
expect(mockClient.on).toHaveBeenCalledWith("stateChange", expect.any(Function))
|
|
120
|
+
expect(mockClient.on).toHaveBeenCalledWith("transcript", expect.any(Function))
|
|
121
|
+
expect(mockClient.on).toHaveBeenCalledWith("response", expect.any(Function))
|
|
122
|
+
expect(mockClient.on).toHaveBeenCalledWith("error", expect.any(Function))
|
|
123
|
+
expect(mockClient.on).toHaveBeenCalledWith("connected", expect.any(Function))
|
|
124
|
+
expect(mockClient.on).toHaveBeenCalledWith("disconnected", expect.any(Function))
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("should disconnect on unmount", () => {
|
|
128
|
+
const { unmount } = render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
129
|
+
|
|
130
|
+
unmount()
|
|
131
|
+
|
|
132
|
+
expect(mockClient.disconnect).toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe("auto-connect", () => {
|
|
137
|
+
it("should auto-connect by default", async () => {
|
|
138
|
+
render(<ChansVoice agentToken="agt_test" />)
|
|
139
|
+
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(mockClient.connect).toHaveBeenCalledWith({ userId: undefined })
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("should not auto-connect when autoConnect=false", async () => {
|
|
146
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
147
|
+
|
|
148
|
+
// Give it a moment to potentially call connect
|
|
149
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
150
|
+
|
|
151
|
+
expect(mockClient.connect).not.toHaveBeenCalled()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("should pass userId to connect", async () => {
|
|
155
|
+
render(<ChansVoice agentToken="agt_test" userId="user-123" />)
|
|
156
|
+
|
|
157
|
+
await waitFor(() => {
|
|
158
|
+
expect(mockClient.connect).toHaveBeenCalledWith({ userId: "user-123" })
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe("state management", () => {
|
|
164
|
+
it("should update state on stateChange event", async () => {
|
|
165
|
+
render(
|
|
166
|
+
<ChansVoice agentToken="agt_test" autoConnect={false}>
|
|
167
|
+
{({ state }) => <div data-testid="state">{state}</div>}
|
|
168
|
+
</ChansVoice>
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
expect(screen.getByTestId("state")).toHaveTextContent("idle")
|
|
172
|
+
|
|
173
|
+
act(() => {
|
|
174
|
+
mockClient._emit("stateChange", "connecting")
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(screen.getByTestId("state")).toHaveTextContent("connecting")
|
|
178
|
+
|
|
179
|
+
act(() => {
|
|
180
|
+
mockClient._emit("stateChange", "listening")
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(screen.getByTestId("state")).toHaveTextContent("listening")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("should calculate isConnected correctly", () => {
|
|
187
|
+
render(
|
|
188
|
+
<ChansVoice agentToken="agt_test" autoConnect={false}>
|
|
189
|
+
{({ isConnected }) => (
|
|
190
|
+
<div data-testid="connected">{isConnected ? "yes" : "no"}</div>
|
|
191
|
+
)}
|
|
192
|
+
</ChansVoice>
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
expect(screen.getByTestId("connected")).toHaveTextContent("no")
|
|
196
|
+
|
|
197
|
+
act(() => {
|
|
198
|
+
mockClient._emit("stateChange", "listening")
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
expect(screen.getByTestId("connected")).toHaveTextContent("yes")
|
|
202
|
+
|
|
203
|
+
act(() => {
|
|
204
|
+
mockClient._emit("stateChange", "error")
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
expect(screen.getByTestId("connected")).toHaveTextContent("no")
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it("should handle errors", async () => {
|
|
211
|
+
const onError = vi.fn()
|
|
212
|
+
render(
|
|
213
|
+
<ChansVoice agentToken="agt_test" autoConnect={false} onError={onError}>
|
|
214
|
+
{({ error }) => (
|
|
215
|
+
<div data-testid="error">{error?.message || "no error"}</div>
|
|
216
|
+
)}
|
|
217
|
+
</ChansVoice>
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
expect(screen.getByTestId("error")).toHaveTextContent("no error")
|
|
221
|
+
|
|
222
|
+
act(() => {
|
|
223
|
+
mockClient._emit("error", new Error("Connection failed"))
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
expect(screen.getByTestId("error")).toHaveTextContent("Connection failed")
|
|
227
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error))
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("should clear error on connected event", async () => {
|
|
231
|
+
render(
|
|
232
|
+
<ChansVoice agentToken="agt_test" autoConnect={false}>
|
|
233
|
+
{({ error }) => (
|
|
234
|
+
<div data-testid="error">{error?.message || "no error"}</div>
|
|
235
|
+
)}
|
|
236
|
+
</ChansVoice>
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
// Set an error
|
|
240
|
+
act(() => {
|
|
241
|
+
mockClient._emit("error", new Error("Some error"))
|
|
242
|
+
})
|
|
243
|
+
expect(screen.getByTestId("error")).toHaveTextContent("Some error")
|
|
244
|
+
|
|
245
|
+
// Connect clears error
|
|
246
|
+
act(() => {
|
|
247
|
+
mockClient._emit("connected")
|
|
248
|
+
})
|
|
249
|
+
expect(screen.getByTestId("error")).toHaveTextContent("no error")
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe("callbacks", () => {
|
|
254
|
+
it("should call onStateChange", () => {
|
|
255
|
+
const onStateChange = vi.fn()
|
|
256
|
+
render(
|
|
257
|
+
<ChansVoice
|
|
258
|
+
agentToken="agt_test"
|
|
259
|
+
autoConnect={false}
|
|
260
|
+
onStateChange={onStateChange}
|
|
261
|
+
/>
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
act(() => {
|
|
265
|
+
mockClient._emit("stateChange", "connecting")
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
expect(onStateChange).toHaveBeenCalledWith("connecting")
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it("should call onTranscript", () => {
|
|
272
|
+
const onTranscript = vi.fn()
|
|
273
|
+
render(
|
|
274
|
+
<ChansVoice
|
|
275
|
+
agentToken="agt_test"
|
|
276
|
+
autoConnect={false}
|
|
277
|
+
onTranscript={onTranscript}
|
|
278
|
+
/>
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
act(() => {
|
|
282
|
+
mockClient._emit("transcript", "Hello world")
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
expect(onTranscript).toHaveBeenCalledWith("Hello world")
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it("should call onResponse", () => {
|
|
289
|
+
const onResponse = vi.fn()
|
|
290
|
+
render(
|
|
291
|
+
<ChansVoice
|
|
292
|
+
agentToken="agt_test"
|
|
293
|
+
autoConnect={false}
|
|
294
|
+
onResponse={onResponse}
|
|
295
|
+
/>
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
act(() => {
|
|
299
|
+
mockClient._emit("response", "Hi there!")
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
expect(onResponse).toHaveBeenCalledWith("Hi there!")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("should call onConnected", () => {
|
|
306
|
+
const onConnected = vi.fn()
|
|
307
|
+
render(
|
|
308
|
+
<ChansVoice
|
|
309
|
+
agentToken="agt_test"
|
|
310
|
+
autoConnect={false}
|
|
311
|
+
onConnected={onConnected}
|
|
312
|
+
/>
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
act(() => {
|
|
316
|
+
mockClient._emit("connected")
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
expect(onConnected).toHaveBeenCalled()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it("should call onDisconnected", () => {
|
|
323
|
+
const onDisconnected = vi.fn()
|
|
324
|
+
render(
|
|
325
|
+
<ChansVoice
|
|
326
|
+
agentToken="agt_test"
|
|
327
|
+
autoConnect={false}
|
|
328
|
+
onDisconnected={onDisconnected}
|
|
329
|
+
/>
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
act(() => {
|
|
333
|
+
mockClient._emit("disconnected")
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
expect(onDisconnected).toHaveBeenCalled()
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe("connect/disconnect controls", () => {
|
|
341
|
+
it("should call connect when clicking button in idle state", async () => {
|
|
342
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
343
|
+
|
|
344
|
+
fireEvent.click(screen.getByRole("button"))
|
|
345
|
+
|
|
346
|
+
await waitFor(() => {
|
|
347
|
+
expect(mockClient.connect).toHaveBeenCalled()
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("should call disconnect when clicking button in connected state", async () => {
|
|
352
|
+
render(
|
|
353
|
+
<ChansVoice agentToken="agt_test" autoConnect={false}>
|
|
354
|
+
{({ isConnected, connect, disconnect }) => (
|
|
355
|
+
<button onClick={isConnected ? disconnect : connect}>
|
|
356
|
+
{isConnected ? "Stop" : "Start"}
|
|
357
|
+
</button>
|
|
358
|
+
)}
|
|
359
|
+
</ChansVoice>
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
// Simulate connected state
|
|
363
|
+
act(() => {
|
|
364
|
+
mockClient._emit("stateChange", "listening")
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
expect(screen.getByRole("button")).toHaveTextContent("Stop")
|
|
368
|
+
|
|
369
|
+
fireEvent.click(screen.getByRole("button"))
|
|
370
|
+
|
|
371
|
+
await waitFor(() => {
|
|
372
|
+
expect(mockClient.disconnect).toHaveBeenCalled()
|
|
373
|
+
})
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it("should expose connect/disconnect in render props", () => {
|
|
377
|
+
const connectFn = vi.fn()
|
|
378
|
+
const disconnectFn = vi.fn()
|
|
379
|
+
|
|
380
|
+
render(
|
|
381
|
+
<ChansVoice agentToken="agt_test" autoConnect={false}>
|
|
382
|
+
{({ connect, disconnect }) => (
|
|
383
|
+
<>
|
|
384
|
+
<button data-testid="connect" onClick={connect}>
|
|
385
|
+
Connect
|
|
386
|
+
</button>
|
|
387
|
+
<button data-testid="disconnect" onClick={disconnect}>
|
|
388
|
+
Disconnect
|
|
389
|
+
</button>
|
|
390
|
+
</>
|
|
391
|
+
)}
|
|
392
|
+
</ChansVoice>
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
fireEvent.click(screen.getByTestId("connect"))
|
|
396
|
+
expect(mockClient.connect).toHaveBeenCalled()
|
|
397
|
+
|
|
398
|
+
fireEvent.click(screen.getByTestId("disconnect"))
|
|
399
|
+
expect(mockClient.disconnect).toHaveBeenCalled()
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
describe("default UI states", () => {
|
|
404
|
+
it("should show 'Click to start' in idle state", () => {
|
|
405
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
406
|
+
expect(screen.getByText("Click to start")).toBeInTheDocument()
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it("should show 'Connecting...' in connecting state", () => {
|
|
410
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
411
|
+
|
|
412
|
+
act(() => {
|
|
413
|
+
mockClient._emit("stateChange", "connecting")
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
expect(screen.getByText("Connecting...")).toBeInTheDocument()
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it("should show 'Listening...' in ready state", () => {
|
|
420
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
421
|
+
|
|
422
|
+
act(() => {
|
|
423
|
+
mockClient._emit("stateChange", "ready")
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
expect(screen.getByText("Listening...")).toBeInTheDocument()
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it("should show 'Agent speaking' in speaking state", () => {
|
|
430
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
431
|
+
|
|
432
|
+
act(() => {
|
|
433
|
+
mockClient._emit("stateChange", "speaking")
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
expect(screen.getByText("Agent speaking")).toBeInTheDocument()
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it("should show error message when error occurs", () => {
|
|
440
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
441
|
+
|
|
442
|
+
act(() => {
|
|
443
|
+
mockClient._emit("error", new Error("Connection failed"))
|
|
444
|
+
mockClient._emit("stateChange", "error")
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
expect(screen.getByText("Connection failed")).toBeInTheDocument()
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it("should disable button in connecting state", () => {
|
|
451
|
+
render(<ChansVoice agentToken="agt_test" autoConnect={false} />)
|
|
452
|
+
|
|
453
|
+
act(() => {
|
|
454
|
+
mockClient._emit("stateChange", "connecting")
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
expect(screen.getByRole("button")).toBeDisabled()
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
describe("useChans", () => {
|
|
463
|
+
let mockClient: ReturnType<typeof createMockClient>
|
|
464
|
+
|
|
465
|
+
beforeEach(() => {
|
|
466
|
+
mockClient = createMockClient()
|
|
467
|
+
currentMockClient = mockClient
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
afterEach(() => {
|
|
471
|
+
vi.clearAllMocks()
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it("should throw error when used outside ChansVoice", () => {
|
|
475
|
+
// Suppress console.error for this test
|
|
476
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
477
|
+
|
|
478
|
+
function TestComponent() {
|
|
479
|
+
useChans()
|
|
480
|
+
return null
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
expect(() => render(<TestComponent />)).toThrow(
|
|
484
|
+
"useChans must be used within a ChansVoice component"
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
consoleSpy.mockRestore()
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it("should provide context values when used inside ChansVoice", () => {
|
|
491
|
+
function ChildComponent() {
|
|
492
|
+
const { state, isConnected } = useChans()
|
|
493
|
+
return (
|
|
494
|
+
<div>
|
|
495
|
+
<span data-testid="state">{state}</span>
|
|
496
|
+
<span data-testid="connected">{isConnected ? "yes" : "no"}</span>
|
|
497
|
+
</div>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
render(
|
|
502
|
+
<ChansVoice agentToken="agt_test" autoConnect={false}>
|
|
503
|
+
{() => <ChildComponent />}
|
|
504
|
+
</ChansVoice>
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
expect(screen.getByTestId("state")).toHaveTextContent("idle")
|
|
508
|
+
expect(screen.getByTestId("connected")).toHaveTextContent("no")
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it("should update when state changes", () => {
|
|
512
|
+
function ChildComponent() {
|
|
513
|
+
const { state } = useChans()
|
|
514
|
+
return <span data-testid="state">{state}</span>
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
render(
|
|
518
|
+
<ChansVoice agentToken="agt_test" autoConnect={false}>
|
|
519
|
+
{() => <ChildComponent />}
|
|
520
|
+
</ChansVoice>
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
act(() => {
|
|
524
|
+
mockClient._emit("stateChange", "listening")
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
expect(screen.getByTestId("state")).toHaveTextContent("listening")
|
|
528
|
+
})
|
|
529
|
+
})
|
package/src/index.tsx
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { ChansClient, ChansState } from "@ai-chans/sdk-js"
|
|
13
13
|
|
|
14
14
|
// Re-export client types
|
|
15
|
-
export type { ChansState } from "@ai-chans/sdk-js"
|
|
15
|
+
export type { ChansState, AgentInfo, SessionInfo } from "@ai-chans/sdk-js"
|
|
16
16
|
|
|
17
17
|
export interface ChansVoiceProps {
|
|
18
18
|
/**
|
|
@@ -65,6 +65,26 @@ export interface ChansVoiceProps {
|
|
|
65
65
|
*/
|
|
66
66
|
onDisconnected?: () => void
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Called when agent joins the room
|
|
70
|
+
*/
|
|
71
|
+
onAgentConnected?: (agent: import("@ai-chans/sdk-js").AgentInfo) => void
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Called when agent leaves the room
|
|
75
|
+
*/
|
|
76
|
+
onAgentDisconnected?: () => void
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Called when user finishes speaking (final transcript)
|
|
80
|
+
*/
|
|
81
|
+
onUserTurnComplete?: (transcript: string) => void
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Called when session is created
|
|
85
|
+
*/
|
|
86
|
+
onSessionCreated?: (session: import("@ai-chans/sdk-js").SessionInfo) => void
|
|
87
|
+
|
|
68
88
|
/**
|
|
69
89
|
* Custom render function for the voice UI
|
|
70
90
|
*/
|
|
@@ -135,6 +155,10 @@ export function ChansVoice({
|
|
|
135
155
|
onError,
|
|
136
156
|
onConnected,
|
|
137
157
|
onDisconnected,
|
|
158
|
+
onAgentConnected,
|
|
159
|
+
onAgentDisconnected,
|
|
160
|
+
onUserTurnComplete,
|
|
161
|
+
onSessionCreated,
|
|
138
162
|
children,
|
|
139
163
|
className,
|
|
140
164
|
}: ChansVoiceProps) {
|
|
@@ -176,6 +200,22 @@ export function ChansVoice({
|
|
|
176
200
|
onDisconnected?.()
|
|
177
201
|
})
|
|
178
202
|
|
|
203
|
+
const unsubAgentConnected = client.on("agentConnected", (agent) => {
|
|
204
|
+
onAgentConnected?.(agent)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const unsubAgentDisconnected = client.on("agentDisconnected", () => {
|
|
208
|
+
onAgentDisconnected?.()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const unsubUserTurnComplete = client.on("userTurnComplete", (transcript) => {
|
|
212
|
+
onUserTurnComplete?.(transcript)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const unsubSessionCreated = client.on("sessionCreated", (session) => {
|
|
216
|
+
onSessionCreated?.(session)
|
|
217
|
+
})
|
|
218
|
+
|
|
179
219
|
return () => {
|
|
180
220
|
unsubState()
|
|
181
221
|
unsubTranscript()
|
|
@@ -183,9 +223,13 @@ export function ChansVoice({
|
|
|
183
223
|
unsubError()
|
|
184
224
|
unsubConnected()
|
|
185
225
|
unsubDisconnected()
|
|
226
|
+
unsubAgentConnected()
|
|
227
|
+
unsubAgentDisconnected()
|
|
228
|
+
unsubUserTurnComplete()
|
|
229
|
+
unsubSessionCreated()
|
|
186
230
|
client.disconnect()
|
|
187
231
|
}
|
|
188
|
-
}, [agentToken, apiUrl, onStateChange, onTranscript, onResponse, onError, onConnected, onDisconnected])
|
|
232
|
+
}, [agentToken, apiUrl, onStateChange, onTranscript, onResponse, onError, onConnected, onDisconnected, onAgentConnected, onAgentDisconnected, onUserTurnComplete, onSessionCreated])
|
|
189
233
|
|
|
190
234
|
// Auto-connect
|
|
191
235
|
useEffect(() => {
|
|
@@ -322,8 +366,9 @@ function DefaultVoiceUI({
|
|
|
322
366
|
>
|
|
323
367
|
{state === "idle" && "Click to start"}
|
|
324
368
|
{state === "connecting" && "Connecting..."}
|
|
325
|
-
{state === "
|
|
326
|
-
{state === "
|
|
369
|
+
{state === "waiting" && "Waiting for agent..."}
|
|
370
|
+
{state === "ready" && "Listening..."}
|
|
371
|
+
{state === "processing" && "Processing..."}
|
|
327
372
|
{state === "speaking" && "Agent speaking"}
|
|
328
373
|
{state === "error" && "Error"}
|
|
329
374
|
</div>
|