"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Bot } from "lucide-react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; import { useAction } from "convex/react"; import type { FunctionReference } from "convex/server"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useAuthQuery } from "@/hooks/use-auth-query"; import { DEFAULT_AGENT_MODEL_ID, getAgentModel, getAvailableAgentModels, type AgentModelId, } from "@/lib/agent-models"; import { type AgentClarificationAnswerMap, type AgentClarificationQuestion, } from "@/lib/agent-run-contract"; import { getAgentTemplate } from "@/lib/agent-templates"; import { normalizePublicTier } from "@/lib/tier-credits"; import { toast } from "@/lib/toast"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import BaseNodeWrapper from "./base-node-wrapper"; type AgentNodeData = { templateId?: string; canvasId?: string; modelId?: string; clarificationQuestions?: AgentClarificationQuestion[]; clarificationAnswers?: AgentClarificationAnswerMap | Array<{ id: string; value: string }>; _status?: string; _statusMessage?: string; }; type AgentNodeType = Node; const DEFAULT_AGENT_TEMPLATE_ID = "campaign-distributor"; function useSafeCanvasSync() { try { return useCanvasSync(); } catch { return { queueNodeDataUpdate: async () => undefined, status: { isOffline: false, isSyncing: false, pendingCount: 0 }, }; } } function useSafeSubscription() { try { return useAuthQuery(api.credits.getSubscription); } catch { return undefined; } } function useSafeAction(reference: FunctionReference<"action", "public", any, unknown>) { try { return useAction(reference); } catch { return async (_args: any) => undefined; } } function normalizeClarificationAnswers(raw: AgentNodeData["clarificationAnswers"]): AgentClarificationAnswerMap { if (!raw) { return {}; } if (Array.isArray(raw)) { const entries = raw .filter((item) => typeof item?.id === "string" && typeof item?.value === "string") .map((item) => [item.id, item.value] as const); return Object.fromEntries(entries); } return raw; } function areAnswerMapsEqual( left: AgentClarificationAnswerMap, right: AgentClarificationAnswerMap, ): boolean { const leftEntries = Object.entries(left); const rightEntries = Object.entries(right); if (leftEntries.length !== rightEntries.length) { return false; } for (const [key, value] of leftEntries) { if (right[key] !== value) { return false; } } return true; } function CompactList({ items }: { items: readonly string[] }) { return ( ); } export default function AgentNode({ id, data, selected }: NodeProps) { const nodeData = data as AgentNodeData; const template = getAgentTemplate(nodeData.templateId ?? DEFAULT_AGENT_TEMPLATE_ID) ?? getAgentTemplate(DEFAULT_AGENT_TEMPLATE_ID); const { queueNodeDataUpdate, status } = useSafeCanvasSync(); const subscription = useSafeSubscription(); const userTier = normalizePublicTier(subscription?.tier ?? "free"); const availableModels = useMemo(() => getAvailableAgentModels(userTier), [userTier]); const [modelId, setModelId] = useState(nodeData.modelId ?? DEFAULT_AGENT_MODEL_ID); const [clarificationAnswers, setClarificationAnswers] = useState( normalizeClarificationAnswers(nodeData.clarificationAnswers), ); const agentActionsApi = api as unknown as { agents: { runAgent: FunctionReference< "action", "public", { canvasId: Id<"canvases">; nodeId: Id<"nodes">; modelId: string; }, unknown >; resumeAgent: FunctionReference< "action", "public", { canvasId: Id<"canvases">; nodeId: Id<"nodes">; clarificationAnswers: AgentClarificationAnswerMap; }, unknown >; }; }; const runAgent = useSafeAction(agentActionsApi.agents.runAgent); const resumeAgent = useSafeAction(agentActionsApi.agents.resumeAgent); useEffect(() => { setModelId(nodeData.modelId ?? DEFAULT_AGENT_MODEL_ID); }, [nodeData.modelId]); useEffect(() => { const normalized = normalizeClarificationAnswers(nodeData.clarificationAnswers); setClarificationAnswers((current) => { if (areAnswerMapsEqual(current, normalized)) { return current; } return normalized; }); }, [nodeData.clarificationAnswers]); useEffect(() => { if (availableModels.length === 0) { return; } if (availableModels.some((model) => model.id === modelId)) { return; } const nextModelId = availableModels[0]!.id; setModelId(nextModelId); }, [availableModels, modelId]); const selectedModel = getAgentModel(modelId) ?? availableModels[0] ?? getAgentModel(DEFAULT_AGENT_MODEL_ID); const resolvedModelId = selectedModel?.id ?? DEFAULT_AGENT_MODEL_ID; const creditCost = selectedModel?.creditCost ?? 0; const clarificationQuestions = nodeData.clarificationQuestions ?? []; const persistNodeData = useCallback( (patch: Partial) => { const raw = data as Record; const { _status, _statusMessage, ...rest } = raw; void _status; void _statusMessage; return queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { ...rest, ...patch, }, }); }, [data, id, queueNodeDataUpdate], ); const handleModelChange = useCallback( (value: string) => { setModelId(value); void persistNodeData({ modelId: value }); }, [persistNodeData], ); const handleClarificationAnswerChange = useCallback( (questionId: string, value: string) => { setClarificationAnswers((prev) => { const next = { ...prev, [questionId]: value, }; void persistNodeData({ clarificationAnswers: next }); return next; }); }, [persistNodeData], ); const handleRunAgent = useCallback(async () => { if (status.isOffline) { toast.warning( "Offline aktuell nicht unterstuetzt", "Agent-Lauf benoetigt eine aktive Verbindung.", ); return; } const canvasId = nodeData.canvasId as Id<"canvases"> | undefined; if (!canvasId) { return; } await runAgent({ canvasId, nodeId: id as Id<"nodes">, modelId: resolvedModelId, }); }, [nodeData.canvasId, id, resolvedModelId, runAgent, status.isOffline]); const handleSubmitClarification = useCallback(async () => { if (status.isOffline) { toast.warning( "Offline aktuell nicht unterstuetzt", "Agent-Lauf benoetigt eine aktive Verbindung.", ); return; } const canvasId = nodeData.canvasId as Id<"canvases"> | undefined; if (!canvasId) { return; } await resumeAgent({ canvasId, nodeId: id as Id<"nodes">, clarificationAnswers, }); }, [clarificationAnswers, nodeData.canvasId, id, resumeAgent, status.isOffline]); if (!template) { return null; } return (
{template.emoji} {template.name}

{template.description}

{selectedModel?.label ?? resolvedModelId} - {creditCost} Cr

{clarificationQuestions.length > 0 ? (

Clarifications

{clarificationQuestions.map((question) => (
handleClarificationAnswerChange(question.id, event.target.value) } className="nodrag nowheel w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm" />
))}
) : null}

Channels

Expected Inputs

Expected Outputs

); }