Canvas: Support Anthropic Claude based AI agents
Change-Id: Ib74c9672da9a80a4f20d63741a471c728a435b8e
diff --git a/apps/canvas/back/prisma/migrations/20250703041848_anthropic_api_key/migration.sql b/apps/canvas/back/prisma/migrations/20250703041848_anthropic_api_key/migration.sql
new file mode 100644
index 0000000..d42d450
--- /dev/null
+++ b/apps/canvas/back/prisma/migrations/20250703041848_anthropic_api_key/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Project" ADD COLUMN "anthropicApiKey" TEXT;
diff --git a/apps/canvas/back/prisma/schema.prisma b/apps/canvas/back/prisma/schema.prisma
index adc03cf..cee6787 100644
--- a/apps/canvas/back/prisma/schema.prisma
+++ b/apps/canvas/back/prisma/schema.prisma
@@ -24,7 +24,8 @@
deployKeyPublic String?
githubToken String?
access String?
- geminiApiKey String?
+ geminiApiKey String?
+ anthropicApiKey String?
logs Log[]
}
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index cd18249..b33cbbc 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -326,6 +326,7 @@
deployKeyPublic: true,
state: true,
geminiApiKey: true,
+ anthropicApiKey: true,
},
});
if (p === null) {
@@ -381,6 +382,7 @@
private: deployKey!,
},
geminiApiKey: p.geminiApiKey ?? undefined,
+ anthropicApiKey: p.anthropicApiKey ?? undefined,
},
};
try {
@@ -637,6 +639,26 @@
}
};
+const handleUpdateAnthropicToken: express.Handler = async (req, resp) => {
+ try {
+ await db.project.update({
+ where: {
+ id: Number(req.params["projectId"]),
+ userId: resp.locals.userId,
+ },
+ data: {
+ anthropicApiKey: req.body.anthropicApiKey,
+ },
+ });
+ resp.status(200);
+ } catch (e) {
+ console.log(e);
+ resp.status(500);
+ } finally {
+ resp.end();
+ }
+};
+
const getNetworks = (username?: string | undefined): Network[] => {
return [
{
@@ -673,6 +695,7 @@
deployKeyPublic: true,
githubToken: true,
geminiApiKey: true,
+ anthropicApiKey: true,
access: true,
instanceId: true,
},
@@ -698,6 +721,7 @@
integrations: {
github: !!project.githubToken,
gemini: !!project.geminiApiKey,
+ anthropic: !!project.anthropicApiKey,
},
networks: getNetworks(username),
services,
@@ -1074,6 +1098,7 @@
projectRouter.get("/:projectId/repos/github", handleGithubRepos);
projectRouter.post("/:projectId/github-token", handleUpdateGithubToken);
projectRouter.post("/:projectId/gemini-token", handleUpdateGeminiToken);
+ projectRouter.post("/:projectId/anthropic-token", handleUpdateAnthropicToken);
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 1deeb7a..9568c7e 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -127,13 +127,18 @@
: {
enabled: false,
},
- ...(n.data.agent != null
- ? {
- agent: {
- geminiApiKey: n.data.agent.geminiApiKey,
- },
- }
- : {}),
+ ...(n.data.model?.name === "gemini" && {
+ model: {
+ name: "gemini",
+ geminiApiKey: n.data.model.apiKey,
+ },
+ }),
+ ...(n.data.model?.name === "claude" && {
+ model: {
+ name: "claude",
+ anthropicApiKey: n.data.model.apiKey,
+ },
+ }),
};
});
return {
@@ -192,7 +197,7 @@
if (networks.length === 0) {
return ret;
}
- const repoNodes = (config.service || [])
+ const repoNodes = [...(config.service || []), ...(config.agent || [])]
.filter((s) => s.source?.repository != null)
.map((s): GithubNode | null => {
const existing = current.nodes.find(
@@ -292,7 +297,12 @@
}),
volume: s.volume || [],
preBuildCommands: s.preBuildCommands?.map((p) => p.bin).join("\n") || "",
- agent: s.agent,
+ ...(s.model != null && {
+ model:
+ s.model.name === "gemini"
+ ? { name: "gemini", apiKey: s.model.geminiApiKey }
+ : { name: "claude", apiKey: s.model.anthropicApiKey },
+ }),
// TODO(gio): dev
isChoosingPortToConnect: false,
},
@@ -306,54 +316,55 @@
},
};
});
- const serviceGateways = config.service?.flatMap((s, index): GatewayHttpsNode[] => {
- return (s.ingress || []).map((i): GatewayHttpsNode => {
- let existing: GatewayHttpsNode | null = null;
- if (i.nodeId !== undefined) {
- existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
- }
- console.log("!!!", i.network, networks);
- return {
- id: existing != null ? existing.id : uuidv4(),
- type: "gateway-https",
- data: {
- label: i.subdomain,
- envVars: [],
- ports: [],
- network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
- subdomain: i.subdomain,
- https: {
- serviceId: services![index]!.id,
- portId: services![index]!.data.ports.find((p) => {
- const port = i.port;
- if ("name" in port) {
- return p.name === port.name;
- } else {
- return `${p.value}` === port.value;
- }
- })!.id,
+ const serviceGateways = [...(config.service || []), ...(config.agent || [])]?.flatMap(
+ (s, index): GatewayHttpsNode[] => {
+ return (s.ingress || []).map((i): GatewayHttpsNode => {
+ let existing: GatewayHttpsNode | null = null;
+ if (i.nodeId !== undefined) {
+ existing = current.nodes.find((n) => n.id === i.nodeId) as GatewayHttpsNode;
+ }
+ return {
+ id: existing != null ? existing.id : uuidv4(),
+ type: "gateway-https",
+ data: {
+ label: i.subdomain,
+ envVars: [],
+ ports: [],
+ network: networks.find((n) => n.name.toLowerCase() === i.network.toLowerCase())!.domain,
+ subdomain: i.subdomain,
+ https: {
+ serviceId: services![index]!.id,
+ portId: services![index]!.data.ports.find((p) => {
+ const port = i.port;
+ if ("name" in port) {
+ return p.name === port.name;
+ } else {
+ return `${p.value}` === port.value;
+ }
+ })!.id,
+ },
+ auth: i.auth.enabled
+ ? {
+ enabled: true,
+ groups: i.auth.groups || [],
+ noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
+ }
+ : {
+ enabled: false,
+ groups: [],
+ noAuthPathPatterns: [],
+ },
},
- auth: i.auth.enabled
- ? {
- enabled: true,
- groups: i.auth.groups || [],
- noAuthPathPatterns: i.auth.noAuthPathPatterns || [],
- }
- : {
- enabled: false,
- groups: [],
- noAuthPathPatterns: [],
- },
- },
- position: {
- x: 0,
- y: 0,
- },
- };
- });
- });
+ position: {
+ x: 0,
+ y: 0,
+ },
+ };
+ });
+ },
+ );
const exposures = new Map<string, GatewayTCPNode>();
- config.service
+ [...(config.service || []), ...(config.agent || [])]
?.flatMap((s, index): GatewayTCPNode[] => {
return (s.expose || []).map((e): GatewayTCPNode => {
let existing: GatewayTCPNode | null = null;
@@ -649,15 +660,20 @@
},
];
});
- const repoEdges = (services || []).map((s): Edge => {
- return {
- id: uuidv4(),
- source: s.data.repository!.repoNodeId!,
- sourceHandle: "repository",
- target: s.id,
- targetHandle: "repository",
- };
- });
+ const repoEdges = (services || [])
+ .map((s): Edge | null => {
+ if (s.data.repository == null) {
+ return null;
+ }
+ return {
+ id: uuidv4(),
+ source: s.data.repository!.repoNodeId!,
+ sourceHandle: "repository",
+ target: s.id,
+ targetHandle: "repository",
+ };
+ })
+ .filter((e) => e != null);
ret.edges = [...repoEdges, ...envVarEdges, ...exposureEdges, ...ingressEdges];
return ret;
}
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index 604bcc4..90523d5 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -163,8 +163,9 @@
codeServerNodeId: string;
sshNodeId: string;
};
- agent?: {
- geminiApiKey?: string;
+ model?: {
+ name: "gemini" | "claude";
+ apiKey?: string;
};
info?: z.infer<typeof serviceAnalyzisSchema>;
};
@@ -306,12 +307,19 @@
});
export const envSchema = z.object({
- instanceId: z.optional(z.string().min(1)),
- deployKeyPublic: z.optional(z.nullable(z.string().min(1))),
- networks: z.array(networkSchema).default([]),
+ deployKeyPublic: z.string().optional(),
+ instanceId: z.string().optional(),
+ networks: z.array(
+ z.object({
+ name: z.string(),
+ domain: z.string(),
+ hasAuth: z.boolean(),
+ }),
+ ),
integrations: z.object({
github: z.boolean(),
gemini: z.boolean(),
+ anthropic: z.boolean(),
}),
services: z.array(serviceInfoSchema),
user: z.object({
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
index de0725a..e186f4b 100644
--- a/apps/canvas/config/src/types.ts
+++ b/apps/canvas/config/src/types.ts
@@ -54,6 +54,17 @@
const ServiceTypeSchema = z.enum(ServiceTypes);
+const ModelSchema = z.discriminatedUnion("name", [
+ z.object({
+ name: z.literal("claude"),
+ anthropicApiKey: z.string().optional(),
+ }),
+ z.object({
+ name: z.literal("gemini"),
+ geminiApiKey: z.string().optional(),
+ }),
+]);
+
const ServiceSchema = z.object({
nodeId: z.string().optional(),
type: ServiceTypeSchema,
@@ -94,15 +105,12 @@
codeServer: DomainSchema.optional(),
})
.optional(),
- agent: z
- .object({
- geminiApiKey: z.string().optional(),
- })
- .optional(),
+ model: ModelSchema.optional(),
});
const AgentSchema = ServiceSchema.extend({
type: z.literal("sketch:latest"),
+ model: ModelSchema,
});
const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
@@ -146,6 +154,7 @@
})
.optional(),
geminiApiKey: z.string().optional(),
+ anthropicApiKey: z.string().optional(),
});
export const ConfigWithInputSchema = ConfigSchema.extend({
diff --git a/apps/canvas/front/src/Integrations.tsx b/apps/canvas/front/src/Integrations.tsx
index cf4b586..09bc28d 100644
--- a/apps/canvas/front/src/Integrations.tsx
+++ b/apps/canvas/front/src/Integrations.tsx
@@ -1,4 +1,4 @@
-import { useProjectId, useGithubService, useStateStore, useGeminiService } from "@/lib/state";
+import { useProjectId, useGithubService, useStateStore, useGeminiService, useAnthropicService } 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";
@@ -17,14 +17,20 @@
geminiApiKey: z.string().min(1, "Gemini API token is required"),
});
+const anthropicSchema = z.object({
+ anthropicApiKey: z.string().min(1, "Anthropic API token is required"),
+});
+
export function Integrations() {
const { toast } = useToast();
const store = useStateStore();
const projectId = useProjectId();
const [isEditingGithub, setIsEditingGithub] = useState(false);
const [isEditingGemini, setIsEditingGemini] = useState(false);
+ const [isEditingAnthropic, setIsEditingAnthropic] = useState(false);
const githubService = useGithubService();
const geminiService = useGeminiService();
+ const anthropicService = useAnthropicService();
const [isSaving, setIsSaving] = useState(false);
const githubForm = useForm<z.infer<typeof githubSchema>>({
@@ -37,6 +43,11 @@
mode: "onChange",
});
+ const anthropicForm = useForm<z.infer<typeof anthropicSchema>>({
+ resolver: zodResolver(anthropicSchema),
+ mode: "onChange",
+ });
+
const onGithubSubmit = useCallback(
async (data: z.infer<typeof githubSchema>) => {
if (!projectId) return;
@@ -109,6 +120,40 @@
[projectId, store, geminiForm, toast, setIsEditingGemini, setIsSaving],
);
+ const onAnthropicSubmit = useCallback(
+ async (data: z.infer<typeof anthropicSchema>) => {
+ if (!projectId) return;
+ setIsSaving(true);
+ try {
+ const response = await fetch(`/api/project/${projectId}/anthropic-token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ anthropicApiKey: data.anthropicApiKey }),
+ });
+ if (!response.ok) {
+ throw new Error("Failed to save Anthropic token");
+ }
+ await store.refreshEnv();
+ setIsEditingAnthropic(false);
+ anthropicForm.reset();
+ toast({
+ title: "Anthropic token saved successfully",
+ });
+ } catch (error) {
+ toast({
+ variant: "destructive",
+ title: "Failed to save Anthropic token",
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ } finally {
+ setIsSaving(false);
+ }
+ },
+ [projectId, store, anthropicForm, toast, setIsEditingAnthropic, setIsSaving],
+ );
+
const handleCancelGithub = () => {
setIsEditingGithub(false);
githubForm.reset();
@@ -119,6 +164,11 @@
geminiForm.reset();
};
+ const handleCancelAnthropic = () => {
+ setIsEditingAnthropic(false);
+ anthropicForm.reset();
+ };
+
return (
<div className="px-4 py-1">
<div className="flex flex-col gap-1">
@@ -261,6 +311,67 @@
</Form>
)}
</div>
+ <div className="flex flex-col gap-1">
+ <div className="flex flex-row items-center gap-1">
+ {anthropicService ? <CircleCheck /> : <CircleX />}
+ <div>Anthropic</div>
+ </div>
+
+ {!!anthropicService && !isEditingAnthropic && (
+ <Button variant="outline" className="w-fit" onClick={() => setIsEditingAnthropic(true)}>
+ Update API Key
+ </Button>
+ )}
+
+ {(!anthropicService || isEditingAnthropic) && (
+ <div className="flex flex-row items-center gap-1 text-sm">
+ <div>
+ Follow the link to generate new API Key:{" "}
+ <a href="https://console.anthropic.com/settings/keys" target="_blank">
+ https://console.anthropic.com/settings/keys
+ </a>
+ </div>
+ </div>
+ )}
+ {(!anthropicService || isEditingAnthropic) && (
+ <Form {...anthropicForm}>
+ <form className="space-y-2" onSubmit={anthropicForm.handleSubmit(onAnthropicSubmit)}>
+ <FormField
+ control={anthropicForm.control}
+ name="anthropicApiKey"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ type="password"
+ placeholder="Anthropic 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>
+ {!!anthropicService && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancelAnthropic}
+ disabled={isSaving}
+ >
+ Cancel
+ </Button>
+ )}
+ </div>
+ </form>
+ </Form>
+ )}
+ </div>
</div>
);
}
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index fa08977..a01709f 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -6,7 +6,7 @@
import { z } from "zod";
import { useForm, EventType, DeepPartial } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
-import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
+import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from "./ui/form";
import { Button } from "./ui/button";
import { Handle, Position, useNodes } from "@xyflow/react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
@@ -80,7 +80,8 @@
});
const agentSchema = z.object({
- geminiApiKey: z.string().optional(),
+ model: z.enum(["gemini", "claude"]),
+ apiKey: z.string().optional(),
});
export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
@@ -253,19 +254,34 @@
resolver: zodResolver(agentSchema),
mode: "onChange",
defaultValues: {
- geminiApiKey: data.agent?.geminiApiKey,
+ apiKey: data.model?.apiKey,
+ model: data.model?.name,
},
});
useEffect(() => {
- const sub = agentForm.watch((value) => {
- store.updateNodeData<"app">(id, {
- agent: {
- geminiApiKey: value.geminiApiKey,
- },
- });
+ const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => {
+ switch (name) {
+ case "model":
+ agentForm.setValue("apiKey", "", { shouldDirty: true });
+ store.updateNodeData<"app">(id, {
+ model: {
+ name: value.model,
+ apiKey: undefined,
+ },
+ });
+ break;
+ case "apiKey":
+ store.updateNodeData<"app">(id, {
+ model: {
+ name: data.model?.name,
+ apiKey: value.apiKey,
+ },
+ });
+ break;
+ }
});
return () => sub.unsubscribe();
- }, [id, agentForm, store]);
+ }, [id, agentForm, store, data]);
return (
<>
<SourceRepo node={node} disabled={disabled} />
@@ -307,16 +323,41 @@
{node.data.type === "sketch:latest" && (
<Form {...agentForm}>
<form className="space-y-2">
- <Label>Gemini API Key</Label>
<FormField
control={agentForm.control}
- name="geminiApiKey"
+ name="model"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>AI Model</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={disabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select a model" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="gemini">Gemini</SelectItem>
+ <SelectItem value="claude">Claude</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <Label>API Key</Label>
+ <FormField
+ control={agentForm.control}
+ name="apiKey"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="password"
- placeholder="Override Gemini API key"
+ placeholder="Override AI Model API key"
{...field}
value={field.value || ""}
disabled={disabled}
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index e2ad19d..1736916 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -396,11 +396,38 @@
function AgentApiKeyValidator(nodes: AppNode[], env: Env): Message[] {
return nodes
.filter((n): n is ServiceNode => n.type === "app" && n.data.type === "sketch:latest")
- .filter((n) => n.data.agent?.geminiApiKey == null && !env.integrations.gemini)
- .map((n) => ({
- id: `${n.id}-no-agent-api-key`,
- type: "FATAL",
- nodeId: n.id,
- message: "Configure Gemini API key either on the service or in the project integrations",
- }));
+ .flatMap((n) => {
+ const messages: Message[] = [];
+ const model = n.data.model;
+
+ if (!model || !model.name) {
+ messages.push({
+ id: `${n.id}-no-agent-model`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Select an AI model for the agent (Gemini or Claude).",
+ });
+ return messages;
+ }
+
+ if (model.name === "gemini" && !model.apiKey && !env.integrations.gemini) {
+ messages.push({
+ id: `${n.id}-no-gemini-api-key`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Configure Gemini API key either on the service or in the project integrations.",
+ });
+ }
+
+ if (model.name === "claude" && !model.apiKey && !env.integrations.anthropic) {
+ messages.push({
+ id: `${n.id}-no-anthropic-api-key`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Configure Anthropic API key either on the service or in the project integrations.",
+ });
+ }
+
+ return messages;
+ });
}
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 8f21c26..e0a858a 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -161,6 +161,7 @@
integrations: {
github: false,
gemini: false,
+ anthropic: false,
},
services: [],
user: {
@@ -283,6 +284,10 @@
return useStateStore(envSelector).integrations.gemini;
}
+export function useAnthropicService(): boolean {
+ return useStateStore(envSelector).integrations.anthropic;
+}
+
export function useGithubRepositories(): GitHubRepository[] {
return useStateStore(githubRepositoriesSelector);
}