@lastbrain/ai-ui-react 1.0.78 → 1.0.80

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.
@@ -6,11 +6,15 @@ import {
6
6
  useEffect,
7
7
  useRef,
8
8
  useLayoutEffect,
9
+ useMemo,
9
10
  type ReactNode,
10
11
  } from "react";
11
12
  import { createPortal } from "react-dom";
12
13
  import {
13
14
  BookOpen,
15
+ Check,
16
+ Copy,
17
+ Eye,
14
18
  Search,
15
19
  Sparkles,
16
20
  Star,
@@ -33,6 +37,19 @@ import { AiProvider, useAiContext } from "../context/AiProvider";
33
37
  import { useI18n } from "../context/I18nContext";
34
38
  import { useLoadingTimer } from "../hooks/useLoadingTimer";
35
39
 
40
+ function tryFormatJson(input?: string): { formatted: string; isJson: boolean } {
41
+ const raw = (input || "").trim();
42
+ if (!raw) {
43
+ return { formatted: "", isJson: false };
44
+ }
45
+ try {
46
+ const parsed = JSON.parse(raw);
47
+ return { formatted: JSON.stringify(parsed, null, 2), isJson: true };
48
+ } catch {
49
+ return { formatted: input || "", isJson: false };
50
+ }
51
+ }
52
+
36
53
  export interface AiPromptPanelProps {
37
54
  isOpen: boolean;
38
55
  onClose: () => void;
@@ -50,6 +67,8 @@ export interface AiPromptPanelProps {
50
67
  apiKey?: string;
51
68
  baseUrl?: string;
52
69
  showOnlyUserModels?: boolean;
70
+ contextPreview?: string;
71
+ contextPreviewTitle?: string;
53
72
  }
54
73
 
55
74
  export interface AiPromptPanelRenderProps {
@@ -114,6 +133,8 @@ function AiPromptPanelInternal({
114
133
  apiKey,
115
134
  baseUrl,
116
135
  showOnlyUserModels = false,
136
+ contextPreview,
137
+ contextPreviewTitle,
117
138
  }: AiPromptPanelProps) {
118
139
  const { t } = useI18n();
119
140
  const [selectedModel, setSelectedModel] = useState("");
@@ -127,6 +148,8 @@ function AiPromptPanelInternal({
127
148
  const [selectedTag, setSelectedTag] = useState<string | "all">("all");
128
149
  const [isGenerating, setIsGenerating] = useState(false);
129
150
  const [isClosing, setIsClosing] = useState(false);
151
+ const [showContextPreview, setShowContextPreview] = useState(false);
152
+ const [contextCopied, setContextCopied] = useState(false);
130
153
  const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
131
154
  const promptRef = useRef<HTMLTextAreaElement>(null);
132
155
  const closeTimeoutRef = useRef<number | null>(null);
@@ -265,6 +288,8 @@ function AiPromptPanelInternal({
265
288
  setPrompt("");
266
289
  setPromptId(undefined);
267
290
  setShowPromptLibrary(false);
291
+ setShowContextPreview(false);
292
+ setContextCopied(false);
268
293
  setSearchQuery("");
269
294
  setSelectedTag("all");
270
295
  setIsClosing(false);
@@ -287,6 +312,12 @@ function AiPromptPanelInternal({
287
312
  };
288
313
  }, []);
289
314
 
315
+ useEffect(() => {
316
+ if (!contextCopied) return;
317
+ const timeout = window.setTimeout(() => setContextCopied(false), 1200);
318
+ return () => window.clearTimeout(timeout);
319
+ }, [contextCopied]);
320
+
290
321
  useEffect(() => {
291
322
  setPortalRoot(document.body);
292
323
  }, []);
@@ -340,6 +371,15 @@ function AiPromptPanelInternal({
340
371
  adjustPromptHeight();
341
372
  }, [prompt]);
342
373
 
374
+ const contextPreviewData = useMemo(
375
+ () => tryFormatJson(contextPreview),
376
+ [contextPreview]
377
+ );
378
+ const contextPreviewLines = useMemo(
379
+ () => contextPreviewData.formatted.split("\n"),
380
+ [contextPreviewData.formatted]
381
+ );
382
+
343
383
  if (!isOpen) return null;
344
384
 
345
385
  const activeModelId = selectedModel || modelOptions[0]?.id || "";
@@ -406,6 +446,8 @@ function AiPromptPanelInternal({
406
446
  }
407
447
 
408
448
  const isDrawer = uiMode === "drawer";
449
+ const hasContextPreview = Boolean(contextPreview?.trim());
450
+ const contextCharCount = contextPreview?.length || 0;
409
451
  const panelContainerStyle = isDrawer
410
452
  ? {
411
453
  ...aiStyles.modal,
@@ -425,6 +467,78 @@ function AiPromptPanelInternal({
425
467
  }
426
468
  : aiStyles.modalContent;
427
469
 
470
+ const renderContextLine = (line: string, lineIndex: number) => {
471
+ if (!contextPreviewData.isJson) {
472
+ return (
473
+ <span
474
+ key={`line-${lineIndex}`}
475
+ style={{ color: "var(--ai-text-secondary)" }}
476
+ >
477
+ {line || " "}
478
+ </span>
479
+ );
480
+ }
481
+
482
+ const tokenRegex =
483
+ /("(?:\\.|[^"\\])*"(?=\s*:)|"(?:\\.|[^"\\])*"|true|false|null|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}[\],:])/g;
484
+ const parts: ReactNode[] = [];
485
+ let lastIndex = 0;
486
+ let match: RegExpExecArray | null;
487
+
488
+ while ((match = tokenRegex.exec(line)) !== null) {
489
+ const token = match[0];
490
+ const index = match.index;
491
+ if (index > lastIndex) {
492
+ parts.push(
493
+ <span
494
+ key={`txt-${lineIndex}-${lastIndex}`}
495
+ style={{ color: "var(--ai-text-secondary)" }}
496
+ >
497
+ {line.slice(lastIndex, index)}
498
+ </span>
499
+ );
500
+ }
501
+
502
+ const isKey = /^".*"$/.test(token) && line.slice(match.index).includes(":");
503
+ const color = isKey
504
+ ? "#7cc5ff"
505
+ : /^".*"$/.test(token)
506
+ ? "#9ad07f"
507
+ : /^(true|false)$/.test(token)
508
+ ? "#f4a259"
509
+ : token === "null"
510
+ ? "#d3869b"
511
+ : /^-?\d/.test(token)
512
+ ? "#f7dc6f"
513
+ : "var(--ai-text-secondary)";
514
+
515
+ parts.push(
516
+ <span key={`tok-${lineIndex}-${index}`} style={{ color }}>
517
+ {token}
518
+ </span>
519
+ );
520
+
521
+ lastIndex = index + token.length;
522
+ }
523
+
524
+ if (lastIndex < line.length) {
525
+ parts.push(
526
+ <span
527
+ key={`txt-end-${lineIndex}`}
528
+ style={{ color: "var(--ai-text-secondary)" }}
529
+ >
530
+ {line.slice(lastIndex)}
531
+ </span>
532
+ );
533
+ }
534
+
535
+ return (
536
+ <span key={`line-${lineIndex}`} style={{ whiteSpace: "pre" }}>
537
+ {parts.length > 0 ? parts : " "}
538
+ </span>
539
+ );
540
+ };
541
+
428
542
  if (children) {
429
543
  return createPortal(
430
544
  <div style={panelContainerStyle} onKeyDown={handleKeyDown}>
@@ -755,18 +869,40 @@ function AiPromptPanelInternal({
755
869
  )}
756
870
  </span>
757
871
  </label>
758
- {promptsLoading ? (
759
- <span className="ai-inline-skeleton" aria-hidden="true" />
760
- ) : filteredPrompts.length > 0 ? (
761
- <button
762
- onClick={() => setShowPromptLibrary(true)}
763
- className="ai-inline-btn"
764
- >
765
- <BookOpen size={14} />
766
- {t("prompt.modal.browsePrompts", "Browse Prompts")} (
767
- {filteredPrompts.length})
768
- </button>
769
- ) : null}
872
+ <div
873
+ style={{
874
+ display: "flex",
875
+ alignItems: "center",
876
+ flexWrap: "wrap",
877
+ justifyContent: "flex-end",
878
+ gap: "8px",
879
+ }}
880
+ >
881
+ {hasContextPreview ? (
882
+ <button
883
+ type="button"
884
+ onClick={() => setShowContextPreview(true)}
885
+ className="ai-inline-btn"
886
+ style={{ whiteSpace: "nowrap" }}
887
+ >
888
+ <Eye size={14} />
889
+ {t("prompt.modal.contextPreview", "Context preview")}
890
+ </button>
891
+ ) : null}
892
+ {promptsLoading ? (
893
+ <span className="ai-inline-skeleton" aria-hidden="true" />
894
+ ) : filteredPrompts.length > 0 ? (
895
+ <button
896
+ onClick={() => setShowPromptLibrary(true)}
897
+ className="ai-inline-btn"
898
+ style={{ whiteSpace: "nowrap" }}
899
+ >
900
+ <BookOpen size={14} />
901
+ {t("prompt.modal.browsePrompts", "Browse Prompts")} (
902
+ {filteredPrompts.length})
903
+ </button>
904
+ ) : null}
905
+ </div>
770
906
  </div>
771
907
  <textarea
772
908
  id="prompt-input"
@@ -1124,6 +1260,137 @@ function AiPromptPanelInternal({
1124
1260
  )}
1125
1261
  </div>
1126
1262
 
1263
+ {showContextPreview ? (
1264
+ <div
1265
+ style={{
1266
+ position: "absolute",
1267
+ inset: 0,
1268
+ zIndex: 12,
1269
+ background: "var(--ai-overlay)",
1270
+ backdropFilter: "blur(3px)",
1271
+ WebkitBackdropFilter: "blur(3px)",
1272
+ display: "flex",
1273
+ alignItems: "stretch",
1274
+ justifyContent: "center",
1275
+ padding: "10px",
1276
+ }}
1277
+ onClick={() => setShowContextPreview(false)}
1278
+ >
1279
+ <div
1280
+ style={{
1281
+ width: "min(980px, 100%)",
1282
+ maxHeight: "100%",
1283
+ background: "var(--ai-bg-secondary)",
1284
+ border: "1px solid var(--ai-border)",
1285
+ borderRadius: "12px",
1286
+ boxShadow: "var(--ai-shadow-lg)",
1287
+ overflow: "hidden",
1288
+ display: "flex",
1289
+ flexDirection: "column",
1290
+ }}
1291
+ onClick={(e) => e.stopPropagation()}
1292
+ >
1293
+ <div
1294
+ style={{
1295
+ padding: "12px 14px",
1296
+ borderBottom: "1px solid var(--ai-border)",
1297
+ display: "flex",
1298
+ alignItems: "center",
1299
+ justifyContent: "space-between",
1300
+ gap: "10px",
1301
+ }}
1302
+ >
1303
+ <div style={{ minWidth: 0 }}>
1304
+ <div style={{ fontSize: "14px", fontWeight: 600 }}>
1305
+ {contextPreviewTitle ||
1306
+ t("prompt.modal.contextPreview", "Context preview")}
1307
+ </div>
1308
+ <div
1309
+ style={{
1310
+ marginTop: "2px",
1311
+ fontSize: "12px",
1312
+ color: "var(--ai-text-secondary)",
1313
+ }}
1314
+ >
1315
+ {t("prompt.modal.contextChars", "{count} chars", {
1316
+ count: contextCharCount.toLocaleString(),
1317
+ })}
1318
+ </div>
1319
+ </div>
1320
+ <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
1321
+ <button
1322
+ type="button"
1323
+ className="ai-inline-btn"
1324
+ onClick={async () => {
1325
+ try {
1326
+ await navigator.clipboard.writeText(
1327
+ contextPreviewData.formatted || ""
1328
+ );
1329
+ setContextCopied(true);
1330
+ } catch {
1331
+ setContextCopied(false);
1332
+ }
1333
+ }}
1334
+ style={{ whiteSpace: "nowrap" }}
1335
+ >
1336
+ {contextCopied ? <Check size={14} /> : <Copy size={14} />}
1337
+ {contextCopied
1338
+ ? t("common.copied", "Copied")
1339
+ : t("common.copy", "Copy")}
1340
+ </button>
1341
+ <button
1342
+ type="button"
1343
+ className="ai-icon-btn"
1344
+ onClick={() => setShowContextPreview(false)}
1345
+ aria-label={t("common.closeLabel", "Close")}
1346
+ >
1347
+ ×
1348
+ </button>
1349
+ </div>
1350
+ </div>
1351
+ <pre
1352
+ style={{
1353
+ margin: 0,
1354
+ padding: "14px",
1355
+ flex: 1,
1356
+ overflow: "auto",
1357
+ background: "color-mix(in srgb, var(--ai-bg) 70%, transparent)",
1358
+ borderTop: "1px solid var(--ai-border)",
1359
+ fontFamily:
1360
+ "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
1361
+ fontSize: "12px",
1362
+ lineHeight: 1.5,
1363
+ }}
1364
+ >
1365
+ {contextPreviewLines.map((line, index) => (
1366
+ <div
1367
+ key={`context-line-${index}`}
1368
+ style={{
1369
+ display: "grid",
1370
+ gridTemplateColumns: "44px 1fr",
1371
+ gap: "12px",
1372
+ minHeight: "18px",
1373
+ }}
1374
+ >
1375
+ <span
1376
+ style={{
1377
+ color: "var(--ai-text-tertiary)",
1378
+ textAlign: "right",
1379
+ userSelect: "none",
1380
+ paddingRight: "8px",
1381
+ borderRight: "1px solid var(--ai-border)",
1382
+ }}
1383
+ >
1384
+ {index + 1}
1385
+ </span>
1386
+ {renderContextLine(line, index)}
1387
+ </div>
1388
+ ))}
1389
+ </pre>
1390
+ </div>
1391
+ </div>
1392
+ ) : null}
1393
+
1127
1394
  <div
1128
1395
  style={{
1129
1396
  ...aiStyles.modalFooter,
@@ -50,21 +50,20 @@ export function AiSelect({
50
50
  // Rendre l'authentification optionnelle
51
51
  let lbStatus: string | undefined;
52
52
  let lbHasSession = false;
53
- let lbHasSelectedKey = false;
54
53
  let lbHasSelectedApiKeyCookie = false;
54
+ let lbHasSelectedKey = false;
55
55
  let hasLBProvider = false;
56
56
  try {
57
57
  const lbContext = useLB();
58
58
  lbStatus = lbContext.status;
59
59
  lbHasSession = Boolean(lbContext.session?.sessionToken);
60
- lbHasSelectedKey = Boolean(lbContext.selectedKey?.id);
61
60
  lbHasSelectedApiKeyCookie = lbContext.hasSelectedApiKeyCookie;
61
+ lbHasSelectedKey = Boolean(lbContext.selectedKey?.id);
62
62
  hasLBProvider = true;
63
63
  } catch {
64
64
  // LBProvider n'est pas disponible, ignorer
65
65
  lbStatus = undefined;
66
66
  lbHasSession = false;
67
- lbHasSelectedKey = false;
68
67
  hasLBProvider = false;
69
68
  }
70
69
 
@@ -93,7 +92,8 @@ export function AiSelect({
93
92
  hasLBProvider &&
94
93
  lbStatus === "ready" &&
95
94
  lbHasSession &&
96
- (!lbHasSelectedKey || !lbHasSelectedApiKeyCookie);
95
+ !lbHasSelectedApiKeyCookie &&
96
+ !lbHasSelectedKey;
97
97
  const isAuthReady =
98
98
  !needsApiKeySelection &&
99
99
  (lbStatus === "ready" || Boolean(process.env.LB_API_KEY));
@@ -245,8 +245,8 @@ export function AiStatusButton({
245
245
  const requiresApiKeySelection =
246
246
  lbStatus === "ready" &&
247
247
  hasLbSession &&
248
- (!lbHasSelectedApiKeyCookie || !hasApiKeySelected) &&
249
- apiKeys.length > 0;
248
+ !lbHasSelectedApiKeyCookie &&
249
+ !hasApiKeySelected;
250
250
  const isApiKeyAuthMode = authTypeValue === "api_key";
251
251
 
252
252
  const [tooltipStyle, setTooltipStyle] = useState<Record<string, string>>({});
@@ -702,7 +702,7 @@ export function AiStatusButton({
702
702
 
703
703
  {tooltipNode}
704
704
 
705
- {showApiKeySelector && apiKeys.length > 0 ? (
705
+ {showApiKeySelector ? (
706
706
  <LBApiKeySelector
707
707
  isOpen={showApiKeySelector}
708
708
  apiKeys={apiKeys}
@@ -64,15 +64,15 @@ export function AiTextarea({
64
64
  // Rendre l'authentification optionnelle
65
65
  let lbStatus: string | undefined;
66
66
  let lbHasSession = false;
67
- let lbHasSelectedKey = false;
68
67
  let lbHasSelectedApiKeyCookie = false;
68
+ let lbHasSelectedKey = false;
69
69
  let hasLBProvider = false;
70
70
  try {
71
71
  const lbContext = useLB();
72
72
  lbStatus = lbContext.status;
73
73
  lbHasSession = Boolean(lbContext.session?.sessionToken);
74
- lbHasSelectedKey = Boolean(lbContext.selectedKey?.id);
75
74
  lbHasSelectedApiKeyCookie = lbContext.hasSelectedApiKeyCookie;
75
+ lbHasSelectedKey = Boolean(lbContext.selectedKey?.id);
76
76
  hasLBProvider = true;
77
77
  } catch {
78
78
  // LBProvider n'est pas disponible, ignorer
@@ -97,7 +97,8 @@ export function AiTextarea({
97
97
  hasLBProvider &&
98
98
  lbStatus === "ready" &&
99
99
  lbHasSession &&
100
- (!lbHasSelectedKey || !lbHasSelectedApiKeyCookie);
100
+ !lbHasSelectedApiKeyCookie &&
101
+ !lbHasSelectedKey;
101
102
 
102
103
  const { models: _models } = useAiModels({
103
104
  baseUrl,
@@ -254,8 +255,18 @@ export function AiTextarea({
254
255
  />
255
256
  <button
256
257
  className={`ai-control-action ai-spark ${sizeClass} ${radiusClass}`}
257
- onClick={hasConfiguration ? handleQuickGenerate : handleOpenPanel}
258
- disabled={disabled || loading || !isAuthReady}
258
+ onClick={() => {
259
+ if (!isAuthReady) {
260
+ setShowAuthModal(true);
261
+ return;
262
+ }
263
+ if (hasConfiguration) {
264
+ void handleQuickGenerate();
265
+ return;
266
+ }
267
+ handleOpenPanel();
268
+ }}
269
+ disabled={disabled || loading}
259
270
  type="button"
260
271
  title={
261
272
  !isAuthReady
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import React, { useState } from "react";
3
+ import React, { useEffect, useState } from "react";
4
4
  import { CheckCircle2, KeyRound, Loader2, XCircle } from "lucide-react";
5
+ import { createPortal } from "react-dom";
5
6
  import type { LBApiKey } from "@lastbrain/ai-ui-core";
6
7
  import { useI18n } from "../context/I18nContext";
7
8
 
@@ -19,12 +20,18 @@ export function LBApiKeySelector({
19
20
  isOpen,
20
21
  }: LBApiKeySelectorProps) {
21
22
  const { t } = useI18n();
23
+ const [canPortal, setCanPortal] = useState(false);
22
24
  const [selectedKeyId, setSelectedKeyId] = useState<string>(
23
25
  apiKeys.find((k) => k.isActive)?.id || apiKeys[0]?.id || ""
24
26
  );
25
27
  const [loading, setLoading] = useState(false);
26
28
  const [error, setError] = useState("");
27
29
 
30
+ useEffect(() => {
31
+ setCanPortal(true);
32
+ return () => setCanPortal(false);
33
+ }, []);
34
+
28
35
  if (!isOpen) return null;
29
36
 
30
37
  const handleSubmit = async (e: React.FormEvent) => {
@@ -50,7 +57,7 @@ export function LBApiKeySelector({
50
57
  }
51
58
  };
52
59
 
53
- return (
60
+ const modal = (
54
61
  <div className="ai-signin-overlay" onClick={onCancel}>
55
62
  <div
56
63
  className="ai-signin-panel ai-key-modal-panel"
@@ -169,4 +176,9 @@ export function LBApiKeySelector({
169
176
  </div>
170
177
  </div>
171
178
  );
179
+
180
+ if (!canPortal) {
181
+ return modal;
182
+ }
183
+ return createPortal(modal, document.body);
172
184
  }
@@ -121,7 +121,7 @@ export function UsageToast({
121
121
  padding: "4px 6px",
122
122
  borderRadius: "6px",
123
123
  marginBottom: "4px",
124
- right: "6px",
124
+ right: "52px",
125
125
  background: "rgba(22, 163, 74, 0.12)",
126
126
  border: "1px solid rgba(22, 163, 74, 0.3)",
127
127
  color: "#16a34a",