@tribe-nest/forge 3.69.0 → 3.74.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.
@@ -16,6 +16,29 @@ export interface WebsiteAgentConfig {
16
16
  excludedPaths?: string[];
17
17
  /** The agent also takes calls from the browser: show "Call us". */
18
18
  voiceEnabled?: boolean;
19
+ /** Through an embed only: the business the key belongs to. */
20
+ profileId?: string;
21
+ /** Through an embed only: the business theme with the key's overrides. */
22
+ theme?: EmbedAgentTheme;
23
+ }
24
+
25
+ /** The look an embed key gives the widget (see packages/embed). */
26
+ export interface EmbedAgentTheme {
27
+ primary: string;
28
+ background: string;
29
+ text: string;
30
+ radius: number;
31
+ position: "bottom-right" | "bottom-left";
32
+ colorScheme: "business" | "light" | "dark";
33
+ }
34
+
35
+ /**
36
+ * Where the website agent's public routes live. A TribeNest site uses
37
+ * `/public/agent`; an embed on another site goes through its key, whose
38
+ * gate answers CORS for the key's allowed origins only.
39
+ */
40
+ export function agentApiPath(embedKey?: string | null): string {
41
+ return embedKey ? `/public/embed/${encodeURIComponent(embedKey)}/agent` : "/public/agent";
19
42
  }
20
43
 
21
44
  /** Widget bootstrap config for the website. When `enabled` is false the widget
@@ -23,11 +46,11 @@ export interface WebsiteAgentConfig {
23
46
  * profile with several sites can give each its own; without it the backend
24
47
  * answers with the profile's default agent. */
