feat: enhance agent tools with end user credential support

This commit is contained in:
zhsama 2025-11-27 17:44:04 +08:00
parent 1400b9c6e2
commit cb4670cd68
7 changed files with 376 additions and 52 deletions

View File

@ -33,7 +33,7 @@ import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTo
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useMittContextSelector } from '@/context/mitt-context'
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
type AgentToolWithMoreInfo = (AgentTool & { icon: any; collection?: Collection; use_end_user_credentials?: boolean; end_user_credential_type?: string }) | null
const AgentTools: FC = () => {
const { t } = useTranslation()
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
@ -102,7 +102,9 @@ const AgentTools: FC = () => {
tool_parameters: tool.params,
notAuthor: !tool.is_team_authorization,
enabled: true,
}
use_end_user_credentials: false,
end_user_credential_type: '',
} as any
}
const handleSelectTool = (tool: ToolDefaultValue) => {
const newModelConfig = produce(modelConfig, (draft) => {
@ -138,6 +140,34 @@ const AgentTools: FC = () => {
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
const handleEndUserCredentialChange = useCallback((enabled: boolean) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
if (tool)
(tool as AgentTool).use_end_user_credentials = enabled
})
setCurrentTool({
...currentTool,
use_end_user_credentials: enabled,
} as any)
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
const handleEndUserCredentialTypeChange = useCallback((type: string) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
if (tool)
(tool as AgentTool).end_user_credential_type = type
})
setCurrentTool({
...currentTool,
end_user_credential_type: type,
} as any)
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
return (
<>
<Panel
@ -315,6 +345,10 @@ const AgentTools: FC = () => {
onHide={() => setIsShowSettingTool(false)}
credentialId={currentTool?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
useEndUserCredentialEnabled={currentTool?.use_end_user_credentials}
endUserCredentialType={currentTool?.end_user_credential_type}
onEndUserCredentialChange={handleEndUserCredentialChange}
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
/>
)}
</>

View File

@ -42,6 +42,10 @@ type Props = {
onSave?: (value: Record<string, any>) => void
credentialId?: string
onAuthorizationItemClick?: (id: string) => void
useEndUserCredentialEnabled?: boolean
endUserCredentialType?: string
onEndUserCredentialChange?: (enabled: boolean) => void
onEndUserCredentialTypeChange?: (type: string) => void
}
const SettingBuiltInTool: FC<Props> = ({
@ -56,6 +60,10 @@ const SettingBuiltInTool: FC<Props> = ({
onSave,
credentialId,
onAuthorizationItemClick,
useEndUserCredentialEnabled,
endUserCredentialType,
onEndUserCredentialChange,
onEndUserCredentialTypeChange,
}) => {
const { locale } = useContext(I18n)
const language = getLanguage(locale)
@ -220,6 +228,10 @@ const SettingBuiltInTool: FC<Props> = ({
}}
credentialId={credentialId}
onAuthorizationItemClick={onAuthorizationItemClick}
useEndUserCredentialEnabled={useEndUserCredentialEnabled}
endUserCredentialType={endUserCredentialType}
onEndUserCredentialChange={onEndUserCredentialChange}
onEndUserCredentialTypeChange={onEndUserCredentialTypeChange}
/>
)
}

View File

@ -13,6 +13,7 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)
const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY)
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
const hasOAuthClientConfigured = data?.is_oauth_custom_client_enabled
return {
isAuthorized,
@ -22,5 +23,6 @@ export const usePluginAuth = (pluginPayload: PluginPayload, enable?: boolean) =>
disabled: !isCurrentWorkspaceManager,
notAllowCustomCredential: data?.allow_custom_token === false,
invalidPluginCredentialInfo,
hasOAuthClientConfigured: !!hasOAuthClientConfigured,
}
}

View File

@ -1,12 +1,23 @@
import {
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { RiArrowDownSLine } from '@remixicon/react'
import type { ReactNode } from 'react'
import {
RiAddLine,
RiArrowDownSLine,
RiEqualizer2Line,
RiKey2Line,
RiUserStarLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Authorize from './authorize'
import Authorized from './authorized'
import AddOAuthButton from './authorize/add-oauth-button'
import AddApiKeyButton from './authorize/add-api-key-button'
import type {
Credential,
PluginPayload,
@ -15,19 +26,35 @@ import { usePluginAuth } from './hooks/use-plugin-auth'
import Button from '@/app/components/base/button'
import Indicator from '@/app/components/header/indicator'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Switch from '@/app/components/base/switch'
type PluginAuthInAgentProps = {
pluginPayload: PluginPayload
credentialId?: string
onAuthorizationItemClick?: (id: string) => void
useEndUserCredentialEnabled?: boolean
endUserCredentialType?: string
onEndUserCredentialChange?: (enabled: boolean) => void
onEndUserCredentialTypeChange?: (type: string) => void
}
const PluginAuthInAgent = ({
pluginPayload,
credentialId,
onAuthorizationItemClick,
useEndUserCredentialEnabled,
endUserCredentialType,
onEndUserCredentialChange,
onEndUserCredentialTypeChange,
}: PluginAuthInAgentProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [showAddMenu, setShowAddMenu] = useState(false)
const [showEndUserTypeMenu, setShowEndUserTypeMenu] = useState(false)
const {
isAuthorized,
canOAuth,
@ -36,6 +63,7 @@ const PluginAuthInAgent = ({
disabled,
invalidPluginCredentialInfo,
notAllowCustomCredential,
hasOAuthClientConfigured,
} = usePluginAuth(pluginPayload, true)
const extraAuthorizationItems: Credential[] = [
@ -94,10 +122,219 @@ const PluginAuthInAgent = ({
)
}, [credentialId, credentials, t])
const availableEndUserTypes = useMemo(() => {
const list: { value: string; label: string; icon: ReactNode }[] = []
if (canOAuth) {
list.push({
value: 'oauth2',
label: t('plugin.auth.endUserCredentials.optionOAuth'),
icon: <RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />,
})
}
if (canApiKey) {
list.push({
value: 'api-key',
label: t('plugin.auth.endUserCredentials.optionApiKey'),
icon: <RiKey2Line className='h-4 w-4 text-text-tertiary' />,
})
}
return list
}, [canOAuth, canApiKey, t])
const endUserCredentialLabel = useMemo(() => {
const found = availableEndUserTypes.find(item => item.value === endUserCredentialType)
return found?.label || availableEndUserTypes[0]?.label || '-'
}, [availableEndUserTypes, endUserCredentialType])
useEffect(() => {
if (!useEndUserCredentialEnabled)
return
if (!availableEndUserTypes.length)
return
const isValid = availableEndUserTypes.some(item => item.value === endUserCredentialType)
if (!isValid)
onEndUserCredentialTypeChange?.(availableEndUserTypes[0].value)
}, [useEndUserCredentialEnabled, endUserCredentialType, availableEndUserTypes, onEndUserCredentialTypeChange])
const handleSelectEndUserType = useCallback((value: string) => {
onEndUserCredentialTypeChange?.(value)
setShowEndUserTypeMenu(false)
}, [onEndUserCredentialTypeChange])
const shouldShowAuthorizeCard = !credentials.length && (canOAuth || canApiKey || hasOAuthClientConfigured)
const endUserSection = (
<div className='flex items-start rounded-lg border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-3 py-3'>
<RiUserStarLine className='mt-0.5 h-4 w-4 text-text-tertiary' />
<div className='flex-1 space-y-3'>
<div className='flex items-center justify-between gap-3'>
<div className='space-y-1'>
<div className='system-sm-semibold text-text-primary'>
{t('plugin.auth.endUserCredentials.title')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.auth.endUserCredentials.desc')}
</div>
</div>
<Switch
size='md'
defaultValue={!!useEndUserCredentialEnabled}
onChange={onEndUserCredentialChange}
disabled={disabled}
/>
</div>
{
useEndUserCredentialEnabled && availableEndUserTypes.length > 0 && (
<div className='flex items-center justify-between gap-3'>
<div className='system-sm-semibold text-text-primary'>
{t('plugin.auth.endUserCredentials.typeLabel')}
</div>
<PortalToFollowElem
open={showEndUserTypeMenu}
onOpenChange={setShowEndUserTypeMenu}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className='border-components-input-border flex h-9 min-w-[190px] items-center justify-between rounded-lg border bg-components-input-bg-normal px-3 text-left text-text-primary shadow-xs hover:bg-components-input-bg-hover'
onClick={() => setShowEndUserTypeMenu(v => !v)}
>
<span className='system-sm-semibold'>{endUserCredentialLabel}</span>
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col gap-1 p-1'>
{canOAuth && (
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled}
onUpdate={() => {
handleSelectEndUserType('oauth2')
invalidPluginCredentialInfo()
}}
/>
)}
{canApiKey && (
<AddApiKeyButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled}
onUpdate={() => {
handleSelectEndUserType('api-key')
invalidPluginCredentialInfo()
}}
/>
)}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
</div>
</div>
)
return (
<>
<div className='border-components-panel-border bg-components-panel-bg'>
<div className='flex items-start justify-between gap-2'>
<div className='flex items-start gap-2'>
<RiKey2Line className='mt-0.5 h-4 w-4 text-text-tertiary' />
<div className='space-y-0.5'>
<div className='system-md-semibold text-text-primary'>
{t('plugin.auth.configuredCredentials.title')}
</div>
<div className='system-xs-regular text-text-tertiary'>
{t('plugin.auth.configuredCredentials.desc')}
</div>
</div>
</div>
<PortalToFollowElem
open={showAddMenu}
onOpenChange={setShowAddMenu}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full bg-primary-600 text-white hover:bg-primary-700',
(disabled || (!canOAuth && !canApiKey && !hasOAuthClientConfigured)) && 'pointer-events-none opacity-50',
)}
onClick={() => setShowAddMenu(v => !v)}
>
<RiAddLine className='h-5 w-5' />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col gap-1 p-1'>
{
canOAuth && (
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
invalidPluginCredentialInfo()
}}
/>
)
}
{
canApiKey && (
<AddApiKeyButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
invalidPluginCredentialInfo()
}}
/>
)
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
{
!isAuthorized && (
!isAuthorized && shouldShowAuthorizeCard && (
<div className='rounded-xl bg-background-section px-4 py-4'>
<div className='flex w-full justify-center'>
<div className='w-full max-w-[520px]'>
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
theme='secondary'
showDivider={!!(canOAuth && canApiKey)}
/>
</div>
</div>
</div>
)
}
{
!isAuthorized && !shouldShowAuthorizeCard && (
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
@ -129,7 +366,8 @@ const PluginAuthInAgent = ({
/>
)
}
</>
{endUserSection}
</div>
)
}

View File

@ -9,7 +9,6 @@ import type { ReactNode } from 'react'
import {
RiAddLine,
RiArrowDownSLine,
RiCheckLine,
RiEqualizer2Line,
RiKey2Line,
RiUserStarLine,
@ -20,7 +19,6 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { SimpleSelect } from '@/app/components/base/select'
import Authorize from './authorize'
import Authorized from './authorized'
import AddApiKeyButton from './authorize/add-api-key-button'
@ -60,11 +58,18 @@ const PluginAuth = ({
disabled,
invalidPluginCredentialInfo,
notAllowCustomCredential,
hasOAuthClientConfigured,
} = usePluginAuth(pluginPayload, !!pluginPayload.provider)
const shouldShowGuide = !!showConnectGuide
const [showCredentialPanel, setShowCredentialPanel] = useState(false)
const [showAddMenu, setShowAddMenu] = useState(false)
const [showEndUserTypeMenu, setShowEndUserTypeMenu] = useState(false)
const configuredDisabled = !!endUserCredentialEnabled
const shouldShowAuthorizeCard = useMemo(() => {
const hasCredential = credentials.length > 0
const canAdd = canOAuth || canApiKey || hasOAuthClientConfigured
return !hasCredential && canAdd
}, [credentials.length, canOAuth, canApiKey, hasOAuthClientConfigured])
const availableEndUserTypes = useMemo(() => {
const list: { value: string; label: string; icon: ReactNode }[] = []
if (canOAuth) {
@ -100,6 +105,7 @@ const PluginAuth = ({
const handleSelectEndUserType = useCallback((value: string) => {
onEndUserCredentialTypeChange?.(value)
setShowEndUserTypeMenu(false)
}, [onEndUserCredentialTypeChange])
const containerClassName = useMemo(() => {
if (showConnectGuide)
@ -165,43 +171,54 @@ const PluginAuth = ({
<div className='system-sm-semibold text-text-primary'>
{t('plugin.auth.endUserCredentials.typeLabel')}
</div>
<SimpleSelect
wrapperClassName='w-[190px]'
items={availableEndUserTypes.map(item => ({
value: item.value,
name: item.label,
icon: item.icon,
}))}
defaultValue={endUserCredentialType || availableEndUserTypes[0]?.value}
disabled={disabled}
onSelect={item => handleSelectEndUserType(item.value as string)}
renderTrigger={(value, open) => (
<PortalToFollowElem
open={showEndUserTypeMenu}
onOpenChange={setShowEndUserTypeMenu}
placement='bottom-end'
offset={6}
>
<PortalToFollowElemTrigger asChild>
<button
type='button'
className='border-components-input-border flex h-9 w-full items-center justify-between rounded-lg border bg-components-input-bg-normal px-3 text-left text-text-primary shadow-xs hover:bg-components-input-bg-hover'
className='border-components-input-border flex h-9 min-w-[190px] items-center justify-between rounded-lg border bg-components-input-bg-normal px-3 text-left text-text-primary shadow-xs hover:bg-components-input-bg-hover'
onClick={() => setShowEndUserTypeMenu(v => !v)}
>
<span className='system-sm-semibold'>{value?.name || endUserCredentialLabel}</span>
<RiArrowDownSLine
className={cn(
'h-4 w-4 text-text-tertiary transition-transform',
open && 'rotate-180',
)}
/>
<span className='system-sm-semibold'>{endUserCredentialLabel}</span>
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
</button>
)}
renderOption={({ item, selected }) => (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
{item.icon}
<span className='system-sm-semibold text-text-primary'>{item.name}</span>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[120]'>
<div className='w-[220px] rounded-xl border border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='flex flex-col gap-1 p-1'>
{canOAuth && (
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled}
onUpdate={() => {
handleSelectEndUserType('oauth2')
invalidPluginCredentialInfo()
}}
/>
)}
{canApiKey && (
<AddApiKeyButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled}
onUpdate={() => {
handleSelectEndUserType('api-key')
invalidPluginCredentialInfo()
}}
/>
)}
</div>
{selected && <RiCheckLine className='h-4 w-4 text-text-accent' />}
</div>
)}
optionWrapClassName='p-1'
optionClassName='px-3 py-2 rounded-lg'
hideChecked
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
@ -282,8 +299,9 @@ const PluginAuth = ({
<AddOAuthButton
pluginPayload={pluginPayload}
buttonVariant='ghost'
className='w-full justify-between bg-transparent text-text-primary hover:bg-transparent'
buttonText={t('plugin.auth.addOAuth')}
disabled={disabled || configuredDisabled}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
invalidPluginCredentialInfo()
@ -297,7 +315,7 @@ const PluginAuth = ({
pluginPayload={pluginPayload}
buttonVariant='ghost'
buttonText={t('plugin.auth.addApi')}
disabled={disabled || configuredDisabled}
disabled={disabled}
onUpdate={() => {
setShowAddMenu(false)
invalidPluginCredentialInfo()
@ -314,22 +332,24 @@ const PluginAuth = ({
{credentialList}
</div>
{
credentials.length === 0 && (
shouldShowAuthorizeCard && (
<div className={cn(
'mt-4 flex items-start gap-1.5 rounded-xl bg-background-section px-4 py-8',
configuredDisabled && 'pointer-events-none opacity-50',
)}>
<div className='flex w-full justify-center'>
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled || configuredDisabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
theme='secondary'
showDivider={!!(canOAuth && canApiKey)}
/>
<div className='w-full max-w-[520px]'>
<Authorize
pluginPayload={pluginPayload}
canOAuth={canOAuth}
canApiKey={canApiKey}
disabled={disabled || configuredDisabled}
onUpdate={invalidPluginCredentialInfo}
notAllowCustomCredential={notAllowCustomCredential}
theme='secondary'
showDivider={!!(canOAuth && canApiKey)}
/>
</div>
</div>
</div>
)

View File

@ -210,6 +210,18 @@ const ToolSelector: FC<Props> = ({
credential_id: id,
} as any)
}
const handleEndUserCredentialChange = (enabled: boolean) => {
onSelect({
...value,
use_end_user_credentials: enabled,
} as any)
}
const handleEndUserCredentialTypeChange = (type: string) => {
onSelect({
...value,
end_user_credential_type: type,
} as any)
}
return (
<>
@ -323,6 +335,10 @@ const ToolSelector: FC<Props> = ({
}}
credentialId={value?.credential_id}
onAuthorizationItemClick={handleAuthorizationItemClick}
useEndUserCredentialEnabled={value?.use_end_user_credentials}
endUserCredentialType={value?.end_user_credential_type}
onEndUserCredentialChange={handleEndUserCredentialChange}
onEndUserCredentialTypeChange={handleEndUserCredentialTypeChange}
/>
</div>
</>

View File

@ -130,6 +130,8 @@ export type AgentTool = {
isDeleted?: boolean
notAuthor?: boolean
credential_id?: string
use_end_user_credentials?: boolean
end_user_credential_type?: string
}
export type ToolItem = {