Skip to content

Commit 1f2bed4

Browse files
committed
AI writing TSQL
1 parent 087087f commit 1f2bed4

File tree

5 files changed

+1328
-1
lines changed

5 files changed

+1328
-1
lines changed
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import { AnimatePresence, motion } from "framer-motion";
2+
import { useCallback, useEffect, useRef, useState } from "react";
3+
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
4+
import { Button } from "~/components/primitives/Buttons";
5+
import { Spinner } from "~/components/primitives/Spinner";
6+
import { useEnvironment } from "~/hooks/useEnvironment";
7+
import { useOrganization } from "~/hooks/useOrganizations";
8+
import { useProject } from "~/hooks/useProject";
9+
10+
type StreamEventType =
11+
| { type: "thinking"; content: string }
12+
| { type: "tool_call"; tool: string; args: unknown }
13+
| { type: "tool_result"; tool: string; result: unknown }
14+
| { type: "result"; success: true; query: string }
15+
| { type: "result"; success: false; error: string };
16+
17+
interface AIQueryInputProps {
18+
onQueryGenerated: (query: string) => void;
19+
/** Set this to a prompt to auto-populate and immediately submit */
20+
autoSubmitPrompt?: string;
21+
}
22+
23+
export function AIQueryInput({ onQueryGenerated, autoSubmitPrompt }: AIQueryInputProps) {
24+
const [prompt, setPrompt] = useState("");
25+
const [isLoading, setIsLoading] = useState(false);
26+
const [thinking, setThinking] = useState("");
27+
const [error, setError] = useState<string | null>(null);
28+
const [showThinking, setShowThinking] = useState(false);
29+
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
30+
const textareaRef = useRef<HTMLTextAreaElement>(null);
31+
const abortControllerRef = useRef<AbortController | null>(null);
32+
const lastAutoSubmitRef = useRef<string | null>(null);
33+
34+
const organization = useOrganization();
35+
const project = useProject();
36+
const environment = useEnvironment();
37+
38+
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-generate`;
39+
40+
const submitQuery = useCallback(
41+
async (queryPrompt: string) => {
42+
if (!queryPrompt.trim() || isLoading) return;
43+
44+
setIsLoading(true);
45+
setThinking("");
46+
setError(null);
47+
setShowThinking(true);
48+
setLastResult(null);
49+
50+
// Abort any existing request
51+
if (abortControllerRef.current) {
52+
abortControllerRef.current.abort();
53+
}
54+
abortControllerRef.current = new AbortController();
55+
56+
try {
57+
const formData = new FormData();
58+
formData.append("prompt", queryPrompt);
59+
60+
const response = await fetch(resourcePath, {
61+
method: "POST",
62+
body: formData,
63+
signal: abortControllerRef.current.signal,
64+
});
65+
66+
if (!response.ok) {
67+
const errorData = (await response.json()) as { error?: string };
68+
setError(errorData.error || "Failed to generate query");
69+
setIsLoading(false);
70+
setLastResult("error");
71+
return;
72+
}
73+
74+
const reader = response.body?.getReader();
75+
if (!reader) {
76+
setError("No response stream");
77+
setIsLoading(false);
78+
setLastResult("error");
79+
return;
80+
}
81+
82+
const decoder = new TextDecoder();
83+
let buffer = "";
84+
85+
while (true) {
86+
const { done, value } = await reader.read();
87+
if (done) break;
88+
89+
buffer += decoder.decode(value, { stream: true });
90+
91+
// Process complete events from buffer
92+
const lines = buffer.split("\n\n");
93+
buffer = lines.pop() || ""; // Keep incomplete line in buffer
94+
95+
for (const line of lines) {
96+
if (line.startsWith("data: ")) {
97+
try {
98+
const event = JSON.parse(line.slice(6)) as StreamEventType;
99+
processStreamEvent(event);
100+
} catch {
101+
// Ignore parse errors
102+
}
103+
}
104+
}
105+
}
106+
107+
// Process any remaining data
108+
if (buffer.startsWith("data: ")) {
109+
try {
110+
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
111+
processStreamEvent(event);
112+
} catch {
113+
// Ignore parse errors
114+
}
115+
}
116+
} catch (err) {
117+
if (err instanceof Error && err.name === "AbortError") {
118+
// Request was aborted, ignore
119+
return;
120+
}
121+
setError(err instanceof Error ? err.message : "An error occurred");
122+
setLastResult("error");
123+
} finally {
124+
setIsLoading(false);
125+
}
126+
},
127+
[isLoading, resourcePath]
128+
);
129+
130+
const processStreamEvent = useCallback(
131+
(event: StreamEventType) => {
132+
switch (event.type) {
133+
case "thinking":
134+
setThinking((prev) => prev + event.content);
135+
break;
136+
case "tool_call":
137+
setThinking((prev) => prev + `\n[Validating query...]\n`);
138+
break;
139+
case "tool_result":
140+
// Optionally show validation result
141+
break;
142+
case "result":
143+
if (event.success) {
144+
onQueryGenerated(event.query);
145+
setPrompt("");
146+
setLastResult("success");
147+
// Keep thinking visible to show what happened
148+
} else {
149+
setError(event.error);
150+
setLastResult("error");
151+
}
152+
break;
153+
}
154+
},
155+
[onQueryGenerated]
156+
);
157+
158+
const handleSubmit = useCallback(
159+
(e?: React.FormEvent) => {
160+
e?.preventDefault();
161+
submitQuery(prompt);
162+
},
163+
[prompt, submitQuery]
164+
);
165+
166+
// Auto-submit when autoSubmitPrompt changes
167+
useEffect(() => {
168+
if (
169+
autoSubmitPrompt &&
170+
autoSubmitPrompt.trim() &&
171+
autoSubmitPrompt !== lastAutoSubmitRef.current &&
172+
!isLoading
173+
) {
174+
lastAutoSubmitRef.current = autoSubmitPrompt;
175+
setPrompt(autoSubmitPrompt);
176+
submitQuery(autoSubmitPrompt);
177+
}
178+
}, [autoSubmitPrompt, isLoading, submitQuery]);
179+
180+
// Cleanup on unmount
181+
useEffect(() => {
182+
return () => {
183+
if (abortControllerRef.current) {
184+
abortControllerRef.current.abort();
185+
}
186+
};
187+
}, []);
188+
189+
// Auto-hide error after delay
190+
useEffect(() => {
191+
if (error) {
192+
const timer = setTimeout(() => setError(null), 15000);
193+
return () => clearTimeout(timer);
194+
}
195+
}, [error]);
196+
197+
return (
198+
<div className="flex flex-col gap-3">
199+
{/* Gradient border wrapper like the schedules AI input */}
200+
<div
201+
className="rounded-md p-px"
202+
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
203+
>
204+
<div className="overflow-hidden rounded-[5px] bg-background-bright">
205+
<form onSubmit={handleSubmit}>
206+
<textarea
207+
ref={textareaRef}
208+
name="prompt"
209+
placeholder="e.g. show me failed runs from the last 7 days"
210+
value={prompt}
211+
onChange={(e) => setPrompt(e.target.value)}
212+
disabled={isLoading}
213+
rows={8}
214+
className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-text-dimmed focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
215+
onKeyDown={(e) => {
216+
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
217+
e.preventDefault();
218+
handleSubmit();
219+
}
220+
}}
221+
/>
222+
<div className="flex justify-end gap-2 px-2 pb-2">
223+
<Button
224+
type="submit"
225+
variant="tertiary/small"
226+
disabled={isLoading || !prompt.trim()}
227+
LeadingIcon={isLoading ? Spinner : AISparkleIcon}
228+
className="pl-1.5"
229+
iconSpacing="gap-1.5"
230+
>
231+
{isLoading ? "Generating" : "Generate"}
232+
</Button>
233+
</div>
234+
</form>
235+
</div>
236+
</div>
237+
238+
{/* Error message */}
239+
<AnimatePresence>
240+
{error && (
241+
<motion.div
242+
initial={{ opacity: 0, height: 0 }}
243+
animate={{ opacity: 1, height: "auto" }}
244+
exit={{ opacity: 0, height: 0 }}
245+
transition={{ duration: 0.2 }}
246+
className="overflow-hidden"
247+
>
248+
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
249+
{error}
250+
</div>
251+
</motion.div>
252+
)}
253+
</AnimatePresence>
254+
255+
{/* Thinking panel - stays visible after completion */}
256+
<AnimatePresence>
257+
{showThinking && thinking && (
258+
<motion.div
259+
initial={{ opacity: 0, height: 0 }}
260+
animate={{ opacity: 1, height: "auto" }}
261+
exit={{ opacity: 0, height: 0 }}
262+
transition={{ duration: 0.2 }}
263+
className="overflow-hidden"
264+
>
265+
<div className="rounded-md border border-grid-dimmed bg-charcoal-850 p-3">
266+
<div className="mb-2 flex items-center justify-between">
267+
<div className="flex items-center gap-2">
268+
{isLoading ? (
269+
<Spinner
270+
color={{
271+
background: "rgba(99, 102, 241, 0.3)",
272+
foreground: "rgba(99, 102, 241, 1)",
273+
}}
274+
className="size-3"
275+
/>
276+
) : lastResult === "success" ? (
277+
<div className="size-3 rounded-full bg-success" />
278+
) : lastResult === "error" ? (
279+
<div className="size-3 rounded-full bg-error" />
280+
) : null}
281+
<span className="text-xs font-medium text-text-dimmed">
282+
{isLoading
283+
? "AI is thinking..."
284+
: lastResult === "success"
285+
? "Query generated"
286+
: lastResult === "error"
287+
? "Generation failed"
288+
: "AI response"}
289+
</span>
290+
</div>
291+
{isLoading ? (
292+
<Button
293+
variant="minimal/small"
294+
onClick={() => {
295+
if (abortControllerRef.current) {
296+
abortControllerRef.current.abort();
297+
}
298+
setIsLoading(false);
299+
setShowThinking(false);
300+
setThinking("");
301+
}}
302+
className="text-xs"
303+
>
304+
Cancel
305+
</Button>
306+
) : (
307+
<Button
308+
variant="minimal/small"
309+
onClick={() => {
310+
setShowThinking(false);
311+
setThinking("");
312+
}}
313+
className="text-xs"
314+
>
315+
Dismiss
316+
</Button>
317+
)}
318+
</div>
319+
<div className="max-h-48 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
320+
<p className="whitespace-pre-wrap text-xs text-text-dimmed">
321+
{thinking.slice(-800)}
322+
</p>
323+
</div>
324+
</div>
325+
</motion.div>
326+
)}
327+
</AnimatePresence>
328+
</div>
329+
);
330+
}

0 commit comments

Comments
 (0)