25
48
  export function useWebsiteAgentConfig() {
26
- const { client, profileId, websiteId } = useForge();
49
+ const { client, profileId, websiteId, embedKey } = useForge();
27
50
  return useQuery<WebsiteAgentConfig>({
28
- queryKey: ["website-agent-config", profileId, websiteId ?? null],
51
+ queryKey: ["website-agent-config", profileId, websiteId ?? null, embedKey ?? null],
29
52
  queryFn: async () => {
30
- const res = await client.get(`/public/agent/config`, {
53
+ const res = await client.get(`${agentApiPath(embedKey)}/config`, {
31
54
  params: { profileId, ...(websiteId ? { websiteId } : {}) },
32
55
  });
33
56
  return res.data;
@@ -37,12 +60,93 @@ export function useWebsiteAgentConfig() {
37
60
  });
38
61
  }
39
62
 
63
+ /** One option of a playbook choice. Live items (services) carry their details, and render as cards. */
64
+ export interface AgentChoiceOption {
65
+ value: string;
66
+ label: string;
67
+ description?: string;
68
+ priceText?: string;
69
+ durationText?: string;
70
+ imageUrl?: string;
71
+ }
72
+
73
+ /**
74
+ * A playbook Choose step, sent as its own event. `text` is the same choice
75
+ * rendered as plain text, for a widget that cannot draw buttons.
76
+ */
77
+ export interface AgentChoiceEvent {
78
+ type: "choice";
79
+ stepId: string;
80
+ question: string;
81
+ multiple: false;
82
+ options: AgentChoiceOption[];
83
+ text: string;
84
+ }
85
+
86
+ /** A tapped option, sent back in place of a typed message. */
87
+ export interface AgentChoiceReply {
88
+ stepId: string;
89
+ value: string;
90
+ }
91
+
40
92
  export type AgentStreamEvent =
41
93
  | { type: "text_delta"; delta: string }
42
94
  | { type: "tool_use_start"; name: string }
43
95
  | { type: "tool_use_result"; name: string }
44
96
  | { type: "turn_complete" }
45
- | { type: "error"; message: string };
97
+ | { type: "error"; message: string }
98
+ | AgentChoiceEvent;
99
+
100
+ /**
101
+ * The words of an assistant message without the choice's plain text. The
102
+ * server streams `choice.text` as the turn's last `text_delta` ("\n\n" + text,
103
+ * or the text alone when nothing came before it) so a widget without buttons
104
+ * still shows the options. A widget that draws the buttons hides that tail so
105
+ * the options are not shown twice. Only an exact suffix is removed.
106
+ */
107
+ export function textWithoutChoice(text: string, choice: Pick<AgentChoiceEvent, "text"> | null | undefined): string {
108
+ const tail = choice?.text;
109
+ if (!tail) return text;
110
+ if (text === tail) return "";
111
+ if (text.endsWith(`\n\n${tail}`)) return text.slice(0, text.length - tail.length - 2);
112
+ return text;
113
+ }
114
+
115
+ /**
116
+ * A choice event in a shape the widget can draw. Anything else, including a
117
+ * choice with no usable options, is dropped rather than crashing the chat.
118
+ */
119
+ export function parseChoiceEvent(raw: unknown): AgentChoiceEvent | null {
120
+ if (!raw || typeof raw !== "object") return null;
121
+ const ev = raw as Record<string, unknown>;
122
+ if (ev.type !== "choice" || typeof ev.stepId !== "string" || !Array.isArray(ev.options)) return null;
123
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
124
+ const options: AgentChoiceOption[] = [];
125
+ for (const o of ev.options) {
126
+ if (!o || typeof o !== "object") continue;
127
+ const opt = o as Record<string, unknown>;
128
+ const value = typeof opt.value === "string" ? opt.value : typeof opt.value === "number" ? String(opt.value) : null;
129
+ const label = str(opt.label) ?? value;
130
+ if (!value || !label) continue;
131
+ options.push({
132
+ value,
133
+ label,
134
+ description: str(opt.description),
135
+ priceText: str(opt.priceText),
136
+ durationText: str(opt.durationText),
137
+ imageUrl: str(opt.imageUrl),
138
+ });
139
+ }
140
+ if (!options.length) return null;
141
+ return {
142
+ type: "choice",
143
+ stepId: ev.stepId,
144
+ question: str(ev.question) ?? "",
145
+ multiple: false,
146
+ options,
147
+ text: str(ev.text) ?? "",
148
+ };
149
+ }
46
150
 
47
151
  /**
48
152
  * Streams a chat turn over SSE using fetch (axios can't stream). Calls
@@ -54,17 +158,26 @@ export async function streamAgentMessage(params: {
54
158
  profileId: string;
55
159
  visitorId: string;
56
160
  conversationId: string;
57
- message: string;
161
+ /** What the visitor typed. */
162
+ message?: string;
163
+ /** A tapped choice, sent instead of `message`. */
164
+ choice?: AgentChoiceReply;
58
165
  onEvent: (ev: AgentStreamEvent) => void;
59
166
  signal?: AbortSignal;
167
+ /** Through an embed: the key the request goes through. */
168
+ embedKey?: string | null;
60
169
  }): Promise<void> {
61
- const res = await fetch(`${params.apiUrl}/public/agent/conversations/${params.conversationId}/messages`, {
170
+ const res = await fetch(`${params.apiUrl}${agentApiPath(params.embedKey)}/conversations/${params.conversationId}/messages`, {
62
171
  method: "POST",
63
172
  headers: {
64
173
  "Content-Type": "application/json",
65
174
  ...(params.token ? { authorization: `Bearer ${params.token}` } : {}),
66
175
  },
67
- body: JSON.stringify({ profileId: params.profileId, visitorId: params.visitorId, message: params.message }),
176
+ body: JSON.stringify({
177
+ profileId: params.profileId,
178
+ visitorId: params.visitorId,
179
+ ...(params.choice ? { choice: params.choice } : { message: params.message }),
180
+ }),
68
181
  signal: params.signal,
69
182
  });
70
183
 
@@ -84,15 +197,96 @@ export async function streamAgentMessage(params: {
84
197
  if (!line) continue;
85
198
  const payload = line.slice(5).trim();
86
199
  if (!payload) continue;
200
+ let parsed: unknown;
87
201
  try {
88
- params.onEvent(JSON.parse(payload) as AgentStreamEvent);
202
+ parsed = JSON.parse(payload);
89
203
  } catch {
90
- /* ignore heartbeats / malformed frames */
204
+ continue; // a heartbeat or a malformed frame
205
+ }
206
+ if (!parsed || typeof parsed !== "object") continue;
207
+ // A choice is validated, so a widget never draws half an option list.
208
+ // Unknown types pass through; every consumer switches on `type` and
209
+ // ignores the rest.
210
+ const ev = (parsed as { type?: unknown }).type === "choice" ? parseChoiceEvent(parsed) : (parsed as AgentStreamEvent);
211
+ if (!ev) continue;
212
+ try {
213
+ params.onEvent(ev);
214
+ } catch {
215
+ /* a consumer's error must not end the stream */
91
216
  }
92
217
  }
93
218
  }
94
219
  }
95
220
 
221
+ /** One message of a saved conversation, as `GET /public/agent/conversations/:id` returns it. */
222
+ export interface AgentTranscriptMessage {
223
+ role: "user" | "assistant";
224
+ text: string;
225
+ /** The playbook choice this assistant message offered. */
226
+ choice?: AgentChoiceEvent;
227
+ /** The value the visitor picked, when the next message is one of the options' labels. */
228
+ picked?: string;
229
+ /** A choice that can no longer be answered: the conversation moved on past it. */
230
+ closed?: boolean;
231
+ }
232
+
233
+ /**
234
+ * A saved transcript in the shape the widget draws. Only user and assistant
235
+ * text survives; a saved choice is validated like a streamed one. A choice is
236
+ * answerable only while it is the last message: one the visitor answered is
237
+ * marked with the picked option (its label is the next user message), and one
238
+ * the conversation moved past is closed.
239
+ */
240
+ export function restoreTranscriptMessages(raw: unknown): AgentTranscriptMessage[] {
241
+ const list = raw && typeof raw === "object" ? (raw as { messages?: unknown }).messages : undefined;
242
+ if (!Array.isArray(list)) return [];
243
+ const out: AgentTranscriptMessage[] = [];
244
+ for (const item of list) {
245
+ if (!item || typeof item !== "object") continue;
246
+ const m = item as Record<string, unknown>;
247
+ if (m.role !== "user" && m.role !== "assistant") continue;
248
+ const text = typeof m.text === "string" ? m.text : "";
249
+ const choice = m.role === "assistant" ? parseChoiceEvent(m.choice) : null;
250
+ if (!text && !choice) continue;
251
+ out.push({ role: m.role, text, ...(choice ? { choice } : {}) });
252
+ }
253
+ out.forEach((m, i) => {
254
+ if (!m.choice || i === out.length - 1) return;
255
+ const next = out[i + 1];
256
+ const picked = next?.role === "user" ? m.choice.options.find((o) => o.label === next.text.trim()) : undefined;
257
+ if (picked) m.picked = picked.value;
258
+ m.closed = true;
259
+ });
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * Loads a saved conversation. Null when it is not this visitor's (any more),
265
+ * so the caller can forget the stored id and start afresh.
266
+ */
267
+ export async function fetchAgentTranscript(params: {
268
+ apiUrl: string;
269
+ token?: string | null;
270
+ profileId: string;
271
+ visitorId: string;
272
+ conversationId: string;
273
+ signal?: AbortSignal;
274
+ embedKey?: string | null;
275
+ }): Promise<{ id: string; messages: AgentTranscriptMessage[] } | null> {
276
+ const qs = new URLSearchParams({ profileId: params.profileId, visitorId: params.visitorId });
277
+ const res = await fetch(
278
+ `${params.apiUrl}${agentApiPath(params.embedKey)}/conversations/${encodeURIComponent(params.conversationId)}?${qs.toString()}`,
279
+ {
280
+ headers: params.token ? { authorization: `Bearer ${params.token}` } : {},
281
+ signal: params.signal,
282
+ },
283
+ );
284
+ if (!res.ok) return null;
285
+ const body = (await res.json()) as { id?: unknown } | null;
286
+ if (!body || typeof body.id !== "string") return null;
287
+ return { id: body.id, messages: restoreTranscriptMessages(body) };
288
+ }
289
+
96
290
  /** Creates a visitor conversation, returning its id (or null when disabled). */
97
291
  export async function createAgentConversation(params: {
98
292
  apiUrl: string;
@@ -100,8 +294,9 @@ export async function createAgentConversation(params: {
100
294
  profileId: string;
101
295
  websiteId?: string;
102
296
  visitorId: string;
297
+ embedKey?: string | null;
103
298
  }): Promise<{ id: string | null; requiresLogin?: boolean }> {
104
- const res = await fetch(`${params.apiUrl}/public/agent/conversations`, {
299
+ const res = await fetch(`${params.apiUrl}${agentApiPath(params.embedKey)}/conversations`, {
105
300
  method: "POST",
106
301
  headers: {
107
302
  "Content-Type": "application/json",
package/src/i18n/de.json CHANGED
@@ -191,6 +191,7 @@
191
191
  "forge.ai_agent_call_room.speaking": "{name} spricht",
192
192
  "forge.ai_agent_widget.aria_call": "Ruf uns an",
193
193
  "forge.ai_agent_widget.aria_close": "Schließen",
194
+ "forge.ai_agent_widget.aria_message": "Nachricht",
194
195
  "forge.ai_agent_widget.aria_open": "Chat-Assistent öffnen",
195
196
  "forge.ai_agent_widget.aria_show_call": "Anruf anzeigen",
196
197
  "forge.ai_agent_widget.call_again": "Erneut anrufen",
@@ -204,6 +205,7 @@
204
205
  "forge.ai_agent_widget.call_error_unavailable": "Anrufe sind gerade nicht möglich.",
205
206
  "forge.ai_agent_widget.call_hang_up": "Auflegen",
206
207
  "forge.ai_agent_widget.call_try_again": "Erneut versuchen",
208
+ "forge.ai_agent_widget.choice_label": "Eine Option wählen",
207
209
  "forge.ai_agent_widget.default_greeting": "Hi! Frag mich, was du willst.",
208
210
  "forge.ai_agent_widget.default_title": "Assistent",
209
211
  "forge.ai_agent_widget.input_placeholder": "Nachricht schreiben…",
@@ -738,6 +740,12 @@
738
740
  "forge.checkout_link_payment.try_again": "Erneut versuchen",
739
741
  "forge.checkout_link_payment.unpaid_body": "Ihre Zahlung ist nicht durchgegangen, und es wurde nichts berechnet. Ihre Reservierung besteht weiter, Sie können es erneut versuchen.",
740
742
  "forge.checkout_link_payment.unpaid_title": "Zahlung nicht abgeschlossen",
743
+ "forge.checkout_link_payment.shipping_title": "Lieferadresse",
744
+ "forge.checkout_link_payment.ship_to": "Lieferung an",
745
+ "forge.checkout_link_payment.change_address": "Ändern",
746
+ "forge.checkout_link_payment.use_shipping": "Diesen Versand nutzen",
747
+ "forge.checkout_link_payment.shipping": "Versand",
748
+ "forge.checkout_link_payment.shipping_unavailable": "Für diese Adresse konnten wir keinen Versand finden. Prüfe sie und versuche es erneut.",
741
749
  "forge.coaching_booking.aria_close": "Schließen",
742
750
  "forge.coaching_booking.aria_next_month": "Nächster Monat",
743
751
  "forge.coaching_booking.aria_next_week": "Nächste Woche",
@@ -2463,5 +2471,6 @@
2463
2471
  "forge.work_task_detail.fallback_title": "Aufgabe",
2464
2472
  "forge.work_task_detail.status_done": "Erledigt",
2465
2473
  "forge.work_task_detail.status_open": "Offen",
2466
- "forge.work_token_report.invalid_link": "Dieser Berichtslink ist ungültig oder abgelaufen."
2474
+ "forge.work_token_report.invalid_link": "Dieser Berichtslink ist ungültig oder abgelaufen.",
2475
+ "forge.form_renderer.consent_phone_required": "Geben Sie Ihre Telefonnummer ein, um Anrufen oder Nachrichten zuzustimmen."
2467
2476
  }
package/src/i18n/en.json CHANGED
@@ -191,6 +191,7 @@
191
191
  "forge.ai_agent_call_room.speaking": "{name} is speaking",
192
192
  "forge.ai_agent_widget.aria_call": "Call us",
193
193
  "forge.ai_agent_widget.aria_close": "Close",
194
+ "forge.ai_agent_widget.aria_message": "Message",
194
195
  "forge.ai_agent_widget.aria_open": "Open chat assistant",
195
196
  "forge.ai_agent_widget.aria_show_call": "Show the call",
196
197
  "forge.ai_agent_widget.call_again": "Call again",
@@ -204,6 +205,7 @@
204
205
  "forge.ai_agent_widget.call_error_unavailable": "Calls are not available right now.",
205
206
  "forge.ai_agent_widget.call_hang_up": "Hang up",
206
207
  "forge.ai_agent_widget.call_try_again": "Try again",
208
+ "forge.ai_agent_widget.choice_label": "Choose an option",
207
209
  "forge.ai_agent_widget.default_greeting": "Hi! Ask me anything.",
208
210
  "forge.ai_agent_widget.default_title": "Assistant",
209
211
  "forge.ai_agent_widget.input_placeholder": "Type a message…",
@@ -738,6 +740,12 @@
738
740
  "forge.checkout_link_payment.try_again": "Try again",
739
741
  "forge.checkout_link_payment.unpaid_body": "Your payment did not go through, and nothing was charged. Your reservation is still held, so you can try again.",
740
742
  "forge.checkout_link_payment.unpaid_title": "Payment not completed",
743
+ "forge.checkout_link_payment.shipping_title": "Delivery address",
744
+ "forge.checkout_link_payment.ship_to": "Delivering to",
745
+ "forge.checkout_link_payment.change_address": "Change",
746
+ "forge.checkout_link_payment.use_shipping": "Use this shipping",
747
+ "forge.checkout_link_payment.shipping": "Shipping",
748
+ "forge.checkout_link_payment.shipping_unavailable": "We could not get shipping for this address. Check it and try again.",
741
749
  "forge.coaching_booking.aria_close": "Close",
742
750
  "forge.coaching_booking.aria_next_month": "Next month",
743
751
  "forge.coaching_booking.aria_next_week": "Next week",
@@ -2463,5 +2471,6 @@
2463
2471
  "forge.work_task_detail.fallback_title": "Task",
2464
2472
  "forge.work_task_detail.status_done": "Done",
2465
2473
  "forge.work_task_detail.status_open": "To do",
2466
- "forge.work_token_report.invalid_link": "This report link is invalid or has expired."
2474
+ "forge.work_token_report.invalid_link": "This report link is invalid or has expired.",
2475
+ "forge.form_renderer.consent_phone_required": "Enter your phone number to agree to calls or messages."
2467
2476
  }
@@ -31,6 +31,13 @@ export interface ForgeContextValue {
31
31
  subdomain?: string;
32
32
  /** Per-tenant publishable key (`pk_site_…`), sent as `x-forge-key`. */
33
33
  publishableKey?: string;
34
+ /**
35
+ * An agent embed key (`pk_embed_...`), set only by the embed loader
36
+ * (packages/embed) on a site TribeNest does not host. The website agent then
37
+ * talks to `/public/embed/<key>/agent/*`, where the key, not `profileId`,
38
+ * decides the business and the agent, and nobody is signed in.
39
+ */
40
+ embedKey?: string;
34
41
  /** The configured Axios client, scoped to this tenant. */
35
42
  client: AxiosInstance;
36
43
  /** Current member bearer token, or null when anonymous. */
@@ -49,6 +56,8 @@ export interface ForgeClientProviderProps {
49
56
  appId?: string;
50
57
  subdomain?: string;
51
58
  publishableKey?: string;
59
+ /** See `ForgeContextValue.embedKey`. */
60
+ embedKey?: string;
52
61
  /** Optional initial token (e.g. restored from storage on mount). */
53
62
  initialToken?: string | null;
54
63
  children: React.ReactNode;
@@ -71,6 +80,7 @@ export const ForgeClientProvider = ({
71
80
  appId,
72
81
  subdomain,
73
82
  publishableKey,
83
+ embedKey,
74
84
  initialToken = null,
75
85
  children,
76
86
  }: ForgeClientProviderProps) => {
@@ -119,8 +129,8 @@ export const ForgeClientProvider = ({
119
129
  );
120
130
 
121
131
  const value = useMemo<ForgeContextValue>(
122
- () => ({ apiUrl, rootDomain, profileId, websiteId, appId, subdomain, publishableKey, client, token, setToken }),
123
- [apiUrl, rootDomain, profileId, websiteId, appId, subdomain, publishableKey, client, token, setToken],
132
+ () => ({ apiUrl, rootDomain, profileId, websiteId, appId, subdomain, publishableKey, embedKey, client, token, setToken }),
133
+ [apiUrl, rootDomain, profileId, websiteId, appId, subdomain, publishableKey, embedKey, client, token, setToken],
124
134
  );
125
135
 
126
136
  return <ForgeContext.Provider value={value}>{children}</ForgeContext.Provider>;
@@ -2076,6 +2076,30 @@ export type FormField = {
2076
2076
  order: number;
2077
2077
  /** Which section this field belongs to (null on legacy flat forms). */
2078
2078
  sectionId?: string | null;
2079
+ /**
2080
+ * Type-specific settings. A consent box (`call_consent`) keeps its channel and
2081
+ * the phone field it is bound to here.
2082
+ */
2083
+ config?: { channel?: ConsentChannel; phoneFieldId?: string | null } | null;
2084
+ /**
2085
+ * On a consent box only: the exact text to show beside it, in the visitor's
2086
+ * language, as the server built it. Show `text` as it is (never rewrite it)
2087
+ * and send `textHash` back with the tick: that is what makes the tick evidence.
2088
+ */
2089
+ consent?: FormFieldConsent | null;
2090
+ };
2091
+
2092
+ /** A channel a person can agree to be contacted on. */
2093
+ export type ConsentChannel = "ai_call" | "sms" | "whatsapp";
2094
+
2095
+ /** The locked consent wording the server serves with a consent box. */
2096
+ export type FormFieldConsent = {
2097
+ channel: ConsentChannel;
2098
+ phoneFieldId: string | null;
2099
+ locale: string;
2100
+ version: string;
2101
+ text: string;
2102
+ textHash: string;
2079
2103
  };
2080
2104
 
2081
2105
  /** Edge scope: field→field jump, section→section page flow, or in-page show/hide. */