caiosilva1221 commited on
Commit
c30f644
·
verified ·
1 Parent(s): a306cb1

Update src/components/ask-ai/ask-ai.tsx

Browse files
Files changed (1) hide show
  1. src/components/ask-ai/ask-ai.tsx +77 -204
src/components/ask-ai/ask-ai.tsx CHANGED
@@ -1,17 +1,13 @@
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useEffect, useState } from "react";
3
  import { RiSparkling2Fill } from "react-icons/ri";
4
  import { GrSend } from "react-icons/gr";
5
  import classNames from "classnames";
6
  import { toast } from "react-toastify";
7
- import { useLocalStorage } from "react-use";
8
  import { MdPreview } from "react-icons/md";
9
 
10
- import Login from "../login/login";
11
- import { defaultHTML } from "./../../../utils/consts";
12
- import SuccessSound from "./../../assets/success.mp3";
13
- import Settings from "../settings/settings";
14
- import ProModal from "../pro-modal/pro-modal";
15
 
16
  function AskAI({
17
  html,
@@ -30,245 +26,122 @@ function AskAI({
30
  setView: React.Dispatch<React.SetStateAction<"editor" | "preview">>;
31
  setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>;
32
  }) {
33
- const [open, setOpen] = useState(false);
34
  const [prompt, setPrompt] = useState("");
35
  const [hasAsked, setHasAsked] = useState(false);
36
  const [previousPrompt, setPreviousPrompt] = useState("");
37
- const [provider, setProvider] = useLocalStorage("provider", "auto");
38
- const [openProvider, setOpenProvider] = useState(false);
39
- const [providerError, setProviderError] = useState("");
40
- const [openProModal, setOpenProModal] = useState(false);
41
- const [localSettings, setLocalSettings] = useState(() => {
42
- const saved = localStorage.getItem('localSettings');
43
- return saved ? JSON.parse(saved) : {
44
- apiKey: "",
45
- apiUrl: "http://localhost:11434/v1",
46
- model: "gemma3:1b",
47
- openRouterApiKey: "<OPENROUTER_API_KEY>",
48
- openRouterApiUrl: "https://openrouter.ai/api/v1",
49
- openRouterModel: "deepseek/deepseek-chat-v3-0324:free",
50
- };
51
- });
52
-
53
- const loadLocalSettings = () => {
54
- const saved = localStorage.getItem('localSettings');
55
- if (saved) {
56
- const parsed = JSON.parse(saved);
57
- setLocalSettings(parsed);
58
- } else {
59
- setLocalSettings({
60
- apiKey: "",
61
- apiUrl: "http://localhost:11434/v1",
62
- model: "gemma3:1b",
63
- openRouterApiKey: "<OPENROUTER_API_KEY>",
64
- openRouterApiUrl: "https://openrouter.ai/api/v1",
65
- openRouterModel: "deepseek/deepseek-chat-v3-0324:free",
66
- });
67
- }
68
- };
69
-
70
- useEffect(() => {
71
- loadLocalSettings();
72
- }, []);
73
-
74
- const audio = new Audio(SuccessSound);
75
- audio.volume = 0.5;
76
 
77
  const callAi = async () => {
78
  if (isAiWorking || !prompt.trim()) return;
79
  setisAiWorking(true);
80
- setProviderError("");
81
 
82
- let contentResponse = "";
83
- let lastRenderTime = 0;
 
 
 
84
  try {
85
  onNewPrompt(prompt);
86
- const request = await fetch("/api/ask-ai", {
 
87
  method: "POST",
 
88
  body: JSON.stringify({
89
  prompt,
90
- provider,
91
- ...(provider === "local"
92
- ? {
93
- ApiKey: localSettings.apiKey,
94
- ApiUrl: localSettings.apiUrl,
95
- Model: localSettings.model,
96
- }
97
- : {}),
98
- ...(provider === "openrouter"
99
- ? {
100
- ApiKey: localSettings.openRouterApiKey,
101
- ApiUrl: localSettings.openRouterApiUrl,
102
- Model: localSettings.openRouterModel,
103
- }
104
- : {}),
105
  ...(html === defaultHTML ? {} : { html }),
106
- ...(previousPrompt ? { previousPrompt } : {}),
107
  }),
108
- headers: {
109
- "Content-Type": "application/json",
110
- },
111
  });
112
- if (request && request.body) {
113
- if (!request.ok) {
114
- const res = await request.json();
115
- if (res.openLogin) {
116
- setOpen(true);
117
- } else if (res.openSelectProvider) {
118
- setOpenProvider(true);
119
- setProviderError(res.message);
120
- } else if (res.openProModal) {
121
- setOpenProModal(true);
122
- } else {
123
- toast.error(res.message);
124
- }
125
- setisAiWorking(false);
126
- return;
127
- }
128
- const reader = request.body.getReader();
129
- const decoder = new TextDecoder("utf-8");
130
-
131
- const read = async () => {
132
- const { done, value } = await reader.read();
133
- if (done) {
134
- toast.success("AI responded successfully");
135
- setPrompt("");
136
- setPreviousPrompt(prompt);
137
- setisAiWorking(false);
138
- setHasAsked(true);
139
- audio.play();
140
- setView("preview");
141
-
142
- // Now we have the complete HTML including </html>, so set it to be sure
143
- const finalDoc = contentResponse.match(
144
- /<!DOCTYPE html>[\s\S]*<\/html>/
145
- )?.[0];
146
- if (finalDoc) {
147
- setHtml(finalDoc);
148
- }
149
-
150
- return;
151
- }
152
-
153
- const chunk = decoder.decode(value, { stream: true });
154
- contentResponse += chunk;
155
- const newHtml = contentResponse.match(/<!DOCTYPE html>[\s\S]*/)?.[0];
156
- if (newHtml) {
157
- // Force-close the HTML tag so the iframe doesn't render half-finished markup
158
- let partialDoc = newHtml;
159
- if (!partialDoc.includes("</html>")) {
160
- partialDoc += "\n</html>";
161
- }
162
 
163
- // Throttle the re-renders to avoid flashing/flicker
164
- const now = Date.now();
165
- if (now - lastRenderTime > 300) {
166
- setHtml(partialDoc);
167
- lastRenderTime = now;
168
- }
169
 
170
- if (partialDoc.length > 200) {
171
- onScrollToBottom();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  }
 
 
173
  }
174
- read();
175
- };
176
-
177
- read();
178
  }
179
 
180
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
- } catch (error: any) {
 
 
182
  setisAiWorking(false);
183
- toast.error(error.message);
184
- if (error.openLogin) {
185
- setOpen(true);
 
 
 
186
  }
 
187
  }
188
  };
189
 
190
  return (
191
- <div
192
- className={`bg-gray-950 rounded-xl py-2 lg:py-2.5 pl-3.5 lg:pl-4 pr-2 lg:pr-2.5 absolute lg:sticky bottom-3 left-3 lg:bottom-4 lg:left-4 w-[calc(100%-1.5rem)] lg:w-[calc(100%-2rem)] z-10 group ${
193
- isAiWorking ? "animate-pulse" : ""
194
- }`}
195
- >
196
  {defaultHTML !== html && (
197
  <button
198
- className="bg-white lg:hidden -translate-y-[calc(100%+8px)] absolute left-0 top-0 shadow-md text-gray-950 text-xs font-medium py-2 px-3 lg:px-4 rounded-lg flex items-center gap-2 border border-gray-100 hover:brightness-150 transition-all duration-100 cursor-pointer"
199
  onClick={() => setView("preview")}
200
  >
201
- <MdPreview className="text-sm" />
202
- View Preview
203
  </button>
204
  )}
205
- <div className="w-full relative flex items-center justify-between">
206
- <RiSparkling2Fill className="text-lg lg:text-xl text-gray-500 group-focus-within:text-pink-500" />
207
  <input
208
  type="text"
209
  disabled={isAiWorking}
210
- className="w-full bg-transparent max-lg:text-sm outline-none px-3 text-white placeholder:text-gray-500 font-code"
211
- placeholder={
212
- hasAsked ? "What do you want to ask AI next?" : "Ask AI anything..."
213
- }
214
  value={prompt}
215
  onChange={(e) => setPrompt(e.target.value)}
216
- onKeyDown={(e) => {
217
- if (e.key === "Enter") {
218
- callAi();
219
- }
220
- }}
221
  />
222
- <div className="flex items-center justify-end gap-2">
223
- {/* <SpeechPrompt setPrompt={setPrompt} /> */}
224
- <Settings
225
- provider={provider as string}
226
- onChange={setProvider}
227
- open={openProvider}
228
- error={providerError}
229
- onClose={setOpenProvider}
230
- setLocalSettings={setLocalSettings}
231
- localSettings={localSettings}
232
- />
233
- <button
234
- disabled={isAiWorking}
235
- className="relative overflow-hidden cursor-pointer flex-none flex items-center justify-center rounded-full text-sm font-semibold size-8 text-center bg-pink-500 hover:bg-pink-400 text-white shadow-sm dark:shadow-highlight/20 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed disabled:hover:bg-gray-300"
236
- onClick={callAi}
237
- >
238
- <GrSend className="-translate-x-[1px]" />
239
- </button>
240
- </div>
241
- </div>
242
- <div
243
- className={classNames(
244
- "h-screen w-screen bg-black/20 fixed left-0 top-0 z-10",
245
- {
246
- "opacity-0 pointer-events-none": !open,
247
- }
248
- )}
249
- onClick={() => setOpen(false)}
250
- ></div>
251
- <div
252
- className={classNames(
253
- "absolute top-0 -translate-y-[calc(100%+8px)] right-0 z-10 w-80 bg-white border border-gray-200 rounded-lg shadow-lg transition-all duration-75 overflow-hidden",
254
- {
255
- "opacity-0 pointer-events-none": !open,
256
- }
257
- )}
258
- >
259
- <Login html={html}>
260
- <p className="text-gray-500 text-sm mb-3">
261
- You reached the limit of free AI usage. Please login to continue.
262
- </p>
263
- </Login>
264
  </div>
265
- <ProModal
266
- html={html}
267
- open={openProModal}
268
- onClose={() => setOpenProModal(false)}
269
- />
270
  </div>
271
  );
272
  }
273
 
274
- export default AskAI;
 
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useEffect, useState, useRef } from "react";
3
  import { RiSparkling2Fill } from "react-icons/ri";
4
  import { GrSend } from "react-icons/gr";
5
  import classNames from "classnames";
6
  import { toast } from "react-toastify";
 
7
  import { MdPreview } from "react-icons/md";
8
 
9
+ import { defaultHTML } from "../../../utils/consts";
10
+ import SuccessSound from "../../assets/success.mp3";
 
 
 
11
 
12
  function AskAI({
13
  html,
 
26
  setView: React.Dispatch<React.SetStateAction<"editor" | "preview">>;
27
  setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>;
28
  }) {
 
29
  const [prompt, setPrompt] = useState("");
30
  const [hasAsked, setHasAsked] = useState(false);
31
  const [previousPrompt, setPreviousPrompt] = useState("");
32
+ const controllerRef = useRef<AbortController | null>(null);
33
+ const audio = useRef(new Audio(SuccessSound));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  const callAi = async () => {
36
  if (isAiWorking || !prompt.trim()) return;
37
  setisAiWorking(true);
 
38
 
39
+ controllerRef.current?.abort();
40
+ controllerRef.current = new AbortController();
41
+
42
+ const signal = controllerRef.current.signal;
43
+
44
  try {
45
  onNewPrompt(prompt);
46
+
47
+ const response = await fetch("/api/ask-ai", {
48
  method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
  body: JSON.stringify({
51
  prompt,
52
+ previousPrompt,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  ...(html === defaultHTML ? {} : { html }),
 
54
  }),
55
+ signal,
 
 
56
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
+ if (!response.ok || !response.body) {
59
+ toast.error("Erro ao chamar a IA.");
60
+ setisAiWorking(false);
61
+ return;
62
+ }
 
63
 
64
+ const reader = response.body.getReader();
65
+ const decoder = new TextDecoder("utf-8");
66
+ let completeResponse = "";
67
+ let lastRenderTime = 0;
68
+
69
+ while (true) {
70
+ const { done, value } = await reader.read();
71
+ if (done) break;
72
+
73
+ const chunk = decoder.decode(value, { stream: true });
74
+ const lines = chunk.split("\n").filter((line) => line.startsWith("data: "));
75
+
76
+ for (const line of lines) {
77
+ try {
78
+ const json = JSON.parse(line.slice(6));
79
+ const content = json?.choices?.[0]?.delta?.content;
80
+ if (content) {
81
+ completeResponse += content;
82
+
83
+ const now = Date.now();
84
+ if (now - lastRenderTime > 300) {
85
+ let partial = completeResponse.match(/<!DOCTYPE html>[\s\S]*/)?.[0];
86
+ if (partial && !partial.includes("</html>")) partial += "</html>";
87
+ if (partial) setHtml(partial);
88
+ lastRenderTime = now;
89
+ }
90
+ if (completeResponse.length > 200) onScrollToBottom();
91
  }
92
+ } catch (e) {
93
+ continue;
94
  }
95
+ }
 
 
 
96
  }
97
 
98
+ const finalDoc = completeResponse.match(/<!DOCTYPE html>[\s\S]*<\/html>/)?.[0];
99
+ if (finalDoc) setHtml(finalDoc);
100
+ setPrompt("");
101
+ setPreviousPrompt(prompt);
102
  setisAiWorking(false);
103
+ setHasAsked(true);
104
+ setView("preview");
105
+ setTimeout(() => audio.current.play(), 0);
106
+ } catch (error: any) {
107
+ if (error.name !== "AbortError") {
108
+ toast.error(error.message || "Erro inesperado");
109
  }
110
+ setisAiWorking(false);
111
  }
112
  };
113
 
114
  return (
115
+ <div className={`bg-gray-950 rounded-xl py-2 px-4 absolute bottom-3 left-3 w-[calc(100%-1.5rem)] z-10 group ${isAiWorking ? "animate-pulse" : ""}`}>
 
 
 
 
116
  {defaultHTML !== html && (
117
  <button
118
+ className="bg-white text-gray-950 text-xs font-medium py-2 px-3 rounded-lg flex items-center gap-2 border border-gray-100 hover:brightness-150"
119
  onClick={() => setView("preview")}
120
  >
121
+ <MdPreview className="text-sm" /> View Preview
 
122
  </button>
123
  )}
124
+ <div className="flex items-center justify-between">
125
+ <RiSparkling2Fill className="text-xl text-gray-500 group-focus-within:text-pink-500" />
126
  <input
127
  type="text"
128
  disabled={isAiWorking}
129
+ className="w-full bg-transparent text-sm outline-none px-3 text-white placeholder:text-gray-500"
130
+ placeholder={hasAsked ? "O que você quer criar agora?" : "Digite sua ideia aqui..."}
 
 
131
  value={prompt}
132
  onChange={(e) => setPrompt(e.target.value)}
133
+ onKeyDown={(e) => e.key === "Enter" && callAi()}
 
 
 
 
134
  />
135
+ <button
136
+ disabled={isAiWorking}
137
+ className="rounded-full bg-pink-500 text-white size-8 flex items-center justify-center disabled:bg-gray-300"
138
+ onClick={callAi}
139
+ >
140
+ <GrSend className="-translate-x-[1px]" />
141
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  </div>
 
 
 
 
 
143
  </div>
144
  );
145
  }
146
 
147
+ export default AskAI;