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>
))}