@tribe-nest/forge 3.65.0 → 3.69.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.
@@ -0,0 +1,213 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
6
+ import { useAgentCall } from "../useAgentCall";
7
+
8
+ /**
9
+ * "Call us", headless. Every call costs the business by the minute, so what
10
+ * is pinned here is the money-shaped part: the request sequence that starts a
11
+ * call, that a reconnect asks for a FRESH ticket with the identity the start
12
+ * gave, and that every way out (hang up, hang up mid-setup, leaving the page,
13
+ * unmounting) ends the call on the server.
14
+ */
15
+
16
+ const API = "https://api.test";
17
+ const PROFILE = "11111111-1111-4111-8111-111111111111";
18
+ const WEBSITE = "22222222-2222-4222-8222-222222222222";
19
+
20
+ const CALL = {
21
+ callId: "call-1",
22
+ roomId: "room-1",
23
+ mediaUrl: "wss://media.test",
24
+ token: "ticket-1",
25
+ expiresAt: "2026-10-05T12:00:00Z",
26
+ identity: "visitor-33333333-3333-4333-8333-333333333333",
27
+ callKey: "key-1",
28
+ };
29
+
30
+ type Route = (body: Record<string, unknown>) => { status: number; body: unknown };
31
+
32
+ let routes: Record<string, Route>;
33
+ let calls: { url: string; body: Record<string, unknown>; keepalive?: boolean }[];
34
+
35
+ beforeEach(() => {
36
+ calls = [];
37
+ routes = {
38
+ "/public/agent/calls/challenge": () => ({ status: 201, body: { challenge: "c1", difficulty: 4, expiresAt: "x" } }),
39
+ "/public/agent/calls": () => ({ status: 201, body: CALL }),
40
+ "/public/agent/calls/call-1/join": () => ({ status: 201, body: { ...CALL, token: "ticket-2" } }),
41
+ "/public/agent/calls/call-1/end": () => ({ status: 201, body: { ended: true } }),
42
+ };
43
+ vi.stubGlobal(
44
+ "fetch",
45
+ vi.fn(async (url: string, init: RequestInit) => {
46
+ const path = url.replace(API, "");
47
+ const body = JSON.parse(String(init.body ?? "{}"));
48
+ calls.push({ url: path, body, keepalive: init.keepalive });
49
+ const route = routes[path];
50
+ const res = route ? route(body) : { status: 404, body: { message: "no route" } };
51
+ return new Response(JSON.stringify(res.body), { status: res.status, headers: { "Content-Type": "application/json" } });
52
+ }),
53
+ );
54
+ });
55
+
56
+ afterEach(() => {
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ const wrapper =
61
+ (websiteId?: string) =>
62
+ ({ children }: { children: ReactNode }) => (
63
+ <ForgeClientProvider apiUrl={API} profileId={PROFILE} websiteId={websiteId}>
64
+ {children}
65
+ </ForgeClientProvider>
66
+ );
67
+
68
+ const mount = ({ websiteId }: { websiteId?: string } = { websiteId: WEBSITE }) =>
69
+ renderHook(() => useAgentCall({ visitorId: "visitor-a", solver: { yieldToPage: async () => undefined } }), {
70
+ wrapper: wrapper(websiteId),
71
+ });
72
+
73
+ describe("useAgentCall: starting a call", () => {
74
+ it("asks for the check, solves it, and starts the call for THIS website", async () => {
75
+ const { result } = mount();
76
+ await act(() => result.current.start());
77
+
78
+ expect(result.current.state).toEqual({ phase: "live", call: CALL });
79
+ expect(calls.map((c) => c.url)).toEqual(["/public/agent/calls/challenge", "/public/agent/calls"]);
80
+ expect(calls[0].body).toEqual({ profileId: PROFILE, websiteId: WEBSITE, visitorId: "visitor-a" });
81
+ const start = calls[1].body;
82
+ expect(start).toMatchObject({ profileId: PROFILE, websiteId: WEBSITE, visitorId: "visitor-a", challenge: "c1" });
83
+ expect(String(start.solution)).toMatch(/^\d{1,12}$/);
84
+ });
85
+
86
+ it("leaves websiteId out when the site does not know it, so the profile's agent answers", async () => {
87
+ const { result } = mount({});
88
+ await act(() => result.current.start());
89
+ expect(calls[0].body).toEqual({ profileId: PROFILE, visitorId: "visitor-a" });
90
+ expect(calls[1].body).not.toHaveProperty("websiteId");
91
+ });
92
+
93
+ it.each([
94
+ [429, "too_many"],
95
+ [404, "unavailable"],
96
+ [409, "busy"],
97
+ [400, "check_failed"],
98
+ [503, "failed"],
99
+ ])("reports a %i refusal as %s, with the API's message", async (status, kind) => {
100
+ routes["/public/agent/calls"] = () => ({ status, body: { status, message: `refused ${status}` } });
101
+ const { result } = mount();
102
+ await act(() => result.current.start());
103
+ expect(result.current.state).toEqual({ phase: "error", error: kind, message: `refused ${status}` });
104
+ });
105
+
106
+ it("reports a refused CHALLENGE too (404: this site's agent takes no calls) without starting anything", async () => {
107
+ routes["/public/agent/calls/challenge"] = () => ({ status: 404, body: { message: "no calls" } });
108
+ const { result } = mount();
109
+ await act(() => result.current.start());
110
+ expect(result.current.state).toMatchObject({ phase: "error", error: "unavailable" });
111
+ expect(calls.map((c) => c.url)).toEqual(["/public/agent/calls/challenge"]);
112
+ });
113
+
114
+ it("can try again after an error", async () => {
115
+ routes["/public/agent/calls"] = () => ({ status: 409, body: { message: "busy" } });
116
+ const { result } = mount();
117
+ await act(() => result.current.start());
118
+ routes["/public/agent/calls"] = () => ({ status: 201, body: CALL });
119
+ await act(() => result.current.start());
120
+ expect(result.current.state.phase).toBe("live");
121
+ });
122
+
123
+ it("does not start a second call while one is connecting", async () => {
124
+ const { result } = mount();
125
+ await act(async () => {
126
+ void result.current.start();
127
+ void result.current.start();
128
+ });
129
+ await waitFor(() => expect(result.current.state.phase).toBe("live"));
130
+ expect(calls.filter((c) => c.url === "/public/agent/calls/challenge")).toHaveLength(1);
131
+ });
132
+ });
133
+
134
+ describe("useAgentCall: the media room's credentials", () => {
135
+ it("uses the start response first, then a fresh ticket per reconnect with the start's identity", async () => {
136
+ const { result } = mount();
137
+ await act(() => result.current.start());
138
+
139
+ await expect(result.current.getCredentials()).resolves.toEqual({ mediaUrl: CALL.mediaUrl, token: "ticket-1" });
140
+ expect(calls.some((c) => c.url.endsWith("/join"))).toBe(false);
141
+
142
+ await expect(result.current.getCredentials()).resolves.toEqual({ mediaUrl: CALL.mediaUrl, token: "ticket-2" });
143
+ const join = calls.find((c) => c.url === "/public/agent/calls/call-1/join");
144
+ expect(join?.body).toEqual({ callKey: CALL.callKey, identity: CALL.identity });
145
+ });
146
+ });
147
+
148
+ describe("useAgentCall: every way out ends the call on the server", () => {
149
+ it("hang up posts the end with the call key and shows the call ended", async () => {
150
+ const { result } = mount();
151
+ await act(() => result.current.start());
152
+ act(() => result.current.end());
153
+
154
+ expect(result.current.state).toEqual({ phase: "ended", callId: "call-1" });
155
+ await waitFor(() =>
156
+ expect(calls.find((c) => c.url === "/public/agent/calls/call-1/end")?.body).toEqual({ callKey: CALL.callKey }),
157
+ );
158
+ });
159
+
160
+ it("hanging up while the call is being set up ends the call the server then creates", async () => {
161
+ let releaseStart!: () => void;
162
+ const gate = new Promise<void>((resolve) => (releaseStart = resolve));
163
+ const fetchMock = globalThis.fetch as ReturnType<typeof vi.fn>;
164
+ const original = fetchMock.getMockImplementation()!;
165
+ fetchMock.mockImplementation(async (url: string, init: RequestInit) => {
166
+ if (url === `${API}/public/agent/calls`) await gate;
167
+ return original(url, init);
168
+ });
169
+
170
+ const { result } = mount();
171
+ let starting!: Promise<void>;
172
+ act(() => {
173
+ starting = result.current.start();
174
+ });
175
+ await waitFor(() => expect(calls.some((c) => c.url === "/public/agent/calls")).toBe(false));
176
+ act(() => result.current.end());
177
+ expect(result.current.state).toEqual({ phase: "idle" });
178
+
179
+ releaseStart();
180
+ await act(() => starting);
181
+ // Either the solver saw the abort before starting (nothing to end), or the
182
+ // start went out and the call it created is ended straight away. Never a
183
+ // live call nobody can hang up.
184
+ expect(result.current.state).toEqual({ phase: "idle" });
185
+ const started = calls.some((c) => c.url === "/public/agent/calls");
186
+ const ended = calls.some((c) => c.url === "/public/agent/calls/call-1/end");
187
+ expect(ended).toBe(started);
188
+ });
189
+
190
+ it("leaving the page ends the call with a request that outlives the page", async () => {
191
+ const { result } = mount();
192
+ await act(() => result.current.start());
193
+ act(() => {
194
+ window.dispatchEvent(new Event("pagehide"));
195
+ });
196
+ const end = calls.find((c) => c.url === "/public/agent/calls/call-1/end");
197
+ expect(end).toMatchObject({ body: { callKey: CALL.callKey }, keepalive: true });
198
+ });
199
+
200
+ it("unmounting ends the call", async () => {
201
+ const { result, unmount } = mount();
202
+ await act(() => result.current.start());
203
+ unmount();
204
+ expect(calls.filter((c) => c.url === "/public/agent/calls/call-1/end")).toHaveLength(1);
205
+ });
206
+
207
+ it("does not end anything when there is no call", () => {
208
+ const { unmount } = mount();
209
+ window.dispatchEvent(new Event("pagehide"));
210
+ unmount();
211
+ expect(calls).toEqual([]);
212
+ });
213
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The bot check in front of "Call us".
3
+ *
4
+ * A call costs real money per minute, so before the backend starts one it asks
5
+ * the browser to spend a second or so of work: find a decimal `solution` such
6
+ * that SHA-256 of `${challenge}:${solution}` starts with `difficulty` zero
7
+ * bits. One visitor barely notices; a script dialling thousands of calls pays
8
+ * for every one. The backend checks the same thing in `callGuard.ts`.
9
+ */
10
+
11
+ /** How many zero bits the digest starts with. */
12
+ export function leadingZeroBits(bytes: Uint8Array): number {
13
+ let bits = 0;
14
+ for (const byte of bytes) {
15
+ if (byte === 0) {
16
+ bits += 8;
17
+ continue;
18
+ }
19
+ return bits + Math.clz32(byte) - 24;
20
+ }
21
+ return bits;
22
+ }
23
+
24
+ /** The backend accepts up to 12 digits. */
25
+ const MAX_SOLUTION = 999_999_999_999;
26
+
27
+ export interface SolveCallChallengeOptions {
28
+ /** Hashes per batch. Between batches the solver yields, so the page can paint. */
29
+ batchSize?: number;
30
+ signal?: AbortSignal;
31
+ /** How the solver gives the page a turn. Defaults to a zero timeout. */
32
+ yieldToPage?: () => Promise<void>;
33
+ }
34
+
35
+ const defaultYield = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
36
+
37
+ /**
38
+ * Finds the smallest solution. At the backend's difficulty of 16 that is about
39
+ * 65k hashes on average. `crypto.subtle` is async, so a batch is hashed in
40
+ * parallel and the solver then yields, which keeps a "Connecting" spinner
41
+ * turning while it works. Rejects with an `AbortError` when `signal` aborts.
42
+ */
43
+ export async function solveCallChallenge(
44
+ challenge: string,
45
+ difficulty: number,
46
+ { batchSize = 512, signal, yieldToPage = defaultYield }: SolveCallChallengeOptions = {},
47
+ ): Promise<string> {
48
+ const subtle = globalThis.crypto?.subtle;
49
+ if (!subtle) throw new Error("This browser cannot run the call check.");
50
+ const encoder = new TextEncoder();
51
+
52
+ for (let start = 0; start <= MAX_SOLUTION; start += batchSize) {
53
+ if (signal?.aborted) throw abortError();
54
+ const end = Math.min(start + batchSize, MAX_SOLUTION + 1);
55
+ const candidates: number[] = [];
56
+ for (let n = start; n < end; n++) candidates.push(n);
57
+ const digests = await Promise.all(
58
+ candidates.map((n) => subtle.digest("SHA-256", encoder.encode(`${challenge}:${n}`))),
59
+ );
60
+ for (let i = 0; i < digests.length; i++) {
61
+ if (leadingZeroBits(new Uint8Array(digests[i])) >= difficulty) return String(candidates[i]);
62
+ }
63
+ await yieldToPage();
64
+ }
65
+ throw new Error("No solution found.");
66
+ }
67
+
68
+ function abortError(): Error {
69
+ const err = new Error("The call check was cancelled.");
70
+ err.name = "AbortError";
71
+ return err;
72
+ }
@@ -0,0 +1,189 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useForge } from "../../../provider/ForgeProvider";
3
+ import {
4
+ AgentCallRequestError,
5
+ endAgentCall,
6
+ joinAgentCall,
7
+ requestAgentCallChallenge,
8
+ startAgentCall,
9
+ type AgentCallCredentials,
10
+ } from "../../../data/queries/useWebsiteAgent";
11
+ import { solveCallChallenge, type SolveCallChallengeOptions } from "./callChallenge";
12
+ import { getAgentVisitorId } from "./visitorId";
13
+
14
+ /**
15
+ * Why a call could not start, by what the backend said.
16
+ *
17
+ * - `too_many`: the visitor or their network called too often (429).
18
+ * - `unavailable`: this website's agent takes no calls (404).
19
+ * - `busy`: every line is taken, or the business's minutes are used up (409).
20
+ * - `check_failed`: the bot check was refused, usually an expired challenge (400).
21
+ * - `failed`: anything else, including the call network being down (503).
22
+ */
23
+ export type AgentCallErrorKind = "too_many" | "unavailable" | "busy" | "check_failed" | "failed";
24
+
25
+ export type AgentCallState =
26
+ | { phase: "idle" }
27
+ /** Asking for the bot check, solving it and starting the call. */
28
+ | { phase: "connecting" }
29
+ /** The call exists: join its media room with `getCredentials`. */
30
+ | { phase: "live"; call: AgentCallCredentials }
31
+ | { phase: "ended"; callId: string }
32
+ | { phase: "error"; error: AgentCallErrorKind; message: string };
33
+
34
+ export function classifyAgentCallError(err: unknown): AgentCallErrorKind {
35
+ if (!(err instanceof AgentCallRequestError)) return "failed";
36
+ switch (err.status) {
37
+ case 429:
38
+ return "too_many";
39
+ case 404:
40
+ return "unavailable";
41
+ case 409:
42
+ return "busy";
43
+ case 400:
44
+ return "check_failed";
45
+ default:
46
+ return "failed";
47
+ }
48
+ }
49
+
50
+ export interface UseAgentCallOptions {
51
+ /** Defaults to the widget's own visitor id, which the chat uses too. */
52
+ visitorId?: string;
53
+ /** Passed to the bot-check solver; tests use it to keep the work small. */
54
+ solver?: SolveCallChallengeOptions;
55
+ }
56
+
57
+ /**
58
+ * Headless "Call us": a voice call with the website's agent, in the browser.
59
+ *
60
+ * `start()` asks for the bot check, solves it, and starts the call; the state
61
+ * then turns `live` and the UI mounts a `MediaRoomProvider` with
62
+ * `getCredentials`. The first connect uses the start response, and every
63
+ * reconnect after it fetches a fresh ticket, because a ticket expires in
64
+ * minutes. `end()` hangs up. Leaving the page, or unmounting the hook, hangs
65
+ * up too, so a forgotten tab does not hold a line that costs the business by
66
+ * the minute.
67
+ *
68
+ * This hook has no media code in it on purpose: the room lives in the UI that
69
+ * mounts on `live`, which a site can load lazily.
70
+ */
71
+ export function useAgentCall(options: UseAgentCallOptions = {}) {
72
+ const { apiUrl, profileId, websiteId } = useForge();
73
+ const visitorId = useMemo(() => options.visitorId ?? getAgentVisitorId(), [options.visitorId]);
74
+ const [state, setState] = useState<AgentCallState>({ phase: "idle" });
75
+
76
+ // Refs, so hanging up from `pagehide` or an unmount reads the call as it is
77
+ // NOW rather than as it was when the listener was attached.
78
+ const apiUrlRef = useRef(apiUrl);
79
+ apiUrlRef.current = apiUrl;
80
+ const solverRef = useRef(options.solver);
81
+ solverRef.current = options.solver;
82
+ const callRef = useRef<AgentCallCredentials | null>(null);
83
+ const firstCredentials = useRef<AgentCallCredentials | null>(null);
84
+ const runRef = useRef<AbortController | null>(null);
85
+
86
+ const start = useCallback(async () => {
87
+ if (!apiUrl || !profileId || runRef.current) return;
88
+ const run = new AbortController();
89
+ runRef.current = run;
90
+ setState({ phase: "connecting" });
91
+ try {
92
+ const check = await requestAgentCallChallenge({ apiUrl, profileId, websiteId, visitorId });
93
+ const solution = await solveCallChallenge(check.challenge, check.difficulty, {
94
+ ...solverRef.current,
95
+ signal: run.signal,
96
+ });
97
+ if (run.signal.aborted) return;
98
+ const call = await startAgentCall({
99
+ apiUrl,
100
+ profileId,
101
+ websiteId,
102
+ visitorId,
103
+ challenge: check.challenge,
104
+ solution,
105
+ });
106
+ if (run.signal.aborted) {
107
+ // Hung up while the call was being set up: it exists now, so end it.
108
+ void endAgentCall({ apiUrl, callId: call.callId, callKey: call.callKey }).catch(() => undefined);
109
+ return;
110
+ }
111
+ callRef.current = call;
112
+ firstCredentials.current = call;
113
+ setState({ phase: "live", call });
114
+ } catch (err) {
115
+ if (run.signal.aborted) return;
116
+ runRef.current = null;
117
+ setState({
118
+ phase: "error",
119
+ error: classifyAgentCallError(err),
120
+ message: err instanceof Error ? err.message : String(err),
121
+ });
122
+ }
123
+ }, [apiUrl, profileId, websiteId, visitorId]);
124
+
125
+ /** Hangs up, or stops a call that is still connecting. */
126
+ const end = useCallback(() => {
127
+ const run = runRef.current;
128
+ runRef.current = null;
129
+ run?.abort();
130
+ const call = callRef.current;
131
+ callRef.current = null;
132
+ firstCredentials.current = null;
133
+ if (call) {
134
+ void endAgentCall({ apiUrl: apiUrlRef.current, callId: call.callId, callKey: call.callKey }).catch(
135
+ () => undefined,
136
+ );
137
+ setState({ phase: "ended", callId: call.callId });
138
+ } else {
139
+ setState({ phase: "idle" });
140
+ }
141
+ }, []);
142
+
143
+ /** Back to idle from `ended` or `error`, ready for another call. */
144
+ const reset = useCallback(() => {
145
+ if (runRef.current) return;
146
+ setState({ phase: "idle" });
147
+ }, []);
148
+
149
+ const getCredentials = useCallback(async () => {
150
+ const first = firstCredentials.current;
151
+ if (first) {
152
+ firstCredentials.current = null;
153
+ return { mediaUrl: first.mediaUrl, token: first.token };
154
+ }
155
+ const call = callRef.current;
156
+ if (!call) throw new Error("The call has ended.");
157
+ const fresh = await joinAgentCall({
158
+ apiUrl: apiUrlRef.current,
159
+ callId: call.callId,
160
+ callKey: call.callKey,
161
+ identity: call.identity,
162
+ });
163
+ return { mediaUrl: fresh.mediaUrl, token: fresh.token };
164
+ }, []);
165
+
166
+ useEffect(() => {
167
+ if (typeof window === "undefined") return;
168
+ const hangUpOnLeave = () => {
169
+ runRef.current?.abort();
170
+ runRef.current = null;
171
+ const call = callRef.current;
172
+ if (!call) return;
173
+ callRef.current = null;
174
+ void endAgentCall({
175
+ apiUrl: apiUrlRef.current,
176
+ callId: call.callId,
177
+ callKey: call.callKey,
178
+ keepalive: true,
179
+ }).catch(() => undefined);
180
+ };
181
+ window.addEventListener("pagehide", hangUpOnLeave);
182
+ return () => {
183
+ window.removeEventListener("pagehide", hangUpOnLeave);
184
+ hangUpOnLeave();
185
+ };
186
+ }, []);
187
+
188
+ return { state, start, end, reset, getCredentials, visitorId };
189
+ }
@@ -0,0 +1,113 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useLocalMedia, useMediaRoom, useRemoteTrack, useRoomState, useVisibleProducers } from "../../media";
3
+
4
+ /**
5
+ * What a call with the agent looks like from inside its media room.
6
+ *
7
+ * - `connecting`: joining the room, or waiting for the agent to arrive.
8
+ * - `listening`: the agent is there and quiet, so it is the caller's turn.
9
+ * - `speaking`: the agent's audio is audible right now.
10
+ * - `no_mic`: the browser refused the microphone.
11
+ */
12
+ export type AgentCallRoomStatus = "connecting" | "listening" | "speaking" | "no_mic";
13
+
14
+ /**
15
+ * Inside a `MediaRoomProvider`: publishes the microphone once joined, finds
16
+ * the agent's audio, and says when the call is over from the room's side (the
17
+ * agent left, or the room closed), so the caller does not sit on a dead line.
18
+ *
19
+ * Imports the media module, so only load it with the call UI.
20
+ */
21
+ export function useAgentCallRoom({ selfIdentity, onEnded }: { selfIdentity: string; onEnded: () => void }) {
22
+ const room = useRoomState();
23
+ const { connectionState } = useMediaRoom();
24
+ const local = useLocalMedia();
25
+ const producers = useVisibleProducers();
26
+ const [micError, setMicError] = useState<string | null>(null);
27
+ const [agentSpeaking, setAgentSpeaking] = useState(false);
28
+ const published = useRef(false);
29
+ const agentWasHere = useRef(false);
30
+ const onEndedRef = useRef(onEnded);
31
+ onEndedRef.current = onEnded;
32
+
33
+ // The browser asks for the microphone here, once in the room. Its own echo
34
+ // cancellation keeps the agent from hearing itself through the speakers.
35
+ useEffect(() => {
36
+ if (room.phase !== "joined" || published.current) return;
37
+ published.current = true;
38
+ local.publishMicrophone().catch((err: unknown) => {
39
+ published.current = false;
40
+ setMicError(err instanceof Error ? err.message : String(err));
41
+ });
42
+ }, [room.phase, local]);
43
+
44
+ const agentHere = room.peers.some((p) => p.identity !== selfIdentity);
45
+ const agentAudioProducerIds = producers
46
+ .filter((p) => p.kind === "audio" && p.identity !== selfIdentity)
47
+ .map((p) => p.producerId);
48
+
49
+ useEffect(() => {
50
+ if (agentHere) agentWasHere.current = true;
51
+ else if (agentWasHere.current) onEndedRef.current();
52
+ }, [agentHere]);
53
+
54
+ useEffect(() => {
55
+ if (connectionState === "closed") onEndedRef.current();
56
+ }, [connectionState]);
57
+
58
+ const status: AgentCallRoomStatus = micError
59
+ ? "no_mic"
60
+ : room.phase !== "joined" || !agentHere
61
+ ? "connecting"
62
+ : agentSpeaking
63
+ ? "speaking"
64
+ : "listening";
65
+
66
+ return { status, micError, agentAudioProducerIds, setAgentSpeaking };
67
+ }
68
+
69
+ /** How loud (RMS, 0 to 1) the agent's audio must be to count as speaking. */
70
+ const SPEAKING_LEVEL = 0.01;
71
+ /** How long below that before it counts as silent again, so a pause between
72
+ * words does not flicker the status. */
73
+ const SPEAKING_HOLD_MS = 400;
74
+
75
+ /**
76
+ * Plays one of the agent's audio producers and reports whether it is audible.
77
+ * Returns the ref for an `<audio autoPlay>` element. Measured in the browser
78
+ * because the node's speaker ranking is for people, and an agent is not in it.
79
+ */
80
+ export function useAgentAudio(producerId: string, onSpeaking: (speaking: boolean) => void) {
81
+ const { track, attach } = useRemoteTrack(producerId);
82
+ const mediaTrack = track?.track ?? null;
83
+
84
+ useEffect(() => {
85
+ if (!mediaTrack || typeof AudioContext === "undefined") return;
86
+ const context = new AudioContext();
87
+ const analyser = context.createAnalyser();
88
+ analyser.fftSize = 512;
89
+ context.createMediaStreamSource(new MediaStream([mediaTrack])).connect(analyser);
90
+ const samples = new Float32Array(analyser.fftSize);
91
+ let lastLoudAt = 0;
92
+ let speaking = false;
93
+ const timer = window.setInterval(() => {
94
+ analyser.getFloatTimeDomainData(samples);
95
+ let sum = 0;
96
+ for (const sample of samples) sum += sample * sample;
97
+ const now = performance.now();
98
+ if (Math.sqrt(sum / samples.length) > SPEAKING_LEVEL) lastLoudAt = now;
99
+ const next = now - lastLoudAt < SPEAKING_HOLD_MS;
100
+ if (next !== speaking) {
101
+ speaking = next;
102
+ onSpeaking(next);
103
+ }
104
+ }, 100);
105
+ return () => {
106
+ window.clearInterval(timer);
107
+ onSpeaking(false);
108
+ void context.close();
109
+ };
110
+ }, [mediaTrack, onSpeaking]);
111
+
112
+ return attach;
113
+ }
@@ -7,35 +7,20 @@ import {
7
7
  } from "../../../data/queries/useWebsiteAgent";
8
8
  import { isPathExcluded } from "./pathExclusion";
9
9
  import { useCurrentPath } from "./useCurrentPath";
10
+ import { getAgentVisitorId } from "./visitorId";
10
11
 
11
12
  export interface AiAgentMessage {
12
13
  role: "user" | "assistant";
13
14
  text: string;
14
15
  }
15
16
 
16
- const VISITOR_KEY = "forge_agent_visitor_id";
17
-
18
- function getVisitorId(): string {
19
- if (typeof window === "undefined") return "ssr";
20
- try {
21
- let id = window.localStorage.getItem(VISITOR_KEY);
22
- if (!id) {
23
- id = (window.crypto?.randomUUID?.() ?? `v_${Date.now()}_${Math.random().toString(36).slice(2)}`);
24
- window.localStorage.setItem(VISITOR_KEY, id);
25
- }
26
- return id;
27
- } catch {
28
- return `v_${Date.now()}`;
29
- }
30
- }
31
-
32
17
  /**
33
18
  * Headless controller for the website AI agent widget. Owns visitor identity,
34
19
  * the conversation (created lazily on first send), the message list, and the
35
20
  * streaming turn. A site can build any UI on top of this.
36
21
  */
37
22
  export function useAiAgent() {
38
- const { apiUrl, profileId, token } = useForge();
23
+ const { apiUrl, profileId, websiteId, token } = useForge();
39
24
  const config = useWebsiteAgentConfig();
40
25
  const path = useCurrentPath();
41
26
 
@@ -43,15 +28,15 @@ export function useAiAgent() {
43
28
  const [sending, setSending] = useState(false);
44
29
  const [error, setError] = useState<string | null>(null);
45
30
  const conversationIdRef = useRef<string | null>(null);
46
- const visitorId = useMemo(getVisitorId, []);
31
+ const visitorId = useMemo(getAgentVisitorId, []);
47
32
 
48
33
  const ensureConversation = useCallback(async (): Promise<string | null> => {
49
34
  if (conversationIdRef.current) return conversationIdRef.current;
50
35
  if (!apiUrl || !profileId) return null;
51
- const res = await createAgentConversation({ apiUrl, token, profileId, visitorId });
36
+ const res = await createAgentConversation({ apiUrl, token, profileId, websiteId, visitorId });
52
37
  conversationIdRef.current = res.id;
53
38
  return res.id;
54
- }, [apiUrl, profileId, token, visitorId]);
39
+ }, [apiUrl, profileId, websiteId, token, visitorId]);
55
40
 
56
41
  const send = useCallback(
57
42
  async (text: string) => {
@@ -107,6 +92,9 @@ export function useAiAgent() {
107
92
  config: config.data,
108
93
  isEnabled: !!config.data?.enabled && !excluded,
109
94
  loading: config.isLoading,
95
+ /** "Call us": the agent takes calls from the browser. See `useAgentCall`. */
96
+ canCall: !!config.data?.enabled && !!config.data?.voiceEnabled && !excluded,
97
+ visitorId,
110
98
  messages,
111
99
  sending,
112
100
  error,
@@ -0,0 +1,20 @@
1
+ const VISITOR_KEY = "forge_agent_visitor_id";
2
+
3
+ /**
4
+ * The widget's anonymous visitor id, kept in localStorage so a returning
5
+ * visitor keeps their conversations. Chat and calls share it, which is also
6
+ * what the per-visitor call limit counts.
7
+ */
8
+ export function getAgentVisitorId(): string {
9
+ if (typeof window === "undefined") return "ssr";
10
+ try {
11
+ let id = window.localStorage.getItem(VISITOR_KEY);
12
+ if (!id) {
13
+ id = window.crypto?.randomUUID?.() ?? `v_${Date.now()}_${Math.random().toString(36).slice(2)}`;
14
+ window.localStorage.setItem(VISITOR_KEY, id);
15
+ }
16
+ return id;
17
+ } catch {
18
+ return `v_${Date.now()}`;
19
+ }
20
+ }