@lastbrain/ai-ui-react 1.0.63 → 1.0.65

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.
@@ -11,6 +11,7 @@ import { aiStyles } from "../styles/inline";
11
11
  import { handleAIError } from "../utils/errorHandler";
12
12
  import { useLB } from "../context/LBAuthProvider";
13
13
  import { LBSigninModal } from "./LBSigninModal";
14
+ import { useAiContext } from "../context/AiProvider";
14
15
 
15
16
  export interface AiInputProps
16
17
  extends
@@ -21,8 +22,8 @@ export interface AiInputProps
21
22
  }
22
23
 
23
24
  export function AiInput({
24
- baseUrl,
25
- apiKeyId,
25
+ baseUrl: propBaseUrl,
26
+ apiKeyId: propApiKeyId,
26
27
  uiMode = "modal",
27
28
  context,
28
29
  model,
@@ -57,6 +58,20 @@ export function AiInput({
57
58
  lbStatus = undefined;
58
59
  }
59
60
 
61
+ let ctxBaseUrl: string | undefined;
62
+ let ctxApiKeyId: string | undefined;
63
+ try {
64
+ const aiContext = useAiContext();
65
+ ctxBaseUrl = aiContext.baseUrl;
66
+ ctxApiKeyId = aiContext.apiKeyId;
67
+ } catch {
68
+ ctxBaseUrl = undefined;
69
+ ctxApiKeyId = undefined;
70
+ }
71
+
72
+ const baseUrl = propBaseUrl ?? ctxBaseUrl;
73
+ const apiKeyId = propApiKeyId ?? ctxApiKeyId;
74
+
60
75
  const { models } = useAiModels({
61
76
  baseUrl,
62
77
  apiKeyId,
@@ -237,6 +252,7 @@ export function AiInput({
237
252
  apiKey={apiKeyId}
238
253
  baseUrl={baseUrl}
239
254
  enableModelManagement={enableModelManagement}
255
+ showOnlyUserModels={true}
240
256
  />
241
257
  )}
242
258
  <LBSigninModal
@@ -19,7 +19,7 @@ import {
19
19
  } from "../hooks/usePrompts";
20
20
  import { useModelManagement } from "../hooks/useModelManagement";
21
21
  import { type AIModel } from "../context/AiProvider";
22
- import { AiProvider } from "../context/AiProvider";
22
+ import { AiProvider, useAiContext } from "../context/AiProvider";
23
23
 
24
24
  export interface AiPromptPanelProps {
25
25
  isOpen: boolean;
@@ -37,6 +37,7 @@ export interface AiPromptPanelProps {
37
37
  onModelToggle?: (modelId: string, isActive: boolean) => Promise<void>;
38
38
  apiKey?: string;
39
39
  baseUrl?: string;
40
+ showOnlyUserModels?: boolean;
40
41
  }
41
42
 
42
43
  export interface AiPromptPanelRenderProps {
@@ -59,8 +60,20 @@ export interface AiPromptPanelRenderProps {
59
60
 
60
61
  export function AiPromptPanel(props: AiPromptPanelProps) {
61
62
  const { apiKey, baseUrl } = props;
63
+ let hasContext = false;
64
+ try {
65
+ useAiContext();
66
+ hasContext = true;
67
+ } catch {
68
+ hasContext = false;
69
+ }
70
+
71
+ // Si un contexte existe déjà, ne pas re-wrapper (évite les refetch multiples).
72
+ if (hasContext) {
73
+ return <AiPromptPanelInternal {...props} />;
74
+ }
62
75
 
63
- // Si apiKey et baseUrl sont fournis, wrapper avec AiProvider
76
+ // Sinon, si apiKey/baseUrl sont fournis, wrapper avec AiProvider
64
77
  if (apiKey || baseUrl) {
65
78
  return (
66
79
  <AiProvider baseUrl={baseUrl || ""} apiKeyId={apiKey || ""}>
@@ -88,6 +101,7 @@ function AiPromptPanelInternal({
88
101
  onModelToggle,
89
102
  apiKey,
90
103
  baseUrl,
104
+ showOnlyUserModels = false,
91
105
  }: AiPromptPanelProps) {
92
106
  const [selectedModel, setSelectedModel] = useState("");
93
107
  const [prompt, setPrompt] = useState("");
@@ -180,13 +194,22 @@ function AiPromptPanelInternal({
180
194
  (m) => m.category === modelCategory
181
195
  );
182
196
 
197
+ if (!showAllModels && showOnlyUserModels) {
198
+ return categoryModels.filter((m) => effectiveUserModels.includes(m.id));
199
+ }
200
+
183
201
  if (showAllModels) {
184
202
  return categoryModels;
185
- } else {
186
- return categoryModels.filter((m) => effectiveUserModels.includes(m.id));
187
203
  }
204
+
205
+ const enabledModels = categoryModels.filter((m) =>
206
+ effectiveUserModels.includes(m.id)
207
+ );
208
+ return enabledModels.length > 0 ? enabledModels : categoryModels;
188
209
  };
189
210
 
211
+ const modelOptions = getFilteredModels();
212
+
190
213
  // Fetch prompts when modal opens
191
214
  useEffect(() => {
192
215
  if (isOpen && (models.length > 0 || enableModelManagement)) {
@@ -209,7 +232,7 @@ function AiPromptPanelInternal({
209
232
  ]);
210
233
 
211
234
  const handleSubmit = async () => {
212
- const activeModelId = selectedModel || models[0]?.id;
235
+ const activeModelId = selectedModel || modelOptions[0]?.id;
213
236
  if (!activeModelId || !prompt.trim()) return;
214
237
  setIsGenerating(true);
215
238
  try {
@@ -249,6 +272,20 @@ function AiPromptPanelInternal({
249
272
  };
250
273
  }, []);
251
274
 
275
+ useEffect(() => {
276
+ if (!isOpen) {
277
+ return;
278
+ }
279
+ if (modelOptions.length === 0) {
280
+ setSelectedModel("");
281
+ return;
282
+ }
283
+ const hasSelected = modelOptions.some((model) => model.id === selectedModel);
284
+ if (!hasSelected) {
285
+ setSelectedModel(modelOptions[0].id);
286
+ }
287
+ }, [isOpen, modelOptions, selectedModel]);
288
+
252
289
  const handleKeyDown = (e: React.KeyboardEvent) => {
253
290
  if (e.key === "Escape") {
254
291
  handleClose();
@@ -284,7 +321,7 @@ function AiPromptPanelInternal({
284
321
 
285
322
  if (!isOpen) return null;
286
323
 
287
- const activeModelId = selectedModel || models[0]?.id || "";
324
+ const activeModelId = selectedModel || modelOptions[0]?.id || "";
288
325
  const currentModelType = models.find((m) => m.id === activeModelId)?.type;
289
326
  const filteredPrompts = prompts.filter((p: Prompt | PublicPrompt) => {
290
327
  const matchesType =
@@ -562,10 +599,14 @@ function AiPromptPanelInternal({
562
599
  ...(modelFocused && aiStyles.selectFocus),
563
600
  }}
564
601
  >
565
- {getFilteredModels().length === 0 && (
566
- <option value="">Loading models...</option>
602
+ {modelOptions.length === 0 && (
603
+ <option value="">
604
+ {showOnlyUserModels
605
+ ? "No active models. Open 'Gérer les modèles'."
606
+ : "Loading models..."}
607
+ </option>
567
608
  )}
568
- {getFilteredModels().map((model) => {
609
+ {modelOptions.map((model) => {
569
610
  const isActive = effectiveUserModels.includes(model.id);
570
611
  return (
571
612
  <option
@@ -43,6 +43,9 @@ export function AiStatusButton({
43
43
  let lbStorageStatus: any = null;
44
44
  let lbIsLoadingStatus: boolean = false;
45
45
  let lbIsLoadingStorage: boolean = false;
46
+ let lbSelectedKey: any = null;
47
+ let lbRefreshBasicStatus: (() => Promise<void>) | undefined;
48
+ let lbRefreshStorageStatus: ((force?: boolean) => Promise<void>) | undefined;
46
49
 
47
50
  try {
48
51
  const lbContext = useLB();
@@ -58,6 +61,9 @@ export function AiStatusButton({
58
61
  lbStorageStatus = lbContext.storageStatus;
59
62
  lbIsLoadingStatus = lbContext.isLoadingStatus || false;
60
63
  lbIsLoadingStorage = lbContext.isLoadingStorage || false;
64
+ lbSelectedKey = lbContext.selectedKey || null;
65
+ lbRefreshBasicStatus = lbContext.refreshBasicStatus;
66
+ lbRefreshStorageStatus = lbContext.refreshStorageStatus;
61
67
  } catch {
62
68
  // LBProvider n'est pas disponible, ignorer
63
69
  lbStatus = undefined;
@@ -65,13 +71,17 @@ export function AiStatusButton({
65
71
  logout = undefined;
66
72
  }
67
73
 
68
- // Utiliser le status du contexte LB si pas de prop status
69
- // Combinaison du basic status et storage status pour backward compatibility
70
- const effectiveStatus = status || {
71
- ...lbApiStatus,
72
- ...lbBasicStatus,
73
- storage: lbStorageStatus?.storage,
74
- };
74
+ // Toujours prioriser les données du contexte LB quand disponibles
75
+ // pour éviter d'afficher un status externe obsolète (Unknown/0).
76
+ const lbEffectiveStatus =
77
+ lbStatus === "ready"
78
+ ? {
79
+ ...(lbApiStatus || {}),
80
+ ...(lbBasicStatus || {}),
81
+ storage: lbStorageStatus?.storage || lbApiStatus?.storage,
82
+ }
83
+ : null;
84
+ const effectiveStatus = lbEffectiveStatus || status || null;
75
85
 
76
86
  // Récupérer refetchProviders depuis AiProvider si disponible
77
87
  let refetchProviders: (() => Promise<void>) | undefined;
@@ -94,6 +104,7 @@ export function AiStatusButton({
94
104
  percentage?: number;
95
105
  purchased?: number;
96
106
  quota?: number;
107
+ remaining?: number;
97
108
  };
98
109
  type StorageUsage = {
99
110
  used_mb?: number;
@@ -104,8 +115,6 @@ export function AiStatusButton({
104
115
  total_mb?: number;
105
116
  };
106
117
 
107
- const formatNumber = (value: number | null | undefined) =>
108
- typeof value === "number" ? value.toLocaleString() : "0";
109
118
  const formatFixed = (value: number | null | undefined, digits: number) =>
110
119
  typeof value === "number" ? value.toFixed(digits) : "0.00";
111
120
  const formatStorage = (valueMb: number | null | undefined) => {
@@ -179,10 +188,13 @@ export function AiStatusButton({
179
188
  const balanceUsage = effectiveStatus?.balance as BalanceUsage | undefined;
180
189
  const storageUsage = effectiveStatus?.storage as StorageUsage | undefined;
181
190
 
182
- const balanceTotal =
191
+ const balanceUsed = safeNumber(balanceUsage?.used);
192
+ const balanceRemaining = safeNumber(balanceUsage?.remaining);
193
+ const rawBalanceTotal =
183
194
  balanceUsage?.total ??
184
195
  safeNumber(balanceUsage?.purchased) + safeNumber(balanceUsage?.quota);
185
- const balanceUsed = balanceUsage?.used ?? balanceUsage?.total ?? 0;
196
+ const balanceTotal =
197
+ rawBalanceTotal > 0 ? rawBalanceTotal : balanceUsed + balanceRemaining;
186
198
  const balancePercentage =
187
199
  balanceUsage?.percentage ??
188
200
  (balanceTotal > 0 ? Math.round((balanceUsed / balanceTotal) * 100) : 0);
@@ -202,6 +214,11 @@ export function AiStatusButton({
202
214
  const buttonRef = useRef<HTMLButtonElement>(null);
203
215
  const tooltipRef = useRef<HTMLDivElement>(null);
204
216
  const canPortal = typeof document !== "undefined";
217
+ const hasApiKeySelected = Boolean(
218
+ effectiveStatus?.apiKey?.id || effectiveStatus?.api_key?.id || lbSelectedKey?.id
219
+ );
220
+ const requiresApiKeySelection =
221
+ lbStatus === "ready" && !hasApiKeySelected && apiKeys.length > 0;
205
222
 
206
223
  useLayoutEffect(() => {
207
224
  if (!showTooltip || !buttonRef.current) {
@@ -279,11 +296,17 @@ export function AiStatusButton({
279
296
  }, [showTooltip, canPortal]);
280
297
 
281
298
  const handleMouseEnter = () => {
299
+ if (requiresApiKeySelection) {
300
+ return;
301
+ }
282
302
  setShowTooltip(true);
283
303
  setIsHovered(true);
284
304
  };
285
305
 
286
306
  const handleMouseLeave = () => {
307
+ if (requiresApiKeySelection) {
308
+ return;
309
+ }
287
310
  // Keep tooltip visible if hovering over it
288
311
  setTimeout(() => {
289
312
  if (
@@ -296,7 +319,37 @@ export function AiStatusButton({
296
319
  }, 100);
297
320
  };
298
321
 
299
- if (loading || isSelectingApiKey || isLoadingStatus || lbIsLoadingStatus) {
322
+ useLayoutEffect(() => {
323
+ if (!showTooltip || requiresApiKeySelection || lbStatus !== "ready") {
324
+ return;
325
+ }
326
+ if (!lbBasicStatus && !lbIsLoadingStatus && lbRefreshBasicStatus) {
327
+ lbRefreshBasicStatus().catch(() => undefined);
328
+ }
329
+ if (
330
+ !lbStorageStatus?.storage &&
331
+ !lbIsLoadingStorage &&
332
+ lbRefreshStorageStatus
333
+ ) {
334
+ lbRefreshStorageStatus().catch(() => undefined);
335
+ }
336
+ }, [
337
+ showTooltip,
338
+ requiresApiKeySelection,
339
+ lbStatus,
340
+ lbBasicStatus,
341
+ lbStorageStatus,
342
+ lbIsLoadingStatus,
343
+ lbIsLoadingStorage,
344
+ lbRefreshBasicStatus,
345
+ lbRefreshStorageStatus,
346
+ ]);
347
+
348
+ if (
349
+ loading ||
350
+ isSelectingApiKey ||
351
+ ((isLoadingStatus || lbIsLoadingStatus) && !effectiveStatus)
352
+ ) {
300
353
  return (
301
354
  <button
302
355
  ref={buttonRef}
@@ -321,6 +374,59 @@ export function AiStatusButton({
321
374
  );
322
375
  }
323
376
 
377
+ if (requiresApiKeySelection) {
378
+ return (
379
+ <div style={{ position: "relative", display: "inline-block" }}>
380
+ <button
381
+ ref={buttonRef}
382
+ style={{
383
+ ...aiStyles.statusButton,
384
+ color: "#f59e0b",
385
+ ...(isHovered && aiStyles.statusButtonHover),
386
+ }}
387
+ className={className}
388
+ onMouseEnter={() => setIsHovered(true)}
389
+ onMouseLeave={() => setIsHovered(false)}
390
+ onClick={() => setShowApiKeySelector(true)}
391
+ title="Select an API key to enable AI status and generation"
392
+ >
393
+ <svg
394
+ width="16"
395
+ height="16"
396
+ viewBox="0 0 24 24"
397
+ fill="none"
398
+ stroke="currentColor"
399
+ >
400
+ <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
401
+ </svg>
402
+ </button>
403
+ {showApiKeySelector && apiKeys.length > 0 && (
404
+ <LBApiKeySelector
405
+ isOpen={showApiKeySelector}
406
+ apiKeys={apiKeys}
407
+ onSelect={async (keyId) => {
408
+ setIsSelectingApiKey(true);
409
+ try {
410
+ if (switchApiKey) {
411
+ await switchApiKey(keyId);
412
+ }
413
+ setShowApiKeySelector(false);
414
+ if (refetchProviders) {
415
+ await refetchProviders();
416
+ }
417
+ } catch (error) {
418
+ console.error("Failed to select API key:", error);
419
+ } finally {
420
+ setIsSelectingApiKey(false);
421
+ }
422
+ }}
423
+ onCancel={() => setShowApiKeySelector(false)}
424
+ />
425
+ )}
426
+ </div>
427
+ );
428
+ }
429
+
324
430
  if (!effectiveStatus) {
325
431
  // Si pas de statut API et pas de LBProvider, afficher message simple
326
432
  if (!lbStatus && lbStatus !== "ready") {
@@ -828,6 +934,14 @@ export function AiStatusButton({
828
934
  req/min
829
935
  </span>
830
936
  </div>
937
+ <div style={aiStyles.tooltipRow}>
938
+ <span style={aiStyles.tooltipLabel}>Auth:</span>
939
+ <span style={aiStyles.tooltipValue}>
940
+ {lbIsLoadingStatus
941
+ ? "..."
942
+ : effectiveStatus?.authType || lbStatus || "unknown"}
943
+ </span>
944
+ </div>
831
945
  </div>
832
946
 
833
947
  <div style={aiStyles.tooltipSection}>
@@ -835,7 +949,7 @@ export function AiStatusButton({
835
949
  <div style={aiStyles.tooltipRow}>
836
950
  <span style={aiStyles.tooltipLabel}>Total:</span>
837
951
  <span style={aiStyles.tooltipValue}>
838
- ${formatFixed(balanceUsed, 6)} / ${formatNumber(balanceTotal)}
952
+ ${formatFixed(balanceUsed, 2)} / ${formatFixed(balanceTotal, 2)}
839
953
  </span>
840
954
  {renderUsageCircle(balancePercentage)}
841
955
  </div>
@@ -16,6 +16,7 @@ import { aiStyles } from "../styles/inline";
16
16
  import { handleAIError } from "../utils/errorHandler";
17
17
  import { useLB } from "../context/LBAuthProvider";
18
18
  import { LBSigninModal } from "./LBSigninModal";
19
+ import { useAiContext } from "../context/AiProvider";
19
20
 
20
21
  export interface AiTextareaProps
21
22
  extends
@@ -25,8 +26,8 @@ export interface AiTextareaProps
25
26
  }
26
27
 
27
28
  export function AiTextarea({
28
- baseUrl,
29
- apiKeyId,
29
+ baseUrl: propBaseUrl,
30
+ apiKeyId: propApiKeyId,
30
31
  uiMode = "modal",
31
32
  context,
32
33
  model,
@@ -63,6 +64,20 @@ export function AiTextarea({
63
64
  lbStatus = undefined;
64
65
  }
65
66
 
67
+ let ctxBaseUrl: string | undefined;
68
+ let ctxApiKeyId: string | undefined;
69
+ try {
70
+ const aiContext = useAiContext();
71
+ ctxBaseUrl = aiContext.baseUrl;
72
+ ctxApiKeyId = aiContext.apiKeyId;
73
+ } catch {
74
+ ctxBaseUrl = undefined;
75
+ ctxApiKeyId = undefined;
76
+ }
77
+
78
+ const baseUrl = propBaseUrl ?? ctxBaseUrl;
79
+ const apiKeyId = propApiKeyId ?? ctxApiKeyId;
80
+
66
81
  const { models } = useAiModels({
67
82
  baseUrl,
68
83
  apiKeyId,
@@ -248,6 +263,7 @@ export function AiTextarea({
248
263
  baseUrl={baseUrl}
249
264
  apiKey={apiKeyId}
250
265
  enableModelManagement={enableModelManagement}
266
+ showOnlyUserModels={true}
251
267
  />
252
268
  )}
253
269
  {Boolean(toastData) && (