@tribe-nest/forge 3.65.0 → 3.67.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/package.json +1 -1
- package/src/data/queries/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +17 -0
- package/src/i18n/en.json +17 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
- package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
- package/src/ui/headless/agent/callChallenge.ts +72 -0
- package/src/ui/headless/agent/useAgentCall.ts +189 -0
- package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
- package/src/ui/headless/agent/useAiAgent.ts +8 -20
- package/src/ui/headless/agent/visitorId.ts +20 -0
- package/src/ui/index.ts +11 -0
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- package/src/ui/styled/CookieConsent.tsx +13 -9
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
|
@@ -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(
|
|
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
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -206,6 +206,17 @@ export { NewDmDialog, type NewDmDialogProps } from "./styled/chat/NewDmDialog";
|
|
|
206
206
|
export { AiAgentWidget, type AiAgentWidgetProps } from "./styled/AiAgentWidget";
|
|
207
207
|
export { Markdown } from "./styled/Markdown";
|
|
208
208
|
export { useAiAgent, type AiAgentMessage } from "./headless/agent/useAiAgent";
|
|
209
|
+
// "Call us". The room hook (`useAgentCallRoom`) is deliberately not exported
|
|
210
|
+
// here: it imports the media SDK, and this entry is on every page. A custom
|
|
211
|
+
// call UI imports the media hooks from `@tribe-nest/forge/media` itself.
|
|
212
|
+
export {
|
|
213
|
+
useAgentCall,
|
|
214
|
+
classifyAgentCallError,
|
|
215
|
+
type AgentCallState,
|
|
216
|
+
type AgentCallErrorKind,
|
|
217
|
+
type UseAgentCallOptions,
|
|
218
|
+
} from "./headless/agent/useAgentCall";
|
|
219
|
+
export { solveCallChallenge, leadingZeroBits, type SolveCallChallengeOptions } from "./headless/agent/callChallenge";
|
|
209
220
|
export { InvoicePayment, type InvoicePaymentProps } from "./styled/InvoicePayment";
|
|
210
221
|
export { PaymentLinkPayment, type PaymentLinkPaymentProps } from "./styled/PaymentLinkPayment";
|
|
211
222
|
export { BlogList, type BlogListProps } from "./styled/BlogList";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
|
+
import { useForgeT } from "../../i18n";
|
|
3
|
+
import { MediaRoomProvider } from "../media";
|
|
4
|
+
import { useAgentAudio, useAgentCallRoom, type AgentCallRoomStatus } from "../headless/agent/useAgentCallRoom";
|
|
5
|
+
|
|
6
|
+
export interface AiAgentCallRoomProps {
|
|
7
|
+
getCredentials: () => Promise<{ mediaUrl: string; token: string }>;
|
|
8
|
+
/** This browser's identity in the room, from the start response. */
|
|
9
|
+
selfIdentity: string;
|
|
10
|
+
/** The agent's name, for the status line. */
|
|
11
|
+
agentName: string;
|
|
12
|
+
/** The room says the call is over: the agent left, or the room closed. */
|
|
13
|
+
onEnded: () => void;
|
|
14
|
+
/** Told every status change, so the widget can style around it. */
|
|
15
|
+
onStatus?: (status: AgentCallRoomStatus) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The live half of "Call us": the media room, the microphone out and the
|
|
20
|
+
* agent's voice in.
|
|
21
|
+
*
|
|
22
|
+
* A DEFAULT export on purpose. `AiAgentWidget` is on every page of every site
|
|
23
|
+
* and reaches this through `React.lazy(() => import(...))`, so the media SDK
|
|
24
|
+
* and mediasoup are fetched only by a visitor who actually presses Call.
|
|
25
|
+
*/
|
|
26
|
+
export default function AiAgentCallRoom({ getCredentials, ...rest }: AiAgentCallRoomProps): ReactNode {
|
|
27
|
+
const t = useForgeT();
|
|
28
|
+
return (
|
|
29
|
+
<MediaRoomProvider
|
|
30
|
+
getCredentials={getCredentials}
|
|
31
|
+
fallback={<StatusLine text={t("forge.ai_agent_call_room.connecting")} />}
|
|
32
|
+
>
|
|
33
|
+
<LiveCall {...rest} />
|
|
34
|
+
</MediaRoomProvider>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function LiveCall({ selfIdentity, agentName, onEnded, onStatus }: Omit<AiAgentCallRoomProps, "getCredentials">) {
|
|
39
|
+
const t = useForgeT();
|
|
40
|
+
const { status, agentAudioProducerIds, setAgentSpeaking } = useAgentCallRoom({ selfIdentity, onEnded });
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
onStatus?.(status);
|
|
44
|
+
}, [status, onStatus]);
|
|
45
|
+
|
|
46
|
+
const text =
|
|
47
|
+
status === "no_mic"
|
|
48
|
+
? t("forge.ai_agent_call_room.no_mic")
|
|
49
|
+
: status === "speaking"
|
|
50
|
+
? t("forge.ai_agent_call_room.speaking", { name: agentName })
|
|
51
|
+
: status === "listening"
|
|
52
|
+
? t("forge.ai_agent_call_room.listening", { name: agentName })
|
|
53
|
+
: t("forge.ai_agent_call_room.connecting");
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{agentAudioProducerIds.map((id) => (
|
|
58
|
+
<AgentAudio key={id} producerId={id} onSpeaking={setAgentSpeaking} />
|
|
59
|
+
))}
|
|
60
|
+
<StatusLine text={text} alert={status === "no_mic"} />
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function AgentAudio({ producerId, onSpeaking }: { producerId: string; onSpeaking: (speaking: boolean) => void }) {
|
|
66
|
+
const attach = useAgentAudio(producerId, onSpeaking);
|
|
67
|
+
return <audio ref={attach} autoPlay data-testid="forge-agent-call-audio" />;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function StatusLine({ text, alert }: { text: string; alert?: boolean }) {
|
|
71
|
+
return (
|
|
72
|
+
<div role={alert ? "alert" : "status"} aria-live="polite" style={{ fontSize: 14, textAlign: "center" }} data-testid="forge-agent-call-status">
|
|
73
|
+
{text}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|