"use client"; import { useCallback, useMemo, useState } from "react"; import { Bot } from "lucide-react"; import { 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, } 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"; import CanvasHandle from "@/components/canvas/canvas-handle"; 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, Output>( reference: FunctionReference<"action", "public", Args, Output>, ) { try { return useAction(reference); } catch { return async (args: Args): Promise => { void args; return 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 (
    {items.slice(0, 4).map((item) => (
  • - {item}
  • ))}
); } function toTemplateTranslationKey(templateId: string): string { return templateId.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase()); } 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 clarificationAnswersFromNode = useMemo( () => normalizeClarificationAnswers(nodeData.clarificationAnswers), [nodeData.clarificationAnswers], ); const briefConstraintsFromNode = useMemo( () => normalizeBriefConstraints(nodeData.briefConstraints), [nodeData.briefConstraints], ); const nodeModelId = typeof nodeData.modelId === "string" && nodeData.modelId.trim().length > 0 ? nodeData.modelId : DEFAULT_AGENT_MODEL_ID; const [modelDraftId, setModelDraftId] = useState(null); const [clarificationAnswersDraft, setClarificationAnswersDraft] = useState(null); const [briefConstraintsDraft, setBriefConstraintsDraft] = useState(null); const modelId = modelDraftId === nodeModelId ? nodeModelId : modelDraftId ?? nodeModelId; const clarificationAnswers = clarificationAnswersDraft && areAnswerMapsEqual(clarificationAnswersDraft, clarificationAnswersFromNode) ? clarificationAnswersFromNode : clarificationAnswersDraft ?? clarificationAnswersFromNode; const briefConstraints = briefConstraintsDraft && areBriefConstraintsEqual(briefConstraintsDraft, briefConstraintsFromNode) ? briefConstraintsFromNode : briefConstraintsDraft ?? briefConstraintsFromNode; 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"; const resolvedModelId = useMemo(() => { if (availableModels.some((model) => model.id === modelId)) { return modelId; } return availableModels[0]?.id ?? DEFAULT_AGENT_MODEL_ID; }, [availableModels, modelId]); const selectedModel = getAgentModel(resolvedModelId) ?? availableModels[0] ?? getAgentModel(DEFAULT_AGENT_MODEL_ID); const creditCost = selectedModel?.creditCost ?? 0; const clarificationQuestions = nodeData.clarificationQuestions ?? []; const templateTranslationKey = `templates.${toTemplateTranslationKey(template?.id ?? DEFAULT_AGENT_TEMPLATE_ID)}`; const translatedTemplateName = t(`${templateTranslationKey}.name`); const translatedTemplateDescription = t(`${templateTranslationKey}.description`); const templateName = translatedTemplateName === `${templateTranslationKey}.name` ? (template?.name ?? "") : translatedTemplateName; const templateDescription = translatedTemplateDescription === `${templateTranslationKey}.description` ? (template?.description ?? "") : translatedTemplateDescription; 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) => { setModelDraftId(value); void persistNodeData({ modelId: value }); }, [persistNodeData], ); const handleClarificationAnswerChange = useCallback( (questionId: string, value: string) => { const next = { ...clarificationAnswers, [questionId]: value, }; setClarificationAnswersDraft(next); void persistNodeData({ clarificationAnswers: next }); }, [clarificationAnswers, persistNodeData], ); const handleBriefConstraintsChange = useCallback( (patch: Partial) => { const next = { ...briefConstraints, ...patch, }; setBriefConstraintsDraft(next); void persistNodeData({ briefConstraints: next }); }, [briefConstraints, persistNodeData], ); const handleRunAgent = 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, }); }; const handleSubmitClarification = 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, }); }; if (!template) { return null; } return (
{template.emoji} {templateName}

{templateDescription}

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