Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/components/credentials/AnswerAgentApi.credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { INodeParams, INodeCredential } from '../src/Interface'

class AnswerAgentApi implements INodeCredential {
label: string
name: string
version: number
description: string
inputs: INodeParams[]

constructor() {
this.label = 'AnswerAgent API'
this.name = 'answerAgentApi'
this.version = 1.0
this.description =
'You can find your API key in <a target="_blank" href="/sidekick-studio/apikey">API Keys</a> settings'
this.inputs = [
{
label: 'API Key',
name: 'apiKey',
type: 'password',
placeholder: 'your-api-key'
},
{
label: 'Instance Domain',
name: 'instanceDomain',
type: 'string',
optional: true,
default: process.env.API_HOST || process.env.FLOWISE_DOMAIN || '',
placeholder: 'https://your-instance.answerai.com',
description:
'The domain of the AnswerAgent instance to connect to. Change this to connect to a different AnswerAgent instance.'
}
]
}
}

module.exports = { credClass: AnswerAgentApi }
104 changes: 24 additions & 80 deletions packages/components/nodes/tools/MCP/AnswerAgent/AnswerAgentMCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* - Implements INode interface
* - Sets tags = ['AAI'] for UI Answer tab integration
* - Sets category = 'Tools (MCP)'
* - Automatically uses user's API key from database (no manual credential setup required)
* - Uses API_HOST environment variable for base URL
* - Uses standard Flowise credential pattern (answerAgentApi)
* - Supports connecting to any AnswerAgent instance via credential domain config
* - Exposes available actions via mcpActions input
* - Registers the node as module.exports = { nodeClass: AnswerAgent_MCP }
*
Expand All @@ -17,7 +17,7 @@
*/
import { Tool } from '@langchain/core/tools'
import { ICommonObject, INode, INodeData, INodeOptionsValue, INodeParams } from '../../../../src/Interface'
import { getNodeModulesPackagePath } from '../../../../src/utils'
import { getCredentialData, getCredentialParam, getNodeModulesPackagePath } from '../../../../src/utils'
import { MCPToolkit } from '../core'

