Spaces:
Sleeping
Sleeping
| /** | |
| * AI Node Validator | |
| * | |
| * Implements validation logic for AI Agent, Chat Trigger, and Basic LLM Chain nodes | |
| * from docs/FINAL_AI_VALIDATION_SPEC.md | |
| * | |
| * Key Features: | |
| * - Reverse connection mapping (AI connections flow TO the consumer) | |
| * - AI Agent comprehensive validation (prompt types, fallback models, streaming mode) | |
| * - Chat Trigger validation (streaming mode constraints) | |
| * - Integration with AI tool validators | |
| */ | |
| import { NodeTypeNormalizer } from '../utils/node-type-normalizer'; | |
| import { | |
| WorkflowNode, | |
| WorkflowJson, | |
| ReverseConnection, | |
| ValidationIssue, | |
| isAIToolSubNode, | |
| validateAIToolSubNode | |
| } from './ai-tool-validators'; | |
| // Re-export types for test files | |
| export type { | |
| WorkflowNode, | |
| WorkflowJson, | |
| ReverseConnection, | |
| ValidationIssue | |
| } from './ai-tool-validators'; | |
| // Validation constants | |
| const MIN_SYSTEM_MESSAGE_LENGTH = 20; | |
| const MAX_ITERATIONS_WARNING_THRESHOLD = 50; | |
| /** | |
| * AI Connection Types | |
| * From spec lines 551-596 | |
| */ | |
| export const AI_CONNECTION_TYPES = [ | |
| 'ai_languageModel', | |
| 'ai_memory', | |
| 'ai_tool', | |
| 'ai_embedding', | |
| 'ai_vectorStore', | |
| 'ai_document', | |
| 'ai_textSplitter', | |
| 'ai_outputParser' | |
| ] as const; | |
| /** | |
| * Build Reverse Connection Map | |
| * | |
| * CRITICAL: AI connections flow TO the consumer node (reversed from standard n8n pattern) | |
| * This utility maps which nodes connect TO each node, essential for AI validation. | |
| * | |
| * From spec lines 551-596 | |
| * | |
| * @example | |
| * Standard n8n: [Source] --main--> [Target] | |
| * workflow.connections["Source"]["main"] = [[{node: "Target", ...}]] | |
| * | |
| * AI pattern: [Language Model] --ai_languageModel--> [AI Agent] | |
| * workflow.connections["Language Model"]["ai_languageModel"] = [[{node: "AI Agent", ...}]] | |
| * | |
| * Reverse map: reverseMap.get("AI Agent") = [{sourceName: "Language Model", type: "ai_languageModel", ...}] | |
| */ | |
| export function buildReverseConnectionMap( | |
| workflow: WorkflowJson | |
| ): Map<string, ReverseConnection[]> { | |
| const map = new Map<string, ReverseConnection[]>(); | |
| // Iterate through all connections | |
| for (const [sourceName, outputs] of Object.entries(workflow.connections)) { | |
| // Validate source name is not empty | |
| if (!sourceName || typeof sourceName !== 'string' || sourceName.trim() === '') { | |
| continue; | |
| } | |
| if (!outputs || typeof outputs !== 'object') continue; | |
| // Iterate through all output types (main, error, ai_tool, ai_languageModel, etc.) | |
| for (const [outputType, connections] of Object.entries(outputs)) { | |
| if (!Array.isArray(connections)) continue; | |
| // Flatten nested arrays and process each connection | |
| const connArray = connections.flat().filter(c => c); | |
| for (const conn of connArray) { | |
| if (!conn || !conn.node) continue; | |
| // Validate target node name is not empty | |
| if (typeof conn.node !== 'string' || conn.node.trim() === '') { | |
| continue; | |
| } | |
| // Initialize array for target node if not exists | |
| if (!map.has(conn.node)) { | |
| map.set(conn.node, []); | |
| } | |
| // Add reverse connection entry | |
| map.get(conn.node)!.push({ | |
| sourceName: sourceName, | |
| sourceType: outputType, | |
| type: outputType, | |
| index: conn.index ?? 0 | |
| }); | |
| } | |
| } | |
| } | |
| return map; | |
| } | |
| /** | |
| * Get AI connections TO a specific node | |
| */ | |
| export function getAIConnections( | |
| nodeName: string, | |
| reverseConnections: Map<string, ReverseConnection[]>, | |
| connectionType?: string | |
| ): ReverseConnection[] { | |
| const incoming = reverseConnections.get(nodeName) || []; | |
| if (connectionType) { | |
| return incoming.filter(c => c.type === connectionType); | |
| } | |
| return incoming.filter(c => AI_CONNECTION_TYPES.includes(c.type as any)); | |
| } | |
| /** | |
| * Validate AI Agent Node | |
| * From spec lines 3-549 | |
| * | |
| * Validates: | |
| * - Language model connections (1 or 2 if fallback) | |
| * - Output parser connection + hasOutputParser flag | |
| * - Prompt type configuration (auto vs define) | |
| * - System message recommendations | |
| * - Streaming mode constraints (CRITICAL) | |
| * - Memory connections (0-1) | |
| * - Tool connections | |
| * - maxIterations validation | |
| */ | |
| export function validateAIAgent( | |
| node: WorkflowNode, | |
| reverseConnections: Map<string, ReverseConnection[]>, | |
| workflow: WorkflowJson | |
| ): ValidationIssue[] { | |
| const issues: ValidationIssue[] = []; | |
| const incoming = reverseConnections.get(node.name) || []; | |
| // 1. Validate language model connections (REQUIRED: 1 or 2 if fallback) | |
| const languageModelConnections = incoming.filter(c => c.type === 'ai_languageModel'); | |
| if (languageModelConnections.length === 0) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" requires an ai_languageModel connection. Connect a language model node (e.g., OpenAI Chat Model, Anthropic Chat Model).`, | |
| code: 'MISSING_LANGUAGE_MODEL' | |
| }); | |
| } else if (languageModelConnections.length > 2) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has ${languageModelConnections.length} ai_languageModel connections. Maximum is 2 (for fallback model support).`, | |
| code: 'TOO_MANY_LANGUAGE_MODELS' | |
| }); | |
| } else if (languageModelConnections.length === 2) { | |
| // Check if fallback is enabled | |
| if (!node.parameters.needsFallback) { | |
| issues.push({ | |
| severity: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has 2 language models but needsFallback is not enabled. Set needsFallback=true or remove the second model.` | |
| }); | |
| } | |
| } else if (languageModelConnections.length === 1 && node.parameters.needsFallback === true) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has needsFallback=true but only 1 language model connected. Connect a second model for fallback or disable needsFallback.`, | |
| code: 'FALLBACK_MISSING_SECOND_MODEL' | |
| }); | |
| } | |
| // 2. Validate output parser configuration | |
| const outputParserConnections = incoming.filter(c => c.type === 'ai_outputParser'); | |
| if (node.parameters.hasOutputParser === true) { | |
| if (outputParserConnections.length === 0) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has hasOutputParser=true but no ai_outputParser connection. Connect an output parser or set hasOutputParser=false.`, | |
| code: 'MISSING_OUTPUT_PARSER' | |
| }); | |
| } | |
| } else if (outputParserConnections.length > 0) { | |
| issues.push({ | |
| severity: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has an output parser connected but hasOutputParser is not true. Set hasOutputParser=true to enable output parsing.` | |
| }); | |
| } | |
| if (outputParserConnections.length > 1) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has ${outputParserConnections.length} output parsers. Only 1 is allowed.`, | |
| code: 'MULTIPLE_OUTPUT_PARSERS' | |
| }); | |
| } | |
| // 3. Validate prompt type configuration | |
| if (node.parameters.promptType === 'define') { | |
| if (!node.parameters.text || node.parameters.text.trim() === '') { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has promptType="define" but the text field is empty. Provide a custom prompt or switch to promptType="auto".`, | |
| code: 'MISSING_PROMPT_TEXT' | |
| }); | |
| } | |
| } | |
| // 4. Check system message (RECOMMENDED) | |
| if (!node.parameters.systemMessage) { | |
| issues.push({ | |
| severity: 'info', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has no systemMessage. Consider adding one to define the agent's role, capabilities, and constraints.` | |
| }); | |
| } else if (node.parameters.systemMessage.trim().length < MIN_SYSTEM_MESSAGE_LENGTH) { | |
| issues.push({ | |
| severity: 'info', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" systemMessage is very short (minimum ${MIN_SYSTEM_MESSAGE_LENGTH} characters recommended). Provide more detail about the agent's role and capabilities.` | |
| }); | |
| } | |
| // 5. Validate streaming mode constraints (CRITICAL) | |
| // From spec lines 753-879: AI Agent with streaming MUST NOT have main output connections | |
| const isStreamingTarget = checkIfStreamingTarget(node, workflow, reverseConnections); | |
| const hasOwnStreamingEnabled = node.parameters?.options?.streamResponse === true; | |
| if (isStreamingTarget || hasOwnStreamingEnabled) { | |
| // Check if AI Agent has any main output connections | |
| const agentMainOutput = workflow.connections[node.name]?.main; | |
| if (agentMainOutput && agentMainOutput.flat().some((c: any) => c)) { | |
| const streamSource = isStreamingTarget | |
| ? 'connected from Chat Trigger with responseMode="streaming"' | |
| : 'has streamResponse=true in options'; | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" is in streaming mode (${streamSource}) but has outgoing main connections. Remove all main output connections - streaming responses flow back through the Chat Trigger.`, | |
| code: 'STREAMING_WITH_MAIN_OUTPUT' | |
| }); | |
| } | |
| } | |
| // 6. Validate memory connections (0-1 allowed) | |
| const memoryConnections = incoming.filter(c => c.type === 'ai_memory'); | |
| if (memoryConnections.length > 1) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has ${memoryConnections.length} ai_memory connections. Only 1 memory is allowed.`, | |
| code: 'MULTIPLE_MEMORY_CONNECTIONS' | |
| }); | |
| } | |
| // 7. Validate tool connections | |
| const toolConnections = incoming.filter(c => c.type === 'ai_tool'); | |
| if (toolConnections.length === 0) { | |
| issues.push({ | |
| severity: 'info', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has no ai_tool connections. Consider adding tools to enhance the agent's capabilities.` | |
| }); | |
| } | |
| // 8. Validate maxIterations if specified | |
| if (node.parameters.maxIterations !== undefined) { | |
| if (typeof node.parameters.maxIterations !== 'number') { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has invalid maxIterations type. Must be a number.`, | |
| code: 'INVALID_MAX_ITERATIONS_TYPE' | |
| }); | |
| } else if (node.parameters.maxIterations < 1) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has maxIterations=${node.parameters.maxIterations}. Must be at least 1.`, | |
| code: 'MAX_ITERATIONS_TOO_LOW' | |
| }); | |
| } else if (node.parameters.maxIterations > MAX_ITERATIONS_WARNING_THRESHOLD) { | |
| issues.push({ | |
| severity: 'warning', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `AI Agent "${node.name}" has maxIterations=${node.parameters.maxIterations}. Very high iteration counts (>${MAX_ITERATIONS_WARNING_THRESHOLD}) may cause long execution times and high costs.` | |
| }); | |
| } | |
| } | |
| return issues; | |
| } | |
| /** | |
| * Check if AI Agent is a streaming target | |
| * Helper function to determine if an AI Agent is receiving streaming input from Chat Trigger | |
| */ | |
| function checkIfStreamingTarget( | |
| node: WorkflowNode, | |
| workflow: WorkflowJson, | |
| reverseConnections: Map<string, ReverseConnection[]> | |
| ): boolean { | |
| const incoming = reverseConnections.get(node.name) || []; | |
| // Check if any incoming main connection is from a Chat Trigger with streaming enabled | |
| const mainConnections = incoming.filter(c => c.type === 'main'); | |
| for (const conn of mainConnections) { | |
| const sourceNode = workflow.nodes.find(n => n.name === conn.sourceName); | |
| if (!sourceNode) continue; | |
| const normalizedType = NodeTypeNormalizer.normalizeToFullForm(sourceNode.type); | |
| if (normalizedType === 'nodes-langchain.chatTrigger') { | |
| const responseMode = sourceNode.parameters?.options?.responseMode || 'lastNode'; | |
| if (responseMode === 'streaming') { | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Validate Chat Trigger Node | |
| * From spec lines 753-879 | |
| * | |
| * Critical validations: | |
| * - responseMode="streaming" requires AI Agent target | |
| * - AI Agent with streaming MUST NOT have main output connections | |
| * - responseMode="lastNode" validation | |
| */ | |
| export function validateChatTrigger( | |
| node: WorkflowNode, | |
| workflow: WorkflowJson, | |
| reverseConnections: Map<string, ReverseConnection[]> | |
| ): ValidationIssue[] { | |
| const issues: ValidationIssue[] = []; | |
| const responseMode = node.parameters?.options?.responseMode || 'lastNode'; | |
| // Get outgoing main connections from Chat Trigger | |
| const outgoingMain = workflow.connections[node.name]?.main; | |
| if (!outgoingMain || outgoingMain.length === 0 || !outgoingMain[0] || outgoingMain[0].length === 0) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Chat Trigger "${node.name}" has no outgoing connections. Connect it to an AI Agent or workflow.`, | |
| code: 'MISSING_CONNECTIONS' | |
| }); | |
| return issues; | |
| } | |
| const firstConnection = outgoingMain[0][0]; | |
| if (!firstConnection) { | |
| return issues; | |
| } | |
| const targetNode = workflow.nodes.find(n => n.name === firstConnection.node); | |
| if (!targetNode) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Chat Trigger "${node.name}" connects to non-existent node "${firstConnection.node}".`, | |
| code: 'INVALID_TARGET_NODE' | |
| }); | |
| return issues; | |
| } | |
| const targetType = NodeTypeNormalizer.normalizeToFullForm(targetNode.type); | |
| // Validate streaming mode | |
| if (responseMode === 'streaming') { | |
| // CRITICAL: Streaming mode only works with AI Agent | |
| if (targetType !== 'nodes-langchain.agent') { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Chat Trigger "${node.name}" has responseMode="streaming" but connects to "${targetNode.name}" (${targetType}). Streaming mode only works with AI Agent. Change responseMode to "lastNode" or connect to an AI Agent.`, | |
| code: 'STREAMING_WRONG_TARGET' | |
| }); | |
| } else { | |
| // CRITICAL: Check AI Agent has NO main output connections | |
| const agentMainOutput = workflow.connections[targetNode.name]?.main; | |
| if (agentMainOutput && agentMainOutput.flat().some((c: any) => c)) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: targetNode.id, | |
| nodeName: targetNode.name, | |
| message: `AI Agent "${targetNode.name}" is in streaming mode but has outgoing main connections. In streaming mode, the AI Agent must NOT have main output connections - responses stream back through the Chat Trigger.`, | |
| code: 'STREAMING_AGENT_HAS_OUTPUT' | |
| }); | |
| } | |
| } | |
| } | |
| // Validate lastNode mode | |
| if (responseMode === 'lastNode') { | |
| // lastNode mode requires a workflow that ends somewhere | |
| // Just informational - this is the default and works with any workflow | |
| if (targetType === 'nodes-langchain.agent') { | |
| issues.push({ | |
| severity: 'info', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Chat Trigger "${node.name}" uses responseMode="lastNode" with AI Agent. Consider using responseMode="streaming" for better user experience with real-time responses.` | |
| }); | |
| } | |
| } | |
| return issues; | |
| } | |
| /** | |
| * Validate Basic LLM Chain Node | |
| * From spec - simplified AI chain without agent loop | |
| * | |
| * Similar to AI Agent but simpler: | |
| * - Requires exactly 1 language model | |
| * - Can have 0-1 memory | |
| * - No tools (not an agent) | |
| * - No fallback model support | |
| */ | |
| export function validateBasicLLMChain( | |
| node: WorkflowNode, | |
| reverseConnections: Map<string, ReverseConnection[]> | |
| ): ValidationIssue[] { | |
| const issues: ValidationIssue[] = []; | |
| const incoming = reverseConnections.get(node.name) || []; | |
| // 1. Validate language model connection (REQUIRED: exactly 1) | |
| const languageModelConnections = incoming.filter(c => c.type === 'ai_languageModel'); | |
| if (languageModelConnections.length === 0) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Basic LLM Chain "${node.name}" requires an ai_languageModel connection. Connect a language model node.`, | |
| code: 'MISSING_LANGUAGE_MODEL' | |
| }); | |
| } else if (languageModelConnections.length > 1) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Basic LLM Chain "${node.name}" has ${languageModelConnections.length} ai_languageModel connections. Basic LLM Chain only supports 1 language model (no fallback).`, | |
| code: 'MULTIPLE_LANGUAGE_MODELS' | |
| }); | |
| } | |
| // 2. Validate memory connections (0-1 allowed) | |
| const memoryConnections = incoming.filter(c => c.type === 'ai_memory'); | |
| if (memoryConnections.length > 1) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Basic LLM Chain "${node.name}" has ${memoryConnections.length} ai_memory connections. Only 1 memory is allowed.`, | |
| code: 'MULTIPLE_MEMORY_CONNECTIONS' | |
| }); | |
| } | |
| // 3. Check for tool connections (not supported) | |
| const toolConnections = incoming.filter(c => c.type === 'ai_tool'); | |
| if (toolConnections.length > 0) { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Basic LLM Chain "${node.name}" has ai_tool connections. Basic LLM Chain does not support tools. Use AI Agent if you need tool support.`, | |
| code: 'TOOLS_NOT_SUPPORTED' | |
| }); | |
| } | |
| // 4. Validate prompt configuration | |
| if (node.parameters.promptType === 'define') { | |
| if (!node.parameters.text || node.parameters.text.trim() === '') { | |
| issues.push({ | |
| severity: 'error', | |
| nodeId: node.id, | |
| nodeName: node.name, | |
| message: `Basic LLM Chain "${node.name}" has promptType="define" but the text field is empty.`, | |
| code: 'MISSING_PROMPT_TEXT' | |
| }); | |
| } | |
| } | |
| return issues; | |
| } | |
| /** | |
| * Validate all AI-specific nodes in a workflow | |
| * | |
| * This is the main entry point called by WorkflowValidator | |
| */ | |
| export function validateAISpecificNodes( | |
| workflow: WorkflowJson | |
| ): ValidationIssue[] { | |
| const issues: ValidationIssue[] = []; | |
| // Build reverse connection map (critical for AI validation) | |
| const reverseConnectionMap = buildReverseConnectionMap(workflow); | |
| for (const node of workflow.nodes) { | |
| if (node.disabled) continue; | |
| const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type); | |
| // Validate AI Agent nodes | |
| if (normalizedType === 'nodes-langchain.agent') { | |
| const nodeIssues = validateAIAgent(node, reverseConnectionMap, workflow); | |
| issues.push(...nodeIssues); | |
| } | |
| // Validate Chat Trigger nodes | |
| if (normalizedType === 'nodes-langchain.chatTrigger') { | |
| const nodeIssues = validateChatTrigger(node, workflow, reverseConnectionMap); | |
| issues.push(...nodeIssues); | |
| } | |
| // Validate Basic LLM Chain nodes | |
| if (normalizedType === 'nodes-langchain.chainLlm') { | |
| const nodeIssues = validateBasicLLMChain(node, reverseConnectionMap); | |
| issues.push(...nodeIssues); | |
| } | |
| // Validate AI tool sub-nodes (13 types) | |
| if (isAIToolSubNode(normalizedType)) { | |
| const nodeIssues = validateAIToolSubNode( | |
| node, | |
| normalizedType, | |
| reverseConnectionMap, | |
| workflow | |
| ); | |
| issues.push(...nodeIssues); | |
| } | |
| } | |
| return issues; | |
| } | |
| /** | |
| * Check if a workflow contains any AI nodes | |
| * Useful for skipping AI validation when not needed | |
| */ | |
| export function hasAINodes(workflow: WorkflowJson): boolean { | |
| const aiNodeTypes = [ | |
| 'nodes-langchain.agent', | |
| 'nodes-langchain.chatTrigger', | |
| 'nodes-langchain.chainLlm', | |
| ]; | |
| return workflow.nodes.some(node => { | |
| const normalized = NodeTypeNormalizer.normalizeToFullForm(node.type); | |
| return aiNodeTypes.includes(normalized) || isAIToolSubNode(normalized); | |
| }); | |
| } | |
| /** | |
| * Helper: Get AI node type category | |
| */ | |
| export function getAINodeCategory(nodeType: string): string | null { | |
| const normalized = NodeTypeNormalizer.normalizeToFullForm(nodeType); | |
| if (normalized === 'nodes-langchain.agent') return 'AI Agent'; | |
| if (normalized === 'nodes-langchain.chatTrigger') return 'Chat Trigger'; | |
| if (normalized === 'nodes-langchain.chainLlm') return 'Basic LLM Chain'; | |
| if (isAIToolSubNode(normalized)) return 'AI Tool'; | |
| // Check for AI component nodes | |
| if (normalized.startsWith('nodes-langchain.')) { | |
| if (normalized.includes('openAi') || normalized.includes('anthropic') || normalized.includes('googleGemini')) { | |
| return 'Language Model'; | |
| } | |
| if (normalized.includes('memory') || normalized.includes('buffer')) { | |
| return 'Memory'; | |
| } | |
| if (normalized.includes('vectorStore') || normalized.includes('pinecone') || normalized.includes('qdrant')) { | |
| return 'Vector Store'; | |
| } | |
| if (normalized.includes('embedding')) { | |
| return 'Embeddings'; | |
| } | |
| return 'AI Component'; | |
| } | |
| return null; | |
| } | |