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/components/canvas.tsx b/apps/canvas/front/src/components/canvas.tsx
index 96b8f03..2d78abd 100644
--- a/apps/canvas/front/src/components/canvas.tsx
+++ b/apps/canvas/front/src/components/canvas.tsx
@@ -10,7 +10,7 @@
 	Panel,
 	useStoreApi,
 } from "@xyflow/react";
-import { useStateStore, AppState, AppNode, useZoom } from "@/lib/state";
+import { useStateStore, AppState, useZoom } from "@/lib/state";
 import { useShallow } from "zustand/react/shallow";
 import { useCallback, useEffect, useMemo } from "react";
 import { NodeGatewayHttps } from "@/components/node-gateway-https";
@@ -22,6 +22,7 @@
 import { Actions } from "./actions";
 import { NodeGatewayTCP } from "./node-gateway-tcp";
 import { NodeNetwork } from "./node-network";
+import { AppNode } from "config";
 
 const selector = (state: AppState) => ({
 	nodes: state.nodes,
@@ -69,6 +70,7 @@
 			}
 			const sn = instance.getNode(c.source)! as AppNode;
 			const tn = instance.getNode(c.target)! as AppNode;
+
 			if (sn.type === "github") {
 				return c.targetHandle === "repository";
 			}
diff --git a/apps/canvas/front/src/components/icon.tsx b/apps/canvas/front/src/components/icon.tsx
index 02be282..6bded3a 100644
--- a/apps/canvas/front/src/components/icon.tsx
+++ b/apps/canvas/front/src/components/icon.tsx
@@ -1,23 +1,31 @@
-import { accessSchema, NodeType } from "@/lib/state";
 import { ReactElement } from "react";
 import { SiGithub, SiMongodb, SiPostgresql } from "react-icons/si";
 import { GrServices } from "react-icons/gr";
 import { GoFileDirectoryFill } from "react-icons/go";
 import { TbWorldWww } from "react-icons/tb";
 import { PiNetwork } from "react-icons/pi";
-import { AiOutlineGlobal } from "react-icons/ai";
+import { AiOutlineGlobal } from "react-icons/ai"; // Corrected import source
+import { Bot } from "lucide-react"; // Bot import
 import { Terminal } from "lucide-react";
 import { z } from "zod";
+import { AppNode, accessSchema } from "config";
 
 type Props = {
-	type: NodeType | undefined;
+	node: AppNode | undefined;
 	className?: string;
 };
 
-export function Icon({ type, className }: Props): ReactElement {
-	switch (type) {
+export function Icon({ node, className }: Props): ReactElement {
+	if (!node) {
+		return <></>;
+	}
+	switch (node.type) {
 		case "app":
-			return <GrServices className={className} />;
+			if (node.data.type === "sketch:latest") {
+				return <Bot className={className} />;
+			} else {
+				return <GrServices className={className} />;
+			}
 		case "github":
 			return <SiGithub className={className} />;
 		case "gateway-https":
@@ -33,7 +41,7 @@
 		case "network":
 			return <AiOutlineGlobal className={className} />;
 		default:
-			throw new Error(`MUST NOT REACH! ${type}`);
+			throw new Error(`MUST NOT REACH! ${node.type}`);
 	}
 }
 
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index 7eb632c..fa08977 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -1,7 +1,7 @@
 import { v4 as uuidv4 } from "uuid";
 import { NodeRect } from "./node-rect";
 import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state";
-import { ServiceNode, ServiceTypes } from "config";
+import { ServiceNode, ServiceTypes, GatewayHttpsNode, GatewayTCPNode, BoundEnvVar, AppNode, GithubNode } from "config";
 import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
 import { z } from "zod";
 import { useForm, EventType, DeepPartial } from "react-hook-form";
@@ -27,7 +27,7 @@
 	const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
 	const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
@@ -79,6 +79,10 @@
 	subdomain: z.string().min(1, "required"),
 });
 
+const agentSchema = z.object({
+	geminiApiKey: z.string().optional(),
+});
+
 export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
 	const { data } = node;
 	return (
@@ -146,22 +150,24 @@
 							</TooltipProvider>
 						)}
 					</TabsTrigger>
