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