@iblai/iblai-js 2.5.6 → 2.5.8

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.
@@ -65112,6 +65112,7 @@ var tabsUserstab$3 = {
65112
65112
  columnStatus: "Status",
65113
65113
  roleAdmin: "Admin",
65114
65114
  roleUser: "User",
65115
+ selectRoleAriaLabel: "Select role for {name}",
65115
65116
  selectPoliciesAriaLabel: "Select policies for {name}",
65116
65117
  allPoliciesSelected: "All ({count}) selected",
65117
65118
  nPoliciesSelected: "{count} selected",
@@ -65343,24 +65344,29 @@ var voiceTabLabels$3 = {
65343
65344
  title: "Agent Voice",
65344
65345
  description: "The voice your agent uses to read out replies in chat.",
65345
65346
  providerLabel: "Voice source",
65346
- providerTooltip: "Browser uses the listener's own device to speak. OpenAI and Google use a custom voice you pick below.",
65347
+ providerTooltip: "Browser and ibl.ai speak on the listener's own device. OpenAI and Google use a custom voice you pick below.",
65347
65348
  providerOptions: {
65348
65349
  browser: {
65349
65350
  label: "Browser",
65350
65351
  description: "Speak through the user's own device. No setup needed."
65351
65352
  },
65353
+ google: {
65354
+ label: "Google",
65355
+ description: "High-quality voices from Google."
65356
+ },
65357
+ iblai: {
65358
+ label: "ibl.ai",
65359
+ description: "High-quality voices from ibl.ai"
65360
+ },
65352
65361
  openai: {
65353
65362
  label: "OpenAI",
65354
65363
  description: "High-quality voices from OpenAI."
65355
- },
65356
- google: {
65357
- label: "Google",
65358
- description: "Natural-sounding voices from Google."
65359
65364
  }
65360
65365
  },
65361
65366
  voicePickerLabel: {
65362
65367
  openai: "OpenAI voice",
65363
- google: "Google voice"
65368
+ google: "Google voice",
65369
+ iblai: "ibl.ai voice"
65364
65370
  },
65365
65371
  searchPlaceholder: "Search voices…",
65366
65372
  emptyState: "No voices match your search.",
@@ -72156,6 +72162,7 @@ var tabsUserstab$2 = {
72156
72162
  columnStatus: "Statut",
72157
72163
  roleAdmin: "Admin",
72158
72164
  roleUser: "Utilisateur",
72165
+ selectRoleAriaLabel: "Sélectionner le rôle pour {name}",
72159
72166
  selectPoliciesAriaLabel: "Sélectionner les politiques pour {name}",
72160
72167
  allPoliciesSelected: "Tout ({count}) sélectionné",
72161
72168
  nPoliciesSelected: "{count} sélectionné(s)",
@@ -72387,24 +72394,29 @@ var voiceTabLabels$2 = {
72387
72394
  title: "Voix de l'agent",
72388
72395
  description: "La voix utilisée par votre agent pour lire les réponses dans le chat.",
72389
72396
  providerLabel: "Source de la voix",
72390
- providerTooltip: "Le navigateur utilise l'appareil de l'auditeur pour parler. OpenAI et Google utilisent une voix personnalisée que vous choisissez ci-dessous.",
72397
+ providerTooltip: "Le navigateur et ibl.ai parlent sur l'appareil de l'auditeur. OpenAI et Google utilisent une voix personnalisée que vous choisissez ci-dessous.",
72391
72398
  providerOptions: {
72392
72399
  browser: {
72393
72400
  label: "Navigateur",
72394
72401
  description: "Parle via l'appareil de l'utilisateur. Aucune configuration requise."
72395
72402
  },
72403
+ google: {
72404
+ label: "Google",
72405
+ description: "Voix de haute qualité de Google."
72406
+ },
72407
+ iblai: {
72408
+ label: "ibl.ai",
72409
+ description: "Voix de haute qualité d'ibl.ai"
72410
+ },
72396
72411
  openai: {
72397
72412
  label: "OpenAI",
72398
72413
  description: "Voix de haute qualité d'OpenAI."
72399
- },
72400
- google: {
72401
- label: "Google",
72402
- description: "Voix naturelles de Google."
72403
72414
  }
72404
72415
  },
72405
72416
  voicePickerLabel: {
72406
72417
  openai: "Voix OpenAI",
72407
- google: "Voix Google"
72418
+ google: "Voix Google",
72419
+ iblai: "Voix ibl.ai"
72408
72420
  },
72409
72421
  searchPlaceholder: "Rechercher des voix…",
72410
72422
  emptyState: "Aucune voix ne correspond à votre recherche.",
@@ -79034,6 +79046,7 @@ var tabsUserstab$1 = {
79034
79046
  columnStatus: "Estado",
79035
79047
  roleAdmin: "Admin",
79036
79048
  roleUser: "Usuario",
79049
+ selectRoleAriaLabel: "Seleccionar rol para {name}",
79037
79050
  selectPoliciesAriaLabel: "Seleccionar políticas para {name}",
79038
79051
  allPoliciesSelected: "Todas ({count}) seleccionadas",
79039
79052
  nPoliciesSelected: "{count} seleccionada(s)",
@@ -79265,24 +79278,29 @@ var voiceTabLabels$1 = {
79265
79278
  title: "Voz del agente",
79266
79279
  description: "La voz que usa tu agente para leer las respuestas en el chat.",
79267
79280
  providerLabel: "Fuente de voz",
79268
- providerTooltip: "El navegador usa el dispositivo del oyente para hablar. OpenAI y Google usan una voz personalizada que eliges a continuación.",
79281
+ providerTooltip: "El navegador e ibl.ai hablan en el dispositivo del oyente. OpenAI y Google usan una voz personalizada que eliges a continuación.",
79269
79282
  providerOptions: {
79270
79283
  browser: {
79271
79284
  label: "Navegador",
79272
79285
  description: "Habla a través del dispositivo del usuario. No requiere configuración."
79273
79286
  },
79287
+ google: {
79288
+ label: "Google",
79289
+ description: "Voces de alta calidad de Google."
79290
+ },
79291
+ iblai: {
79292
+ label: "ibl.ai",
79293
+ description: "Voces de alta calidad de ibl.ai"
79294
+ },
79274
79295
  openai: {
79275
79296
  label: "OpenAI",
79276
79297
  description: "Voces de alta calidad de OpenAI."
79277
- },
79278
- google: {
79279
- label: "Google",
79280
- description: "Voces naturales de Google."
79281
79298
  }
79282
79299
  },
79283
79300
  voicePickerLabel: {
79284
79301
  openai: "Voz de OpenAI",
79285
- google: "Voz de Google"
79302
+ google: "Voz de Google",
79303
+ iblai: "Voz de ibl.ai"
79286
79304
  },
79287
79305
  searchPlaceholder: "Buscar voces…",
79288
79306
  emptyState: "Ninguna voz coincide con tu búsqueda.",
@@ -85912,6 +85930,7 @@ var tabsUserstab = {
85912
85930
  columnStatus: "状态",
85913
85931
  roleAdmin: "管理员",
85914
85932
  roleUser: "用户",
85933
+ selectRoleAriaLabel: "为 {name} 选择角色",
85915
85934
  selectPoliciesAriaLabel: "为 {name} 选择策略",
85916
85935
  allPoliciesSelected: "全部({count})已选",
85917
85936
  nPoliciesSelected: "已选 {count} 个",
@@ -86143,24 +86162,29 @@ var voiceTabLabels = {
86143
86162
  title: "智能体声音",
86144
86163
  description: "您的智能体在聊天中朗读回复时使用的声音。",
86145
86164
  providerLabel: "声音来源",
86146
- providerTooltip: "浏览器使用听者自己的设备发声。OpenAI 和 Google 使用您在下方选择的自定义声音。",
86165
+ providerTooltip: "浏览器和 ibl.ai 在听者自己的设备上发声。OpenAI 和 Google 使用您在下方选择的自定义声音。",
86147
86166
  providerOptions: {
86148
86167
  browser: {
86149
86168
  label: "浏览器",
86150
86169
  description: "通过用户自己的设备发声,无需任何设置。"
86151
86170
  },
86171
+ google: {
86172
+ label: "Google",
86173
+ description: "来自 Google 的高质量声音。"
86174
+ },
86175
+ iblai: {
86176
+ label: "ibl.ai",
86177
+ description: "来自 ibl.ai 的高质量声音"
86178
+ },
86152
86179
  openai: {
86153
86180
  label: "OpenAI",
86154
86181
  description: "来自 OpenAI 的高质量声音。"
86155
- },
86156
- google: {
86157
- label: "Google",
86158
- description: "来自 Google 的自然声音。"
86159
86182
  }
86160
86183
  },
86161
86184
  voicePickerLabel: {
86162
86185
  openai: "OpenAI 声音",
86163
- google: "Google 声音"
86186
+ google: "Google 声音",
86187
+ iblai: "ibl.ai 语音"
86164
86188
  },
86165
86189
  searchPlaceholder: "搜索声音…",
86166
86190
  emptyState: "没有与您搜索匹配的声音。",
@@ -147875,6 +147899,7 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
147875
147899
  const [updateUserStatus] = useUpdateUserStatusMutation();
147876
147900
  const [updatePlatformUserRoleWithPolicies] = useUpdatePlatformUserRoleWithPoliciesMutation();
147877
147901
  const [updatingUserIds, setUpdatingUserIds] = useState(new Set());
147902
+ const [pendingRoles, setPendingRoles] = useState({});
147878
147903
  const [openPolicyPopover, setOpenPolicyPopover] = useState(null);
147879
147904
  const [selectedUser, setSelectedUser] = useState(null);
147880
147905
  const { currentTenant } = useCurrentTenant();
@@ -147927,18 +147952,19 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
147927
147952
  if (!isWatcher)
147928
147953
  await refetchPlatformUsers();
147929
147954
  };
147930
- const handleUserRoleChange = async (user) => {
147955
+ const handleUserRoleChange = async (user, nextRole) => {
147931
147956
  const userId = user === null || user === void 0 ? void 0 : user.user_id;
147932
147957
  if (!userId)
147933
147958
  return;
147934
147959
  // Add to updating state
147935
147960
  setUpdatingUserIds((prev) => new Set(prev).add(userId));
147961
+ setPendingRoles((prev) => ({ ...prev, [userId]: nextRole }));
147936
147962
  try {
147937
147963
  await updateUserRole({
147938
147964
  username: user === null || user === void 0 ? void 0 : user.username,
147939
147965
  org: user === null || user === void 0 ? void 0 : user.platform_org,
147940
147966
  role: 'org-instructor',
147941
- active: !(user === null || user === void 0 ? void 0 : user.is_admin),
147967
+ active: nextRole === 'admin',
147942
147968
  }).unwrap();
147943
147969
  // Only clear policies if role change succeeded
147944
147970
  try {
@@ -147960,6 +147986,7 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
147960
147986
  toast.success(t('tabsUserstab.roleUpdatedSuccess'));
147961
147987
  // Manually invalidate the platformUsers query since it's in a different API slice
147962
147988
  dispatch(platformApiSlice.util.invalidateTags([featureTags.PLATFORM_USERS]));
147989
+ await refetchPlatformUsers();
147963
147990
  }
147964
147991
  catch (error) {
147965
147992
  toast.error(t('tabsUserstab.roleUpdateFailed'));
@@ -147970,6 +147997,10 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
147970
147997
  newSet.delete(userId);
147971
147998
  return newSet;
147972
147999
  });
148000
+ setPendingRoles((prev) => {
148001
+ const { [userId]: _removed, ...rest } = prev;
148002
+ return rest;
148003
+ });
147973
148004
  }
147974
148005
  };
147975
148006
  const handleUserStatusChange = async (user, newStatus) => {
@@ -148070,6 +148101,7 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
148070
148101
  const totalCount = (_b = (isWatcher ? accessibleUsers === null || accessibleUsers === void 0 ? void 0 : accessibleUsers.count : users === null || users === void 0 ? void 0 : users.count)) !== null && _b !== void 0 ? _b : 0;
148071
148102
  const totalPages = Math.ceil(totalCount / PAGE_SIZE$5);
148072
148103
  return (jsxs("div", { className: "mt-4", children: [jsxs("div", { className: "flex justify-between items-center gap-1", children: [jsxs("div", { className: "relative mb-6 max-w-sm", children: [jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" }), jsx(Input, { type: "text", value: searchQuery, onChange: (event) => setQuerySearch(event.target.value), placeholder: t('tabsUserstab.searchPlaceholder'), className: "pl-10 pr-10 focus:ring-blue-500 focus:border-blue-500 h-[35px]", "aria-label": t('tabsUserstab.searchAriaLabel') }), searchQuery.length > 0 && (searchQuery !== debouncedSearchQuery || isLoading) && (jsx(LoaderCircle, { "data-testid": "search-spinner", className: "absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 animate-spin", "aria-hidden": "true" }))] }), !isWatcher && (jsx("div", { className: "mb-6", children: jsx(Button$1, { className: "bg-gradient-to-r from-[#2563EB] to-[#93C5FD] hover:opacity-90 text-white h-[35px]", onClick: onInviteClick, children: t('tabsUserstab.inviteButton') }) }))] }), isLoading ? (jsx("div", { className: "flex justify-center items-center h-64", children: jsx(Spinner, {}) })) : (jsxs(Fragment$1, { children: [jsx("div", { className: "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-x-auto", children: jsxs("table", { className: "w-full text-sm", children: [jsx("thead", { className: "bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600", children: jsxs("tr", { children: [jsx("th", { className: `px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400 ${isWatcher ? 'w-full' : 'w-[41%]'}`, children: t('tabsUserstab.columnEmail') }), !isWatcher && (jsxs(Fragment$1, { children: [jsx("th", { className: "px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400 w-[16%]", children: t('tabsUserstab.columnRole') }), jsx("th", { className: "px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400 w-[25%]", children: t('tabsUserstab.columnPolicies') }), jsx("th", { className: "px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400 w-[18%]", children: t('tabsUserstab.columnStatus') })] }))] }) }), jsxs("tbody", { className: "divide-y divide-gray-200 dark:divide-gray-600", children: [rows.map((row) => {
148104
+ var _a;
148073
148105
  // Only set on the admin path — see {@link UserRow}.
148074
148106
  const user = row.managed;
148075
148107
  return (jsxs("tr", { onClick: () => setSelectedUser(row), className: "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors", children: [jsx("td", { "aria-label": 'user-management-email-column', className: "px-6 py-4 text-gray-900 dark:text-gray-100 text-sm max-w-0", children: jsx("button", { type: "button", onClick: (e) => {
@@ -148077,7 +148109,9 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
148077
148109
  setSelectedUser(row);
148078
148110
  }, className: "block max-w-full truncate text-left hover:text-blue-600 hover:underline focus:outline-none focus:text-blue-600", "aria-label": t('tabsUserstab.viewProfileAriaLabel', {
148079
148111
  name: row.name || row.email,
148080
- }), children: row.email }) }), user && (jsxs(Fragment$1, { children: [jsx("td", { className: "px-6 py-4", onClick: (e) => e.stopPropagation(), children: jsxs(Select$1, { value: user.is_admin ? 'admin' : 'student', onValueChange: () => handleUserRoleChange(user), children: [jsx(SelectTrigger, { className: "w-full text-sm", children: jsx(SelectValue, {}) }), jsxs(SelectContent, { className: "text-sm", children: [jsx(SelectItem, { value: "admin", children: t('tabsUserstab.roleAdmin') }), jsx(SelectItem, { value: "student", children: t('tabsUserstab.roleUser') })] })] }) }), jsx("td", { className: "px-6 py-4", onClick: (e) => e.stopPropagation(), children: jsxs(Popover, { modal: true, open: openPolicyPopover === user.user_id, onOpenChange: (open) => setOpenPolicyPopover(open ? user.user_id : null), children: [jsx(PopoverTrigger, { asChild: true, children: jsxs(Button$1, { variant: "outline", role: "combobox", "aria-expanded": openPolicyPopover === user.user_id, "aria-label": t('tabsUserstab.selectPoliciesAriaLabel', {
148112
+ }), children: row.email }) }), user && (jsxs(Fragment$1, { children: [jsx("td", { className: "px-6 py-4", onClick: (e) => e.stopPropagation(), children: jsxs(Select$1, { value: (_a = pendingRoles[user.user_id]) !== null && _a !== void 0 ? _a : (user.is_admin ? 'admin' : 'student'), onValueChange: (nextRole) => handleUserRoleChange(user, nextRole), disabled: updatingUserIds.has(user === null || user === void 0 ? void 0 : user.user_id), children: [jsxs(SelectTrigger, { className: "w-full text-sm", "aria-label": t('tabsUserstab.selectRoleAriaLabel', {
148113
+ name: user.name,
148114
+ }), children: [jsx(SelectValue, {}), updatingUserIds.has(user === null || user === void 0 ? void 0 : user.user_id) && (jsx(LoaderCircle, { "data-testid": "role-update-spinner", className: "h-4 w-4 animate-spin", "aria-hidden": "true" }))] }), jsxs(SelectContent, { className: "text-sm", children: [jsx(SelectItem, { value: "admin", children: t('tabsUserstab.roleAdmin') }), jsx(SelectItem, { value: "student", children: t('tabsUserstab.roleUser') })] })] }) }), jsx("td", { className: "px-6 py-4", onClick: (e) => e.stopPropagation(), children: jsxs(Popover, { modal: true, open: openPolicyPopover === user.user_id, onOpenChange: (open) => setOpenPolicyPopover(open ? user.user_id : null), children: [jsx(PopoverTrigger, { asChild: true, children: jsxs(Button$1, { variant: "outline", role: "combobox", "aria-expanded": openPolicyPopover === user.user_id, "aria-label": t('tabsUserstab.selectPoliciesAriaLabel', {
148081
148115
  name: user.name,
148082
148116
  }), className: "w-full justify-between text-sm h-9 font-normal", disabled: updatingUserIds.has(user === null || user === void 0 ? void 0 : user.user_id), children: [jsx("span", { className: "truncate", children: user.is_admin
148083
148117
  ? t('tabsUserstab.allPoliciesSelected', {
@@ -101937,6 +101937,7 @@ var tabsUserstab$3 = {
101937
101937
  columnStatus: "Status",
101938
101938
  roleAdmin: "Admin",
101939
101939
  roleUser: "User",
101940
+ selectRoleAriaLabel: "Select role for {name}",
101940
101941
  selectPoliciesAriaLabel: "Select policies for {name}",
101941
101942
  allPoliciesSelected: "All ({count}) selected",
101942
101943
  nPoliciesSelected: "{count} selected",
@@ -102168,24 +102169,29 @@ var voiceTabLabels$3 = {
102168
102169
  title: "Agent Voice",
102169
102170
  description: "The voice your agent uses to read out replies in chat.",
102170
102171
  providerLabel: "Voice source",
102171
- providerTooltip: "Browser uses the listener's own device to speak. OpenAI and Google use a custom voice you pick below.",
102172
+ providerTooltip: "Browser and ibl.ai speak on the listener's own device. OpenAI and Google use a custom voice you pick below.",
102172
102173
  providerOptions: {
102173
102174
  browser: {
102174
102175
  label: "Browser",
102175
102176
  description: "Speak through the user's own device. No setup needed."
102176
102177
  },
102178
+ google: {
102179
+ label: "Google",
102180
+ description: "High-quality voices from Google."
102181
+ },
102182
+ iblai: {
102183
+ label: "ibl.ai",
102184
+ description: "High-quality voices from ibl.ai"
102185
+ },
102177
102186
  openai: {
102178
102187
  label: "OpenAI",
102179
102188
  description: "High-quality voices from OpenAI."
102180
- },
102181
- google: {
102182
- label: "Google",
102183
- description: "Natural-sounding voices from Google."
102184
102189
  }
102185
102190
  },
102186
102191
  voicePickerLabel: {
102187
102192
  openai: "OpenAI voice",
102188
- google: "Google voice"
102193
+ google: "Google voice",
102194
+ iblai: "ibl.ai voice"
102189
102195
  },
102190
102196
  searchPlaceholder: "Search voices…",
102191
102197
  emptyState: "No voices match your search.",
@@ -108981,6 +108987,7 @@ var tabsUserstab$2 = {
108981
108987
  columnStatus: "Statut",
108982
108988
  roleAdmin: "Admin",
108983
108989
  roleUser: "Utilisateur",
108990
+ selectRoleAriaLabel: "Sélectionner le rôle pour {name}",
108984
108991
  selectPoliciesAriaLabel: "Sélectionner les politiques pour {name}",
108985
108992
  allPoliciesSelected: "Tout ({count}) sélectionné",
108986
108993
  nPoliciesSelected: "{count} sélectionné(s)",
@@ -109212,24 +109219,29 @@ var voiceTabLabels$2 = {
109212
109219
  title: "Voix de l'agent",
109213
109220
  description: "La voix utilisée par votre agent pour lire les réponses dans le chat.",
109214
109221
  providerLabel: "Source de la voix",
109215
- providerTooltip: "Le navigateur utilise l'appareil de l'auditeur pour parler. OpenAI et Google utilisent une voix personnalisée que vous choisissez ci-dessous.",
109222
+ providerTooltip: "Le navigateur et ibl.ai parlent sur l'appareil de l'auditeur. OpenAI et Google utilisent une voix personnalisée que vous choisissez ci-dessous.",
109216
109223
  providerOptions: {
109217
109224
  browser: {
109218
109225
  label: "Navigateur",
109219
109226
  description: "Parle via l'appareil de l'utilisateur. Aucune configuration requise."
109220
109227
  },
109228
+ google: {
109229
+ label: "Google",
109230
+ description: "Voix de haute qualité de Google."
109231
+ },
109232
+ iblai: {
109233
+ label: "ibl.ai",
109234
+ description: "Voix de haute qualité d'ibl.ai"
109235
+ },
109221
109236
  openai: {
109222
109237
  label: "OpenAI",
109223
109238
  description: "Voix de haute qualité d'OpenAI."
109224
- },
109225
- google: {
109226
- label: "Google",
109227
- description: "Voix naturelles de Google."
109228
109239
  }
109229
109240
  },
109230
109241
  voicePickerLabel: {
109231
109242
  openai: "Voix OpenAI",
109232
- google: "Voix Google"
109243
+ google: "Voix Google",
109244
+ iblai: "Voix ibl.ai"
109233
109245
  },
109234
109246
  searchPlaceholder: "Rechercher des voix…",
109235
109247
  emptyState: "Aucune voix ne correspond à votre recherche.",
@@ -115859,6 +115871,7 @@ var tabsUserstab$1 = {
115859
115871
  columnStatus: "Estado",
115860
115872
  roleAdmin: "Admin",
115861
115873
  roleUser: "Usuario",
115874
+ selectRoleAriaLabel: "Seleccionar rol para {name}",
115862
115875
  selectPoliciesAriaLabel: "Seleccionar políticas para {name}",
115863
115876
  allPoliciesSelected: "Todas ({count}) seleccionadas",
115864
115877
  nPoliciesSelected: "{count} seleccionada(s)",
@@ -116090,24 +116103,29 @@ var voiceTabLabels$1 = {
116090
116103
  title: "Voz del agente",
116091
116104
  description: "La voz que usa tu agente para leer las respuestas en el chat.",
116092
116105
  providerLabel: "Fuente de voz",
116093
- providerTooltip: "El navegador usa el dispositivo del oyente para hablar. OpenAI y Google usan una voz personalizada que eliges a continuación.",
116106
+ providerTooltip: "El navegador e ibl.ai hablan en el dispositivo del oyente. OpenAI y Google usan una voz personalizada que eliges a continuación.",
116094
116107
  providerOptions: {
116095
116108
  browser: {
116096
116109
  label: "Navegador",
116097
116110
  description: "Habla a través del dispositivo del usuario. No requiere configuración."
116098
116111
  },
116112
+ google: {
116113
+ label: "Google",
116114
+ description: "Voces de alta calidad de Google."
116115
+ },
116116
+ iblai: {
116117
+ label: "ibl.ai",
116118
+ description: "Voces de alta calidad de ibl.ai"
116119
+ },
116099
116120
  openai: {
116100
116121
  label: "OpenAI",
116101
116122
  description: "Voces de alta calidad de OpenAI."
116102
- },
116103
- google: {
116104
- label: "Google",
116105
- description: "Voces naturales de Google."
116106
116123
  }
116107
116124
  },
116108
116125
  voicePickerLabel: {
116109
116126
  openai: "Voz de OpenAI",
116110
- google: "Voz de Google"
116127
+ google: "Voz de Google",
116128
+ iblai: "Voz de ibl.ai"
116111
116129
  },
116112
116130
  searchPlaceholder: "Buscar voces…",
116113
116131
  emptyState: "Ninguna voz coincide con tu búsqueda.",
@@ -122737,6 +122755,7 @@ var tabsUserstab = {
122737
122755
  columnStatus: "状态",
122738
122756
  roleAdmin: "管理员",
122739
122757
  roleUser: "用户",
122758
+ selectRoleAriaLabel: "为 {name} 选择角色",
122740
122759
  selectPoliciesAriaLabel: "为 {name} 选择策略",
122741
122760
  allPoliciesSelected: "全部({count})已选",
122742
122761
  nPoliciesSelected: "已选 {count} 个",
@@ -122968,24 +122987,29 @@ var voiceTabLabels = {
122968
122987
  title: "智能体声音",
122969
122988
  description: "您的智能体在聊天中朗读回复时使用的声音。",
122970
122989
  providerLabel: "声音来源",
122971
- providerTooltip: "浏览器使用听者自己的设备发声。OpenAI 和 Google 使用您在下方选择的自定义声音。",
122990
+ providerTooltip: "浏览器和 ibl.ai 在听者自己的设备上发声。OpenAI 和 Google 使用您在下方选择的自定义声音。",
122972
122991
  providerOptions: {
122973
122992
  browser: {
122974
122993
  label: "浏览器",
122975
122994
  description: "通过用户自己的设备发声,无需任何设置。"
122976
122995
  },
122996
+ google: {
122997
+ label: "Google",
122998
+ description: "来自 Google 的高质量声音。"
122999
+ },
123000
+ iblai: {
123001
+ label: "ibl.ai",
123002
+ description: "来自 ibl.ai 的高质量声音"
123003
+ },
122977
123004
  openai: {
122978
123005
  label: "OpenAI",
122979
123006
  description: "来自 OpenAI 的高质量声音。"
122980
- },
122981
- google: {
122982
- label: "Google",
122983
- description: "来自 Google 的自然声音。"
122984
123007
  }
122985
123008
  },
122986
123009
  voicePickerLabel: {
122987
123010
  openai: "OpenAI 声音",
122988
- google: "Google 声音"
123011
+ google: "Google 声音",
123012
+ iblai: "ibl.ai 语音"
122989
123013
  },
122990
123014
  searchPlaceholder: "搜索声音…",
122991
123015
  emptyState: "没有与您搜索匹配的声音。",
@@ -180988,6 +181012,7 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
180988
181012
  const [updateUserStatus] = useUpdateUserStatusMutation();
180989
181013
  const [updatePlatformUserRoleWithPolicies] = useUpdatePlatformUserRoleWithPoliciesMutation();
180990
181014
  const [updatingUserIds, setUpdatingUserIds] = useState(new Set());
181015
+ const [pendingRoles, setPendingRoles] = useState({});
180991
181016
  const [openPolicyPopover, setOpenPolicyPopover] = useState(null);
180992
181017
  const [selectedUser, setSelectedUser] = useState(null);
180993
181018
  const { currentTenant } = useCurrentTenant();
@@ -181040,18 +181065,19 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
181040
181065
  if (!isWatcher)
181041
181066
  await refetchPlatformUsers();
181042
181067
  };
181043
- const handleUserRoleChange = async (user) => {
181068
+ const handleUserRoleChange = async (user, nextRole) => {
181044
181069
  const userId = user === null || user === void 0 ? void 0 : user.user_id;
181045
181070
  if (!userId)
181046
181071
  return;
181047
181072
  // Add to updating state
181048
181073
  setUpdatingUserIds((prev) => new Set(prev).add(userId));
181074
+ setPendingRoles((prev) => ({ ...prev, [userId]: nextRole }));
181049
181075
  try {
181050
181076
  await updateUserRole({
181051
181077
  username: user === null || user === void 0 ? void 0 : user.username,
181052
181078
  org: user === null || user === void 0 ? void 0 : user.platform_org,
181053
181079
  role: 'org-instructor',
181054
- active: !(user === null || user === void 0 ? void 0 : user.is_admin),
181080
+ active: nextRole === 'admin',
181055
181081
  }).unwrap();
181056
181082
  // Only clear policies if role change succeeded
181057
181083
  try {
@@ -181073,6 +181099,7 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
181073
181099
  toast.success(t('tabsUserstab.roleUpdatedSuccess'));
181074
181100
  // Manually invalidate the platformUsers query since it's in a different API slice
181075
181101
  dispatch(platformApiSlice.util.invalidateTags([featureTags.PLATFORM_USERS]));
181102
+ await refetchPlatformUsers();
181076
181103
  }
181077
181104
  catch (error) {
181078
181105
  toast.error(t('tabsUserstab.roleUpdateFailed'));
@@ -181083,6 +181110,10 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
181083
181110
  newSet.delete(userId);
181084
181111
  return newSet;
181085
181112
  });
181113
+ setPendingRoles((prev) => {
181114
+ const { [userId]: _removed, ...rest } = prev;
181115
+ return rest;
181116
+ });
181086
181117
  }
181087
181118
  };
181088
181119
  const handleUserStatusChange = async (user, newStatus) => {
@@ -181183,6 +181214,7 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
181183
181214
  const totalCount = (_b = (isWatcher ? accessibleUsers === null || accessibleUsers === void 0 ? void 0 : accessibleUsers.count : users === null || users === void 0 ? void 0 : users.count)) !== null && _b !== void 0 ? _b : 0;
181184
181215
  const totalPages = Math.ceil(totalCount / PAGE_SIZE$d);
181185
181216
  return (jsxs("div", { className: "mt-4", children: [jsxs("div", { className: "flex justify-between items-center gap-1", children: [jsxs("div", { className: "relative mb-6 max-w-sm", children: [jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" }), jsx(Input, { type: "text", value: searchQuery, onChange: (event) => setQuerySearch(event.target.value), placeholder: t('tabsUserstab.searchPlaceholder'), className: "pl-10 pr-10 focus:ring-blue-500 focus:border-blue-500 h-[35px]", "aria-label": t('tabsUserstab.searchAriaLabel') }), searchQuery.length > 0 && (searchQuery !== debouncedSearchQuery || isLoading) && (jsx(LoaderCircle, { "data-testid": "search-spinner", className: "absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 animate-spin", "aria-hidden": "true" }))] }), !isWatcher && (jsx("div", { className: "mb-6", children: jsx(Button$1, { className: "bg-gradient-to-r from-[#2563EB] to-[#93C5FD] hover:opacity-90 text-white h-[35px]", onClick: onInviteClick, children: t('tabsUserstab.inviteButton') }) }))] }), isLoading ? (jsx("div", { className: "flex justify-center items-center h-64", children: jsx(Spinner, {}) })) : (jsxs(Fragment$1, { children: [jsx("div", { className: "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-x-auto", children: jsxs("table", { className: "w-full text-sm", children: [jsx("thead", { className: "bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600", children: jsxs("tr", { children: [jsx("th", { className: `px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400 ${isWatcher ? 'w-full' : 'w-[41%]'}`, children: t('tabsUserstab.columnEmail') }), !isWatcher && (jsxs(Fragment$1, { children: [jsx("th", { className: "px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400 w-[16%]", children: t('tabsUserstab.columnRole') }), jsx("th", { className: "px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400 w-[25%]", children: t('tabsUserstab.columnPolicies') }), jsx("th", { className: "px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400 w-[18%]", children: t('tabsUserstab.columnStatus') })] }))] }) }), jsxs("tbody", { className: "divide-y divide-gray-200 dark:divide-gray-600", children: [rows.map((row) => {
181217
+ var _a;
181186
181218
  // Only set on the admin path — see {@link UserRow}.
181187
181219
  const user = row.managed;
181188
181220
  return (jsxs("tr", { onClick: () => setSelectedUser(row), className: "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors", children: [jsx("td", { "aria-label": 'user-management-email-column', className: "px-6 py-4 text-gray-900 dark:text-gray-100 text-sm max-w-0", children: jsx("button", { type: "button", onClick: (e) => {
@@ -181190,7 +181222,9 @@ function UsersTab({ tenant, onInviteClick, currentSpa, showGradebookTab = false,
181190
181222
  setSelectedUser(row);
181191
181223
  }, className: "block max-w-full truncate text-left hover:text-blue-600 hover:underline focus:outline-none focus:text-blue-600", "aria-label": t('tabsUserstab.viewProfileAriaLabel', {
181192
181224
  name: row.name || row.email,
181193
- }), children: row.email }) }), user && (jsxs(Fragment$1, { children: [jsx("td", { className: "px-6 py-4", onClick: (e) => e.stopPropagation(), children: jsxs(Select$1, { value: user.is_admin ? 'admin' : 'student', onValueChange: () => handleUserRoleChange(user), children: [jsx(SelectTrigger, { className: "w-full text-sm", children: jsx(SelectValue, {}) }), jsxs(SelectContent, { className: "text-sm", children: [jsx(SelectItem, { value: "admin", children: t('tabsUserstab.roleAdmin') }), jsx(SelectItem, { value: "student", children: t('tabsUserstab.roleUser') })] })] }) }), jsx("td", { className: "px-6 py-4", onClick: (e) => e.stopPropagation(), children: jsxs(Popover, { modal: true, open: openPolicyPopover === user.user_id, onOpenChange: (open) => setOpenPolicyPopover(open ? user.user_id : null), children: [jsx(PopoverTrigger, { asChild: true, children: jsxs(Button$1, { variant: "outline", role: "combobox", "aria-expanded": openPolicyPopover === user.user_id, "aria-label": t('tabsUserstab.selectPoliciesAriaLabel', {
181225
+ }), children: row.email }) }), user && (jsxs(Fragment$1, { children: [jsx("td", { className: "px-6 py-4", onClick: (e) => e.stopPropagation(), children: jsxs(Select$1, { value: (_a = pendingRoles[user.user_id]) !== null && _a !== void 0 ? _a : (user.is_admin ? 'admin' : 'student'), onValueChange: (nextRole) => handleUserRoleChange(user, nextRole), disabled: updatingUserIds.has(user === null || user === void 0 ? void 0 : user.user_id), children: [jsxs(SelectTrigger, { className: "w-full text-sm", "aria-label": t('tabsUserstab.selectRoleAriaLabel', {
181226
+ name: user.name,
181227
+ }), children: [jsx(SelectValue, {}), updatingUserIds.has(user === null || user === void 0 ? void 0 : user.user_id) && (jsx(LoaderCircle, { "data-testid": "role-update-spinner", className: "h-4 w-4 animate-spin", "aria-hidden": "true" }))] }), jsxs(SelectContent, { className: "text-sm", children: [jsx(SelectItem, { value: "admin", children: t('tabsUserstab.roleAdmin') }), jsx(SelectItem, { value: "student", children: t('tabsUserstab.roleUser') })] })] }) }), jsx("td", { className: "px-6 py-4", onClick: (e) => e.stopPropagation(), children: jsxs(Popover, { modal: true, open: openPolicyPopover === user.user_id, onOpenChange: (open) => setOpenPolicyPopover(open ? user.user_id : null), children: [jsx(PopoverTrigger, { asChild: true, children: jsxs(Button$1, { variant: "outline", role: "combobox", "aria-expanded": openPolicyPopover === user.user_id, "aria-label": t('tabsUserstab.selectPoliciesAriaLabel', {
181194
181228
  name: user.name,
181195
181229
  }), className: "w-full justify-between text-sm h-9 font-normal", disabled: updatingUserIds.has(user === null || user === void 0 ? void 0 : user.user_id), children: [jsx("span", { className: "truncate", children: user.is_admin
181196
181230
  ? t('tabsUserstab.allPoliciesSelected', {
@@ -272019,23 +272053,28 @@ const AGENT_VOICE_TAB_LABELS = {
272019
272053
  title: 'Agent Voice',
272020
272054
  description: 'The voice your agent uses to read out replies in chat.',
272021
272055
  providerLabel: 'Voice source',
272022
- providerTooltip: "Browser uses the listener's own device to speak. OpenAI and Google use a custom voice you pick below.",
272056
+ providerTooltip: "Browser and ibl.ai speak on the listener's own device. OpenAI and Google use a custom voice you pick below.",
272023
272057
  providerOptions: {
272024
272058
  browser: {
272025
272059
  label: 'Browser',
272026
272060
  description: "Speak through the user's own device. No setup needed.",
272027
272061
  },
272062
+ google: {
272063
+ label: 'Google',
272064
+ description: 'High-quality voices from Google.',
272065
+ },
272066
+ iblai: {
272067
+ label: 'ibl.ai',
272068
+ description: 'High-quality voices from ibl.ai',
272069
+ },
272028
272070
  openai: {
272029
272071
  label: 'OpenAI',
272030
272072
  description: 'High-quality voices from OpenAI.',
272031
272073
  },
272032
- google: {
272033
- label: 'Google',
272034
- description: 'Natural-sounding voices from Google.',
272035
- },
272036
272074
  },
272037
272075
  voicePickerLabel: {
272038
272076
  openai: 'OpenAI voice',
272077
+ iblai: 'ibl.ai voice',
272039
272078
  google: 'Google voice',
272040
272079
  },
272041
272080
  searchPlaceholder: 'Search voices…',
@@ -272144,18 +272183,23 @@ function buildVoiceTabLabels(t) {
272144
272183
  label: t('voiceTabLabels.mentorVoice.providerOptions.browser.label'),
272145
272184
  description: t('voiceTabLabels.mentorVoice.providerOptions.browser.description'),
272146
272185
  },
272147
- openai: {
272148
- label: t('voiceTabLabels.mentorVoice.providerOptions.openai.label'),
272149
- description: t('voiceTabLabels.mentorVoice.providerOptions.openai.description'),
272150
- },
272151
272186
  google: {
272152
272187
  label: t('voiceTabLabels.mentorVoice.providerOptions.google.label'),
272153
272188
  description: t('voiceTabLabels.mentorVoice.providerOptions.google.description'),
272154
272189
  },
272190
+ iblai: {
272191
+ label: t('voiceTabLabels.mentorVoice.providerOptions.iblai.label'),
272192
+ description: t('voiceTabLabels.mentorVoice.providerOptions.iblai.description'),
272193
+ },
272194
+ openai: {
272195
+ label: t('voiceTabLabels.mentorVoice.providerOptions.openai.label'),
272196
+ description: t('voiceTabLabels.mentorVoice.providerOptions.openai.description'),
272197
+ },
272155
272198
  },
272156
272199
  voicePickerLabel: {
272157
272200
  openai: t('voiceTabLabels.mentorVoice.voicePickerLabel.openai'),
272158
272201
  google: t('voiceTabLabels.mentorVoice.voicePickerLabel.google'),
272202
+ iblai: t('voiceTabLabels.mentorVoice.voicePickerLabel.iblai'),
272159
272203
  },
272160
272204
  searchPlaceholder: t('voiceTabLabels.mentorVoice.searchPlaceholder'),
272161
272205
  emptyState: t('voiceTabLabels.mentorVoice.emptyState'),
@@ -272322,6 +272366,46 @@ function toTitleCase(name) {
272322
272366
  .map((word) => (word.length > 0 ? word[0].toUpperCase() + word.slice(1).toLowerCase() : word))
272323
272367
  .join(' ');
272324
272368
  }
272369
+ /**
272370
+ * On-device catalogue rows are stored under the model's own voice id -- two
272371
+ * letters of accent+gender, an underscore, then the voice's name
272372
+ * (`af_heart`, `bm_george`, `ef_dora`). Only the name is shown.
272373
+ *
272374
+ * Derived rather than mapped: the catalogue carries 50+ voices across several
272375
+ * languages and grows whenever the model does, so a hand-written table would
272376
+ * silently fall back to raw ids for everything it had not caught up with --
272377
+ * which is exactly what it did.
272378
+ */
272379
+ const KOKORO_VOICE_ID = /^[a-z]{2}_([a-z]+)$/;
272380
+ /**
272381
+ * What to show for a voice. Only the on-device provider needs unpacking;
272382
+ * every other catalogue already stores something human-readable, so it just
272383
+ * gets its casing normalised.
272384
+ *
272385
+ * A name that does not match the id shape falls through to the raw value, so
272386
+ * an unexpected row still renders instead of showing blank.
272387
+ */
272388
+ function voiceDisplayName(voice) {
272389
+ var _a;
272390
+ const name = (_a = voice.name) !== null && _a !== void 0 ? _a : '';
272391
+ if (isIblaiVoice(voice)) {
272392
+ const match = KOKORO_VOICE_ID.exec(name);
272393
+ if (match)
272394
+ return toTitleCase(match[1]);
272395
+ }
272396
+ return toTitleCase(name);
272397
+ }
272398
+ /**
272399
+ * Whether a catalogue row is an on-device (Kokoro) voice.
272400
+ *
272401
+ * The cast is deliberate: `VoiceProviderEnum` in the generated API client
272402
+ * predates this provider, so a direct comparison is a type error even though
272403
+ * the backend already returns `"iblai"` -- the same lag that leaves
272404
+ * `voice_provider` and `iblai_voice` off the generated mentor settings type.
272405
+ */
272406
+ function isIblaiVoice(voice) {
272407
+ return voice.provider === 'iblai';
272408
+ }
272325
272409
 
272326
272410
  const PAGE_SIZE$2 = 20;
272327
272411
  function VoicePicker({ org, userId, provider, selectedVoiceId, onSelect, disabled, labels, }) {
@@ -272367,10 +272451,36 @@ function VoicePicker({ org, userId, provider, selectedVoiceId, onSelect, disable
272367
272451
  !voices.some((v) => v.id === pinnedVoice.id);
272368
272452
  const { playingId, isLoading: isPreviewLoading, play, stop } = useAudioPreview();
272369
272453
  React__default.useEffect(() => () => stop(), [provider, stop]);
272370
- return (jsxs("div", { className: "space-y-3", children: [jsxs("div", { className: "relative", children: [jsx(Search, { className: "absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" }), jsx(Input, { type: "search", placeholder: labels.searchPlaceholder, className: "w-full pl-9", value: search, onChange: (e) => setSearch(e.target.value), disabled: disabled, "aria-label": labels.searchPlaceholder, "data-testid": "voice-picker-search" })] }), isError && jsx("p", { className: "text-sm text-red-500", children: labels.errorState }), isFetching ? (jsx("div", { className: "flex items-center justify-center py-8", children: jsx(Spinner, {}) })) : voices.length === 0 && !showPinned ? (jsx("div", { className: "rounded-md border border-dashed border-gray-200 py-8 text-center text-sm text-gray-500", children: labels.emptyState })) : (jsxs(Fragment$1, { children: [showPinned && pinnedVoice && (jsx("div", { className: "rounded-md bg-blue-50/40 p-2", "data-testid": "pinned-voice-row", children: jsx(VoiceRow, { voice: pinnedVoice, isSelected: true, isPlaying: playingId === pinnedVoice.id, showPlayLoader: playingId === pinnedVoice.id && isPreviewLoading, disabled: !!disabled, onSelect: onSelect, play: play, labels: labels }) })), jsx("div", {
272371
- // Padding (`p-1`) prevents the selected border from being
272372
- // clipped by `overflow-y-auto` on the first/last rows.
272373
- className: "grid max-h-[360px] grid-cols-1 gap-2 overflow-y-auto p-1 sm:grid-cols-2", role: "radiogroup", "aria-label": t('voiceTabVoicePicker.availableVoicesAriaLabel'), children: voices.map((voice) => (jsx(VoiceRow, { voice: voice, isSelected: selectedVoiceId === voice.id, isPlaying: playingId === voice.id, showPlayLoader: playingId === voice.id && isPreviewLoading, disabled: !!disabled, onSelect: onSelect, play: play, labels: labels }, voice.id))) }), totalPages > 1 && (jsxs("div", { className: "flex items-center justify-between gap-2 border-t border-gray-100 pt-3 text-xs text-gray-600", "data-testid": "voice-pagination", children: [jsxs("span", { "aria-live": "polite", children: [t('voiceTabVoicePicker.paginationPage'), " ", jsx("strong", { children: page }), ' ', t('voiceTabVoicePicker.paginationOf'), " ", jsx("strong", { children: totalPages }), jsx("span", { className: "ml-1 text-gray-400", children: t('voiceTabVoicePicker.paginationVoiceCount', { total }) })] }), jsxs("div", { className: "flex items-center gap-2", children: [jsxs(Button$1, { type: "button", variant: "outline", size: "sm", disabled: disabled || page <= 1, onClick: () => setPage((p) => Math.max(1, p - 1)), "aria-label": t('voiceTabVoicePicker.prevPageAriaLabel'), "data-testid": "voice-prev-page", children: [jsx(ChevronLeft, { className: "h-4 w-4" }), t('voiceTabVoicePicker.prevButton')] }), jsxs(Button$1, { type: "button", variant: "outline", size: "sm", disabled: disabled || page >= totalPages, onClick: () => setPage((p) => Math.min(totalPages, p + 1)), "aria-label": t('voiceTabVoicePicker.nextPageAriaLabel'), "data-testid": "voice-next-page", children: [t('voiceTabVoicePicker.nextButton'), jsx(ChevronRight, { className: "h-4 w-4" })] })] })] }))] }))] }));
272454
+ /**
272455
+ * Width the scrollbar takes out of the list's content box.
272456
+ *
272457
+ * Measured rather than assumed: it is ~15px on Windows and most Linux
272458
+ * desktops but 0 wherever the platform draws overlay scrollbars (macOS by
272459
+ * default), so a hardcoded value would misalign the list on half the
272460
+ * machines that see it. Used below to pull the list's right edge out by
272461
+ * exactly that much, so the cards line up with the search box above them
272462
+ * and the scrollbar sits in the dialog's own padding.
272463
+ */
272464
+ const listRef = React__default.useRef(null);
272465
+ const [scrollbarWidth, setScrollbarWidth] = React__default.useState(0);
272466
+ React__default.useLayoutEffect(() => {
272467
+ const el = listRef.current;
272468
+ if (!el)
272469
+ return;
272470
+ const measure = () => setScrollbarWidth(el.offsetWidth - el.clientWidth);
272471
+ measure();
272472
+ const observer = new ResizeObserver(measure);
272473
+ observer.observe(el);
272474
+ return () => observer.disconnect();
272475
+ }, [voices.length]);
272476
+ return (jsxs("div", { className: "space-y-3", children: [jsxs("div", { className: "relative", children: [jsx(Search, { className: "absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" }), jsx(Input, { type: "search", placeholder: labels.searchPlaceholder, className: "w-full pl-9", value: search, onChange: (e) => setSearch(e.target.value), disabled: disabled, "aria-label": labels.searchPlaceholder, "data-testid": "voice-picker-search" })] }), isError && jsx("p", { className: "text-sm text-red-500", children: labels.errorState }), isFetching ? (jsx("div", { className: "flex items-center justify-center py-8", children: jsx(Spinner, {}) })) : voices.length === 0 && !showPinned ? (jsx("div", { className: "rounded-md border border-dashed border-gray-200 py-8 text-center text-sm text-gray-500", children: labels.emptyState })) : (jsxs(Fragment$1, { children: [showPinned && pinnedVoice && (jsx("div", { "data-testid": "pinned-voice-row", children: jsx(VoiceRow, { voice: pinnedVoice, isSelected: true, isPlaying: playingId === pinnedVoice.id, showPlayLoader: playingId === pinnedVoice.id && isPreviewLoading, disabled: !!disabled, onSelect: onSelect, play: play, labels: labels }) })), jsx("div", { ref: listRef,
272477
+ // Vertical padding keeps the selected card's border from being
272478
+ // clipped by `overflow-y-auto` on the first and last rows. There is
272479
+ // deliberately no horizontal padding: the cards are meant to line
272480
+ // up with the search box, and the negative margin below moves the
272481
+ // scrollbar out of that alignment rather than letting it eat into
272482
+ // the last column.
272483
+ className: "grid max-h-[360px] grid-cols-1 gap-2 overflow-y-auto py-1 sm:grid-cols-2", style: { marginRight: -scrollbarWidth }, role: "radiogroup", "aria-label": t('voiceTabVoicePicker.availableVoicesAriaLabel'), children: voices.map((voice) => (jsx(VoiceRow, { voice: voice, isSelected: selectedVoiceId === voice.id, isPlaying: playingId === voice.id, showPlayLoader: playingId === voice.id && isPreviewLoading, disabled: !!disabled, onSelect: onSelect, play: play, labels: labels }, voice.id))) }), totalPages > 1 && (jsxs("div", { className: "flex items-center justify-between gap-2 border-t border-gray-100 pt-3 text-xs text-gray-600", "data-testid": "voice-pagination", children: [jsxs("span", { "aria-live": "polite", children: [t('voiceTabVoicePicker.paginationPage'), " ", jsx("strong", { children: page }), ' ', t('voiceTabVoicePicker.paginationOf'), " ", jsx("strong", { children: totalPages }), jsx("span", { className: "ml-1 text-gray-400", children: t('voiceTabVoicePicker.paginationVoiceCount', { total }) })] }), jsxs("div", { className: "flex items-center gap-2", children: [jsxs(Button$1, { type: "button", variant: "outline", size: "sm", disabled: disabled || page <= 1, onClick: () => setPage((p) => Math.max(1, p - 1)), "aria-label": t('voiceTabVoicePicker.prevPageAriaLabel'), "data-testid": "voice-prev-page", children: [jsx(ChevronLeft, { className: "h-4 w-4" }), t('voiceTabVoicePicker.prevButton')] }), jsxs(Button$1, { type: "button", variant: "outline", size: "sm", disabled: disabled || page >= totalPages, onClick: () => setPage((p) => Math.min(totalPages, p + 1)), "aria-label": t('voiceTabVoicePicker.nextPageAriaLabel'), "data-testid": "voice-next-page", children: [t('voiceTabVoicePicker.nextButton'), jsx(ChevronRight, { className: "h-4 w-4" })] })] })] }))] }))] }));
272374
272484
  }
272375
272485
  /**
272376
272486
  * A single voice row. Rendered as a `<div role="radio">` so the inner
@@ -272381,19 +272491,24 @@ function VoicePicker({ org, userId, provider, selectedVoiceId, onSelect, disable
272381
272491
  * clipped by the parent scroll container's `overflow-y-auto`.
272382
272492
  */
272383
272493
  function VoiceRow({ voice, isSelected, isPlaying, showPlayLoader, disabled, onSelect, play, labels, }) {
272384
- return (jsxs("div", { role: "radio", tabIndex: disabled ? -1 : 0, "aria-checked": isSelected, "aria-disabled": disabled || undefined, "aria-label": toTitleCase(voice.name), "data-voice-id": voice.id, "data-selected": isSelected || undefined, onClick: () => !disabled && onSelect(voice), onKeyDown: (e) => {
272494
+ // Whether a second line renders under the name. It decides the cross-axis
272495
+ // alignment: with a description the 36px badge should sit level with the
272496
+ // first line (`items-start`), but on a single-line card that pushes the
272497
+ // badge 4px below the name and preview button, which reads as crooked.
272498
+ const hasMeta = !isIblaiVoice(voice) && Boolean(voice.language || voice.description);
272499
+ return (jsxs("div", { role: "radio", tabIndex: disabled ? -1 : 0, "aria-checked": isSelected, "aria-disabled": disabled || undefined, "aria-label": voiceDisplayName(voice), "data-voice-id": voice.id, "data-selected": isSelected || undefined, onClick: () => !disabled && onSelect(voice), onKeyDown: (e) => {
272385
272500
  if (disabled)
272386
272501
  return;
272387
272502
  if (e.key === ' ' || e.key === 'Enter') {
272388
272503
  e.preventDefault();
272389
272504
  onSelect(voice);
272390
272505
  }
272391
- }, className: cn('flex cursor-pointer items-start gap-3 rounded-lg border-2 p-3 text-left transition-all hover:shadow-sm focus-visible:outline-blue-500', isSelected
272506
+ }, className: cn('flex cursor-pointer gap-3 rounded-lg border-2 p-3 text-left transition-all hover:shadow-sm focus-visible:outline-blue-500', hasMeta ? 'items-start' : 'items-center', isSelected
272392
272507
  ? 'border-blue-500 bg-blue-50/40'
272393
- : 'border-gray-200 bg-white hover:border-gray-300', disabled && 'cursor-not-allowed opacity-60'), children: [jsx("div", { className: "flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600", children: jsx(Volume2, { className: "h-4 w-4", "aria-hidden": "true" }) }), jsxs("div", { className: "min-w-0 flex-1", children: [jsxs("div", { className: "flex items-center justify-between gap-2", children: [jsx("span", { className: "truncate text-sm font-medium text-gray-900", children: toTitleCase(voice.name) }), voice.audio_url && (jsx(Button$1, { type: "button", variant: "ghost", size: "icon", className: "h-7 w-7 flex-shrink-0 text-gray-500 hover:text-blue-600", onClick: (e) => {
272394
- e.stopPropagation();
272395
- play(voice.id, voice.audio_url);
272396
- }, disabled: disabled, "aria-label": labels.previewAria(toTitleCase(voice.name), isPlaying ? 'stop' : 'play'), children: showPlayLoader ? (jsx(LoaderCircle, { className: "h-4 w-4 animate-spin" })) : isPlaying ? (jsx(Pause, { className: "h-4 w-4" })) : (jsx(Play, { className: "h-4 w-4" })) }))] }), (voice.language || voice.description) && (jsx("p", { className: "mt-1 line-clamp-2 text-xs text-gray-500", children: [voice.language, voice.description].filter(Boolean).join(' · ') }))] })] }));
272508
+ : 'border-gray-200 bg-white hover:border-gray-300', disabled && 'cursor-not-allowed opacity-60'), children: [jsx("div", { className: "flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600", children: jsx(Volume2, { className: "h-4 w-4", "aria-hidden": "true" }) }), jsxs("div", { className: "min-w-0 flex-1", children: [jsxs("div", { className: "flex items-center justify-between gap-2", children: [jsx("span", { className: "truncate text-sm font-medium text-gray-900", children: voiceDisplayName(voice) }), jsx("span", { className: "flex h-7 w-7 flex-shrink-0 items-center justify-center", children: voice.audio_url && (jsx(Button$1, { type: "button", variant: "ghost", size: "icon", className: "h-7 w-7 flex-shrink-0 text-gray-500 hover:text-blue-600", onClick: (e) => {
272509
+ e.stopPropagation();
272510
+ play(voice.id, voice.audio_url);
272511
+ }, disabled: disabled, "aria-label": labels.previewAria(voiceDisplayName(voice), isPlaying ? 'stop' : 'play'), children: showPlayLoader ? (jsx(LoaderCircle, { className: "h-4 w-4 animate-spin" })) : isPlaying ? (jsx(Pause, { className: "h-4 w-4" })) : (jsx(Play, { className: "h-4 w-4" })) })) })] }), hasMeta && (jsx("p", { className: "mt-1 line-clamp-2 text-xs text-gray-500", children: [voice.language, voice.description].filter(Boolean).join(' · ') }))] })] }));
272397
272512
  }
272398
272513
 
272399
272514
  /**
@@ -272433,8 +272548,10 @@ function VoicePickerTrigger({ org, userId, provider, selectedVoice, onSelect, di
272433
272548
  if (isOpen)
272434
272549
  stop();
272435
272550
  }, [isOpen, stop]);
272436
- const displayName = selectedVoice ? toTitleCase(selectedVoice.name) : null;
272437
- const language = (selectedVoice === null || selectedVoice === void 0 ? void 0 : selectedVoice.language) || null;
272551
+ const displayName = selectedVoice ? voiceDisplayName(selectedVoice) : null;
272552
+ // See the note in voice-picker.tsx: the on-device rows all carry the same
272553
+ // locale, so showing it adds nothing.
272554
+ const language = selectedVoice && isIblaiVoice(selectedVoice) ? null : (selectedVoice === null || selectedVoice === void 0 ? void 0 : selectedVoice.language) || null;
272438
272555
  const audioUrl = (selectedVoice === null || selectedVoice === void 0 ? void 0 : selectedVoice.audio_url) || '';
272439
272556
  const isPlaying = selectedVoice != null && playingId === selectedVoice.id;
272440
272557
  const showPlayLoader = isPlaying && isPreviewLoading;
@@ -272738,6 +272855,29 @@ function PromptCard$1({ label, placeholder, value, disabled, renderContent, onEd
272738
272855
  return (jsxs("div", { className: "overflow-hidden rounded-lg bg-gray-50", "data-testid": testId, children: [jsx("div", { className: "flex items-center justify-between border-b border-gray-200 p-[1.12rem]", children: jsxs("div", { className: "flex items-center gap-2", children: [jsx("h3", { className: "text-sm font-medium text-gray-900", children: label }), headerExtra] }) }), jsxs("div", { className: "flex items-start gap-2 p-4", children: [jsx("div", { className: "mt-1 flex-shrink-0", "aria-hidden": "true", children: "\uD83D\uDCDD" }), jsx("div", { className: "flex h-[180px] flex-1 flex-col", children: jsx("div", { className: "mb-4 flex-grow overflow-y-auto", tabIndex: 0, role: "region", "aria-label": `${label} content`, children: isEmpty ? (jsx("p", { className: "text-sm text-gray-400 italic", children: placeholder })) : (renderContent(value)) }) })] }), jsxs("div", { className: "flex w-full gap-2 px-4 pb-4", children: [jsxs(Button$1, { variant: "outline", size: "sm", className: "h-8 flex-1 py-5", disabled: disabled, onClick: onEdit, "aria-label": `Edit ${label}`, children: [jsx(SquarePen, { className: "mr-2 h-4 w-4" }), "Edit"] }), jsx(CopyButton, { text: value, disabled: disabled })] })] }));
272739
272856
  }
272740
272857
 
272858
+ /**
272859
+ * Providers whose voice comes from the server-side voices catalogue, and so
272860
+ * need a voice picker plus a voice id on save. Only `browser` is excluded: it
272861
+ * speaks through whatever voice the listener's own OS provides, which we
272862
+ * neither choose nor know.
272863
+ *
272864
+ * `iblai` synthesises on-device but still picks from the catalogue, because
272865
+ * the backend stores `iblai_voice` as a foreign key into the same table --
272866
+ * sending it a voice name is rejected with "A valid integer is required".
272867
+ */
272868
+ const CATALOGUE_VOICE_PROVIDERS = ['openai', 'google', 'iblai'];
272869
+ /**
272870
+ * Voice used when a mentor has never had one chosen.
272871
+ *
272872
+ * Must stay in step with the consuming app's own fallback (mentorai's
272873
+ * `DEFAULT_VOICE` in `lib/tts/config.ts`): the agent speaks that voice whether
272874
+ * or not anything is saved, so showing an empty picker would tell the admin
272875
+ * "nothing selected" while the listener hears Heart.
272876
+ */
272877
+ const DEFAULT_IBLAI_VOICE = 'af_heart';
272878
+ function usesVoiceCatalogue(provider) {
272879
+ return CATALOGUE_VOICE_PROVIDERS.includes(provider);
272880
+ }
272741
272881
  const SUB_TAB_VALUES = {
272742
272882
  voice: 'voice',
272743
272883
  callConfig: 'call-config',
@@ -272764,7 +272904,7 @@ function readVoiceName(value) {
272764
272904
  return null;
272765
272905
  }
272766
272906
  function AgentVoiceTab({ labels: labelsOverride, defaultSubTab = 'voice', renderPromptContent, tenantKey: tenantKeyProp, mentorId: mentorIdProp, username: usernameProp, enableRBAC: enableRBACProp, }) {
272767
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
272907
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
272768
272908
  const ctx = useAgentSettingsOptional();
272769
272909
  const tenantKey = (_a = tenantKeyProp !== null && tenantKeyProp !== void 0 ? tenantKeyProp : ctx === null || ctx === void 0 ? void 0 : ctx.tenantKey) !== null && _a !== void 0 ? _a : '';
272770
272910
  const mentorId = (_b = mentorIdProp !== null && mentorIdProp !== void 0 ? mentorIdProp : ctx === null || ctx === void 0 ? void 0 : ctx.mentorId) !== null && _b !== void 0 ? _b : '';
@@ -272786,6 +272926,10 @@ function AgentVoiceTab({ labels: labelsOverride, defaultSubTab = 'voice', render
272786
272926
  // (with audio_url for the play button) and send the id on save.
272787
272927
  const savedOpenaiName = (_f = readVoiceName(mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.openai_voice)) !== null && _f !== void 0 ? _f : null;
272788
272928
  const savedGoogleName = (_g = readVoiceName(mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.google_voice)) !== null && _g !== void 0 ? _g : null;
272929
+ // Falls back to the default rather than null so the trigger shows the voice
272930
+ // that will actually be used. Nothing is written to the mentor by this: an
272931
+ // unsaved default and an explicitly-chosen Heart behave identically.
272932
+ const savedIblaiName = (_h = readVoiceName(mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.iblai_voice)) !== null && _h !== void 0 ? _h : DEFAULT_IBLAI_VOICE;
272789
272933
  const { voice: resolvedOpenaiVoice } = useResolveVoiceByName({
272790
272934
  org: tenantKey,
272791
272935
  userId: username,
@@ -272798,28 +272942,37 @@ function AgentVoiceTab({ labels: labelsOverride, defaultSubTab = 'voice', render
272798
272942
  provider: 'google',
272799
272943
  name: savedGoogleName,
272800
272944
  });
272945
+ const { voice: resolvedIblaiVoice } = useResolveVoiceByName({
272946
+ org: tenantKey,
272947
+ userId: username,
272948
+ provider: 'iblai',
272949
+ name: savedIblaiName,
272950
+ });
272801
272951
  const savedInstructions = typeof (mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.voice_instructions) === 'string' ? mentorSettings.voice_instructions : '';
272802
272952
  // Picked voices override the resolved server values once the user
272803
272953
  // makes a choice. Null means "use what came from the server".
272804
272954
  const [provider, setProvider] = React__default.useState(initialProvider);
272805
272955
  const [pickedOpenaiVoice, setPickedOpenaiVoice] = React__default.useState(null);
272806
272956
  const [pickedGoogleVoice, setPickedGoogleVoice] = React__default.useState(null);
272957
+ const [pickedIblaiVoice, setPickedIblaiVoice] = React__default.useState(null);
272807
272958
  const [instructions, setInstructions] = React__default.useState(savedInstructions);
272808
272959
  const [selectedPrompt, setSelectedPrompt] = React__default.useState(null);
272809
272960
  React__default.useEffect(() => {
272810
272961
  setProvider(initialProvider);
272811
272962
  setPickedOpenaiVoice(null);
272812
272963
  setPickedGoogleVoice(null);
272964
+ setPickedIblaiVoice(null);
272813
272965
  setInstructions(savedInstructions);
272814
272966
  // eslint-disable-next-line react-hooks/exhaustive-deps
272815
272967
  }, [mentorId, rawMentorSettings]);
272816
- const effectiveOpenaiVoice = (_h = pickedOpenaiVoice !== null && pickedOpenaiVoice !== void 0 ? pickedOpenaiVoice : resolvedOpenaiVoice) !== null && _h !== void 0 ? _h : null;
272817
- const effectiveGoogleVoice = (_j = pickedGoogleVoice !== null && pickedGoogleVoice !== void 0 ? pickedGoogleVoice : resolvedGoogleVoice) !== null && _j !== void 0 ? _j : null;
272968
+ const effectiveOpenaiVoice = (_j = pickedOpenaiVoice !== null && pickedOpenaiVoice !== void 0 ? pickedOpenaiVoice : resolvedOpenaiVoice) !== null && _j !== void 0 ? _j : null;
272969
+ const effectiveGoogleVoice = (_k = pickedGoogleVoice !== null && pickedGoogleVoice !== void 0 ? pickedGoogleVoice : resolvedGoogleVoice) !== null && _k !== void 0 ? _k : null;
272970
+ const effectiveIblaiVoice = (_l = pickedIblaiVoice !== null && pickedIblaiVoice !== void 0 ? pickedIblaiVoice : resolvedIblaiVoice) !== null && _l !== void 0 ? _l : null;
272818
272971
  const [editMentor, { isLoading: isSavingMentor }] = useEditMentorMutation();
272819
272972
  // "Enable voice calls" master toggle. Optimistic local mirror of the
272820
272973
  // server value so the switch responds instantly; re-synced whenever the
272821
272974
  // settings response changes.
272822
- const serverVoiceCallEnabled = (_k = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.show_voice_call) !== null && _k !== void 0 ? _k : true;
272975
+ const serverVoiceCallEnabled = (_m = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.show_voice_call) !== null && _m !== void 0 ? _m : true;
272823
272976
  const [voiceCallEnabled, setVoiceCallEnabled] = React__default.useState(serverVoiceCallEnabled);
272824
272977
  const [isTogglingVoiceCall, setIsTogglingVoiceCall] = React__default.useState(false);
272825
272978
  React__default.useEffect(() => {
@@ -272855,33 +273008,48 @@ function AgentVoiceTab({ labels: labelsOverride, defaultSubTab = 'voice', render
272855
273008
  const isDirty = provider !== initialProvider ||
272856
273009
  pickedOpenaiVoice != null ||
272857
273010
  pickedGoogleVoice != null ||
273011
+ pickedIblaiVoice != null ||
272858
273012
  instructionsDirty;
272859
273013
  /**
272860
- * Provider chips. We reuse the OpenAI / Google logos already shipped in
272861
- * the host's static-root (see `getLLMProviderDetails` for the canonical
272862
- * paths). Browser falls back to a Lucide globe there's no vendor logo
272863
- * for "device speech engine".
273014
+ * Provider chips, ordered by lowercasing each label and then sorting
273015
+ * alphabetically, so a newly added provider slots itself in without a
273016
+ * hand-edit. We reuse the vendor logos already shipped in the host's
273017
+ * static-root (see `getLLMProviderDetails` for the canonical paths).
273018
+ * Browser falls back to a Lucide globe — there's no vendor logo for
273019
+ * "device speech engine".
272864
273020
  */
272865
- const providerOptions = [
272866
- {
272867
- value: 'browser',
272868
- label: labels.mentorVoice.providerOptions.browser.label,
272869
- description: labels.mentorVoice.providerOptions.browser.description,
272870
- logo: { type: 'icon', Icon: Globe },
272871
- },
272872
- {
272873
- value: 'openai',
272874
- label: labels.mentorVoice.providerOptions.openai.label,
272875
- description: labels.mentorVoice.providerOptions.openai.description,
272876
- logo: { type: 'image', src: '/llm-openai-provider-2.svg', alt: 'OpenAI' },
272877
- },
272878
- {
272879
- value: 'google',
272880
- label: labels.mentorVoice.providerOptions.google.label,
272881
- description: labels.mentorVoice.providerOptions.google.description,
272882
- logo: { type: 'image', src: '/llm-google-provider.svg', alt: 'Google' },
272883
- },
272884
- ];
273021
+ const providerOptions = React__default.useMemo(() => {
273022
+ const options = [
273023
+ {
273024
+ value: 'browser',
273025
+ label: labels.mentorVoice.providerOptions.browser.label,
273026
+ description: labels.mentorVoice.providerOptions.browser.description,
273027
+ logo: { type: 'icon', Icon: Globe },
273028
+ },
273029
+ {
273030
+ value: 'google',
273031
+ label: labels.mentorVoice.providerOptions.google.label,
273032
+ description: labels.mentorVoice.providerOptions.google.description,
273033
+ logo: { type: 'image', src: '/llm-google-provider.svg', alt: 'Google' },
273034
+ },
273035
+ {
273036
+ value: 'iblai',
273037
+ label: labels.mentorVoice.providerOptions.iblai.label,
273038
+ description: labels.mentorVoice.providerOptions.iblai.description,
273039
+ logo: { type: 'image', src: '/llm-iblai-provider.png', alt: 'ibl.ai' },
273040
+ },
273041
+ {
273042
+ value: 'openai',
273043
+ label: labels.mentorVoice.providerOptions.openai.label,
273044
+ description: labels.mentorVoice.providerOptions.openai.description,
273045
+ logo: { type: 'image', src: '/llm-openai-provider-2.svg', alt: 'OpenAI' },
273046
+ },
273047
+ ];
273048
+ // Lowercase first, then compare. A case-sensitive sort would rank every
273049
+ // capitalised label ahead of the lowercase "ibl.ai" and push it past
273050
+ // OpenAI.
273051
+ return [...options].sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase()));
273052
+ }, [labels.mentorVoice.providerOptions]);
272885
273053
  const handleSaveVoice = async () => {
272886
273054
  try {
272887
273055
  const formData = { voice_provider: provider };
@@ -272894,6 +273062,9 @@ function AgentVoiceTab({ labels: labelsOverride, defaultSubTab = 'voice', render
272894
273062
  if (provider === 'google' && pickedGoogleVoice) {
272895
273063
  formData.google_voice = pickedGoogleVoice.id;
272896
273064
  }
273065
+ if (provider === 'iblai' && pickedIblaiVoice) {
273066
+ formData.iblai_voice = pickedIblaiVoice.id;
273067
+ }
272897
273068
  // Same "absent = keep" contract: only send voice_instructions when
272898
273069
  // the user actually edited it. Clearing sends "" (never null) —
272899
273070
  // that's what resets the stored value server-side.
@@ -272914,16 +273085,21 @@ function AgentVoiceTab({ labels: labelsOverride, defaultSubTab = 'voice', render
272914
273085
  toast.error(labels.toasts.voiceError);
272915
273086
  }
272916
273087
  };
273088
+ // Only `browser` has no voice to show -- it uses the listener's OS voice.
272917
273089
  const selectedVoice = provider === 'openai'
272918
273090
  ? effectiveOpenaiVoice
272919
273091
  : provider === 'google'
272920
273092
  ? effectiveGoogleVoice
272921
- : null;
273093
+ : provider === 'iblai'
273094
+ ? effectiveIblaiVoice
273095
+ : null;
272922
273096
  const onSelectVoice = (voice) => {
272923
273097
  if (provider === 'openai')
272924
273098
  setPickedOpenaiVoice(voice);
272925
273099
  else if (provider === 'google')
272926
273100
  setPickedGoogleVoice(voice);
273101
+ else if (provider === 'iblai')
273102
+ setPickedIblaiVoice(voice);
272927
273103
  };
272928
273104
  // Mirrors `AgentScreenShareTab`: hosts can render prompt content as
272929
273105
  // Markdown via `renderPromptContent`; plain text otherwise.
@@ -272934,19 +273110,19 @@ function AgentVoiceTab({ labels: labelsOverride, defaultSubTab = 'voice', render
272934
273110
  };
272935
273111
  return (jsxs(Fragment$1, { children: [jsx("div", { className: "flex h-[73px] flex-shrink-0 items-center border-b border-gray-200 bg-white p-4 lg:block", children: jsxs("div", { children: [jsx("h3", { className: "mb-1 text-base font-medium text-gray-900", children: labels.header.title }), jsx("p", { className: "text-xs text-gray-600", children: labels.header.description })] }) }), jsx("div", { className: "flex-1 space-y-4 p-3 lg:p-4", style: { overflowY: 'auto', overflowX: 'hidden' }, "data-testid": "voice-tab-body", children: isMentorSettingsLoading ? (jsx("div", { className: "flex items-center justify-center py-12", "data-testid": "voice-tab-loading", children: jsx(Spinner, {}) })) : (jsxs(CapabilityGate, { title: labels.capability.title, description: labels.capability.description, offHint: labels.capability.offHint, enabled: voiceCallEnabled, onToggle: handleToggleVoiceCall, disabled: isDisabled || isTogglingVoiceCall, ariaLabel: labels.capability.title, toggleTestId: "voice-capability-toggle", children: [jsxs(Tabs, { defaultValue: SUB_TAB_VALUES[defaultSubTab], className: "w-full", children: [jsxs(TabsList, { "aria-label": labels.header.title, "data-testid": "voice-sub-tabs", children: [jsx(TabsTrigger, { value: SUB_TAB_VALUES.voice, "data-testid": "voice-sub-tab-voice", children: labels.subTabs.voice }), jsx(TabsTrigger, { value: SUB_TAB_VALUES.callConfig, "data-testid": "voice-sub-tab-call-config", children: labels.subTabs.callConfig })] }), jsxs(TabsContent, { value: SUB_TAB_VALUES.voice, className: "space-y-4", children: [jsx("p", { className: "text-xs text-gray-600", children: labels.mentorVoice.description }), jsx(WithFormPermissions, { name: "voice_provider",
272936
273112
  // @ts-expect-error permissions.field is opaque
272937
- permissions: (_l = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.permissions) === null || _l === void 0 ? void 0 : _l.field, enableRBAC: enableRBAC, children: ({ disabled }) => (jsxs("div", { className: "space-y-2", children: [jsxs("div", { className: "flex items-center gap-2", children: [jsx(Label$1, { className: "text-sm font-medium text-[#646464]", children: labels.mentorVoice.providerLabel }), jsx(FieldTooltip, { text: labels.mentorVoice.providerTooltip })] }), jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", role: "radiogroup", "aria-label": labels.mentorVoice.providerLabel, children: providerOptions.map((option) => {
273113
+ permissions: (_o = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.permissions) === null || _o === void 0 ? void 0 : _o.field, enableRBAC: enableRBAC, children: ({ disabled }) => (jsxs("div", { className: "space-y-2", children: [jsxs("div", { className: "flex items-center gap-2", children: [jsx(Label$1, { className: "text-sm font-medium text-[#646464]", children: labels.mentorVoice.providerLabel }), jsx(FieldTooltip, { text: labels.mentorVoice.providerTooltip })] }), jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4", role: "radiogroup", "aria-label": labels.mentorVoice.providerLabel, children: providerOptions.map((option) => {
272938
273114
  const isSelected = provider === option.value;
272939
273115
  const isDisabledOption = isDisabled || disabled;
272940
273116
  return (jsxs("button", { type: "button", role: "radio", "aria-checked": isSelected, "aria-label": option.label, disabled: isDisabledOption, "data-testid": `voice-provider-${option.value}`, "data-voice-provider": option.value, onClick: () => setProvider(option.value), className: cn('flex flex-col items-start gap-2 rounded-lg border-2 p-3 text-left transition-all hover:shadow-sm', isSelected
272941
273117
  ? 'border-blue-500 bg-blue-50/40'
272942
273118
  : 'border-gray-200 bg-white hover:border-gray-300', isDisabledOption && 'cursor-not-allowed opacity-60'), children: [jsxs("div", { className: "flex items-center gap-2", children: [jsx("span", { className: "flex h-6 w-6 flex-shrink-0 items-center justify-center", children: option.logo.type === 'image' ? (jsx(Image$4, { src: option.logo.src, alt: option.logo.alt, width: 24, height: 24, className: "h-6 w-6 object-contain" })) : (jsx(option.logo.Icon, { className: "h-5 w-5 text-blue-600", "aria-hidden": "true" })) }), jsx("span", { className: "text-sm font-medium text-gray-900", children: option.label })] }), jsx("p", { className: "text-xs text-gray-500", children: option.description })] }, option.value));
272943
- }) })] })) }), (provider === 'openai' || provider === 'google') && (jsx(WithFormPermissions, { name: provider === 'openai' ? 'openai_voice' : 'google_voice',
273119
+ }) })] })) }), usesVoiceCatalogue(provider) && (jsx(WithFormPermissions, { name: `${provider}_voice`,
272944
273120
  // @ts-expect-error permissions.field is opaque
272945
- permissions: (_m = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.permissions) === null || _m === void 0 ? void 0 : _m.field, enableRBAC: enableRBAC, children: ({ disabled }) => (jsxs("div", { className: "space-y-2", children: [jsx(Label$1, { className: "text-sm font-medium text-[#646464]", children: t('tabsVoiceTab.voiceLabel') }), jsx(VoicePickerTrigger, { org: tenantKey, userId: username, provider: provider, selectedVoice: selectedVoice, onSelect: onSelectVoice, disabled: isDisabled || disabled, labels: labels.mentorVoice, modalTitle: t('tabsVoiceTab.selectVoiceModalTitle', {
273121
+ permissions: (_p = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.permissions) === null || _p === void 0 ? void 0 : _p.field, enableRBAC: enableRBAC, children: ({ disabled }) => (jsxs("div", { className: "space-y-2", children: [jsx(Label$1, { className: "text-sm font-medium text-[#646464]", children: t('tabsVoiceTab.voiceLabel') }), jsx(VoicePickerTrigger, { org: tenantKey, userId: username, provider: provider, selectedVoice: selectedVoice, onSelect: onSelectVoice, disabled: isDisabled || disabled, labels: labels.mentorVoice, modalTitle: t('tabsVoiceTab.selectVoiceModalTitle', {
272946
273122
  voiceType: labels.mentorVoice.voicePickerLabel[provider],
272947
273123
  }), modalDescription: t('tabsVoiceTab.selectVoiceModalDescription'), testId: "mentor-voice-trigger" })] })) })), (provider === 'openai' || provider === 'google') && (jsx(WithFormPermissions, { name: "voice_instructions",
272948
273124
  // @ts-expect-error permissions.field is opaque
272949
- permissions: (_o = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.permissions) === null || _o === void 0 ? void 0 : _o.field, enableRBAC: enableRBAC, children: ({ disabled }) => {
273125
+ permissions: (_q = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.permissions) === null || _q === void 0 ? void 0 : _q.field, enableRBAC: enableRBAC, children: ({ disabled }) => {
272950
273126
  const isFieldDisabled = isDisabled || disabled;
272951
273127
  const presetKeys = ['warm', 'calm', 'energetic'];
272952
273128
  return (jsxs("div", { className: "space-y-2", children: [jsx(PromptCard$1, { label: labels.mentorVoice.instructions.label, placeholder: labels.mentorVoice.instructions.placeholder, value: instructions, disabled: isFieldDisabled, renderContent: renderInstructionsContent, testId: "voice-instructions-card", headerExtra: jsx(FieldTooltip, { text: labels.mentorVoice.instructions.tooltip }), onEdit: () => setSelectedPrompt({
@@ -272959,7 +273135,7 @@ function AgentVoiceTab({ labels: labelsOverride, defaultSubTab = 'voice', render
272959
273135
  }) }), jsxs("div", { className: "flex items-start justify-between gap-3", children: [jsx("p", { id: "voice-instructions-help", className: "text-xs text-gray-500", children: labels.mentorVoice.instructions.helpText }), jsxs("span", { id: "voice-instructions-counter", "aria-live": "polite", "data-testid": "voice-instructions-counter", className: cn('flex-shrink-0 text-xs text-gray-400', instructionsOverCap && 'text-red-500'), children: [instructions.length, "/", VOICE_INSTRUCTIONS_MAX] })] }), jsxs("div", { className: "flex flex-wrap items-center gap-2", role: "group", "aria-label": labels.mentorVoice.instructions.presetsLabel, children: [jsxs("span", { className: "text-xs text-gray-500", children: [labels.mentorVoice.instructions.presetsLabel, ":"] }), presetKeys.map((key) => (jsx("button", { type: "button", disabled: isFieldDisabled, "data-testid": `voice-instructions-preset-${key}`, onClick: () => setInstructions(labels.mentorVoice.instructions.presets[key].text), className: cn('inline-flex items-center rounded-full border border-gray-300 bg-white px-3 py-1 text-xs text-gray-700 transition-colors hover:bg-gray-50', isFieldDisabled && 'cursor-not-allowed opacity-50'), children: labels.mentorVoice.instructions.presets[key].label }, key)))] })] }));
272960
273136
  } })), jsx("div", { className: "flex items-center justify-end border-t border-gray-100 pt-3", children: jsx(Button$1, { type: "button", onClick: handleSaveVoice, disabled: isDisabled || !isDirty || instructionsOverCap, "data-testid": "voice-save-button", className: "bg-gradient-to-r from-[#2563EB] to-[#93C5FD] text-white hover:opacity-90", children: isSavingMentor
272961
273137
  ? labels.mentorVoice.savingButton
272962
- : labels.mentorVoice.saveButton }) })] }), jsxs(TabsContent, { value: SUB_TAB_VALUES.callConfig, className: "space-y-4", children: [jsx("p", { className: "text-xs text-gray-600", children: labels.callConfig.description }), mentorId && username && tenantKey && (jsx(CallConfigSection, { org: tenantKey, userId: username, mentorId: mentorId, isDisabled: isDisabled, labels: labels.callConfig, voicePickerLabels: labels.mentorVoice, initialConfig: (_p = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.call_configuration) !== null && _p !== void 0 ? _p : null, renderPromptContent: renderPromptContent, toastLabels: {
273138
+ : labels.mentorVoice.saveButton }) })] }), jsxs(TabsContent, { value: SUB_TAB_VALUES.callConfig, className: "space-y-4", children: [jsx("p", { className: "text-xs text-gray-600", children: labels.callConfig.description }), mentorId && username && tenantKey && (jsx(CallConfigSection, { org: tenantKey, userId: username, mentorId: mentorId, isDisabled: isDisabled, labels: labels.callConfig, voicePickerLabels: labels.mentorVoice, initialConfig: (_r = mentorSettings === null || mentorSettings === void 0 ? void 0 : mentorSettings.call_configuration) !== null && _r !== void 0 ? _r : null, renderPromptContent: renderPromptContent, toastLabels: {
272963
273139
  saved: labels.toasts.callConfigSaved,
272964
273140
  error: labels.toasts.callConfigError,
272965
273141
  } }))] })] }), selectedPrompt && (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iblai/iblai-js",
3
- "version": "2.5.6",
3
+ "version": "2.5.8",
4
4
  "description": "Unified JavaScript SDK for IBL.ai — re-exports data-layer, web-containers, and web-utils under a single package",
5
5
  "type": "module",
6
6
  "engines": {
@@ -77,8 +77,8 @@
77
77
  "dotenv": "16.6.1",
78
78
  "winston": "3.19.0",
79
79
  "@iblai/data-layer": "1.12.106",
80
- "@iblai/web-containers": "1.18.6",
81
- "@iblai/mcp": "1.10.4",
80
+ "@iblai/mcp": "1.10.5",
81
+ "@iblai/web-containers": "1.18.8",
82
82
  "@iblai/web-utils": "2.1.17"
83
83
  },
84
84
  "peerDependencies": {