-					<TabsTrigger value="dev">
-						{isOverview ? (
-							<div className="flex flex-row gap-1 items-center">
-								<Code /> Dev
-							</div>
-						) : (
-							<TooltipProvider>
-								<Tooltip>
-									<TooltipTrigger className="flex flex-row gap-1 items-center">
-										<Code />
-									</TooltipTrigger>
-									<TooltipContent>Dev</TooltipContent>
-								</Tooltip>
-							</TooltipProvider>
-						)}
-					</TabsTrigger>
+					{node.data.type !== "sketch:latest" && (
+						<TabsTrigger value="dev">
+							{isOverview ? (
+								<div className="flex flex-row gap-1 items-center">
+									<Code /> Dev
+								</div>
+							) : (
+								<TooltipProvider>
+									<Tooltip>
+										<TooltipTrigger className="flex flex-row gap-1 items-center">
+											<Code />
+										</TooltipTrigger>
+										<TooltipContent>Dev</TooltipContent>
+									</Tooltip>
+								</TooltipProvider>
+							)}
+						</TabsTrigger>
+					)}
 				</TabsList>
 				<TabsContent value="runtime">
 					<Runtime node={node} disabled={disabled} />
@@ -172,9 +178,11 @@
 				<TabsContent value="vars">
 					<EnvVars node={node} disabled={disabled} />
 				</TabsContent>
-				<TabsContent value="dev">
-					<Dev node={node} disabled={disabled} />
-				</TabsContent>
+				{node.data.type !== "sketch:latest" && (
+					<TabsContent value="dev">
+						<Dev node={node} disabled={disabled} />
+					</TabsContent>
+				)}
 			</Tabs>
 		</>
 	);
@@ -241,49 +249,97 @@
 		},
 		[id, store],
 	);
+	const agentForm = useForm<z.infer<typeof agentSchema>>({
+		resolver: zodResolver(agentSchema),
+		mode: "onChange",
+		defaultValues: {
+			geminiApiKey: data.agent?.geminiApiKey,
+		},
+	});
+	useEffect(() => {
+		const sub = agentForm.watch((value) => {
+			store.updateNodeData<"app">(id, {
+				agent: {
+					geminiApiKey: value.geminiApiKey,
+				},
+			});
+		});
+		return () => sub.unsubscribe();
+	}, [id, agentForm, store]);
 	return (
 		<>
 			<SourceRepo node={node} disabled={disabled} />
-			<Form {...form}>
-				<form className="space-y-2">
-					<Label>Container Image</Label>
-					<FormField
-						control={form.control}
-						name="type"
-						render={({ field }) => (
-							<FormItem>
-								<Select
-									onValueChange={field.onChange}
-									value={field.value || ""}
-									{...typeProps}
-									disabled={disabled}
-								>
+			{node.data.type !== "sketch:latest" && (
+				<Form {...form}>
+					<form className="space-y-2">
+						<Label>Container Image</Label>
+						<FormField
+							control={form.control}
+							name="type"
+							render={({ field }) => (
+								<FormItem>
+									<Select
+										onValueChange={field.onChange}
+										value={field.value || ""}
+										{...typeProps}
+										disabled={disabled}
+									>
+										<FormControl>
+											<SelectTrigger>
+												<SelectValue />
+											</SelectTrigger>
+										</FormControl>
+										<SelectContent>
+											{ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => (
+												<SelectItem key={t} value={t}>
+													{t}
+												</SelectItem>
+											))}
+										</SelectContent>
+									</Select>
+									<FormMessage />
+								</FormItem>
+							)}
+						/>
+					</form>
+				</Form>
+			)}
+			{node.data.type === "sketch:latest" && (
+				<Form {...agentForm}>
+					<form className="space-y-2">
+						<Label>Gemini API Key</Label>
+						<FormField
+							control={agentForm.control}
+							name="geminiApiKey"
+							render={({ field }) => (
+								<FormItem>
 									<FormControl>
-										<SelectTrigger>
-											<SelectValue />
-										</SelectTrigger>
+										<Input
+											type="password"
+											placeholder="Override Gemini API key"
+											{...field}
+											value={field.value || ""}
+											disabled={disabled}
+										/>
 									</FormControl>
-									<SelectContent>
-										{ServiceTypes.map((t) => (
-											<SelectItem key={t} value={t}>
-												{t}
-											</SelectItem>
-										))}
-									</SelectContent>
-								</Select>
-								<FormMessage />
-							</FormItem>
-						)}
+									<FormMessage />
+								</FormItem>
+							)}
+						/>
+					</form>
+				</Form>
+			)}
+			{node.data.type !== "sketch:latest" && (
+				<>
+					<Label>Pre-Build Commands</Label>
+					<Textarea
+						placeholder="new line separated list of commands to run before running the service"
+						value={data.preBuildCommands}
+						onChange={setPreBuildCommands}
+						disabled={disabled}
 					/>
-				</form>
-			</Form>
-			<Label>Pre-Build Commands</Label>
-			<Textarea
-				placeholder="new line separated list of commands to run before running the service"
-				value={data.preBuildCommands}
-				onChange={setPreBuildCommands}
-				disabled={disabled}
-			/>
+				</>
+			)}
 		</>
 	);
 }