class AnswerAgent_MCP implements INode {
Expand All @@ -30,19 +30,26 @@ class AnswerAgent_MCP implements INode {
category: string
baseClasses: string[]
documentation: string
credential: INodeParams
inputs: INodeParams[]
tags: string[]

constructor() {
this.label = 'AnswerAgent MCP'
this.name = 'answerAgentMCP'
this.version = 1.0
this.version = 2.0
this.type = 'AnswerAgent MCP Tool'
this.icon = 'answerai-square-black.png'
this.category = 'Tools (MCP)'
this.tags = ['AAI']
this.description = 'MCP server that integrates with AnswerAgent API • Zero configuration required'
this.description = 'MCP server that integrates with AnswerAgent API'
this.documentation = 'https://www.npmjs.com/package/@answerai/answeragent-mcp'
this.credential = {
label: 'Connect Credential',
name: 'credential',
type: 'credential',
credentialNames: ['answerAgentApi']
}
this.inputs = [
{
label: 'Available Actions',
Expand Down Expand Up @@ -100,17 +107,20 @@ class AnswerAgent_MCP implements INode {
}

async getTools(nodeData: INodeData, options: ICommonObject): Promise<Tool[]> {
// Get API host from environment variable
const apiHost = options.user?.chatflowDomain
if (!apiHost) {
console.error('AnswerAgent MCP: API_HOST environment variable is not set')
// Get API key and optional instance domain from credential
const credentialData = await getCredentialData(nodeData.credential ?? '', options)
const apiKey = getCredentialParam('apiKey', credentialData, nodeData)
const instanceDomain = getCredentialParam('instanceDomain', credentialData, nodeData)

if (!apiKey) {
console.error('AnswerAgent MCP: Missing AnswerAgent API Key')
return []
}

// Get user's API key from database
const apiKey = await this.getUserApiKey(nodeData, options)
if (!apiKey) {
console.error('AnswerAgent MCP: Unable to retrieve user API key from database')
// Use instance domain from credential, fall back to current instance domain or env var
const apiHost = instanceDomain || options.user?.chatflowDomain || process.env.API_HOST || process.env.FLOWISE_DOMAIN
if (!apiHost) {
console.error('AnswerAgent MCP: No instance domain configured and API_HOST is not set')
return []
}

Expand All @@ -121,7 +131,7 @@ class AnswerAgent_MCP implements INode {
command: process.execPath,
args: [packagePath],
env: {
ANSWERAGENT_AI_API_HOST: apiHost,
ANSWERAGENT_AI_API_BASE_URL: apiHost,
ANSWERAGENT_AI_API_TOKEN: apiKey
}
}
Expand All @@ -134,72 +144,6 @@ class AnswerAgent_MCP implements INode {

return tools
}

/**
* Retrieve the user's API key from the database
* This automatically uses the user's default API key, eliminating the need for manual credential setup
*/
private async getUserApiKey(nodeData: INodeData, options: ICommonObject): Promise<string | null> {
try {
// Get database connection from options
const appDataSource = options.appDataSource
if (!appDataSource) {
console.error('No database connection available')
return null
}

// Get user ID - handle both contexts:
// 1. Load method context: options.userId
// 2. Workflow execution context: options.user.id
const userId = options.userId || options.user?.id
if (!userId) {
console.error('No user ID available in options')
return null
}

// Get organization ID - handle both contexts:
// 1. Load method context: options.organizationId
// 2. Workflow execution context: options.user.organizationId
const organizationId = options.organizationId || options.user?.organizationId
if (!organizationId) {
console.error('No organization ID available')
return null
}

// Fallback to ApiKey table
const databaseEntities = options.databaseEntities
if (!databaseEntities || !databaseEntities['ApiKey']) {
console.error('ApiKey entity not available in databaseEntities')
return null
}

// Query the database for the user's API keys using the entity from databaseEntities

const apiKeys = await appDataSource.getRepository(databaseEntities['ApiKey']).find({
where: {
userId: userId,
organizationId: organizationId,
isActive: true
},
order: {
updatedDate: 'DESC' // Get the most recently updated key
}
})

if (apiKeys.length === 0) {
console.error('No active API keys found for user')
return null
}

// Return the first (most recent) API key
const selectedApiKey = apiKeys[0].apiKey

return selectedApiKey
} catch (error) {
console.error('Error retrieving user API key:', error)
return null
}
}
}

module.exports = { nodeClass: AnswerAgent_MCP }
73 changes: 63 additions & 10 deletions packages/ui/src/components/SidekickSetupModal.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client'
import { useCallback, useState, useEffect } from 'react'
import { useCallback, useState, useEffect, useContext } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { useDispatch } from 'react-redux'
import dynamic from 'next/dynamic'
Expand All @@ -14,18 +14,36 @@ import useNotifier from '@/utils/useNotifier'

// UI components and utilities
import { IconX } from '@tabler/icons-react'
import { flowContext } from '@/store/context/ReactFlowContext'
import { FLOWISE_CREDENTIAL_ID } from '@/store/constant'
import { useSidekickWithCredentials } from '@/hooks/useSidekickWithCredentials'
import { updateFlowDataWithCredentials } from '@/utils/flowCredentialsHelper'
import { extractAllCredentials, updateFlowDataWithCredentials } from '@/utils/flowCredentialsHelper'
import { getCredentialModalDismissed, setCredentialModalDismissed } from '@/utils/credentialModalPreference'
import useConfirm from '@/hooks/useConfirm'

// Dynamic imports
const UnifiedCredentialsModal = dynamic(() => import('@/ui-component/dialog/UnifiedCredentialsModal'), { ssr: false })
const ConfirmDialog = dynamic(() => import('@/ui-component/dialog/ConfirmDialog'), { ssr: false })

const applyCredentialToNode = (node, credentialAssignments) => {
if (!credentialAssignments[node.id] || !node.data) return node
return {
...node,
data: {
...node.data,
credential: credentialAssignments[node.id],
inputs: {
...node.data.inputs,
[FLOWISE_CREDENTIAL_ID]: credentialAssignments[node.id]
}
}
}
}

const SidekickSetupModal = ({ sidekickId, onComplete }) => {
const { confirm } = useConfirm()
const preferenceScope = sidekickId ? `sidekick:${sidekickId}` : null
const { reactFlowInstance } = useContext(flowContext)

// Local state to track if user has skipped setup for this instance
const [hasSkipped, setHasSkipped] = useState(false)
Expand Down Expand Up @@ -106,12 +124,27 @@ const SidekickSetupModal = ({ sidekickId, onComplete }) => {
async (credentialAssignments, options) => {
try {
if (credentialAssignments && Object.keys(credentialAssignments).length > 0) {
const updatedFlowData = updateFlowDataWithCredentials(sidekick.flowData, credentialAssignments)
await updateSidekick({
flowData: JSON.stringify(updatedFlowData)
})
// Notify canvas and other listeners to refresh with updated credentials
window.dispatchEvent(new CustomEvent('credentials-updated', { detail: { chatflowId: sidekickId } }))
let flowDataForSave
if (reactFlowInstance) {
const rfObject = reactFlowInstance.toObject()
rfObject.nodes = rfObject.nodes.map((node) => applyCredentialToNode(node, credentialAssignments))
flowDataForSave = JSON.stringify(rfObject)
} else {
const updatedFlowData = updateFlowDataWithCredentials(sidekick.flowData, credentialAssignments)
flowDataForSave = JSON.stringify(updatedFlowData)
}
await updateSidekick({ flowData: flowDataForSave })

if (reactFlowInstance) {
reactFlowInstance.setNodes((nodes) => nodes.map((node) => applyCredentialToNode(node, credentialAssignments)))
}

// Skip refetch on canvas to preserve unsaved changes
window.dispatchEvent(
new CustomEvent('credentials-updated', {
detail: { chatflowId: sidekickId, skipRefetch: !!reactFlowInstance }
})
)
enqueueSnackbar({
message: 'Credentials saved successfully!',
options: { variant: 'success' }
Expand All @@ -124,7 +157,16 @@ const SidekickSetupModal = ({ sidekickId, onComplete }) => {
handleModalError('Error assigning credentials. Please try again.')
}
},
[sidekick, updateSidekick, enqueueSnackbar, handleURLCleanup, handleModalError, persistDismissPreference]
[
sidekick,
reactFlowInstance,
updateSidekick,
enqueueSnackbar,
sidekickId,
handleURLCleanup,
handleModalError,
persistDismissPreference
]
)

const handleModalSkip = useCallback(
Expand Down Expand Up @@ -168,12 +210,23 @@ const SidekickSetupModal = ({ sidekickId, onComplete }) => {
return null
}

// When canvas is open, use live nodes for credentials. Otherwise fall back to SWR data.
let effectiveCredentials = credentialsToShow
try {
if (reactFlowInstance) {
const { allCredentials } = extractAllCredentials(reactFlowInstance.toObject())
if (allCredentials?.length) effectiveCredentials = allCredentials
}
} catch (error) {
console.error('[SidekickSetupModal] Failed to extract credentials from canvas:', error)
}

return (
<>
<ConfirmDialog />
<UnifiedCredentialsModal
show={true}
missingCredentials={credentialsToShow}
missingCredentials={effectiveCredentials}
onAssign={handleModalAssign}
onSkip={handleModalSkip}
onCancel={handleModalCancel}
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/ui-component/dropdown/AsyncDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ export const AsyncDropdown = ({
}
const getDefaultOptionValue = () => (multiple ? [] : '')
const addNewOption = [{ label: '- Create New -', name: '-create-' }]
let [internalValue, setInternalValue] = useState(value ?? 'choose an option')
const [internalValue, setInternalValue] = useState(value ?? 'choose an option')

useEffect(() => {
setInternalValue(value ?? 'choose an option')
}, [value])

const { reactFlowInstance } = useContext(flowContext)

const fetchCredentialList = async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/views/agentflowsv2/Canvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,7 @@ const AgentflowCanvas = ({ chatflowid: chatflowId }) => {
// Re-fetch canvas when credentials are updated via the setup modal
useEffect(() => {
const handleCredentialsUpdated = (e) => {
if (e.detail?.chatflowId === chatflowId) {
if (e.detail?.chatflowId === chatflowId && !e.detail?.skipRefetch) {
getSpecificChatflowApi.request(chatflowId)
}
}
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/views/canvas/CredentialInputHandler.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ const CredentialInputHandler = ({ inputParam, data, onSelect, disabled = false }
setCredentialId(data?.credential || (data?.inputs && data.inputs[FLOWISE_CREDENTIAL_ID]) || '')
}, [data])

useEffect(() => {
const handleCredentialsUpdated = () => {
setReloadTimestamp(Date.now().toString())
}
window.addEventListener('credentials-updated', handleCredentialsUpdated)
return () => window.removeEventListener('credentials-updated', handleCredentialsUpdated)
}, [])

return (
<div ref={ref}>
{inputParam && (
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/views/canvas/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ const Canvas = ({ chatflowid: chatflowId }) => {
// Re-fetch canvas when credentials are updated via the setup modal
useEffect(() => {
const handleCredentialsUpdated = (e) => {
if (e.detail?.chatflowId === chatflowId) {
if (e.detail?.chatflowId === chatflowId && !e.detail?.skipRefetch) {
getSpecificChatflowApi.request(chatflowId)
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/views/marketplaces/MarketplaceCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const MarketplaceCanvas = () => {
<Box>
<AppBar
enableColorOnDark
position='fixed'
position='relative'
color='inherit'
elevation={1}
sx={{
Expand Down
Loading