Canvas: Implement Agent Sketch node, update dodo-app.jsonschema

- Add Gemini API key to the project
- Update dodo schema to support Gemini API key
- Update dodo schema to support Agent Sketch node

Change-Id: I6a96186f86ad169152ca0021b38130e485ebbf14
diff --git a/apps/canvas/front/src/Integrations.tsx b/apps/canvas/front/src/Integrations.tsx
index 722c43c..cf4b586 100644
--- a/apps/canvas/front/src/Integrations.tsx
+++ b/apps/canvas/front/src/Integrations.tsx
@@ -1,4 +1,4 @@
-import { useProjectId, useGithubService, useStateStore } from "@/lib/state";
+import { useProjectId, useGithubService, useStateStore, useGeminiService } from "@/lib/state";
 import { Form, FormControl, FormField, FormItem, FormMessage } from "./components/ui/form";
 import { Input } from "./components/ui/input";
 import { useForm } from "react-hook-form";
@@ -9,25 +9,36 @@
 import { CircleCheck, CircleX } from "lucide-react";
 import { useState, useCallback } from "react";
 
-const schema = z.object({
+const githubSchema = z.object({
 	githubToken: z.string().min(1, "GitHub token is required"),
 });
 
+const geminiSchema = z.object({
+	geminiApiKey: z.string().min(1, "Gemini API token is required"),
+});
+
 export function Integrations() {
 	const { toast } = useToast();
 	const store = useStateStore();
 	const projectId = useProjectId();
-	const [isEditing, setIsEditing] = useState(false);
+	const [isEditingGithub, setIsEditingGithub] = useState(false);
+	const [isEditingGemini, setIsEditingGemini] = useState(false);
 	const githubService = useGithubService();
+	const geminiService = useGeminiService();
 	const [isSaving, setIsSaving] = useState(false);
 
-	const form = useForm<z.infer<typeof schema>>({
-		resolver: zodResolver(schema),
+	const githubForm = useForm<z.infer<typeof githubSchema>>({
+		resolver: zodResolver(githubSchema),
 		mode: "onChange",
 	});
 
-	const onSubmit = useCallback(
-		async (data: z.infer<typeof schema>) => {
+	const geminiForm = useForm<z.infer<typeof geminiSchema>>({
+		resolver: zodResolver(geminiSchema),
+		mode: "onChange",
+	});
+
+	const onGithubSubmit = useCallback(
+		async (data: z.infer<typeof githubSchema>) => {
 			if (!projectId) return;
 
 			setIsSaving(true);
@@ -46,8 +57,8 @@
 				}
 
 				await store.refreshEnv();
-				setIsEditing(false);
-				form.reset();
+				setIsEditingGithub(false);
+				githubForm.reset();
 				toast({
 					title: "GitHub token saved successfully",
 				});
@@ -61,12 +72,51 @@
 				setIsSaving(false);
 			}
 		},
-		[projectId, store, form, toast, setIsEditing, setIsSaving],
+		[projectId, store, githubForm, toast, setIsEditingGithub, setIsSaving],
 	);
 
-	const handleCancel = () => {
-		setIsEditing(false);
-		form.reset();
+	const onGeminiSubmit = useCallback(
+		async (data: z.infer<typeof geminiSchema>) => {
+			if (!projectId) return;
+			setIsSaving(true);
+			try {
+				const response = await fetch(`/api/project/${projectId}/gemini-token`, {
+					method: "POST",
+					headers: {
+						"Content-Type": "application/json",
+					},
+					body: JSON.stringify({ geminiApiKey: data.geminiApiKey }),
+				});
+				if (!response.ok) {
+					throw new Error("Failed to save Gemini token");
+				}
+				await store.refreshEnv();
+				setIsEditingGemini(false);
+				geminiForm.reset();
+				toast({
+					title: "Gemini token saved successfully",
+				});
+			} catch (error) {
+				toast({
+					variant: "destructive",
+					title: "Failed to save Gemini token",
+					description: error instanceof Error ? error.message : "Unknown error",
+				});
+			} finally {
+				setIsSaving(false);
+			}
+		},
+		[projectId, store, geminiForm, toast, setIsEditingGemini, setIsSaving],
+	);
+
+	const handleCancelGithub = () => {
+		setIsEditingGithub(false);
+		githubForm.reset();
+	};
+
+	const handleCancelGemini = () => {
+		setIsEditingGemini(false);
+		geminiForm.reset();
 	};
 
 	return (
@@ -77,13 +127,13 @@
 					<div>Github</div>
 				</div>
 
-				{!!githubService && !isEditing && (
-					<Button variant="outline" className="w-fit" onClick={() => setIsEditing(true)}>
+				{!!githubService && !isEditingGithub && (
+					<Button variant="outline" className="w-fit" onClick={() => setIsEditingGithub(true)}>
 						Update Access Token
 					</Button>
 				)}
 
-				{(!githubService || isEditing) && (
+				{(!githubService || isEditingGithub) && (
 					<div className="flex flex-row items-center gap-1 text-sm">
 						<div>
 							Follow the link to generate new PAT:{" "}
@@ -111,11 +161,11 @@
 						</div>
 					</div>
 				)}
-				{(!githubService || isEditing) && (
-					<Form {...form}>
-						<form className="space-y-2" onSubmit={form.handleSubmit(onSubmit)}>
+				{(!githubService || isEditingGithub) && (
+					<Form {...githubForm}>
+						<form className="space-y-2" onSubmit={githubForm.handleSubmit(onGithubSubmit)}>
 							<FormField
-								control={form.control}
+								control={githubForm.control}
 								name="githubToken"
 								render={({ field }) => (
 									<FormItem>
@@ -136,7 +186,73 @@
 									{isSaving ? "Saving..." : "Save"}
 								</Button>
 								{!!githubService && (
-									<Button type="button" variant="outline" onClick={handleCancel} disabled={isSaving}>
+									<Button
+										type="button"
+										variant="outline"
+										onClick={handleCancelGithub}
+										disabled={isSaving}
+									>
+										Cancel
+									</Button>
+								)}
+							</div>
+						</form>
+					</Form>
+				)}
+			</div>
+			<div className="flex flex-col gap-1">
+				<div className="flex flex-row items-center gap-1">
+					{geminiService ? <CircleCheck /> : <CircleX />}
+					<div>Gemini</div>
+				</div>
+
+				{!!geminiService && !isEditingGemini && (
+					<Button variant="outline" className="w-fit" onClick={() => setIsEditingGemini(true)}>
+						Update API Key
+					</Button>
+				)}
+
+				{(!geminiService || isEditingGemini) && (
+					<div className="flex flex-row items-center gap-1 text-sm">
+						<div>
+							Follow the link to generate new API Key:{" "}
+							<a href="https://aistudio.google.com/app/apikey" target="_blank">
+								https://aistudio.google.com/app/apikey
+							</a>
+						</div>
+					</div>
+				)}
+				{(!geminiService || isEditingGemini) && (
+					<Form {...geminiForm}>
+						<form className="space-y-2" onSubmit={geminiForm.handleSubmit(onGeminiSubmit)}>
+							<FormField
+								control={geminiForm.control}
+								name="geminiApiKey"
+								render={({ field }) => (
+									<FormItem>
+										<FormControl>
+											<Input
+												type="password"
+												placeholder="Gemini API Token"
+												className="w-1/4"
+												{...field}
+											/>
+										</FormControl>
+										<FormMessage />
+									</FormItem>
+								)}
+							/>
+							<div className="flex flex-row items-center gap-1">
+								<Button type="submit" disabled={isSaving}>
+									{isSaving ? "Saving..." : "Save"}
+								</Button>
+								{!!geminiService && (
+									<Button
+										type="button"
+										variant="outline"
+										onClick={handleCancelGemini}
+										disabled={isSaving}
+									>
 										Cancel
 									</Button>
 								)}