mirror of
https://github.com/langgenius/dify.git
synced 2026-04-18 12:28:32 +08:00
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
'use client'
|
|
import type { TFunction } from 'i18next'
|
|
import type { FC, ReactNode } from 'react'
|
|
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
|
|
import type { AppDetailResponse } from '@/models/app'
|
|
import type { AppSSO } from '@/types/app'
|
|
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import Button from '@/app/components/base/button'
|
|
import Confirm from '@/app/components/base/confirm'
|
|
import CopyFeedback from '@/app/components/base/copy-feedback'
|
|
import Divider from '@/app/components/base/divider'
|
|
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
|
import Switch from '@/app/components/base/switch'
|
|
import Tooltip from '@/app/components/base/tooltip'
|
|
import Indicator from '@/app/components/header/indicator'
|
|
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
|
|
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
|
import { useDocLink } from '@/context/i18n'
|
|
import { useInvalidateMCPServerDetail } from '@/service/use-tools'
|
|
import { cn } from '@/utils/classnames'
|
|
import { useMCPServiceCardState } from './hooks/use-mcp-service-card'
|
|
|
|
// Sub-components
|
|
type StatusIndicatorProps = {
|
|
serverActivated: boolean
|
|
}
|
|
|
|
const StatusIndicator: FC<StatusIndicatorProps> = ({ serverActivated }) => {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<Indicator color={serverActivated ? 'green' : 'yellow'} />
|
|
<div className={cn('system-xs-semibold-uppercase', serverActivated ? 'text-text-success' : 'text-text-warning')}>
|
|
{serverActivated
|
|
? t('overview.status.running', { ns: 'appOverview' })
|
|
: t('overview.status.disable', { ns: 'appOverview' })}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type ServerURLSectionProps = {
|
|
serverURL: string
|
|
serverPublished: boolean
|
|
isCurrentWorkspaceManager: boolean
|
|
genLoading: boolean
|
|
onRegenerate: () => void
|
|
}
|
|
|
|
const ServerURLSection: FC<ServerURLSectionProps> = ({
|
|
serverURL,
|
|
serverPublished,
|
|
isCurrentWorkspaceManager,
|
|
genLoading,
|
|
onRegenerate,
|
|
}) => {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<div className="flex flex-col items-start justify-center self-stretch">
|
|
<div className="system-xs-medium pb-1 text-text-tertiary">
|
|
{t('mcp.server.url', { ns: 'tools' })}
|
|
</div>
|
|
<div className="inline-flex h-9 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 pl-2">
|
|
<div className="flex h-4 min-w-0 flex-1 items-start justify-start gap-2 px-1">
|
|
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-xs font-medium text-text-secondary">
|
|
{serverURL}
|
|
</div>
|
|
</div>
|
|
{serverPublished && (
|
|
<>
|
|
<CopyFeedback content={serverURL} className="size-6!" />
|
|
<Divider type="vertical" className="mx-0.5! h-3.5! shrink-0" />
|
|
{isCurrentWorkspaceManager && (
|
|
<Tooltip popupContent={t('overview.appInfo.regenerate', { ns: 'appOverview' }) || ''}>
|
|
<div
|
|
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
|
onClick={onRegenerate}
|
|
>
|
|
<RiLoopLeftLine className={cn('h-4 w-4 text-text-tertiary hover:text-text-secondary', genLoading && 'animate-spin')} />
|
|
</div>
|
|
</Tooltip>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type TriggerModeOverlayProps = {
|
|
triggerModeMessage: ReactNode
|
|
}
|
|
|
|
const TriggerModeOverlay: FC<TriggerModeOverlayProps> = ({ triggerModeMessage }) => {
|
|
if (triggerModeMessage) {
|
|
return (
|
|
<Tooltip
|
|
popupContent={triggerModeMessage}
|
|
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
|
|
position="right"
|
|
>
|
|
<div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
return <div className="absolute inset-0 z-10 cursor-not-allowed rounded-xl" aria-hidden="true"></div>
|
|
}
|
|
|
|
// Helper function for tooltip content
|
|
type TooltipContentParams = {
|
|
toggleDisabled: boolean
|
|
appUnpublished: boolean
|
|
missingStartNode: boolean
|
|
triggerModeMessage: ReactNode
|
|
t: TFunction
|
|
docLink: ReturnType<typeof useDocLink>
|
|
}
|
|
|
|
function getTooltipContent({
|
|
toggleDisabled,
|
|
appUnpublished,
|
|
missingStartNode,
|
|
triggerModeMessage,
|
|
t,
|
|
docLink,
|
|
}: TooltipContentParams): ReactNode {
|
|
if (!toggleDisabled)
|
|
return ''
|
|
|
|
if (appUnpublished)
|
|
return t('mcp.server.publishTip', { ns: 'tools' })
|
|
|
|
if (missingStartNode) {
|
|
return (
|
|
<>
|
|
<div className="mb-1 text-xs font-normal text-text-secondary">
|
|
{t('overview.appInfo.enableTooltip.description', { ns: 'appOverview' })}
|
|
</div>
|
|
<div
|
|
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
|
|
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
|
|
>
|
|
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return triggerModeMessage || ''
|
|
}
|
|
|
|
// Main component
|
|
type IAppCardProps = {
|
|
appInfo: AppDetailResponse & Partial<AppSSO>
|
|
triggerModeDisabled?: boolean
|
|
triggerModeMessage?: ReactNode
|
|
}
|
|
|
|
const MCPServiceCard: FC<IAppCardProps> = ({
|
|
appInfo,
|
|
triggerModeDisabled = false,
|
|
triggerModeMessage = '',
|
|
}) => {
|
|
const { t } = useTranslation()
|
|
const docLink = useDocLink()
|
|
const appId = appInfo.id
|
|
const invalidateMCPServerDetail = useInvalidateMCPServerDetail()
|
|
const invalidateMCPServerDetailRef = useRef(invalidateMCPServerDetail)
|
|
|
|
useEffect(() => {
|
|
invalidateMCPServerDetailRef.current = invalidateMCPServerDetail
|
|
}, [invalidateMCPServerDetail])
|
|
|
|
const {
|
|
genLoading,
|
|
isLoading,
|
|
serverPublished,
|
|
serverActivated,
|
|
serverURL,
|
|
detail,
|
|
isCurrentWorkspaceManager,
|
|
toggleDisabled,
|
|
isMinimalState,
|
|
appUnpublished,
|
|
missingStartNode,
|
|
showConfirmDelete,
|
|
showMCPServerModal,
|
|
latestParams,
|
|
handleGenCode,
|
|
handleStatusChange,
|
|
handleServerModalHide,
|
|
openConfirmDelete,
|
|
closeConfirmDelete,
|
|
openServerModal,
|
|
} = useMCPServiceCardState(appInfo, triggerModeDisabled)
|
|
|
|
// Pending status for optimistic updates (null means use server state)
|
|
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
|
|
const activated = pendingStatus ?? serverActivated
|
|
|
|
const emitMcpServerUpdate = async (data: Record<string, unknown>) => {
|
|
try {
|
|
const { webSocketClient } = await import('@/app/components/workflow/collaboration/core/websocket-manager')
|
|
const socket = webSocketClient.getSocket(appId)
|
|
if (!socket)
|
|
return
|
|
|
|
const timestamp = Date.now()
|
|
socket.emit('collaboration_event', {
|
|
type: 'mcp_server_update',
|
|
data: {
|
|
...data,
|
|
timestamp,
|
|
},
|
|
timestamp,
|
|
})
|
|
}
|
|
catch (error) {
|
|
console.error('MCP collaboration event emit failed:', error)
|
|
}
|
|
}
|
|
|
|
const onChangeStatus = async (state: boolean) => {
|
|
setPendingStatus(state)
|
|
const result = await handleStatusChange(state)
|
|
if (!result.activated && state) {
|
|
// Server modal was opened instead, clear pending status
|
|
setPendingStatus(null)
|
|
}
|
|
|
|
if (result.activated !== state)
|
|
return
|
|
|
|
// Emit collaboration event to notify other clients of MCP server status change
|
|
void emitMcpServerUpdate({
|
|
action: 'statusChanged',
|
|
status: state ? 'active' : 'inactive',
|
|
})
|
|
}
|
|
|
|
const onServerModalHide = () => {
|
|
handleServerModalHide(serverActivated)
|
|
// Clear pending status when modal closes to sync with server state
|
|
setPendingStatus(null)
|
|
}
|
|
|
|
const onConfirmRegenerate = () => {
|
|
closeConfirmDelete()
|
|
|
|
void (async () => {
|
|
await handleGenCode()
|
|
|
|
// Emit collaboration event to notify other clients of MCP server code changes
|
|
await emitMcpServerUpdate({
|
|
action: 'codeRegenerated',
|
|
})
|
|
})()
|
|
}
|
|
|
|
// Listen for collaborative MCP server updates from other clients
|
|
useEffect(() => {
|
|
if (!appId)
|
|
return
|
|
|
|
const unsubscribe = collaborationManager.onMcpServerUpdate((_update: CollaborationUpdate) => {
|
|
try {
|
|
invalidateMCPServerDetailRef.current(appId)
|
|
}
|
|
catch (error) {
|
|
console.error('MCP server update failed:', error)
|
|
}
|
|
})
|
|
|
|
return unsubscribe
|
|
}, [appId])
|
|
|
|
if (isLoading)
|
|
return null
|
|
|
|
const tooltipContent = getTooltipContent({
|
|
toggleDisabled,
|
|
appUnpublished,
|
|
missingStartNode,
|
|
triggerModeMessage,
|
|
t,
|
|
docLink,
|
|
})
|
|
|
|
return (
|
|
<>
|
|
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
|
|
<div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}>
|
|
{triggerModeDisabled && (
|
|
<TriggerModeOverlay triggerModeMessage={triggerModeMessage} />
|
|
)}
|
|
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
|
|
<div className="flex w-full items-center gap-3 self-stretch">
|
|
<div className="flex grow items-center">
|
|
<div className="mr-2 shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md">
|
|
<Mcp className="h-4 w-4 text-text-primary-on-surface" />
|
|
</div>
|
|
<div className="group w-full">
|
|
<div className="system-md-semibold min-w-0 overflow-hidden text-ellipsis break-normal text-text-secondary group-hover:text-text-primary">
|
|
{t('mcp.server.title', { ns: 'tools' })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<StatusIndicator serverActivated={serverActivated} />
|
|
<Tooltip
|
|
popupContent={tooltipContent}
|
|
position="right"
|
|
popupClassName="w-58 max-w-60 rounded-xl bg-components-panel-bg px-3.5 py-3 shadow-lg"
|
|
offset={24}
|
|
>
|
|
<div>
|
|
<Switch value={activated} onChange={onChangeStatus} disabled={toggleDisabled} />
|
|
</div>
|
|
</Tooltip>
|
|
</div>
|
|
{!isMinimalState && (
|
|
<ServerURLSection
|
|
serverURL={serverURL}
|
|
serverPublished={serverPublished}
|
|
isCurrentWorkspaceManager={isCurrentWorkspaceManager}
|
|
genLoading={genLoading}
|
|
onRegenerate={openConfirmDelete}
|
|
/>
|
|
)}
|
|
</div>
|
|
{!isMinimalState && (
|
|
<div className="flex items-center gap-1 self-stretch p-3">
|
|
<Button
|
|
disabled={toggleDisabled}
|
|
size="small"
|
|
variant="ghost"
|
|
onClick={openServerModal}
|
|
>
|
|
<div className="flex items-center justify-center gap-px">
|
|
<RiEditLine className="h-3.5 w-3.5" />
|
|
<div className="system-xs-medium px-[3px] text-text-tertiary">
|
|
{serverPublished ? t('mcp.server.edit', { ns: 'tools' }) : t('mcp.server.addDescription', { ns: 'tools' })}
|
|
</div>
|
|
</div>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{showMCPServerModal && (
|
|
<MCPServerModal
|
|
show={showMCPServerModal}
|
|
appID={appId}
|
|
data={serverPublished ? detail : undefined}
|
|
latestParams={latestParams}
|
|
onHide={onServerModalHide}
|
|
appInfo={appInfo}
|
|
/>
|
|
)}
|
|
|
|
{showConfirmDelete && (
|
|
<Confirm
|
|
type="warning"
|
|
title={t('overview.appInfo.regenerate', { ns: 'appOverview' })}
|
|
content={t('mcp.server.reGen', { ns: 'tools' })}
|
|
isShow={showConfirmDelete}
|
|
onConfirm={onConfirmRegenerate}
|
|
onCancel={closeConfirmDelete}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default MCPServiceCard
|