"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 { useTranslations } from "next-intl"; import { useLocale } from "next-intl"; 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; briefConstraints?: { briefing?: string; audience?: string; tone?: string; targetChannels?: string[]; hardConstraints?: string[]; }; executionSteps?: Array<{ stepIndex?: number; stepTotal?: number }>; executionStepIndex?: number; executionStepTotal?: number; _executionStepIndex?: number; _executionStepTotal?: number; clarificationQuestions?: AgentClarificationQuestion[]; clarificationAnswers?: AgentClarificationAnswerMap | Array<{ id: string; value: string }>; _status?: string; _statusMessage?: string; }; type AgentBriefConstraints = { briefing: string; audience: string; tone: string; targetChannels: string[]; hardConstraints: 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 normalizeDelimitedList(value: string, allowNewLines = false): string[] { const separator = allowNewLines ? /[\n,]/ : /,/; return value .split(separator) .map((item) => item.trim()) .filter((item) => item.length > 0); } function normalizeBriefConstraints(raw: AgentNodeData["briefConstraints"]): AgentBriefConstraints { return { briefing: typeof raw?.briefing === "string" ? raw.briefing : "", audience: typeof raw?.audience === "string" ? raw.audience : "", tone: typeof raw?.tone === "string" ? raw.tone : "", targetChannels: Array.isArray(raw?.targetChannels) ? raw.targetChannels.filter((item): item is string => typeof item === "string") : [], hardConstraints: Array.isArray(raw?.hardConstraints) ? raw.hardConstraints.filter((item): item is string => typeof item === "string") : [], }; } function areStringArraysEqual(left: string[], right: string[]): boolean { if (left.length !== right.length) { return false; } return left.every((value, index) => value === right[index]); } function areBriefConstraintsEqual(left: AgentBriefConstraints, right: AgentBriefConstraints): boolean { return ( left.briefing === right.briefing && left.audience === right.audience && left.tone === right.tone && areStringArraysEqual(left.targetChannels, right.targetChannels) && areStringArraysEqual(left.hardConstraints, right.hardConstraints) ); } function CompactList({ items }: { items: readonly string[] }) { return ( ); } export default function AgentNode({ id, data, selected }: NodeProps) { const t = useTranslations("agentNode"); const locale = useLocale(); 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 [briefConstraints, setBriefConstraints] = useState( normalizeBriefConstraints(nodeData.briefConstraints), ); const agentActionsApi = api as unknown as { agents: { runAgent: FunctionReference< "action", "public", { canvasId: Id<"canvases">; nodeId: Id<"nodes">; modelId: string; locale: "de" | "en"; }, unknown >; resumeAgent: FunctionReference< "action", "public", { canvasId: Id<"canvases">; nodeId: Id<"nodes">; clarificationAnswers: AgentClarificationAnswerMap; locale: "de" | "en"; }, unknown >; }; }; const runAgent = useSafeAction(agentActionsApi.agents.runAgent); const resumeAgent = useSafeAction(agentActionsApi.agents.resumeAgent); const normalizedLocale = locale === "en" ? "en" : "de"; 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(() => { const normalized = normalizeBriefConstraints(nodeData.briefConstraints); setBriefConstraints((current) => { if (areBriefConstraintsEqual(current, normalized)) { return current; } return normalized; }); }, [nodeData.briefConstraints]); 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 templateName = template?.id === "campaign-distributor" ? t("templates.campaignDistributor.name") : (template?.name ?? ""); const templateDescription = template?.id === "campaign-distributor" ? t("templates.campaignDistributor.description") : (template?.description ?? ""); const isExecutionActive = nodeData._status === "analyzing" || nodeData._status === "executing"; const executionProgressLine = useMemo(() => { if (nodeData._status !== "executing") { return null; } const statusMessage = typeof nodeData._statusMessage === "string" ? nodeData._statusMessage.trim() : ""; if (statusMessage.length > 0) { return statusMessage; } const totalFromSteps = Array.isArray(nodeData.executionSteps) ? nodeData.executionSteps.length : 0; const stepIndexCandidate = typeof nodeData.executionStepIndex === "number" ? nodeData.executionStepIndex : nodeData._executionStepIndex; const stepTotalCandidate = typeof nodeData.executionStepTotal === "number" ? nodeData.executionStepTotal : nodeData._executionStepTotal; const hasExecutionNumbers = typeof stepIndexCandidate === "number" && Number.isFinite(stepIndexCandidate) && typeof stepTotalCandidate === "number" && Number.isFinite(stepTotalCandidate) && stepTotalCandidate > 0; if (hasExecutionNumbers) { return t("executingStepFallback", { current: Math.max(0, Math.floor(stepIndexCandidate)) + 1, total: Math.floor(stepTotalCandidate), }); } if (totalFromSteps > 0) { return t("executingPlannedTotalFallback", { total: totalFromSteps, }); } return t("executingPlannedFallback"); }, [ nodeData._executionStepIndex, nodeData._executionStepTotal, nodeData._status, nodeData._statusMessage, nodeData.executionStepIndex, nodeData.executionStepTotal, nodeData.executionSteps, t, ]); const resolveClarificationPrompt = useCallback( (question: AgentClarificationQuestion) => { if (question.id === "briefing") { return t("clarificationPrompts.briefing"); } if (question.id === "target-channels") { return t("clarificationPrompts.targetChannels"); } if (question.id === "incoming-context") { return t("clarificationPrompts.incomingContext"); } return question.prompt; }, [t], ); 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 handleBriefConstraintsChange = useCallback( (patch: Partial) => { setBriefConstraints((prev) => { const next = { ...prev, ...patch, }; void persistNodeData({ briefConstraints: next }); return next; }); }, [persistNodeData], ); const handleRunAgent = useCallback(async () => { if (isExecutionActive) { return; } if (status.isOffline) { toast.warning( t("offlineTitle"), t("offlineDescription"), ); return; } const canvasId = nodeData.canvasId as Id<"canvases"> | undefined; if (!canvasId) { return; } await runAgent({ canvasId, nodeId: id as Id<"nodes">, modelId: resolvedModelId, locale: normalizedLocale, }); }, [isExecutionActive, nodeData.canvasId, id, normalizedLocale, resolvedModelId, runAgent, status.isOffline, t]); const handleSubmitClarification = useCallback(async () => { if (status.isOffline) { toast.warning( t("offlineTitle"), t("offlineDescription"), ); return; } const canvasId = nodeData.canvasId as Id<"canvases"> | undefined; if (!canvasId) { return; } await resumeAgent({ canvasId, nodeId: id as Id<"nodes">, clarificationAnswers, locale: normalizedLocale, }); }, [clarificationAnswers, nodeData.canvasId, id, normalizedLocale, resumeAgent, status.isOffline, t]); if (!template) { return null; } return (
{template.emoji} {templateName}

{templateDescription}

{t("modelCreditMeta", { model: selectedModel?.label ?? resolvedModelId, credits: creditCost, })}