@lastbrain/ai-ui-react 1.0.9 → 1.0.11

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.
@@ -1,10 +1,17 @@
1
1
  "use client";
2
2
 
3
- import React, { useState, useRef, type TextareaHTMLAttributes } from "react";
3
+ import React, {
4
+ useState,
5
+ useRef,
6
+ useLayoutEffect,
7
+ type TextareaHTMLAttributes,
8
+ } from "react";
9
+ import { Sparkles } from "lucide-react";
4
10
  import type { BaseAiProps } from "../types";
5
11
  import { useAiCallText } from "../hooks/useAiCallText";
6
12
  import { useAiModels } from "../hooks/useAiModels";
7
13
  import { AiPromptPanel } from "./AiPromptPanel";
14
+ import { UsageToast, useUsageToast } from "./UsageToast";
8
15
  import { aiStyles } from "../styles/inline";
9
16
 
10
17
  export interface AiTextareaProps
@@ -37,6 +44,7 @@ export function AiTextarea({
37
44
  const [isFocused, setIsFocused] = useState(false);
38
45
  const [isButtonHovered, setIsButtonHovered] = useState(false);
39
46
  const textareaRef = useRef<HTMLTextAreaElement>(null);
47
+ const { showUsageToast, toastData, toastKey, clearToast } = useUsageToast();
40
48
 
41
49
  const { models } = useAiModels({ baseUrl, apiKeyId });
42
50
  const { generateText, loading } = useAiCallText({ baseUrl, apiKeyId });
@@ -53,14 +61,22 @@ export function AiTextarea({
53
61
 
54
62
  const handleSubmit = async (
55
63
  selectedModel: string,
56
- selectedPrompt: string
64
+ selectedPrompt: string,
65
+ promptId?: string
57
66
  ) => {
58
67
  try {
68
+ const resolvedContext = textareaValue || context || undefined;
69
+ const hasContext = Boolean(
70
+ resolvedContext && String(resolvedContext).trim()
71
+ );
72
+ const promptWithContext = hasContext
73
+ ? `${selectedPrompt}\n\nTexte:\n${String(resolvedContext)}`
74
+ : selectedPrompt;
59
75
  const result = await generateText({
60
76
  model: selectedModel,
61
- prompt: selectedPrompt,
62
- context: textareaValue || context || undefined,
63
- actionType: "autocomplete",
77
+ prompt: promptWithContext,
78
+ context: resolvedContext,
79
+ actionType: hasContext ? "generate-text" : "autocomplete",
64
80
  });
65
81
 
66
82
  if (result.text) {
@@ -70,6 +86,7 @@ export function AiTextarea({
70
86
  }
71
87
  onValue?.(result.text);
72
88
  onToast?.({ type: "success", message: "AI generation successful" });
89
+ showUsageToast(result);
73
90
  }
74
91
  } catch (error) {
75
92
  onToast?.({ type: "error", message: "Failed to generate text" });
@@ -82,11 +99,18 @@ export function AiTextarea({
82
99
  if (!model || !prompt) return;
83
100
 
84
101
  try {
102
+ const resolvedContext = textareaValue || context || undefined;
103
+ const hasContext = Boolean(
104
+ resolvedContext && String(resolvedContext).trim()
105
+ );
106
+ const promptWithContext = hasContext
107
+ ? `${prompt}\n\nTexte:\n${String(resolvedContext)}`
108
+ : prompt;
85
109
  const result = await generateText({
86
110
  model,
87
- prompt,
88
- context: textareaValue || context || undefined,
89
- actionType: "autocomplete",
111
+ prompt: promptWithContext,
112
+ context: resolvedContext,
113
+ actionType: hasContext ? "generate-text" : "autocomplete",
90
114
  });
91
115
 
92
116
  if (result.text) {
@@ -96,6 +120,7 @@ export function AiTextarea({
96
120
  }
97
121
  onValue?.(result.text);
98
122
  onToast?.({ type: "success", message: "AI generation successful" });
123
+ showUsageToast(result);
99
124
  }
100
125
  } catch (error) {
101
126
  onToast?.({ type: "error", message: "Failed to generate text" });
@@ -108,6 +133,20 @@ export function AiTextarea({
108
133
  textareaProps.onChange?.(e);
109
134
  };
110
135
 
136
+ const adjustHeight = () => {
137
+ const element = textareaRef.current;
138
+ if (!element) {
139
+ return;
140
+ }
141
+
142
+ element.style.height = "auto";
143
+ element.style.height = `${element.scrollHeight}px`;
144
+ };
145
+
146
+ useLayoutEffect(() => {
147
+ adjustHeight();
148
+ }, [textareaValue]);
149
+
111
150
  return (
112
151
  <div style={aiStyles.textareaWrapper} className={className}>
113
152
  <textarea
@@ -122,6 +161,7 @@ export function AiTextarea({
122
161
  onFocus={(e) => {
123
162
  setIsFocused(true);
124
163
  textareaProps.onFocus?.(e);
164
+ adjustHeight();
125
165
  }}
126
166
  onBlur={(e) => {
127
167
  setIsFocused(false);
@@ -155,28 +195,8 @@ export function AiTextarea({
155
195
  >
156
196
  <path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" />
157
197
  </svg>
158
- ) : hasConfiguration ? (
159
- <svg
160
- width="16"
161
- height="16"
162
- viewBox="0 0 24 24"
163
- fill="none"
164
- stroke="currentColor"
165
- strokeWidth="2"
166
- >
167
- <path d="M12 5v14M5 12h14" />
168
- </svg>
169
198
  ) : (
170
- <svg
171
- width="16"
172
- height="16"
173
- viewBox="0 0 24 24"
174
- fill="none"
175
- stroke="currentColor"
176
- strokeWidth="2"
177
- >
178
- <path d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
179
- </svg>
199
+ <Sparkles size={16} />
180
200
  )}
181
201
  </button>
182
202
  {isOpen && (
@@ -186,6 +206,15 @@ export function AiTextarea({
186
206
  onSubmit={handleSubmit}
187
207
  uiMode={uiMode}
188
208
  models={models || []}
209
+ sourceText={textareaValue || undefined}
210
+ />
211
+ )}
212
+ {Boolean(toastData) && (
213
+ <UsageToast
214
+ key={toastKey}
215
+ result={toastData}
216
+ position="bottom-right"
217
+ onComplete={clearToast}
189
218
  />
190
219
  )}
191
220
  </div>
@@ -0,0 +1,182 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { X } from "lucide-react";
5
+
6
+ interface UsageToastProps {
7
+ result: unknown;
8
+ position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
9
+ onComplete?: () => void;
10
+ }
11
+
12
+ export function UsageToast({
13
+ result,
14
+ position = "bottom-right",
15
+ onComplete,
16
+ }: UsageToastProps) {
17
+ const [isVisible, setIsVisible] = useState(false);
18
+ const [isClosing, setIsClosing] = useState(false);
19
+ const fadeTimeoutRef = useRef<number | null>(null);
20
+
21
+ useEffect(() => {
22
+ if (result) {
23
+ // Show toast immediately
24
+ setIsVisible(true);
25
+ setIsClosing(false);
26
+ }
27
+
28
+ return () => {
29
+ if (fadeTimeoutRef.current) {
30
+ window.clearTimeout(fadeTimeoutRef.current);
31
+ }
32
+ };
33
+ }, [result]);
34
+
35
+ const handleClose = () => {
36
+ if (isClosing) return;
37
+ setIsClosing(true);
38
+ fadeTimeoutRef.current = window.setTimeout(() => {
39
+ setIsVisible(false);
40
+ onComplete?.();
41
+ }, 200);
42
+ };
43
+
44
+ const extractUsageMessage = (data: unknown) => {
45
+ const result = data as any;
46
+
47
+ // Extract cost from various possible locations
48
+ const rawCost =
49
+ result?.cost ??
50
+ result?.price ??
51
+ result?.usage?.cost ??
52
+ result?.usage?.total ??
53
+ result?.usage?.usd ??
54
+ result?.usage?.credits ??
55
+ result?.tokens?.cost ??
56
+ result?.tokens?.price ??
57
+ result?.balance?.cost ??
58
+ result?.balance?.used ??
59
+ result?.balance?.spent ??
60
+ result?.credits_used ??
61
+ result?.usd ??
62
+ 0;
63
+
64
+ const cost = Number.isFinite(Number(rawCost)) ? Number(rawCost) : 0;
65
+
66
+ // Smart formatting: show meaningful decimals
67
+ let formatted;
68
+ if (cost >= 1) {
69
+ formatted = cost.toFixed(2);
70
+ } else if (cost >= 0.01) {
71
+ formatted = cost.toFixed(4);
72
+ } else if (cost >= 0.0001) {
73
+ formatted = cost.toFixed(6);
74
+ } else if (cost > 0) {
75
+ formatted = cost.toFixed(8);
76
+ } else {
77
+ formatted = "0.0000";
78
+ }
79
+
80
+ // Remove trailing zeros
81
+ formatted = parseFloat(formatted).toString();
82
+
83
+ return `${formatted}$ used`;
84
+ };
85
+
86
+ const message = extractUsageMessage(result);
87
+ if (!result) return null;
88
+
89
+ const getPositionStyles = () => {
90
+ const baseStyles = {
91
+ position: "absolute" as const,
92
+ zIndex: 1000,
93
+ };
94
+
95
+ switch (position) {
96
+ case "bottom-right":
97
+ return { ...baseStyles, bottom: "8px", right: "8px" };
98
+ case "bottom-left":
99
+ return { ...baseStyles, bottom: "8px", left: "8px" };
100
+ case "top-right":
101
+ return { ...baseStyles, top: "8px", right: "8px" };
102
+ case "top-left":
103
+ return { ...baseStyles, top: "8px", left: "8px" };
104
+ default:
105
+ return { ...baseStyles, bottom: "8px", right: "8px" };
106
+ }
107
+ };
108
+
109
+ return (
110
+ <div
111
+ style={{
112
+ ...getPositionStyles(),
113
+ opacity: isVisible && !isClosing ? 1 : 0,
114
+ transform: `translateY(${isVisible && !isClosing ? "0" : "8px"})`,
115
+ transition: "opacity 200ms ease, transform 200ms ease",
116
+ padding: "4px 6px",
117
+ borderRadius: "6px",
118
+ marginBottom: "4px",
119
+ right: "6px",
120
+ background: "rgba(22, 163, 74, 0.12)",
121
+ border: "1px solid rgba(22, 163, 74, 0.3)",
122
+ color: "#16a34a",
123
+ fontSize: "9px",
124
+ fontWeight: 600,
125
+ display: "flex",
126
+ alignItems: "center",
127
+ gap: "8px",
128
+ boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
129
+ backdropFilter: "blur(4px)",
130
+ WebkitBackdropFilter: "blur(4px)",
131
+ }}
132
+ >
133
+ <span>{message}</span>
134
+ <button
135
+ onClick={handleClose}
136
+ style={{
137
+ background: "transparent",
138
+ border: "none",
139
+ color: "#16a34a",
140
+ cursor: "pointer",
141
+ padding: "2px",
142
+ display: "flex",
143
+ alignItems: "center",
144
+ justifyContent: "center",
145
+ borderRadius: "4px",
146
+ transition: "background-color 150ms ease",
147
+ }}
148
+ onMouseEnter={(e) => {
149
+ e.currentTarget.style.backgroundColor = "rgba(22, 163, 74, 0.2)";
150
+ }}
151
+ onMouseLeave={(e) => {
152
+ e.currentTarget.style.backgroundColor = "transparent";
153
+ }}
154
+ title="Close"
155
+ >
156
+ <X size={12} />
157
+ </button>
158
+ </div>
159
+ );
160
+ }
161
+
162
+ export function useUsageToast() {
163
+ const [toastData, setToastData] = useState<unknown>(null);
164
+ const [toastKey, setToastKey] = useState(0);
165
+
166
+ const showUsageToast = (result: unknown) => {
167
+ // Replace any existing toast with new one
168
+ setToastKey((prev) => prev + 1);
169
+ setToastData(result);
170
+ };
171
+
172
+ const clearToast = () => {
173
+ setToastData(null);
174
+ };
175
+
176
+ return {
177
+ showUsageToast,
178
+ toastData,
179
+ toastKey,
180
+ clearToast,
181
+ };
182
+ }
@@ -12,6 +12,7 @@ export interface Prompt {
12
12
  is_public: boolean;
13
13
  favorite: boolean;
14
14
  tags: string[];
15
+ model?: string | null;
15
16
  created_at: string;
16
17
  updated_at?: string;
17
18
  }
@@ -168,7 +169,6 @@ export function usePrompts(): UsePromptsReturn {
168
169
  });
169
170
  } catch (err) {
170
171
  // Silent fail for stats
171
- console.error("Failed to increment stat:", err);
172
172
  }
173
173
  },
174
174
  []