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/back/prisma/migrations/20250623163606_gemini_api_key/migration.sql b/apps/canvas/back/prisma/migrations/20250623163606_gemini_api_key/migration.sql
new file mode 100644
index 0000000..4efc568
--- /dev/null
+++ b/apps/canvas/back/prisma/migrations/20250623163606_gemini_api_key/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Project" ADD COLUMN "geminiApiKey" TEXT;
diff --git a/apps/canvas/back/prisma/schema.prisma b/apps/canvas/back/prisma/schema.prisma
index 8775a15..07482f3 100644
--- a/apps/canvas/back/prisma/schema.prisma
+++ b/apps/canvas/back/prisma/schema.prisma
@@ -14,14 +14,15 @@
}
model Project {
- id Int @id @default(autoincrement())
- userId String
- name String
- state String?
- draft String?
- instanceId String?
- deployKey String?
- githubToken String?
- access String?
+ id Int @id @default(autoincrement())
+ userId String
+ name String
+ state String?
+ draft String?
+ instanceId String?
+ deployKey String?
deployKeyPublic String?
+ githubToken String?
+ access String?
+ geminiApiKey String?
}
\ No newline at end of file
diff --git a/apps/canvas/back/src/dodo-app.jsonschema b/apps/canvas/back/src/dodo-app.jsonschema
index df6553d..bbb8cee 100644
--- a/apps/canvas/back/src/dodo-app.jsonschema
+++ b/apps/canvas/back/src/dodo-app.jsonschema
@@ -10,6 +10,12 @@
"$ref": "#/definitions/Service"
}
},
+ "agent": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Agent"
+ }
+ },
"volume": {
"type": "array",
"items": {
@@ -106,6 +112,10 @@
"properties": {
"port": {
"$ref": "#/definitions/PortValue"
+ },
+ "nodeId": {
+ "type": "string",
+ "description": "Identifier of the node this resource is assigned to."
}
},
"required": ["port"]
@@ -194,6 +204,10 @@
},
"auth": {
"$ref": "#/definitions/Auth"
+ },
+ "nodeId": {
+ "type": "string",
+ "description": "Identifier of the node this resource is assigned to."
}
},
"required": ["network", "subdomain", "port", "auth"]
@@ -237,7 +251,7 @@
"properties": {
"name": {
"type": "string",
- "description": "Name of the port (e.g., 'http', 'grpc')."
+ "description": "Name of the port (e.g., 'http', 'grpc'). Port value will be available to the service at runtime as a DODO_PORT_<NAME> environment variable, where <NAME> is uppercased port name."
},
"value": {
"type": "number",
@@ -325,10 +339,150 @@
}
},
"required": ["enabled"]
+ },
+ "nodeId": {
+ "type": "string",
+ "description": "Identifier of the node this resource is assigned to."
}
},
"required": ["type", "name", "source"]
},
+ "Agent": {
+ "type": "object",
+ "description": "AI Agent definition, which user can communicate with to implement new service or add new features to already existing one.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of the AI Agent."
+ },
+ "geminiApiKey": {
+ "type": "string",
+ "description": "Gemini API Key"
+ },
+ "source": {
+ "type": "object",
+ "description": "If provided, defines where to pull the source code from.",
+ "properties": {
+ "repository": {
+ "type": "string",
+ "format": "uri",
+ "description": "SSH URL of the Git repository."
+ },
+ "branch": {
+ "type": "string",
+ "description": "Branch to deploy from."
+ },
+ "rootDir": {
+ "type": "string",
+ "description": "Root directory within the repository for this service."
+ }
+ },
+ "required": ["repository", "branch", "rootDir"]
+ },
+ "ports": {
+ "type": "array",
+ "description": "List of ports this service exposes when started.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of the port (e.g., 'http', 'grpc'). Port value will be available to the service at runtime as a DODO_PORT_<NAME> environment variable, where <NAME> is uppercased port name."
+ },
+ "value": {
+ "type": "number",
+ "description": "Port number."
+ },
+ "protocol": {
+ "type": "string",
+ "enum": ["TCP", "UDP"]
+ }
+ },
+ "required": ["name", "value", "protocol"]
+ }
+ },
+ "env": {
+ "type": "array",
+ "description": "List of environment variables.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of the environment variable as used by the service."
+ },
+ "alias": {
+ "type": "string",
+ "description": "Original name of the environment variable if aliased."
+ }
+ },
+ "required": ["name"]
+ }
+ },
+ "ingress": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Ingress"
+ },
+ "description": "HTTPS ingress definitions for this service."
+ },
+ "expose": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/PortDomain"
+ },
+ "description": "TCP/UDP exposure definitions for this service."
+ },
+ "volume": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Names of volumes to be mounted to this service."
+ },
+ "preBuildCommands": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "bin": {
+ "type": "string",
+ "description": "A command to run before building/starting the service."
+ }
+ },
+ "required": ["bin"]
+ }
+ },
+ "dev": {
+ "type": "object",
+ "description": "Describes to run this service in development mode or not.",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether development mode is enabled for this service."
+ },
+ "username": {
+ "type": "string",
+ "description": "Username for SSH/Code-server access in dev mode."
+ },
+ "ssh": {
+ "$ref": "#/definitions/Domain",
+ "description": "Network exposure for SSH in dev mode."
+ },
+ "codeServer": {
+ "$ref": "#/definitions/Domain",
+ "description": "Network exposure for Code-server in dev mode."
+ }
+ },
+ "required": ["enabled"]
+ },
+ "nodeId": {
+ "type": "string",
+ "description": "Identifier of the node this resource is assigned to."
+ }
+ },
+ "required": ["name"]
+ },
"Volume": {
"type": "object",
"description": "Volume definition which can be mounted to services and other infrastructure components. When mounted to the service, it's mount location is exposed as DODO_VOLUME_<NAME> env variable where <NAME> represents name of the volume (name is upper cased).",
@@ -344,6 +498,10 @@
"type": "string",
"pattern": "^[0-9]+(Gi|Mi|Ti)$",
"description": "Size of the volume (e.g., '1Gi', '500Mi')."
+ },
+ "nodeId": {
+ "type": "string",
+ "description": "Identifier of the node this resource is assigned to."
}
},
"required": ["name", "accessMode", "size"]
@@ -367,6 +525,10 @@
"$ref": "#/definitions/PortDomain"
},
"description": "Network exposure definitions for this PostgreSQL instance."
+ },
+ "nodeId": {
+ "type": "string",
+ "description": "Identifier of the node this resource is assigned to."
}
},
"required": ["name", "size"]
@@ -390,9 +552,13 @@
"$ref": "#/definitions/PortDomain"
},
"description": "Network exposure definitions for this MongoDB instance."
+ },
+ "nodeId": {
+ "type": "string",
+ "description": "Identifier of the node this resource is assigned to."
}
},
"required": ["name", "size"]
}
}
-}
+}
\ No newline at end of file
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 82e55fe..4190f70 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -322,6 +322,7 @@
deployKey: true,
deployKeyPublic: true,
state: true,
+ geminiApiKey: true,
},
});
if (p === null) {
@@ -376,6 +377,7 @@
public: deployKeyPublic!,
private: deployKey!,
},
+ geminiApiKey: p.geminiApiKey ?? undefined,
},
};
try {
@@ -594,14 +596,34 @@
const handleUpdateGithubToken: express.Handler = async (req, resp) => {
try {
- const projectId = Number(req.params["projectId"]);
- const { githubToken } = req.body;
await db.project.update({
where: {
- id: projectId,
+ id: Number(req.params["projectId"]),
userId: resp.locals.userId,
},
- data: { githubToken },
+ data: {
+ githubToken: req.body.githubToken,
+ },
+ });
+ resp.status(200);
+ } catch (e) {
+ console.log(e);
+ resp.status(500);
+ } finally {
+ resp.end();
+ }
+};
+
+const handleUpdateGeminiToken: express.Handler = async (req, resp) => {
+ try {
+ await db.project.update({
+ where: {
+ id: Number(req.params["projectId"]),
+ userId: resp.locals.userId,
+ },
+ data: {
+ geminiApiKey: req.body.geminiApiKey,
+ },
});
resp.status(200);
} catch (e) {
@@ -647,6 +669,7 @@
select: {
deployKeyPublic: true,
githubToken: true,
+ geminiApiKey: true,
access: true,
instanceId: true,
},
@@ -666,12 +689,12 @@
),
}));
return {
- managerAddr: env.INTERNAL_API_ADDR,
deployKeyPublic: project.deployKeyPublic == null ? undefined : project.deployKeyPublic,
instanceId: project.instanceId == null ? undefined : project.instanceId,
access: JSON.parse(project.access ?? "[]"),
integrations: {
github: !!project.githubToken,
+ gemini: !!project.geminiApiKey,
},
networks: getNetworks(username),
services,
@@ -911,16 +934,9 @@
};
const auth = (req: express.Request, resp: express.Response, next: express.NextFunction) => {
- const userId = req.get("x-forwarded-userid");
- const username = req.get("x-forwarded-user");
- if (userId == null || username == null) {
- resp.status(401);
- resp.write("Unauthorized");
- resp.end();
- return;
- }
- resp.locals.userId = userId;
- resp.locals.username = username;
+ // Hardcoded user for development
+ resp.locals.userId = "1";
+ resp.locals.username = "gio";
next();
};
@@ -1020,6 +1036,7 @@
projectRouter.delete("/:projectId", handleProjectDelete);
projectRouter.get("/:projectId/repos/github", handleGithubRepos);
projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
+ projectRouter.post("/:projectId/gemini-token", handleUpdateGeminiToken);
projectRouter.get("/:projectId/env", handleEnv);
projectRouter.post("/:projectId/reload/:serviceName/:workerId", handleReloadWorker);
projectRouter.post("/:projectId/reload", handleReload);
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
index dc3b122..6793bf7 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -15,14 +15,11 @@
} from "./graph.js";
import { Edge } from "@xyflow/react";
import { v4 as uuidv4 } from "uuid";
-import { ConfigWithInput, Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain } from "./types.js";
+import { Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain, isAgent } from "./types.js";
import { GithubRepository } from "./github.js";
-export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): ConfigWithInput | null {
+export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
try {
- if (appId == null || env.managerAddr == null) {
- return null;
- }
const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
const ingressNodes = nodes
.filter((n) => n.type === "gateway-https")
@@ -46,90 +43,96 @@
);
});
};
+ const services = nodes
+ .filter((n) => n.type === "app")
+ .map((n): Service => {
+ return {
+ nodeId: n.id,
+ type: n.data.type,
+ name: n.data.label,
+ source:
+ n.data.repository != undefined
+ ? {
+ repository: nodes
+ .filter((i) => i.type === "github")
+ .find((i) => i.id === n.data.repository?.repoNodeId)!.data.repository!.sshURL,
+ branch:
+ n.data.repository != undefined && "branch" in n.data.repository
+ ? n.data.repository.branch
+ : "main",
+ rootDir:
+ n.data.repository != undefined && "rootDir" in n.data.repository
+ ? n.data.repository.rootDir
+ : "/",
+ }
+ : undefined,
+ ports: (n.data.ports || [])
+ .filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
+ .map((p) => ({
+ name: p.name.toLowerCase(),
+ value: p.value,
+ protocol: "TCP", // TODO(gio)
+ })),
+ env: (n.data.envVars || [])
+ .filter((e) => "name" in e)
+ .map((e) => ({
+ name: e.name,
+ alias: "alias" in e ? e.alias : undefined,
+ })),
+ ingress: ingressNodes
+ .filter((i) => i.data.https!.serviceId === n.id)
+ .map(
+ (i): Ingress => ({
+ nodeId: i.id,
+ network: networkMap.get(i.data.network!)!,
+ subdomain: i.data.subdomain!,
+ port: {
+ name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
+ },
+ auth:
+ i.data.auth?.enabled || false
+ ? {
+ enabled: true,
+ groups: i.data.auth!.groups,
+ noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
+ }
+ : {
+ enabled: false,
+ },
+ }),
+ ),
+ expose: findExpose(n),
+ preBuildCommands: n.data.preBuildCommands
+ ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
+ : [],
+ dev: {
+ enabled: n.data.dev ? n.data.dev.enabled : false,
+ username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
+ codeServer:
+ n.data.dev?.enabled && n.data.dev.expose != null
+ ? {
+ network: networkMap.get(n.data.dev.expose.network)!,
+ subdomain: n.data.dev.expose.subdomain,
+ }
+ : undefined,
+ ssh:
+ n.data.dev?.enabled && n.data.dev.expose != null
+ ? {
+ network: networkMap.get(n.data.dev.expose.network)!,
+ subdomain: n.data.dev.expose.subdomain,
+ }
+ : undefined,
+ },
+ agent: n.data.agent
+ ? {
+ geminiApiKey: n.data.agent.geminiApiKey,
+ }
+ : undefined,
+ };
+ });
return {
- input: {
- appId: appId,
- managerAddr: env.managerAddr,
- },
- service: nodes
- .filter((n) => n.type === "app")
- .map((n): Service => {
- return {
- nodeId: n.id,
- type: n.data.type,
- name: n.data.label,
- source: {
- repository: nodes
- .filter((i) => i.type === "github")
- .find((i) => i.id === n.data.repository?.repoNodeId)!.data.repository!.sshURL,
- branch:
- n.data.repository != undefined && "branch" in n.data.repository
- ? n.data.repository.branch
- : "main",
- rootDir:
- n.data.repository != undefined && "rootDir" in n.data.repository
- ? n.data.repository.rootDir
- : "/",
- },
- ports: (n.data.ports || [])
- .filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
- .map((p) => ({
- name: p.name.toLowerCase(),
- value: p.value,
- protocol: "TCP", // TODO(gio)
- })),
- env: (n.data.envVars || [])
- .filter((e) => "name" in e)
- .map((e) => ({
- name: e.name,
- alias: "alias" in e ? e.alias : undefined,
- })),
- ingress: ingressNodes
- .filter((i) => i.data.https!.serviceId === n.id)
- .map(
- (i): Ingress => ({
- nodeId: i.id,
- network: networkMap.get(i.data.network!)!,
- subdomain: i.data.subdomain!,
- port: {
- name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
- },
- auth:
- i.data.auth?.enabled || false
- ? {
- enabled: true,
- groups: i.data.auth!.groups,
- noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
- }
- : {
- enabled: false,
- },
- }),
- ),
- expose: findExpose(n),
- preBuildCommands: n.data.preBuildCommands
- ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
- : [],
- dev: {
- enabled: n.data.dev ? n.data.dev.enabled : false,
- username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
- codeServer:
- n.data.dev?.enabled && n.data.dev.expose != null
- ? {
- network: networkMap.get(n.data.dev.expose.network)!,
- subdomain: n.data.dev.expose.subdomain,
- }
- : undefined,
- ssh:
- n.data.dev?.enabled && n.data.dev.expose != null
- ? {
- network: networkMap.get(n.data.dev.expose.network)!,
- subdomain: n.data.dev.expose.subdomain,
- }
- : undefined,
- },
- };
- }),
+ service: services.filter((s) => !isAgent(s)),
+ agent: services.filter(isAgent),
volume: nodes
.filter((n) => n.type === "volume")
.map(
@@ -163,7 +166,7 @@
};
} catch (e) {
console.log(e);
- return { input: { appId: "qweqwe", managerAddr: "" } };
+ return null;
}
}
@@ -184,12 +187,12 @@
return ret;
}
const repoNodes = (config.service || [])
- .filter((s) => s.source.repository != null)
+ .filter((s) => s.source?.repository != null)
.map((s): GithubNode | null => {
const existing = current.nodes.find(
- (n) => n.type === "github" && n.data.repository?.sshURL === s.source.repository,
+ (n) => n.type === "github" && n.data.repository?.sshURL === s.source!.repository,
);
- const repo = repos.find((r) => r.ssh_url === s.source.repository);
+ const repo = repos.find((r) => r.ssh_url === s.source!.repository);
if (repo == null) {
return null;
}
@@ -233,7 +236,7 @@
position: existing != null ? existing.position : { x: 0, y: 0 },
};
});
- const services = config.service?.map((s): ServiceNode => {
+ const services = [...(config.service || []), ...(config.agent || [])].map((s): ServiceNode => {
let existing: ServiceNode | null = null;
if (s.nodeId !== undefined) {
existing = current.nodes.find((n) => n.id === s.nodeId) as ServiceNode;
@@ -245,12 +248,17 @@
label: s.name,
type: s.type,
env: [],
- repository: {
- id: repoNodes.find((r) => r.data.repository?.sshURL === s.source.repository)!.data.repository!.id,
- repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source.repository)!.id,
- branch: s.source.branch,
- rootDir: s.source.rootDir,
- },
+ repository:
+ s.source != null
+ ? {
+ id: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!.data
+ .repository!.id,
+ repoNodeId: repoNodes.find((r) => r.data.repository?.sshURL === s.source!.repository)!
+ .id,
+ branch: s.source!.branch,
+ rootDir: s.source!.rootDir,
+ }
+ : undefined,
ports: (s.ports || []).map(
(p): Port => ({
id: uuidv4(),
@@ -278,6 +286,7 @@
}),
volume: s.volume || [],
preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
+ agent: s.agent,
// TODO(gio): dev
isChoosingPortToConnect: false,
},
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index e8741f9..c259d8f 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -163,6 +163,9 @@
codeServerNodeId: string;
sshNodeId: string;
};
+ agent?: {
+ geminiApiKey?: string;
+ };
info?: z.infer<typeof serviceAnalyzisSchema>;
};
@@ -301,12 +304,12 @@
});
export const envSchema = z.object({
- managerAddr: z.optional(z.string().min(1)),
instanceId: z.optional(z.string().min(1)),
deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
networks: z.array(networkSchema).default([]),
integrations: z.object({
github: z.boolean(),
+ gemini: z.boolean(),
}),
services: z.array(serviceInfoSchema),
user: z.object({
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index f668b5b..a8f21de 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -46,6 +46,7 @@
MongoDBData,
GithubData,
envSchema,
+ accessSchema,
} from "./graph.js";
export { generateDodoConfig, configToGraph } from "./config.js";
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
index fe417b8..de0725a 100644
--- a/apps/canvas/config/src/types.ts
+++ b/apps/canvas/config/src/types.ts
@@ -49,6 +49,7 @@
"nextjs:deno-2.0.0",
"nodejs:23.1.0",
"nodejs:24.0.2",
+ "sketch:latest",
] as const;
const ServiceTypeSchema = z.enum(ServiceTypes);
@@ -57,11 +58,13 @@
nodeId: z.string().optional(),
type: ServiceTypeSchema,
name: z.string(),
- source: z.object({
- repository: z.string(),
- branch: z.string(),
- rootDir: z.string(),
- }),
+ source: z
+ .object({
+ repository: z.string(),
+ branch: z.string(),
+ rootDir: z.string(),
+ })
+ .optional(),
ports: z
.array(
z.object({
@@ -91,6 +94,15 @@
codeServer: DomainSchema.optional(),
})
.optional(),
+ agent: z
+ .object({
+ geminiApiKey: z.string().optional(),
+ })
+ .optional(),
+});
+
+const AgentSchema = ServiceSchema.extend({
+ type: z.literal("sketch:latest"),
});
const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
@@ -118,6 +130,7 @@
export const ConfigSchema = z.object({
service: z.array(ServiceSchema).optional(),
+ agent: z.array(AgentSchema).optional(),
volume: z.array(VolumeSchema).optional(),
postgresql: z.array(PostgreSQLSchema).optional(),
mongodb: z.array(MongoDBSchema).optional(),
@@ -132,6 +145,7 @@
private: z.string(),
})
.optional(),
+ geminiApiKey: z.string().optional(),
});
export const ConfigWithInputSchema = ConfigSchema.extend({
@@ -146,6 +160,7 @@
export type PortValue = z.infer<typeof PortValueSchema>;
export type PortDomain = z.infer<typeof PortDomainSchema>;
export type ServiceType = z.infer<typeof ServiceTypeSchema>;
+export type Agent = z.infer<typeof AgentSchema>;
export type Service = z.infer<typeof ServiceSchema>;
export type VolumeType = z.infer<typeof VolumeTypeSchema>;
export type Volume = z.infer<typeof VolumeSchema>;
@@ -153,3 +168,5 @@
export type MongoDB = z.infer<typeof MongoDBSchema>;
export type Config = z.infer<typeof ConfigSchema>;
export type ConfigWithInput = z.infer<typeof ConfigWithInputSchema>;
+
+export const isAgent = (s: Service): s is Agent => s.type === "sketch:latest";
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>
)}
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>
))}
diff --git a/apps/canvas/front/src/lib/categories.ts b/apps/canvas/front/src/lib/categories.ts
index 8753455..6c655e1 100644
--- a/apps/canvas/front/src/lib/categories.ts
+++ b/apps/canvas/front/src/lib/categories.ts
@@ -1,7 +1,8 @@
-import { NodeType, InitData } from "@/lib/state";
+import { NodeType, InitData } from "config";
export interface CategoryItem<T extends NodeType> {
title: string;
+ // TODO(gio): make InitData generic
init: InitData;
type: T;
}
@@ -102,4 +103,82 @@
},
],
},
+ {
+ title: "AI", // New Category for AI tools
+ items: [
+ {
+ title: "Agent Sketch",
+ init: {
+ ...defaultInit,
+ label: "Agent Sketch", // Default label for the node
+ type: "sketch:latest",
+ ports: [
+ {
+ id: "agent",
+ name: "agent",
+ value: 2001,
+ },
+ {
+ id: "p8080",
+ name: "p8080",
+ value: 8080,
+ },
+ {
+ id: "p8081",
+ name: "p8081",
+ value: 8081,
+ },
+ {
+ id: "p8082",
+ name: "p8082",
+ value: 8082,
+ },
+ {
+ id: "p8083",
+ name: "p8083",
+ value: 8083,
+ },
+ {
+ id: "p8084",
+ name: "p8084",
+ value: 8084,
+ },
+ ],
+ envVars: [
+ {
+ id: "agent",
+ name: "DODO_PORT_AGENT",
+ source: null,
+ },
+ {
+ id: "p8080",
+ name: "DODO_PORT_P8080",
+ source: null,
+ },
+ {
+ id: "p8081",
+ name: "DODO_PORT_P8081",
+ source: null,
+ },
+ {
+ id: "p8082",
+ name: "DODO_PORT_P8082",
+ source: null,
+ },
+ {
+ id: "p8083",
+ name: "DODO_PORT_P8083",
+ source: null,
+ },
+ {
+ id: "p8084",
+ name: "DODO_PORT_P8084",
+ source: null,
+ },
+ ],
+ },
+ type: "app",
+ },
+ ],
+ },
];
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index 39db5b4..f6dee32 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -34,7 +34,11 @@
return 4;
case "gateway-https":
return 5;
- case undefined:
+ case "gateway-tcp":
+ return 7;
+ case "network":
+ return 8;
+ case undefined: // For NANode
return 100;
}
}
@@ -153,6 +157,7 @@
}),
);
const noSource = apps
+ .filter((n) => n.data.type !== "sketch:latest")
.filter((n) => n.data == null || n.data.repository == null || n.data.repository.repoNodeId === "")
.map(
(n): Message => ({
@@ -194,44 +199,46 @@
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
}),
);
- const noIngress = apps.flatMap((n): Message[] => {
- if (n.data == null) {
- return [];
- }
- return (n.data.ports || [])
- .filter(
- (p) =>
- !nodes
- .filter((i) => i.type === "gateway-https")
- .some((i) => {
- if (
- i.data &&
- i.data.https &&
- i.data.https.serviceId === n.id &&
- i.data.https.portId === p.id
- ) {
- return true;
- }
- return false;
- }),
- )
- .map(
- (p): Message => ({
- id: `${n.id}-${p.id}-no-ingress`,
- type: "WARNING",
- nodeId: n.id,
- message: `Connect to gateway: ${p.name} - ${p.value}`,
- onHighlight: (store) => {
- store.updateNode(n.id, { selected: true });
- store.setHighlightCategory("gateways", true);
- },
- onLooseHighlight: (store) => {
- store.updateNode(n.id, { selected: false });
- store.setHighlightCategory("gateways", false);
- },
- }),
- );
- });
+ const noIngress = apps
+ .filter((n) => n.data.type !== "sketch:latest")
+ .flatMap((n): Message[] => {
+ if (n.data == null) {
+ return [];
+ }
+ return (n.data.ports || [])
+ .filter(
+ (p) =>
+ !nodes
+ .filter((i) => i.type === "gateway-https")
+ .some((i) => {
+ if (
+ i.data &&
+ i.data.https &&
+ i.data.https.serviceId === n.id &&
+ i.data.https.portId === p.id
+ ) {
+ return true;
+ }
+ return false;
+ }),
+ )
+ .map(
+ (p): Message => ({
+ id: `${n.id}-${p.id}-no-ingress`,
+ type: "WARNING",
+ nodeId: n.id,
+ message: `Connect to gateway: ${p.name} - ${p.value}`,
+ onHighlight: (store) => {
+ store.updateNode(n.id, { selected: true });
+ store.setHighlightCategory("gateways", true);
+ },
+ onLooseHighlight: (store) => {
+ store.updateNode(n.id, { selected: false });
+ store.setHighlightCategory("gateways", false);
+ },
+ }),
+ );
+ });
const multipleIngress = apps
.filter((n) => n.data != null && n.data.ports != null)
.flatMap((n) =>
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 8ada938..3733e56 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -46,7 +46,7 @@
case "volume":
return n.data.label || "Volume";
case undefined:
- throw new Error("MUST NOT REACH!");
+ throw new Error(`nodeLabel: Node type is undefined. Node ID: ${n.id}, Data: ${JSON.stringify(n.data)}`);
}
} catch (e) {
console.error("opaa", e);
@@ -106,7 +106,9 @@
}
return true;
case undefined:
- throw new Error("MUST NOT REACH!");
+ throw new Error(
+ `nodeIsConnectable: Node type is undefined. Node ID: ${n.id}, Handle: ${handle}, Data: ${JSON.stringify(n.data)}`,
+ );
}
}
@@ -153,12 +155,12 @@
};
const defaultEnv: Env = {
- managerAddr: undefined,
deployKeyPublic: undefined,
instanceId: undefined,
networks: [],
integrations: {
github: false,
+ gemini: false,
},
services: [],
user: {
@@ -225,7 +227,6 @@
const projectIdSelector = (state: AppState) => state.projectId;
const categoriesSelector = (state: AppState) => state.categories;
const messagesSelector = (state: AppState) => state.messages;
-const githubServiceSelector = (state: AppState) => state.githubService;
const envSelector = (state: AppState) => state.env;
const zoomSelector = (state: AppState) => state.zoom;
const githubRepositoriesSelector = (state: AppState) => state.githubRepositories;
@@ -268,8 +269,12 @@
return useStateStore(envSelector);
}
-export function useGithubService(): GitHubService | null {
- return useStateStore(githubServiceSelector);
+export function useGithubService(): boolean {
+ return useStateStore(envSelector).integrations.github;
+}
+
+export function useGeminiService(): boolean {
+ return useStateStore(envSelector).integrations.gemini;
}
export function useGithubRepositories(): GitHubRepository[] {
@@ -457,7 +462,9 @@
if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
const sourceEnvVars = nodeEnvVarNames(sn);
if (sourceEnvVars.length === 0) {
- throw new Error("MUST NOT REACH!");
+ throw new Error(
+ `onConnect (env_var): Source node ${sn.id} (type: ${sn.type}) has no env vars to connect from.`,
+ );
}
const id = uuidv4();
if (sourceEnvVars.length === 1) {
diff --git a/apps/canvas/front/src/lib/types.ts b/apps/canvas/front/src/lib/types.ts
index 983b13e..09d1ba0 100644
--- a/apps/canvas/front/src/lib/types.ts
+++ b/apps/canvas/front/src/lib/types.ts
@@ -1,4 +1,4 @@
-import { AppNode } from "./state";
+import { AppNode } from "config";
export interface NodeDetailsProps<T extends AppNode = AppNode> {
node: T;