diff --git a/apps/canvas/front/src/components/node-gateway-https.tsx b/apps/canvas/front/src/components/node-gateway-https.tsx
index fe17527..ebd1f61 100644
--- a/apps/canvas/front/src/components/node-gateway-https.tsx
+++ b/apps/canvas/front/src/components/node-gateway-https.tsx
@@ -1,13 +1,5 @@
 import { v4 as uuidv4 } from "uuid";
-import {
-	useStateStore,
-	AppNode,
-	GatewayHttpsNode,
-	ServiceNode,
-	nodeLabel,
-	useEnv,
-	nodeIsConnectable,
-} from "@/lib/state";
+import { useStateStore, nodeLabel, useEnv, nodeIsConnectable } from "@/lib/state";
 import { Handle, Position, useNodes } from "@xyflow/react";
 import { NodeRect } from "./node-rect";
 import { useCallback, useEffect, useMemo } from "react";
@@ -22,6 +14,7 @@
 import { XIcon } from "lucide-react";
 import { Switch } from "./ui/switch";
 import { NodeDetailsProps } from "@/lib/types";
+import { AppNode, GatewayHttpsNode, ServiceNode } from "config";
 
 const schema = z.object({
 	network: z.string().min(1, "reqired"),
@@ -50,7 +43,7 @@
 	const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
 	const isConnectable = useMemo(() => nodeIsConnectable(node, "https"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			{nodeLabel(node)}
 			<Handle
 				type={"source"}
diff --git a/apps/canvas/front/src/components/node-gateway-tcp.tsx b/apps/canvas/front/src/components/node-gateway-tcp.tsx
index 919bf83..6f89c1f 100644
--- a/apps/canvas/front/src/components/node-gateway-tcp.tsx
+++ b/apps/canvas/front/src/components/node-gateway-tcp.tsx
@@ -1,5 +1,5 @@
 import { v4 as uuidv4 } from "uuid";
-import { useStateStore, AppNode, nodeLabel, useEnv, GatewayTCPNode, nodeIsConnectable } from "@/lib/state";
+import { useStateStore, nodeLabel, useEnv, nodeIsConnectable } from "@/lib/state";
 import { Edge, Handle, Position, useNodes } from "@xyflow/react";
 import { NodeRect } from "./node-rect";
 import { useCallback, useEffect, useMemo, useState } from "react";
@@ -11,6 +11,7 @@
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
 import { Button } from "./ui/button";
 import { NodeDetailsProps } from "@/lib/types";
+import { AppNode, GatewayTCPNode } from "config";
 
 const schema = z.object({
 	network: z.string().min(1, "reqired"),
@@ -27,7 +28,7 @@
 	const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
 	const isConnectable = useMemo(() => nodeIsConnectable(node, "tcp"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			{nodeLabel(node)}
 			<Handle
 				type={"source"}
diff --git a/apps/canvas/front/src/components/node-github.tsx b/apps/canvas/front/src/components/node-github.tsx
index ef289cb..fbb63e4 100644
--- a/apps/canvas/front/src/components/node-github.tsx
+++ b/apps/canvas/front/src/components/node-github.tsx
@@ -29,7 +29,7 @@
 	const { id, selected } = node;
 	const isConnectable = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
diff --git a/apps/canvas/front/src/components/node-mongodb.tsx b/apps/canvas/front/src/components/node-mongodb.tsx
index 8b9e53b..865631f 100644
--- a/apps/canvas/front/src/components/node-mongodb.tsx
+++ b/apps/canvas/front/src/components/node-mongodb.tsx
@@ -1,13 +1,14 @@
 import { NodeRect } from "./node-rect";
-import { nodeLabel, MongoDBNode } from "@/lib/state";
+import { nodeLabel } from "@/lib/state";
 import { Handle, Position } from "@xyflow/react";
 import { Name } from "./node-name";
 import { NodeDetailsProps } from "@/lib/types";
+import { MongoDBNode } from "config";
 
 export function NodeMongoDB(node: MongoDBNode) {
 	const { id, selected } = node;
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
diff --git a/apps/canvas/front/src/components/node-name.tsx b/apps/canvas/front/src/components/node-name.tsx
index 4a62206..fee274a 100644
--- a/apps/canvas/front/src/components/node-name.tsx
+++ b/apps/canvas/front/src/components/node-name.tsx
@@ -1,8 +1,8 @@
 import { useState, useEffect } from "react";
 import { nodeLabel, useStateStore } from "@/lib/state";
-import { AppNode } from "@/lib/state";
 import { Icon } from "./icon";
 import { Input } from "./ui/input";
+import { AppNode } from "config";
 
 export function Name({
 	node,
@@ -24,7 +24,7 @@
 	if (node.type === "github" || node.type === "gateway-https" || node.type === "gateway-tcp") {
 		return (
 			<div className="w-full flex flex-row gap-1 items-center">
-				<Icon type={node.type} />
+				<Icon node={node} />
 				<h3 className="w-full text-lg font-bold cursor-text select-none hover:outline-solid hover:outline-2 hover:outline-gray-200">
 					{nodeLabel(node)}
 				</h3>
@@ -33,7 +33,7 @@
 	}
 	return (
 		<div className="w-full flex flex-row gap-1 items-center">
-			<Icon type={node.type} />
+			<Icon node={node} />
 			{isEditing || editing ? (
 				<Input
 					placeholder="Name"
diff --git a/apps/canvas/front/src/components/node-network.tsx b/apps/canvas/front/src/components/node-network.tsx
index 8fa62f2..55d0b7a 100644
--- a/apps/canvas/front/src/components/node-network.tsx
+++ b/apps/canvas/front/src/components/node-network.tsx
@@ -1,11 +1,12 @@
 import { NodeRect } from "./node-rect";
-import { nodeLabel, NetworkNode } from "@/lib/state";
+import { nodeLabel } from "@/lib/state";
 import { Handle, Position } from "@xyflow/react";
+import { NetworkNode } from "config";
 
 export function NodeNetwork(node: NetworkNode) {
 	const { id, selected } = node;
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
diff --git a/apps/canvas/front/src/components/node-postgresql.tsx b/apps/canvas/front/src/components/node-postgresql.tsx
index 0ae86a1..e33295a 100644
--- a/apps/canvas/front/src/components/node-postgresql.tsx
+++ b/apps/canvas/front/src/components/node-postgresql.tsx
@@ -1,13 +1,14 @@
 import { NodeRect } from "./node-rect";
-import { nodeLabel, PostgreSQLNode } from "@/lib/state";
+import { nodeLabel } from "@/lib/state";
 import { Handle, Position } from "@xyflow/react";
 import { Name } from "./node-name";
 import { NodeDetailsProps } from "@/lib/types";
+import { PostgreSQLNode } from "config";
 
 export function NodePostgreSQL(node: PostgreSQLNode) {
 	const { id, selected } = node;
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				{nodeLabel(node)}
 				<Handle
diff --git a/apps/canvas/front/src/components/node-rect.tsx b/apps/canvas/front/src/components/node-rect.tsx
index a0a1842..da3cb35 100644
--- a/apps/canvas/front/src/components/node-rect.tsx
+++ b/apps/canvas/front/src/components/node-rect.tsx
@@ -1,12 +1,13 @@
-import { NodeType, useMode, useNodeMessages } from "@/lib/state";
+import { useMode, useNodeMessages } from "@/lib/state";
 import { Icon } from "./icon";
 import { useEffect, useState } from "react";
+import { AppNode } from "config";
 
 export type Props = {
 	id: string;
 	selected?: boolean;
 	children: React.ReactNode;
-	type: NodeType;
+	node: AppNode;
 	state?: string | null;
 };
 
@@ -48,7 +49,7 @@
 	return (
 		<div className={classes.join(" ")}>
 			<div style={{ position: "absolute", top: "5px", left: "5px" }}>
-				<Icon type={p.type} />
+				<Icon node={p.node} />
 			</div>
 			{mode === "deploy" && (
 				<div
diff --git a/apps/canvas/front/src/components/node-volume.tsx b/apps/canvas/front/src/components/node-volume.tsx
index 39bf15a..c58b600 100644
--- a/apps/canvas/front/src/components/node-volume.tsx
+++ b/apps/canvas/front/src/components/node-volume.tsx
@@ -1,5 +1,5 @@
 import { NodeRect } from "./node-rect";
-import { nodeIsConnectable, nodeLabel, useStateStore, VolumeNode } from "@/lib/state";
+import { nodeIsConnectable, nodeLabel, useStateStore } from "@/lib/state";
 import { useEffect, useMemo } from "react";
 import { z } from "zod";
 import { DeepPartial, EventType, useForm } from "react-hook-form";
@@ -10,12 +10,13 @@
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
 import { Name } from "./node-name";
 import { NodeDetailsProps } from "@/lib/types";
+import { VolumeNode } from "config";
 
 export function NodeVolume(node: VolumeNode) {
 	const { id, data, selected } = node;
 	const isConnectable = useMemo(() => nodeIsConnectable(node, "volume"), [node]);
 	return (
-		<NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
+		<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
 			<div style={{ padding: "10px 20px" }}>
 				<div>{nodeLabel(node)}</div>
 				<div>{data.type && `${data.type}`}</div>
diff --git a/apps/canvas/front/src/components/resources.tsx b/apps/canvas/front/src/components/resources.tsx
index 1fd631a..3a5b334 100644
--- a/apps/canvas/front/src/components/resources.tsx
+++ b/apps/canvas/front/src/components/resources.tsx
@@ -3,9 +3,10 @@
 import { useCallback, useState } from "react";
 import { Accordion, AccordionTrigger } from "./ui/accordion";
 import { AccordionContent, AccordionItem } from "@radix-ui/react-accordion";
-import { AppState, NodeType, useCategories, useMode, useProjectId, useStateStore } from "@/lib/state";
+import { AppState, useCategories, useMode, useProjectId, useStateStore } from "@/lib/state";
 import { CategoryItem } from "@/lib/categories";
 import { Icon } from "./icon";
+import { AppNode, NodeType } from "config";
 
 function addResource(i: CategoryItem<NodeType>, store: AppState) {
 	const deselected = store.nodes.map((n) => ({
@@ -51,7 +52,7 @@
 										style={{ justifyContent: "flex-start" }}
 										disabled={projectId == null || mode !== "edit"}
 									>
-										<Icon type={item.type} />
+										<Icon node={{ type: item.type, data: item.init } as AppNode} />
 										{item.title}
 									</Button>
 								))}