Canvas: Support Anthropic Claude based AI agents
Change-Id: Ib74c9672da9a80a4f20d63741a471c728a435b8e
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);
}