mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
Merge branch 'feat/collaboration2' into feat/support-agent-sandbox
This commit is contained in:
@ -14,6 +14,8 @@ NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX=http://localhost:5001/api
|
||||
# When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1.
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
# WebSocket server URL.
|
||||
NEXT_PUBLIC_SOCKET_URL=ws://localhost:5001
|
||||
|
||||
# The API PREFIX for MARKETPLACE
|
||||
NEXT_PUBLIC_MARKETPLACE_API_PREFIX=https://marketplace.dify.ai/api/v1
|
||||
|
||||
@ -43,6 +43,8 @@ NEXT_PUBLIC_EDITION=SELF_HOSTED
|
||||
# example: http://cloud.dify.ai/console/api
|
||||
NEXT_PUBLIC_API_PREFIX=http://localhost:5001/console/api
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
# WebSocket server URL.
|
||||
NEXT_PUBLIC_SOCKET_URL=ws://localhost:5001
|
||||
# The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from
|
||||
# console or api domain.
|
||||
# example: http://udify.app/api
|
||||
|
||||
@ -5,7 +5,8 @@ import type { BlockEnum } from '@/app/components/workflow/types'
|
||||
import type { UpdateAppSiteCodeResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import AppCard from '@/app/components/app/overview/app-card'
|
||||
@ -14,6 +15,8 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { isTriggerNode } from '@/app/components/workflow/types'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import {
|
||||
@ -74,28 +77,59 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
|
||||
? buildTriggerModeMessage(t('mcp.server.title', { ns: 'tools' }))
|
||||
: null
|
||||
|
||||
const updateAppDetail = async () => {
|
||||
const updateAppDetail = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appId })
|
||||
setAppDetail({ ...res })
|
||||
}
|
||||
catch (error) { console.error(error) }
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}, [appId, setAppDetail])
|
||||
|
||||
const handleCallbackResult = (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
|
||||
const type = err ? 'error' : 'success'
|
||||
|
||||
message ||= (type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully')
|
||||
|
||||
if (type === 'success')
|
||||
if (type === 'success') {
|
||||
updateAppDetail()
|
||||
|
||||
// Emit collaboration event to notify other clients of app state changes
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_state_update',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
notify({
|
||||
type,
|
||||
message: t(`actionMsg.${message}`, { ns: 'common' }) as string,
|
||||
})
|
||||
}
|
||||
|
||||
// Listen for collaborative app state updates from other clients
|
||||
useEffect(() => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onAppStateUpdate(async () => {
|
||||
try {
|
||||
// Update app detail when other clients modify app state
|
||||
await updateAppDetail()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('app state update failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, updateAppDetail])
|
||||
|
||||
const onChangeSiteStatus = async (value: boolean) => {
|
||||
const [err] = await asyncRunSafe<App>(
|
||||
updateAppSiteStatus({
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
|
||||
@ -22,10 +22,12 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ContentDialog from '@/app/components/base/content-dialog'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import { copyApp, deleteApp, exportAppConfig, fetchAppDetail, updateAppInfo } from '@/service/apps'
|
||||
import { useInvalidateAppList } from '@/service/use-apps'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -77,6 +79,19 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
const [showExportWarning, setShowExportWarning] = useState(false)
|
||||
|
||||
const emitAppMetaUpdate = useCallback(() => {
|
||||
if (!appDetail?.id)
|
||||
return
|
||||
const socket = webSocketClient.getSocket(appDetail.id)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_meta_update',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}, [appDetail])
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
icon_type,
|
||||
@ -105,11 +120,12 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
message: t('editDone', { ns: 'app' }),
|
||||
})
|
||||
setAppDetail(app)
|
||||
emitAppMetaUpdate()
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('editFailed', { ns: 'app' }) })
|
||||
}
|
||||
}, [appDetail, notify, setAppDetail, t])
|
||||
}, [appDetail, notify, setAppDetail, t, emitAppMetaUpdate])
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
|
||||
if (!appDetail)
|
||||
@ -207,6 +223,23 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
setShowConfirmDelete(false)
|
||||
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!appDetail?.id)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onAppMetaUpdate(async () => {
|
||||
try {
|
||||
const res = await fetchAppDetail({ url: '/apps', id: appDetail.id })
|
||||
setAppDetail({ ...res })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('failed to refresh app detail from collaboration update:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appDetail?.id, setAppDetail])
|
||||
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
if (!appDetail)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
@ -13,7 +14,7 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
type Props = Omit<AppPublisherProps, 'onPublish'> & {
|
||||
onPublish?: (modelAndParameter?: ModelAndParameter, features?: any) => Promise<any> | any
|
||||
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
|
||||
publishedConfig?: any
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
@ -62,8 +63,8 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
|
||||
const handlePublish = useCallback((modelAndParameter?: ModelAndParameter) => {
|
||||
return props.onPublish?.(modelAndParameter, features)
|
||||
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
return props.onPublish?.(params, features)
|
||||
}, [features, props])
|
||||
|
||||
return (
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import {
|
||||
@ -18,6 +20,7 @@ import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
@ -35,6 +38,9 @@ import {
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
@ -43,6 +49,8 @@ import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { useInvalidateAppWorkflow } from '@/service/use-workflow'
|
||||
import { fetchPublishedWorkflow } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import Divider from '../../base/divider'
|
||||
@ -56,6 +64,10 @@ import SuggestedAction from './suggested-action'
|
||||
|
||||
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
|
||||
|
||||
type InstalledAppsResponse = {
|
||||
installed_apps?: InstalledApp[]
|
||||
}
|
||||
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: React.ElementType }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
label: 'organization',
|
||||
@ -102,8 +114,8 @@ export type AppPublisherProps = {
|
||||
debugWithMultipleModel?: boolean
|
||||
multipleModelConfigs?: ModelAndParameter[]
|
||||
/** modelAndParameter is passed when debugWithMultipleModel is true */
|
||||
onPublish?: (params?: any) => Promise<any> | any
|
||||
onRestore?: () => Promise<any> | any
|
||||
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> | void
|
||||
onRestore?: () => Promise<void> | void
|
||||
onToggle?: (state: boolean) => void
|
||||
crossAxisOffset?: number
|
||||
toolPublished?: boolean
|
||||
@ -146,6 +158,7 @@ const AppPublisher = ({
|
||||
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
|
||||
const workflowStore = useContext(WorkflowContext)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
@ -158,6 +171,7 @@ const AppPublisher = ({
|
||||
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const invalidateAppWorkflow = useInvalidateAppWorkflow()
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
|
||||
@ -193,12 +207,39 @@ const AppPublisher = ({
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
setPublished(true)
|
||||
|
||||
const appId = appDetail?.id
|
||||
const socket = appId ? webSocketClient.getSocket(appId) : null
|
||||
console.warn('[app-publisher] publish success', {
|
||||
appId,
|
||||
hasSocket: Boolean(socket),
|
||||
})
|
||||
if (appId)
|
||||
invalidateAppWorkflow(appId)
|
||||
else
|
||||
console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit')
|
||||
if (socket) {
|
||||
const timestamp = Date.now()
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_publish_update',
|
||||
data: {
|
||||
action: 'published',
|
||||
timestamp,
|
||||
},
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
else if (appId) {
|
||||
console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId })
|
||||
}
|
||||
|
||||
trackEvent('app_published_time', { action_mode: 'app', app_id: appDetail?.id, app_name: appDetail?.name })
|
||||
}
|
||||
catch {
|
||||
catch (error) {
|
||||
console.warn('[app-publisher] publish failed', error)
|
||||
setPublished(false)
|
||||
}
|
||||
}, [appDetail, onPublish])
|
||||
}, [appDetail, onPublish, invalidateAppWorkflow])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
try {
|
||||
@ -227,9 +268,10 @@ const AppPublisher = ({
|
||||
await openAsyncWindow(async () => {
|
||||
if (!appDetail?.id)
|
||||
throw new Error('App not found')
|
||||
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
return `${basePath}/explore/installed/${installed_apps[0].id}`
|
||||
const response = (await fetchInstalledAppList(appDetail?.id)) as InstalledAppsResponse
|
||||
const installedApps = response?.installed_apps
|
||||
if (installedApps?.length)
|
||||
return `${basePath}/explore/installed/${installedApps[0].id}`
|
||||
throw new Error('No app found in Explore')
|
||||
}, {
|
||||
onError: (err) => {
|
||||
@ -257,6 +299,29 @@ const AppPublisher = ({
|
||||
handlePublish()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useEffect(() => {
|
||||
const appId = appDetail?.id
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => {
|
||||
const action = typeof update.data.action === 'string' ? update.data.action : undefined
|
||||
if (action === 'published') {
|
||||
invalidateAppWorkflow(appId)
|
||||
fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`)
|
||||
.then((publishedWorkflow) => {
|
||||
if (publishedWorkflow?.created_at)
|
||||
workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[app-publisher] refresh published workflow failed', error)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appDetail?.id, invalidateAppWorkflow, workflowStore])
|
||||
|
||||
const hasPublishedVersion = !!publishedAt
|
||||
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
|
||||
const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined
|
||||
|
||||
@ -18,6 +18,7 @@ import type {
|
||||
TextToSpeechConfig,
|
||||
} from '@/models/debug'
|
||||
import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { CodeBracketIcon } from '@heroicons/react/20/solid'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { clone } from 'es-toolkit/object'
|
||||
@ -760,7 +761,8 @@ const Configuration: FC = () => {
|
||||
else { return promptEmpty }
|
||||
})()
|
||||
const contextVarEmpty = mode === AppModeEnum.COMPLETION && dataSets.length > 0 && !hasSetContextVar
|
||||
const onPublish = async (modelAndParameter?: ModelAndParameter, features?: FeaturesData) => {
|
||||
const onPublish = async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
|
||||
const modelAndParameter = params && 'model' in params ? params : undefined
|
||||
const modelId = modelAndParameter?.model || modelConfig.model_id
|
||||
const promptTemplate = modelConfig.configs.prompt_template
|
||||
const promptVariables = modelConfig.configs.prompt_variables
|
||||
|
||||
@ -5,6 +5,7 @@ import type { HtmlContentProps } from '@/app/components/base/popover'
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import type { WorkflowOnlineUser } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import dynamic from 'next/dynamic'
|
||||
@ -20,6 +21,7 @@ import CustomPopover from '@/app/components/base/popover'
|
||||
import TagSelector from '@/app/components/base/tag-management/selector'
|
||||
import Toast, { ToastContext } from '@/app/components/base/toast'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
@ -58,9 +60,10 @@ const AccessControl = dynamic(() => import('@/app/components/app/app-access-cont
|
||||
export type AppCardProps = {
|
||||
app: App
|
||||
onRefresh?: () => void
|
||||
onlineUsers?: WorkflowOnlineUser[]
|
||||
}
|
||||
|
||||
const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
const AppCard = ({ app, onRefresh, onlineUsers = [] }: AppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
@ -362,6 +365,19 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
return `${t('segment.editedAt', { ns: 'datasetDocuments' })} ${timeText}`
|
||||
}, [app.updated_at, app.created_at])
|
||||
|
||||
const onlineUserAvatars = useMemo(() => {
|
||||
if (!onlineUsers.length)
|
||||
return []
|
||||
|
||||
return onlineUsers
|
||||
.map(user => ({
|
||||
id: user.user_id || user.sid || '',
|
||||
name: user.username || 'User',
|
||||
avatar_url: user.avatar || undefined,
|
||||
}))
|
||||
.filter(user => !!user.id)
|
||||
}, [onlineUsers])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -414,6 +430,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{onlineUserAvatars.length > 0 && (
|
||||
<UserAvatarList users={onlineUserAvatars} maxVisible={3} size={20} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary">
|
||||
<div
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
@ -141,9 +142,13 @@ vi.mock('@/app/components/base/tag-management/filter', () => ({
|
||||
}))
|
||||
|
||||
// Mock config
|
||||
vi.mock('@/config', () => ({
|
||||
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
|
||||
}))
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return {
|
||||
...actual,
|
||||
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
|
||||
}
|
||||
})
|
||||
|
||||
// Mock pay hook
|
||||
vi.mock('@/hooks/use-pay', () => ({
|
||||
@ -234,6 +239,21 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
const renderList = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<List />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -260,13 +280,13 @@ describe('List', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
// Tab slider renders app type tabs
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
@ -277,48 +297,48 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should render search input', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
// Input component renders a searchbox
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
// Tag filter renders with placeholder text
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render new app card for editors', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer when branding is disabled', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render drop DSL hint for editors', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should call setActiveTab when tab is clicked', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
|
||||
@ -326,7 +346,7 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should call setActiveTab for all tab', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
|
||||
@ -336,12 +356,12 @@ describe('List', () => {
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should render search input field', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search input change', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
@ -350,7 +370,7 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should handle search input interaction', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
@ -360,7 +380,7 @@ describe('List', () => {
|
||||
// Set initial keywords to make clear button visible
|
||||
mockQueryState.keywords = 'existing search'
|
||||
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// Find and click clear button (Input component uses .group class for clear icon container)
|
||||
const clearButton = document.querySelector('.group')
|
||||
@ -375,12 +395,12 @@ describe('List', () => {
|
||||
|
||||
describe('Tag Filter', () => {
|
||||
it('should render tag filter component', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter with placeholder', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// Tag filter is rendered
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
@ -389,12 +409,12 @@ describe('List', () => {
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// Checkbox component uses data-testid="checkbox-{id}"
|
||||
// CheckboxWithLabel doesn't pass testId, so id is undefined
|
||||
@ -409,7 +429,7 @@ describe('List', () => {
|
||||
it('should not render new app card for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
})
|
||||
@ -417,7 +437,7 @@ describe('List', () => {
|
||||
it('should not render drop DSL hint for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
|
||||
})
|
||||
@ -427,7 +447,7 @@ describe('List', () => {
|
||||
it('should redirect dataset operators to datasets page', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
|
||||
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
@ -437,7 +457,7 @@ describe('List', () => {
|
||||
it('should call refetch when refresh key is set in localStorage', () => {
|
||||
localStorage.setItem('needRefreshAppList', '1')
|
||||
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
|
||||
@ -446,22 +466,23 @@ describe('List', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<List />)
|
||||
const { unmount } = renderList()
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
|
||||
rerender(<List />)
|
||||
unmount()
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with all filter options visible', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
@ -471,14 +492,14 @@ describe('List', () => {
|
||||
|
||||
describe('Dragging State', () => {
|
||||
it('should show drop hint when DSL feature is enabled for editors', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
@ -489,7 +510,7 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should call setActiveTab for each app type', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
@ -508,7 +529,7 @@ describe('List', () => {
|
||||
|
||||
describe('Search and Filter Integration', () => {
|
||||
it('should display search input with correct attributes', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
@ -516,13 +537,13 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should have tag filter component', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display created by me label', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
@ -530,14 +551,14 @@ describe('List', () => {
|
||||
|
||||
describe('App List Display', () => {
|
||||
it('should display all app cards from data', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app names correctly', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
@ -546,7 +567,7 @@ describe('List', () => {
|
||||
|
||||
describe('Footer Visibility', () => {
|
||||
it('should render footer when branding is disabled', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
@ -558,14 +579,14 @@ describe('List', () => {
|
||||
describe('Additional Coverage', () => {
|
||||
it('should render dragging state overlay when dragging', () => {
|
||||
mockDragging = true
|
||||
const { container } = render(<List />)
|
||||
const { container } = renderList()
|
||||
|
||||
// Component should render successfully with dragging state
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle app mode filter in query params', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
const workflowTab = screen.getByText('app.types.workflow')
|
||||
fireEvent.click(workflowTab)
|
||||
@ -574,7 +595,7 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should render new app card for editors', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||
})
|
||||
@ -582,7 +603,7 @@ describe('List', () => {
|
||||
|
||||
describe('DSL File Drop', () => {
|
||||
it('should handle DSL file drop and show modal', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// Simulate DSL file drop via the callback
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
@ -596,7 +617,7 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should close DSL modal when onClose is called', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// Open modal via DSL file drop
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
@ -614,7 +635,7 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should close DSL modal and refetch when onSuccess is called', () => {
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// Open modal via DSL file drop
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
@ -637,7 +658,7 @@ describe('List', () => {
|
||||
describe('Tag Filter Change', () => {
|
||||
it('should handle tag filter value change', () => {
|
||||
vi.useFakeTimers()
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// TagFilter component is rendered
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
@ -661,7 +682,7 @@ describe('List', () => {
|
||||
|
||||
it('should handle empty tag filter selection', () => {
|
||||
vi.useFakeTimers()
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// Trigger tag filter change with empty array
|
||||
act(() => {
|
||||
@ -683,7 +704,7 @@ describe('List', () => {
|
||||
describe('Infinite Scroll', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// Simulate intersection
|
||||
if (intersectionCallback) {
|
||||
@ -700,7 +721,7 @@ describe('List', () => {
|
||||
|
||||
it('should not call fetchNextPage when not intersecting', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
// Simulate non-intersection
|
||||
if (intersectionCallback) {
|
||||
@ -718,7 +739,7 @@ describe('List', () => {
|
||||
it('should not call fetchNextPage when loading', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
mockServiceState.isLoading = true
|
||||
render(<List />)
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
@ -736,7 +757,7 @@ describe('List', () => {
|
||||
describe('Error State', () => {
|
||||
it('should handle error state in useEffect', () => {
|
||||
mockServiceState.error = new Error('Test error')
|
||||
const { container } = render(<List />)
|
||||
const { container } = renderList()
|
||||
|
||||
// Component should still render
|
||||
expect(container).toBeInTheDocument()
|
||||
|
||||
@ -9,13 +9,14 @@ import {
|
||||
RiMessage3Line,
|
||||
RiRobot3Line,
|
||||
} from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import dynamic from 'next/dynamic'
|
||||
import {
|
||||
useRouter,
|
||||
} from 'next/navigation'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Input from '@/app/components/base/input'
|
||||
@ -29,7 +30,7 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import { DSLImportStatus } from '@/models/app'
|
||||
import { importAppBundle } from '@/service/apps'
|
||||
import { fetchWorkflowOnlineUsers, importAppBundle } from '@/service/apps'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
@ -156,6 +157,37 @@ const List: FC<Props> = ({
|
||||
refetch,
|
||||
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
|
||||
|
||||
const apps = useMemo(() => data?.pages?.flatMap(page => page.data) ?? [], [data])
|
||||
|
||||
const workflowIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
apps.forEach((appItem) => {
|
||||
const workflowId = appItem.id
|
||||
if (!workflowId)
|
||||
return
|
||||
|
||||
if (appItem.mode === 'workflow' || appItem.mode === 'advanced-chat')
|
||||
ids.add(workflowId)
|
||||
})
|
||||
return Array.from(ids)
|
||||
}, [apps])
|
||||
|
||||
const { data: onlineUsersByWorkflow = {}, refetch: refreshOnlineUsers } = useQuery({
|
||||
queryKey: ['apps', 'workflow-online-users', workflowIds],
|
||||
queryFn: () => fetchWorkflowOnlineUsers({ workflowIds }),
|
||||
enabled: workflowIds.length > 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
refetch()
|
||||
if (workflowIds.length)
|
||||
refreshOnlineUsers()
|
||||
}, 10000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [workflowIds, refetch, refreshOnlineUsers])
|
||||
|
||||
useEffect(() => {
|
||||
if (controlRefreshList > 0) {
|
||||
refetch()
|
||||
@ -294,7 +326,7 @@ const List: FC<Props> = ({
|
||||
|
||||
if (hasAnyApp) {
|
||||
return pages.flatMap(({ data: apps }) => apps).map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} onlineUsers={onlineUsersByWorkflow?.[app.id] ?? []} />
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@ -35,12 +35,14 @@ describe('Avatar', () => {
|
||||
it.each([
|
||||
{ size: undefined, expected: '30px', label: 'default (30px)' },
|
||||
{ size: 50, expected: '50px', label: 'custom (50px)' },
|
||||
])('should apply $label size to img element', ({ size, expected }) => {
|
||||
])('should apply $label size to avatar container', ({ size, expected }) => {
|
||||
const props = { name: 'Test', avatar: 'https://example.com/avatar.jpg', size }
|
||||
|
||||
render(<Avatar {...props} />)
|
||||
|
||||
expect(screen.getByRole('img')).toHaveStyle({
|
||||
const img = screen.getByRole('img')
|
||||
const wrapper = img.parentElement as HTMLElement
|
||||
expect(wrapper).toHaveStyle({
|
||||
width: expected,
|
||||
height: expected,
|
||||
fontSize: expected,
|
||||
@ -60,7 +62,7 @@ describe('Avatar', () => {
|
||||
})
|
||||
|
||||
describe('className prop', () => {
|
||||
it('should merge className with default avatar classes on img', () => {
|
||||
it('should merge className with default avatar classes on container', () => {
|
||||
const props = {
|
||||
name: 'Test',
|
||||
avatar: 'https://example.com/avatar.jpg',
|
||||
@ -70,8 +72,9 @@ describe('Avatar', () => {
|
||||
render(<Avatar {...props} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveClass('custom-class')
|
||||
expect(img).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
|
||||
const wrapper = img.parentElement as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
expect(wrapper).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
|
||||
})
|
||||
|
||||
it('should merge className with default avatar classes on fallback div', () => {
|
||||
@ -277,10 +280,11 @@ describe('Avatar', () => {
|
||||
render(<Avatar {...props} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
const wrapper = img.parentElement as HTMLElement
|
||||
expect(img).toHaveAttribute('alt', 'Test User')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
|
||||
expect(img).toHaveStyle({ width: '64px', height: '64px' })
|
||||
expect(img).toHaveClass('custom-avatar')
|
||||
expect(wrapper).toHaveStyle({ width: '64px', height: '64px' })
|
||||
expect(wrapper).toHaveClass('custom-avatar')
|
||||
|
||||
// Trigger load to verify onError callback
|
||||
fireEvent.load(img)
|
||||
|
||||
@ -9,6 +9,7 @@ export type AvatarProps = {
|
||||
className?: string
|
||||
textClassName?: string
|
||||
onError?: (x: boolean) => void
|
||||
backgroundColor?: string
|
||||
}
|
||||
const Avatar = ({
|
||||
name,
|
||||
@ -17,9 +18,18 @@ const Avatar = ({
|
||||
className,
|
||||
textClassName,
|
||||
onError,
|
||||
backgroundColor,
|
||||
}: AvatarProps) => {
|
||||
const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600'
|
||||
const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` }
|
||||
const avatarClassName = backgroundColor
|
||||
? 'shrink-0 flex items-center rounded-full'
|
||||
: 'shrink-0 flex items-center rounded-full bg-primary-600'
|
||||
const style = {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
fontSize: `${size}px`,
|
||||
lineHeight: `${size}px`,
|
||||
...(backgroundColor && !avatar ? { backgroundColor } : {}),
|
||||
}
|
||||
const [imgError, setImgError] = useState(false)
|
||||
|
||||
const handleError = () => {
|
||||
@ -35,14 +45,18 @@ const Avatar = ({
|
||||
|
||||
if (avatar && !imgError) {
|
||||
return (
|
||||
<img
|
||||
<span
|
||||
className={cn(avatarClassName, className)}
|
||||
style={style}
|
||||
alt={name}
|
||||
src={avatar}
|
||||
onError={handleError}
|
||||
onLoad={() => onError?.(false)}
|
||||
/>
|
||||
>
|
||||
<img
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
alt={name}
|
||||
src={avatar}
|
||||
onError={handleError}
|
||||
onLoad={() => onError?.(false)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ const ContentDialog = ({
|
||||
<Transition
|
||||
show={show}
|
||||
as="div"
|
||||
className="absolute left-0 top-0 z-30 box-border h-full w-full p-2"
|
||||
className="absolute left-0 top-0 z-[70] box-border h-full w-full p-2"
|
||||
>
|
||||
<TransitionChild>
|
||||
<div
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 4C0 1.79086 1.79086 0 4 0H12C14.2091 0 16 1.79086 16 4V12C16 14.2091 14.2091 16 12 16H4C1.79086 16 0 14.2091 0 12V4Z" fill="white" fill-opacity="0.12"/>
|
||||
<path d="M3.42756 8.7358V7.62784H10.8764C11.2003 7.62784 11.4957 7.5483 11.7628 7.3892C12.0298 7.23011 12.2415 7.01705 12.3977 6.75C12.5568 6.48295 12.6364 6.1875 12.6364 5.86364C12.6364 5.53977 12.5568 5.24574 12.3977 4.98153C12.2386 4.71449 12.0256 4.50142 11.7585 4.34233C11.4943 4.18324 11.2003 4.10369 10.8764 4.10369H10.3991V3H10.8764C11.4048 3 11.8849 3.12926 12.3168 3.38778C12.7486 3.64631 13.0938 3.99148 13.3523 4.4233C13.6108 4.85511 13.7401 5.33523 13.7401 5.86364C13.7401 6.25852 13.6648 6.62926 13.5142 6.97585C13.3665 7.32244 13.1619 7.62784 12.9006 7.89205C12.6392 8.15625 12.3352 8.36364 11.9886 8.5142C11.642 8.66193 11.2713 8.7358 10.8764 8.7358H3.42756ZM6.16761 12.0554L2.29403 8.18182L6.16761 4.30824L6.9304 5.07102L3.81534 8.18182L6.9304 11.2926L6.16761 12.0554Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" viewBox="0 0 14 12" fill="none">
|
||||
<path d="M12.3334 4C12.3334 2.52725 11.1395 1.33333 9.66671 1.33333H4.33337C2.86062 1.33333 1.66671 2.52724 1.66671 4V10.6667H9.66671C11.1395 10.6667 12.3334 9.47274 12.3334 8V4ZM7.66671 6.66667V8H4.33337V6.66667H7.66671ZM9.66671 4V5.33333H4.33337V4H9.66671ZM13.6667 8C13.6667 10.2091 11.8758 12 9.66671 12H0.333374V4C0.333374 1.79086 2.12424 0 4.33337 0H9.66671C11.8758 0 13.6667 1.79086 13.6667 4V8Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 527 B |
@ -0,0 +1,36 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M0 4C0 1.79086 1.79086 0 4 0H12C14.2091 0 16 1.79086 16 4V12C16 14.2091 14.2091 16 12 16H4C1.79086 16 0 14.2091 0 12V4Z",
|
||||
"fill": "white",
|
||||
"fill-opacity": "0.12"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.42756 8.7358V7.62784H10.8764C11.2003 7.62784 11.4957 7.5483 11.7628 7.3892C12.0298 7.23011 12.2415 7.01705 12.3977 6.75C12.5568 6.48295 12.6364 6.1875 12.6364 5.86364C12.6364 5.53977 12.5568 5.24574 12.3977 4.98153C12.2386 4.71449 12.0256 4.50142 11.7585 4.34233C11.4943 4.18324 11.2003 4.10369 10.8764 4.10369H10.3991V3H10.8764C11.4048 3 11.8849 3.12926 12.3168 3.38778C12.7486 3.64631 13.0938 3.99148 13.3523 4.4233C13.6108 4.85511 13.7401 5.33523 13.7401 5.86364C13.7401 6.25852 13.6648 6.62926 13.5142 6.97585C13.3665 7.32244 13.1619 7.62784 12.9006 7.89205C12.6392 8.15625 12.3352 8.36364 11.9886 8.5142C11.642 8.66193 11.2713 8.7358 10.8764 8.7358H3.42756ZM6.16761 12.0554L2.29403 8.18182L6.16761 4.30824L6.9304 5.07102L3.81534 8.18182L6.9304 11.2926L6.16761 12.0554Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "EnterKey"
|
||||
}
|
||||
20
web/app/components/base/icons/src/public/common/EnterKey.tsx
Normal file
20
web/app/components/base/icons/src/public/common/EnterKey.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './EnterKey.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'EnterKey'
|
||||
|
||||
export default Icon
|
||||
@ -1,6 +1,7 @@
|
||||
export { default as D } from './D'
|
||||
export { default as DiagonalDividingLine } from './DiagonalDividingLine'
|
||||
export { default as Dify } from './Dify'
|
||||
export { default as EnterKey } from './EnterKey'
|
||||
export { default as Gdpr } from './Gdpr'
|
||||
export { default as Github } from './Github'
|
||||
export { default as Highlight } from './Highlight'
|
||||
|
||||
26
web/app/components/base/icons/src/public/other/Comment.json
Normal file
26
web/app/components/base/icons/src/public/other/Comment.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"xmlns": "http://www.w3.org/2000/svg",
|
||||
"width": "14",
|
||||
"height": "12",
|
||||
"viewBox": "0 0 14 12",
|
||||
"fill": "none"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M12.3334 4C12.3334 2.52725 11.1395 1.33333 9.66671 1.33333H4.33337C2.86062 1.33333 1.66671 2.52724 1.66671 4V10.6667H9.66671C11.1395 10.6667 12.3334 9.47274 12.3334 8V4ZM7.66671 6.66667V8H4.33337V6.66667H7.66671ZM9.66671 4V5.33333H4.33337V4H9.66671ZM13.6667 8C13.6667 10.2091 11.8758 12 9.66671 12H0.333374V4C0.333374 1.79086 2.12424 0 4.33337 0H9.66671C11.8758 0 13.6667 1.79086 13.6667 4V8Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Comment"
|
||||
}
|
||||
20
web/app/components/base/icons/src/public/other/Comment.tsx
Normal file
20
web/app/components/base/icons/src/public/other/Comment.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Comment.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Comment'
|
||||
|
||||
export default Icon
|
||||
@ -1,3 +1,4 @@
|
||||
export { default as Comment } from './Comment'
|
||||
export { default as DefaultToolIcon } from './DefaultToolIcon'
|
||||
export { default as Icon3Dots } from './Icon3Dots'
|
||||
export { default as Message3Fill } from './Message3Fill'
|
||||
|
||||
@ -18,6 +18,7 @@ import type {
|
||||
} from './types'
|
||||
import { CodeNode } from '@lexical/code'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
|
||||
@ -93,6 +94,29 @@ import {
|
||||
} from './plugins/workflow-variable-block'
|
||||
import { textToEditorState } from './utils'
|
||||
|
||||
const ValueSyncPlugin: FC<{ value?: string }> = ({ value }) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (value === undefined)
|
||||
return
|
||||
|
||||
const incomingValue = value ?? ''
|
||||
const shouldUpdate = editor.getEditorState().read(() => {
|
||||
const currentText = $getRoot().getChildren().map(node => node.getTextContent()).join('\n')
|
||||
return currentText !== incomingValue
|
||||
})
|
||||
|
||||
if (!shouldUpdate)
|
||||
return
|
||||
|
||||
const editorState = editor.parseEditorState(textToEditorState(incomingValue))
|
||||
editor.setEditorState(editorState)
|
||||
}, [editor, value])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export type PromptEditorProps = {
|
||||
instanceId?: string
|
||||
compact?: boolean
|
||||
@ -357,6 +381,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
<VariableValueBlock />
|
||||
)
|
||||
}
|
||||
<ValueSyncPlugin value={value} />
|
||||
<OnChangePlugin onChange={handleEditorChange} />
|
||||
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
|
||||
<UpdateBlock instanceId={instanceId} />
|
||||
|
||||
79
web/app/components/base/user-avatar-list/index.tsx
Normal file
79
web/app/components/base/user-avatar-list/index.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
type User = {
|
||||
id: string
|
||||
name: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
type UserAvatarListProps = {
|
||||
users: User[]
|
||||
maxVisible?: number
|
||||
size?: number
|
||||
className?: string
|
||||
showCount?: boolean
|
||||
}
|
||||
|
||||
export const UserAvatarList: FC<UserAvatarListProps> = memo(({
|
||||
users,
|
||||
maxVisible = 3,
|
||||
size = 24,
|
||||
className = '',
|
||||
showCount = true,
|
||||
}) => {
|
||||
const { userProfile } = useAppContext()
|
||||
if (!users.length)
|
||||
return null
|
||||
|
||||
const shouldShowCount = showCount && users.length > maxVisible
|
||||
const actualMaxVisible = shouldShowCount ? Math.max(1, maxVisible - 1) : maxVisible
|
||||
const visibleUsers = users.slice(0, actualMaxVisible)
|
||||
const remainingCount = users.length - actualMaxVisible
|
||||
|
||||
const currentUserId = userProfile?.id
|
||||
|
||||
return (
|
||||
<div className={`flex items-center -space-x-1 ${className}`}>
|
||||
{visibleUsers.map((user, index) => {
|
||||
const isCurrentUser = user.id === currentUserId
|
||||
const userColor = isCurrentUser ? undefined : getUserColor(user.id)
|
||||
return (
|
||||
<div
|
||||
key={`${user.id}-${index}`}
|
||||
className="relative"
|
||||
style={{ zIndex: visibleUsers.length - index }}
|
||||
>
|
||||
<Avatar
|
||||
name={user.name}
|
||||
avatar={user.avatar_url || null}
|
||||
size={size}
|
||||
className="ring-2 ring-components-panel-bg"
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
)}
|
||||
{shouldShowCount && remainingCount > 0 && (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-full bg-gray-500 text-[10px] leading-none text-white ring-2 ring-components-panel-bg"
|
||||
style={{
|
||||
zIndex: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
>
|
||||
+
|
||||
{remainingCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
UserAvatarList.displayName = 'UserAvatarList'
|
||||
@ -144,7 +144,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
|
||||
{t('modelProvider.systemModelSettings', { ns: 'common' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[60]">
|
||||
<PortalToFollowElemContent className="z-[75]">
|
||||
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg pt-4 shadow-xl">
|
||||
<div className="px-6 py-1">
|
||||
<div className="flex h-8 items-center text-[13px] font-medium text-text-primary">
|
||||
|
||||
@ -76,7 +76,7 @@ const ActionList = ({
|
||||
className='w-full'
|
||||
onClick={() => setShowSettingAuth(true)}
|
||||
disabled={!isCurrentWorkspaceManager}
|
||||
>{t('workflow.nodes.tool.authorize')}</Button>
|
||||
>{t('nodes.tool.authorize', { ns: 'workflow' })}</Button>
|
||||
)} */}
|
||||
</div>
|
||||
{/* <div className='flex flex-col gap-2'>
|
||||
|
||||
@ -24,7 +24,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
},
|
||||
knowledgeBaseDefault,
|
||||
dataSourceEmptyDefault,
|
||||
], [])
|
||||
] as AvailableNodesMetaData['nodes'], [])
|
||||
|
||||
const helpLinkUri = useMemo(() => docLink(
|
||||
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration',
|
||||
|
||||
@ -3,8 +3,14 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
export const useGetRunAndTraceUrl = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const getWorkflowRunAndTraceUrl = useCallback((runId: string) => {
|
||||
const getWorkflowRunAndTraceUrl = useCallback((runId?: string) => {
|
||||
const { pipelineId } = workflowStore.getState()
|
||||
if (!pipelineId || !runId) {
|
||||
return {
|
||||
runUrl: '',
|
||||
traceUrl: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runUrl: `/rag/pipelines/${pipelineId}/workflow-runs/${runId}`,
|
||||
|
||||
@ -10,6 +10,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import {
|
||||
useCreateMCPServer,
|
||||
useInvalidateMCPServerDetail,
|
||||
@ -59,6 +60,22 @@ const MCPServerModal = ({
|
||||
return res
|
||||
}
|
||||
|
||||
const emitMcpServerUpdate = (action: 'created' | 'updated') => {
|
||||
const socket = webSocketClient.getSocket(appID)
|
||||
if (!socket)
|
||||
return
|
||||
|
||||
const timestamp = Date.now()
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'mcp_server_update',
|
||||
data: {
|
||||
action,
|
||||
timestamp,
|
||||
},
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!data) {
|
||||
const payload: any = {
|
||||
@ -71,6 +88,7 @@ const MCPServerModal = ({
|
||||
|
||||
await createMCPServer(payload)
|
||||
invalidateMCPServerDetail(appID)
|
||||
emitMcpServerUpdate('created')
|
||||
onHide()
|
||||
}
|
||||
else {
|
||||
@ -83,6 +101,7 @@ const MCPServerModal = ({
|
||||
payload.description = description
|
||||
await updateMCPServer(payload)
|
||||
invalidateMCPServerDetail(appID)
|
||||
emitMcpServerUpdate('updated')
|
||||
onHide()
|
||||
}
|
||||
}
|
||||
@ -92,6 +111,7 @@ const MCPServerModal = ({
|
||||
isShow={show}
|
||||
onClose={onHide}
|
||||
className={cn('relative !max-w-[520px] !p-0')}
|
||||
highPriority
|
||||
>
|
||||
<div className="absolute right-5 top-5 z-10 cursor-pointer p-1.5" onClick={onHide}>
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import type { AppSSO, ModelConfig, UserInputFormItem } from '@/types/app'
|
||||
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@ -16,6 +18,8 @@ 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 { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
@ -36,6 +40,16 @@ export type IAppCardProps = {
|
||||
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
|
||||
}
|
||||
|
||||
type BasicAppConfig = Partial<ModelConfig> & {
|
||||
updated_at?: number
|
||||
}
|
||||
|
||||
type McpServerParam = {
|
||||
label: string
|
||||
variable: string
|
||||
type: string
|
||||
}
|
||||
|
||||
function MCPServiceCard({
|
||||
appInfo,
|
||||
triggerModeDisabled = false,
|
||||
@ -54,16 +68,16 @@ function MCPServiceCard({
|
||||
const isAdvancedApp = appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW
|
||||
const isBasicApp = !isAdvancedApp
|
||||
const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '')
|
||||
const [basicAppConfig, setBasicAppConfig] = useState<any>({})
|
||||
const basicAppInputForm = useMemo(() => {
|
||||
if (!isBasicApp || !basicAppConfig?.user_input_form)
|
||||
const [basicAppConfig, setBasicAppConfig] = useState<BasicAppConfig>({})
|
||||
const basicAppInputForm = useMemo<McpServerParam[]>(() => {
|
||||
if (!isBasicApp || !basicAppConfig.user_input_form)
|
||||
return []
|
||||
return basicAppConfig.user_input_form.map((item: any) => {
|
||||
const type = Object.keys(item)[0]
|
||||
return {
|
||||
...item[type],
|
||||
type: type || 'text-input',
|
||||
}
|
||||
return basicAppConfig.user_input_form.map((item: UserInputFormItem) => {
|
||||
if ('text-input' in item)
|
||||
return { label: item['text-input'].label, variable: item['text-input'].variable, type: 'text-input' }
|
||||
if ('select' in item)
|
||||
return { label: item.select.label, variable: item.select.variable, type: 'select' }
|
||||
return { label: item.paragraph.label, variable: item.paragraph.variable, type: 'paragraph' }
|
||||
})
|
||||
}, [basicAppConfig.user_input_form, isBasicApp])
|
||||
useEffect(() => {
|
||||
@ -90,12 +104,22 @@ function MCPServiceCard({
|
||||
|
||||
const [activated, setActivated] = useState(serverActivated)
|
||||
|
||||
const latestParams = useMemo(() => {
|
||||
const latestParams = useMemo<McpServerParam[]>(() => {
|
||||
if (isAdvancedApp) {
|
||||
if (!currentWorkflow?.graph)
|
||||
return []
|
||||
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start) as any
|
||||
return startNode?.data.variables as any[] || []
|
||||
const startNode = currentWorkflow?.graph.nodes.find(node => node.data.type === BlockEnum.Start)
|
||||
const variables = (startNode?.data as { variables?: InputVar[] } | undefined)?.variables || []
|
||||
return variables.map((variable) => {
|
||||
const label = typeof variable.label === 'string'
|
||||
? variable.label
|
||||
: (variable.label.variable || variable.label.nodeName)
|
||||
return {
|
||||
label,
|
||||
variable: variable.variable,
|
||||
type: variable.type,
|
||||
}
|
||||
})
|
||||
}
|
||||
return basicAppInputForm
|
||||
}, [currentWorkflow, basicAppInputForm, isAdvancedApp])
|
||||
@ -103,6 +127,19 @@ function MCPServiceCard({
|
||||
const onGenCode = async () => {
|
||||
await refreshMCPServerCode(detail?.id || '')
|
||||
invalidateMCPServerDetail(appId)
|
||||
|
||||
// Emit collaboration event to notify other clients of MCP server changes
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'mcp_server_update',
|
||||
data: {
|
||||
action: 'codeRegenerated',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onChangeStatus = async (state: boolean) => {
|
||||
@ -132,6 +169,20 @@ function MCPServiceCard({
|
||||
})
|
||||
invalidateMCPServerDetail(appId)
|
||||
}
|
||||
|
||||
// Emit collaboration event to notify other clients of MCP server status change
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'mcp_server_update',
|
||||
data: {
|
||||
action: 'statusChanged',
|
||||
status: state ? 'active' : 'inactive',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleServerModalHide = () => {
|
||||
@ -144,6 +195,23 @@ function MCPServiceCard({
|
||||
setActivated(serverActivated)
|
||||
}, [serverActivated])
|
||||
|
||||
// Listen for collaborative MCP server updates from other clients
|
||||
useEffect(() => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onMcpServerUpdate(async (_update: CollaborationUpdate) => {
|
||||
try {
|
||||
invalidateMCPServerDetail(appId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('MCP server update failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, invalidateMCPServerDetail])
|
||||
|
||||
if (!currentWorkflow && isAdvancedApp)
|
||||
return null
|
||||
|
||||
|
||||
@ -108,7 +108,7 @@ vi.mock('@/app/components/app/app-publisher', () => ({
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.()).catch(() => undefined) }}>
|
||||
publisher-publish
|
||||
</button>
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
||||
<button type="button" onClick={() => { Promise.resolve(props.onPublish?.({ url: '/apps/app-id/workflows/publish', title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
|
||||
publisher-publish-with-params
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import type {
|
||||
@ -140,24 +141,38 @@ const FeaturesTrigger = () => {
|
||||
const needWarningNodes = useChecklist(nodes, edges)
|
||||
|
||||
const updatePublishedWorkflow = useInvalidateAppWorkflow()
|
||||
const onPublish = useCallback(async (params?: PublishWorkflowParams) => {
|
||||
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
const publishParams = params && 'title' in params ? params : undefined
|
||||
// First check if there are any items in the checklist
|
||||
// if (!validateBeforeRun())
|
||||
// throw new Error('Checklist has unresolved items')
|
||||
|
||||
if (needWarningNodes.length > 0) {
|
||||
console.warn('[workflow-header] publish blocked by checklist', {
|
||||
appId: appID,
|
||||
warningCount: needWarningNodes.length,
|
||||
})
|
||||
notify({ type: 'error', message: t('panel.checklistTip', { ns: 'workflow' }) })
|
||||
throw new Error('Checklist has unresolved items')
|
||||
}
|
||||
|
||||
// Then perform the detailed validation
|
||||
if (await handleCheckBeforePublish()) {
|
||||
console.warn('[workflow-header] publish start', {
|
||||
appId: appID,
|
||||
title: publishParams?.title ?? '',
|
||||
})
|
||||
const res = await publishWorkflow({
|
||||
url: `/apps/${appID}/workflows/publish`,
|
||||
title: params?.title || '',
|
||||
releaseNotes: params?.releaseNotes || '',
|
||||
title: publishParams?.title || '',
|
||||
releaseNotes: publishParams?.releaseNotes || '',
|
||||
})
|
||||
|
||||
console.warn('[workflow-header] publish response', {
|
||||
appId: appID,
|
||||
hasResponse: Boolean(res),
|
||||
createdAt: res?.created_at,
|
||||
})
|
||||
if (res) {
|
||||
notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) })
|
||||
updatePublishedWorkflow(appID!)
|
||||
|
||||
@ -1,13 +1,26 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Features as FeaturesData } from '@/app/components/base/features/types'
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store/store'
|
||||
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { WorkflowWithInnerContext } from '@/app/components/workflow'
|
||||
import { collaborationManager, useCollaboration } from '@/app/components/workflow/collaboration'
|
||||
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions'
|
||||
import { MCPToolAvailabilityProvider } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import {
|
||||
useAvailableNodesMetaData,
|
||||
useConfigsMap,
|
||||
@ -25,6 +38,7 @@ import WorkflowChildren from './workflow-children'
|
||||
type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'> & {
|
||||
headerLeftSlot?: ReactNode
|
||||
}
|
||||
type WorkflowDataUpdatePayload = Pick<FetchWorkflowDraftResponse, 'features' | 'conversation_variables' | 'environment_variables'>
|
||||
const WorkflowMain = ({
|
||||
nodes,
|
||||
edges,
|
||||
@ -34,8 +48,48 @@ const WorkflowMain = ({
|
||||
const sandboxEnabled = useFeatures(state => state.features.sandbox?.enabled) ?? false
|
||||
const featuresStore = useFeaturesStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const appId = useStore(s => s.appId)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const reactFlow = useReactFlow()
|
||||
|
||||
const handleWorkflowDataUpdate = useCallback((payload: any) => {
|
||||
const reactFlowStore = useMemo(() => ({
|
||||
getState: () => ({
|
||||
getNodes: () => reactFlow.getNodes(),
|
||||
setNodes: (nodesToSet: Node[]) => reactFlow.setNodes(nodesToSet),
|
||||
getEdges: () => reactFlow.getEdges(),
|
||||
setEdges: (edgesToSet: Edge[]) => reactFlow.setEdges(edgesToSet),
|
||||
}),
|
||||
}), [reactFlow])
|
||||
const {
|
||||
startCursorTracking,
|
||||
stopCursorTracking,
|
||||
onlineUsers,
|
||||
cursors,
|
||||
isConnected,
|
||||
isEnabled: isCollaborationEnabled,
|
||||
} = useCollaboration(appId || '', reactFlowStore)
|
||||
const myUserId = useMemo(
|
||||
() => (isCollaborationEnabled && isConnected ? 'current-user' : null),
|
||||
[isCollaborationEnabled, isConnected],
|
||||
)
|
||||
|
||||
const filteredCursors = Object.fromEntries(
|
||||
Object.entries(cursors).filter(([userId]) => userId !== myUserId),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCollaborationEnabled)
|
||||
return
|
||||
|
||||
if (containerRef.current)
|
||||
startCursorTracking(containerRef as React.RefObject<HTMLElement>, reactFlow)
|
||||
|
||||
return () => {
|
||||
stopCursorTracking()
|
||||
}
|
||||
}, [startCursorTracking, stopCursorTracking, reactFlow, isCollaborationEnabled])
|
||||
|
||||
const handleWorkflowDataUpdate = useCallback((payload: WorkflowDataUpdatePayload) => {
|
||||
const {
|
||||
features,
|
||||
conversation_variables,
|
||||
@ -44,7 +98,33 @@ const WorkflowMain = ({
|
||||
if (features && featuresStore) {
|
||||
const { setFeatures } = featuresStore.getState()
|
||||
|
||||
setFeatures(features)
|
||||
const transformedFeatures: FeaturesData = {
|
||||
file: {
|
||||
image: {
|
||||
enabled: !!features.file_upload?.image?.enabled,
|
||||
number_limits: features.file_upload?.image?.number_limits || 3,
|
||||
transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
},
|
||||
enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
|
||||
allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
|
||||
allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
|
||||
},
|
||||
opening: {
|
||||
enabled: !!features.opening_statement,
|
||||
opening_statement: features.opening_statement,
|
||||
suggested_questions: features.suggested_questions,
|
||||
},
|
||||
suggested: features.suggested_questions_after_answer || { enabled: false },
|
||||
speech2text: features.speech_to_text || { enabled: false },
|
||||
text2speech: features.text_to_speech || { enabled: false },
|
||||
citation: features.retriever_resource || { enabled: false },
|
||||
moderation: features.sensitive_word_avoidance || { enabled: false },
|
||||
annotationReply: features.annotation_reply || { enabled: false },
|
||||
}
|
||||
|
||||
setFeatures(transformedFeatures)
|
||||
}
|
||||
if (conversation_variables) {
|
||||
const { setConversationVariables } = workflowStore.getState()
|
||||
@ -61,6 +141,7 @@ const WorkflowMain = ({
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
} = useNodesSyncDraft()
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
const {
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
@ -68,6 +149,64 @@ const WorkflowMain = ({
|
||||
handleRun,
|
||||
handleStopRun,
|
||||
} = useWorkflowRun()
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onVarsAndFeaturesUpdate(async (_update: CollaborationUpdate) => {
|
||||
try {
|
||||
const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
|
||||
handleWorkflowDataUpdate(response)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('workflow vars and features update failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, handleWorkflowDataUpdate, isCollaborationEnabled])
|
||||
|
||||
// Listen for workflow updates from other users
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onWorkflowUpdate(async () => {
|
||||
try {
|
||||
const response = await fetchWorkflowDraft(`/apps/${appId}/workflows/draft`)
|
||||
|
||||
// Handle features, variables etc.
|
||||
handleWorkflowDataUpdate(response)
|
||||
|
||||
// Update workflow canvas (nodes, edges, viewport)
|
||||
if (response.graph) {
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes: response.graph.nodes || [],
|
||||
edges: response.graph.edges || [],
|
||||
viewport: response.graph.viewport || { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch updated workflow:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, handleWorkflowDataUpdate, handleUpdateWorkflowCanvas, isCollaborationEnabled])
|
||||
|
||||
// Listen for sync requests from other users (only processed by leader)
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onSyncRequest(() => {
|
||||
doSyncWorkflowDraft()
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, doSyncWorkflowDraft, isCollaborationEnabled])
|
||||
const {
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInChatflow,
|
||||
@ -85,6 +224,7 @@ const WorkflowMain = ({
|
||||
} = useDSL()
|
||||
|
||||
const configsMap = useConfigsMap()
|
||||
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
|
||||
...configsMap,
|
||||
})
|
||||
@ -105,7 +245,7 @@ const WorkflowMain = ({
|
||||
invalidateConversationVarValues,
|
||||
} = useInspectVarsCrud()
|
||||
|
||||
const hooksStore = useMemo(() => {
|
||||
const hooksStore = useMemo<Partial<HooksStoreShape>>(() => {
|
||||
return {
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
doSyncWorkflowDraft,
|
||||
@ -182,17 +322,25 @@ const WorkflowMain = ({
|
||||
])
|
||||
|
||||
return (
|
||||
<WorkflowWithInnerContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport}
|
||||
onWorkflowDataUpdate={handleWorkflowDataUpdate}
|
||||
hooksStore={hooksStore as any}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full"
|
||||
>
|
||||
<MCPToolAvailabilityProvider sandboxEnabled={sandboxEnabled}>
|
||||
<WorkflowChildren headerLeftSlot={headerLeftSlot} />
|
||||
</MCPToolAvailabilityProvider>
|
||||
</WorkflowWithInnerContext>
|
||||
<WorkflowWithInnerContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport}
|
||||
onWorkflowDataUpdate={handleWorkflowDataUpdate}
|
||||
hooksStore={hooksStore}
|
||||
cursors={filteredCursors}
|
||||
myUserId={myUserId}
|
||||
onlineUsers={onlineUsers}
|
||||
>
|
||||
<MCPToolAvailabilityProvider sandboxEnabled={sandboxEnabled}>
|
||||
<WorkflowChildren headerLeftSlot={headerLeftSlot} />
|
||||
</MCPToolAvailabilityProvider>
|
||||
</WorkflowWithInnerContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Panel from '@/app/components/workflow/panel'
|
||||
import CommentsPanel from '@/app/components/workflow/panel/comments-panel'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
useIsChatMode,
|
||||
@ -67,6 +68,7 @@ const WorkflowPanelOnRight = () => {
|
||||
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
|
||||
const showChatVariablePanel = useStore(s => s.showChatVariablePanel)
|
||||
const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -100,6 +102,7 @@ const WorkflowPanelOnRight = () => {
|
||||
<GlobalVariablePanel />
|
||||
)
|
||||
}
|
||||
{controlMode === 'comment' && <CommentsPanel />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ export const useAvailableNodesMetaData = () => {
|
||||
TriggerPluginDefault,
|
||||
]
|
||||
),
|
||||
], [isChatMode, startNodeMetaData])
|
||||
] as AvailableNodesMetaData['nodes'], [isChatMode, startNodeMetaData])
|
||||
|
||||
const availableNodesMetaData = useMemo<NodeDefaultBase[]>(() => {
|
||||
const toNodeDefaultBase = (
|
||||
|
||||
@ -3,8 +3,14 @@ import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
export const useGetRunAndTraceUrl = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const getWorkflowRunAndTraceUrl = useCallback((runId: string) => {
|
||||
const getWorkflowRunAndTraceUrl = useCallback((runId?: string) => {
|
||||
const { appId } = workflowStore.getState()
|
||||
if (!appId || !runId) {
|
||||
return {
|
||||
runUrl: '',
|
||||
traceUrl: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runUrl: `/apps/${appId}/workflow-runs/${runId}`,
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import type { WorkflowDraftFeaturesPayload } from '@/service/workflow'
|
||||
import { produce } from 'immer'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { syncWorkflowDraft } from '@/service/workflow'
|
||||
import { useWorkflowRefreshDraft } from '.'
|
||||
|
||||
@ -15,6 +19,8 @@ export const useNodesSyncDraft = () => {
|
||||
const featuresStore = useFeaturesStore()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const params = useParams()
|
||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
|
||||
const getPostParams = useCallback(() => {
|
||||
const {
|
||||
@ -52,7 +58,16 @@ export const useNodesSyncDraft = () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
const viewport = { x, y, zoom }
|
||||
const featuresPayload: WorkflowDraftFeaturesPayload = {
|
||||
opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '',
|
||||
suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [],
|
||||
suggested_questions_after_answer: features.suggested,
|
||||
text_to_speech: features.text2speech,
|
||||
speech_to_text: features.speech2text,
|
||||
retriever_resource: features.citation,
|
||||
sensitive_word_avoidance: features.moderation,
|
||||
file_upload: features.file,
|
||||
}
|
||||
|
||||
return {
|
||||
url: `/apps/${appId}/workflows/draft`,
|
||||
@ -60,34 +75,41 @@ export const useNodesSyncDraft = () => {
|
||||
graph: {
|
||||
nodes: producedNodes,
|
||||
edges: producedEdges,
|
||||
viewport,
|
||||
},
|
||||
features: {
|
||||
opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '',
|
||||
suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [],
|
||||
suggested_questions_after_answer: features.suggested,
|
||||
text_to_speech: features.text2speech,
|
||||
speech_to_text: features.speech2text,
|
||||
retriever_resource: features.citation,
|
||||
sensitive_word_avoidance: features.moderation,
|
||||
file_upload: features.file,
|
||||
sandbox: features.sandbox,
|
||||
viewport: {
|
||||
x,
|
||||
y,
|
||||
zoom,
|
||||
},
|
||||
},
|
||||
features: featuresPayload,
|
||||
environment_variables: environmentVariables,
|
||||
conversation_variables: conversationVariables,
|
||||
hash: syncWorkflowDraftHash,
|
||||
_is_collaborative: isCollaborationEnabled,
|
||||
},
|
||||
}
|
||||
}, [store, featuresStore, workflowStore])
|
||||
}, [store, featuresStore, workflowStore, isCollaborationEnabled])
|
||||
|
||||
const syncWorkflowDraftWhenPageClose = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
// Check leader status at sync time
|
||||
const currentIsLeader = isCollaborationEnabled ? collaborationManager.getIsLeader() : true
|
||||
|
||||
// Only allow leader to sync data
|
||||
if (isCollaborationEnabled && !currentIsLeader)
|
||||
return
|
||||
|
||||
const postParams = getPostParams()
|
||||
|
||||
if (postParams)
|
||||
navigator.sendBeacon(`${API_PREFIX}${postParams.url}`, JSON.stringify(postParams.params))
|
||||
}, [getPostParams, getNodesReadOnly])
|
||||
if (postParams) {
|
||||
navigator.sendBeacon(
|
||||
`${API_PREFIX}/apps/${params.appId}/workflows/draft`,
|
||||
JSON.stringify(postParams.params),
|
||||
)
|
||||
}
|
||||
}, [getPostParams, params.appId, getNodesReadOnly, isCollaborationEnabled])
|
||||
|
||||
const performSync = useCallback(async (
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
@ -99,6 +121,17 @@ export const useNodesSyncDraft = () => {
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
// Check leader status at sync time
|
||||
const currentIsLeader = isCollaborationEnabled ? collaborationManager.getIsLeader() : true
|
||||
|
||||
// If not leader, request the leader to sync
|
||||
if (isCollaborationEnabled && !currentIsLeader) {
|
||||
if (isCollaborationEnabled)
|
||||
collaborationManager.emitSyncRequest()
|
||||
callback?.onSettled?.()
|
||||
return
|
||||
}
|
||||
const postParams = getPostParams()
|
||||
|
||||
if (postParams) {
|
||||
@ -106,6 +139,7 @@ export const useNodesSyncDraft = () => {
|
||||
setSyncWorkflowDraftHash,
|
||||
setDraftUpdatedAt,
|
||||
} = workflowStore.getState()
|
||||
|
||||
try {
|
||||
const res = await syncWorkflowDraft({
|
||||
...postParams,
|
||||
@ -128,7 +162,7 @@ export const useNodesSyncDraft = () => {
|
||||
callback?.onSettled?.()
|
||||
}
|
||||
}
|
||||
}, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft])
|
||||
}, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft, isCollaborationEnabled])
|
||||
|
||||
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
|
||||
const syncWorkflowDraftImmediately = useCallback((
|
||||
|
||||
@ -18,6 +18,7 @@ import { FeaturesProvider } from '@/app/components/base/features'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import WorkflowWithDefaultContext from '@/app/components/workflow'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import {
|
||||
WorkflowContextProvider,
|
||||
} from '@/app/components/workflow/context'
|
||||
@ -185,15 +186,20 @@ const WorkflowAppWithAdditionalContext = () => {
|
||||
}, [workflowStore])
|
||||
|
||||
const nodesData = useMemo(() => {
|
||||
if (data)
|
||||
return initialNodes(data.graph.nodes, data.graph.edges)
|
||||
|
||||
if (data) {
|
||||
const processedNodes = initialNodes(data.graph.nodes, data.graph.edges)
|
||||
collaborationManager.setNodes([], processedNodes)
|
||||
return processedNodes
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
const edgesData = useMemo(() => {
|
||||
if (data)
|
||||
return initialEdges(data.graph.edges, data.graph.nodes)
|
||||
|
||||
const edgesData = useMemo(() => {
|
||||
if (data) {
|
||||
const processedEdges = initialEdges(data.graph.edges, data.graph.nodes)
|
||||
collaborationManager.setEdges([], processedEdges)
|
||||
return processedEdges
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ const NodeSelectorWrapper = (props: NodeSelectorProps) => {
|
||||
|
||||
return true
|
||||
})
|
||||
}, [availableNodesMetaData?.nodes])
|
||||
}, [availableNodesMetaData?.nodes]) as NodeSelectorProps['blocks']
|
||||
|
||||
return (
|
||||
<NodeSelector
|
||||
|
||||
@ -11,9 +11,9 @@ import {
|
||||
} from 'react'
|
||||
import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
|
||||
import { CUSTOM_NODE } from './constants'
|
||||
import { useAutoGenerateWebhookUrl, useNodesInteractions, useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from './hooks'
|
||||
import CustomNode from './nodes'
|
||||
@ -32,7 +32,6 @@ type Props = {
|
||||
const CandidateNodeMain: FC<Props> = ({
|
||||
candidateNode,
|
||||
}) => {
|
||||
const store = useStoreApi()
|
||||
const reactflow = useReactFlow()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
@ -41,15 +40,12 @@ const CandidateNodeMain: FC<Props> = ({
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
|
||||
useEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.push({
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CursorPosition, OnlineUser } from '@/app/components/workflow/collaboration/types'
|
||||
import { useViewport } from 'reactflow'
|
||||
import { getUserColor } from '../utils/user-color'
|
||||
|
||||
type UserCursorsProps = {
|
||||
cursors: Record<string, CursorPosition>
|
||||
myUserId: string | null
|
||||
onlineUsers: OnlineUser[]
|
||||
}
|
||||
|
||||
const UserCursors: FC<UserCursorsProps> = ({
|
||||
cursors,
|
||||
myUserId,
|
||||
onlineUsers,
|
||||
}) => {
|
||||
const viewport = useViewport()
|
||||
|
||||
const convertToScreenCoordinates = (cursor: CursorPosition) => {
|
||||
// Convert world coordinates to screen coordinates using current viewport
|
||||
const screenX = cursor.x * viewport.zoom + viewport.x
|
||||
const screenY = cursor.y * viewport.zoom + viewport.y
|
||||
|
||||
return { x: screenX, y: screenY }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{Object.entries(cursors || {}).map(([userId, cursor]) => {
|
||||
if (userId === myUserId)
|
||||
return null
|
||||
|
||||
const userInfo = onlineUsers.find(user => user.user_id === userId)
|
||||
const userName = userInfo?.username || `User ${userId.slice(-4)}`
|
||||
const userColor = getUserColor(userId)
|
||||
const screenPos = convertToScreenCoordinates(cursor)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={userId}
|
||||
className="pointer-events-none absolute z-[8] transition-all duration-150 ease-out"
|
||||
style={{
|
||||
left: screenPos.x,
|
||||
top: screenPos.y,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="drop-shadow-md"
|
||||
>
|
||||
<path
|
||||
d="M5 3L5 15L8 11.5L11 16L13 15L10 10.5L14 10.5L5 3Z"
|
||||
fill={userColor}
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute left-4 top-4 max-w-[120px] overflow-hidden text-ellipsis whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm"
|
||||
style={{
|
||||
backgroundColor: userColor,
|
||||
}}
|
||||
>
|
||||
{userName}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserCursors
|
||||
@ -0,0 +1,331 @@
|
||||
import type { LoroMap } from 'loro-crdt'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { LoroDoc } from 'loro-crdt'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { CollaborationManager } from '../collaboration-manager'
|
||||
|
||||
const NODE_ID = 'node-1'
|
||||
const LLM_NODE_ID = 'llm-node'
|
||||
const PARAM_NODE_ID = 'parameter-node'
|
||||
|
||||
type WorkflowVariable = {
|
||||
variable: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
default: string
|
||||
max_length: number
|
||||
placeholder: string
|
||||
options: string[]
|
||||
hint: string
|
||||
}
|
||||
|
||||
type PromptTemplateItem = {
|
||||
id: string
|
||||
role: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type ParameterItem = {
|
||||
description: string
|
||||
name: string
|
||||
required: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
type StartNodeData = {
|
||||
variables: WorkflowVariable[]
|
||||
}
|
||||
|
||||
type LLMNodeData = {
|
||||
model: {
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
completion_params: {
|
||||
temperature: number
|
||||
}
|
||||
}
|
||||
context: {
|
||||
enabled: boolean
|
||||
variable_selector: string[]
|
||||
}
|
||||
vision: {
|
||||
enabled: boolean
|
||||
}
|
||||
prompt_template: PromptTemplateItem[]
|
||||
}
|
||||
|
||||
type ParameterExtractorNodeData = {
|
||||
model: {
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
completion_params: {
|
||||
temperature: number
|
||||
}
|
||||
}
|
||||
parameters: ParameterItem[]
|
||||
query: unknown[]
|
||||
reasoning_mode: string
|
||||
vision: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type CollaborationManagerInternals = {
|
||||
doc: LoroDoc
|
||||
nodesMap: LoroMap
|
||||
edgesMap: LoroMap
|
||||
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
||||
}
|
||||
|
||||
const createNode = (variables: string[]): Node<StartNodeData> => ({
|
||||
id: NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
variables: variables.map(name => ({
|
||||
variable: name,
|
||||
label: name,
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
default: '',
|
||||
max_length: 48,
|
||||
placeholder: '',
|
||||
options: [],
|
||||
hint: '',
|
||||
})),
|
||||
},
|
||||
})
|
||||
|
||||
const createLLMNode = (templates: PromptTemplateItem[]): Node<LLMNodeData> => ({
|
||||
id: LLM_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 200, y: 200 },
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
desc: '',
|
||||
selected: false,
|
||||
model: {
|
||||
mode: 'chat',
|
||||
name: 'gemini-2.5-pro',
|
||||
provider: 'langgenius/gemini/google',
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
enabled: false,
|
||||
variable_selector: [],
|
||||
},
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
prompt_template: templates,
|
||||
},
|
||||
})
|
||||
|
||||
const createParameterExtractorNode = (parameters: ParameterItem[]): Node<ParameterExtractorNodeData> => ({
|
||||
id: PARAM_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 400, y: 120 },
|
||||
data: {
|
||||
type: BlockEnum.ParameterExtractor,
|
||||
title: 'ParameterExtractor',
|
||||
desc: '',
|
||||
selected: true,
|
||||
model: {
|
||||
mode: 'chat',
|
||||
name: '',
|
||||
provider: '',
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
query: [],
|
||||
reasoning_mode: 'prompt',
|
||||
parameters,
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||
manager as unknown as CollaborationManagerInternals
|
||||
|
||||
const getManager = (doc: LoroDoc) => {
|
||||
const manager = new CollaborationManager()
|
||||
const internals = getManagerInternals(manager)
|
||||
internals.doc = doc
|
||||
internals.nodesMap = doc.getMap('nodes')
|
||||
internals.edgesMap = doc.getMap('edges')
|
||||
return manager
|
||||
}
|
||||
|
||||
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
||||
|
||||
const syncNodes = (manager: CollaborationManager, previous: Node[], next: Node[]) => {
|
||||
const internals = getManagerInternals(manager)
|
||||
internals.syncNodes(previous, next)
|
||||
}
|
||||
|
||||
const exportNodes = (manager: CollaborationManager) => manager.getNodes()
|
||||
|
||||
describe('Loro merge behavior smoke test', () => {
|
||||
it('inspects concurrent edits after merge', () => {
|
||||
const docA = new LoroDoc()
|
||||
const managerA = getManager(docA)
|
||||
syncNodes(managerA, [], [createNode(['a'])])
|
||||
|
||||
const snapshot = docA.export({ mode: 'snapshot' })
|
||||
|
||||
const docB = LoroDoc.fromSnapshot(snapshot)
|
||||
const managerB = getManager(docB)
|
||||
|
||||
syncNodes(managerA, [createNode(['a'])], [createNode(['a', 'b'])])
|
||||
syncNodes(managerB, [createNode(['a'])], [createNode(['a', 'c'])])
|
||||
|
||||
const updateForA = docB.export({ mode: 'update', from: docA.version() })
|
||||
docA.import(updateForA)
|
||||
|
||||
const updateForB = docA.export({ mode: 'update', from: docB.version() })
|
||||
docB.import(updateForB)
|
||||
|
||||
const finalA = exportNodes(managerA)
|
||||
const finalB = exportNodes(managerB)
|
||||
expect(finalA.length).toBe(1)
|
||||
expect(finalB.length).toBe(1)
|
||||
})
|
||||
|
||||
it('merges prompt template insertions and edits across replicas', () => {
|
||||
const baseTemplate = [
|
||||
{
|
||||
id: 'system-1',
|
||||
role: 'system',
|
||||
text: 'base instruction',
|
||||
},
|
||||
]
|
||||
|
||||
const docA = new LoroDoc()
|
||||
const managerA = getManager(docA)
|
||||
syncNodes(managerA, [], [createLLMNode(deepClone(baseTemplate))])
|
||||
|
||||
const snapshot = docA.export({ mode: 'snapshot' })
|
||||
const docB = LoroDoc.fromSnapshot(snapshot)
|
||||
const managerB = getManager(docB)
|
||||
|
||||
const additionTemplate = [
|
||||
...baseTemplate,
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
text: 'hello from docA',
|
||||
},
|
||||
]
|
||||
syncNodes(managerA, [createLLMNode(deepClone(baseTemplate))], [createLLMNode(deepClone(additionTemplate))])
|
||||
|
||||
const editedTemplate = [
|
||||
{
|
||||
id: 'system-1',
|
||||
role: 'system',
|
||||
text: 'updated by docB',
|
||||
},
|
||||
]
|
||||
syncNodes(managerB, [createLLMNode(deepClone(baseTemplate))], [createLLMNode(deepClone(editedTemplate))])
|
||||
|
||||
const updateForA = docB.export({ mode: 'update', from: docA.version() })
|
||||
docA.import(updateForA)
|
||||
|
||||
const updateForB = docA.export({ mode: 'update', from: docB.version() })
|
||||
docB.import(updateForB)
|
||||
|
||||
const finalA = exportNodes(managerA).find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
|
||||
const finalB = exportNodes(managerB).find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
|
||||
|
||||
expect(finalA).toBeDefined()
|
||||
expect(finalB).toBeDefined()
|
||||
|
||||
const expectedTemplates = [
|
||||
{
|
||||
id: 'system-1',
|
||||
role: 'system',
|
||||
text: 'updated by docB',
|
||||
},
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
text: 'hello from docA',
|
||||
},
|
||||
]
|
||||
|
||||
expect(finalA!.data.prompt_template).toEqual(expectedTemplates)
|
||||
expect(finalB!.data.prompt_template).toEqual(expectedTemplates)
|
||||
})
|
||||
|
||||
it('converges when parameter lists are edited concurrently', () => {
|
||||
const baseParameters = [
|
||||
{ description: 'bb', name: 'aa', required: false, type: 'string' },
|
||||
{ description: 'dd', name: 'cc', required: false, type: 'string' },
|
||||
]
|
||||
|
||||
const docA = new LoroDoc()
|
||||
const managerA = getManager(docA)
|
||||
syncNodes(managerA, [], [createParameterExtractorNode(deepClone(baseParameters))])
|
||||
|
||||
const snapshot = docA.export({ mode: 'snapshot' })
|
||||
const docB = LoroDoc.fromSnapshot(snapshot)
|
||||
const managerB = getManager(docB)
|
||||
|
||||
const docAUpdate = [
|
||||
{ description: 'bb updated by A', name: 'aa', required: true, type: 'string' },
|
||||
{ description: 'dd', name: 'cc', required: false, type: 'string' },
|
||||
{ description: 'new from A', name: 'ee', required: false, type: 'number' },
|
||||
]
|
||||
syncNodes(
|
||||
managerA,
|
||||
[createParameterExtractorNode(deepClone(baseParameters))],
|
||||
[createParameterExtractorNode(deepClone(docAUpdate))],
|
||||
)
|
||||
|
||||
const docBUpdate = [
|
||||
{ description: 'bb', name: 'aa', required: false, type: 'string' },
|
||||
{ description: 'dd updated by B', name: 'cc', required: true, type: 'string' },
|
||||
]
|
||||
syncNodes(
|
||||
managerB,
|
||||
[createParameterExtractorNode(deepClone(baseParameters))],
|
||||
[createParameterExtractorNode(deepClone(docBUpdate))],
|
||||
)
|
||||
|
||||
const updateForA = docB.export({ mode: 'update', from: docA.version() })
|
||||
docA.import(updateForA)
|
||||
|
||||
const updateForB = docA.export({ mode: 'update', from: docB.version() })
|
||||
docB.import(updateForB)
|
||||
|
||||
const finalA = exportNodes(managerA).find(node => node.id === PARAM_NODE_ID) as
|
||||
| Node<ParameterExtractorNodeData>
|
||||
| undefined
|
||||
const finalB = exportNodes(managerB).find(node => node.id === PARAM_NODE_ID) as
|
||||
| Node<ParameterExtractorNodeData>
|
||||
| undefined
|
||||
|
||||
expect(finalA).toBeDefined()
|
||||
expect(finalB).toBeDefined()
|
||||
|
||||
const expectedParameters = [
|
||||
{ description: 'bb updated by A', name: 'aa', required: true, type: 'string' },
|
||||
{ description: 'dd updated by B', name: 'cc', required: true, type: 'string' },
|
||||
{ description: 'new from A', name: 'ee', required: false, type: 'number' },
|
||||
]
|
||||
|
||||
expect(finalA!.data.parameters).toEqual(expectedParameters)
|
||||
expect(finalB!.data.parameters).toEqual(expectedParameters)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,763 @@
|
||||
import type { LoroMap } from 'loro-crdt'
|
||||
import type {
|
||||
NodePanelPresenceMap,
|
||||
NodePanelPresenceUser,
|
||||
} from '@/app/components/workflow/collaboration/types/collaboration'
|
||||
import type { CommonNodeType, Edge, Node } from '@/app/components/workflow/types'
|
||||
import { LoroDoc } from 'loro-crdt'
|
||||
import { Position } from 'reactflow'
|
||||
import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
const NODE_ID = '1760342909316'
|
||||
|
||||
type WorkflowVariable = {
|
||||
default: string
|
||||
hint: string
|
||||
label: string
|
||||
max_length: number
|
||||
options: string[]
|
||||
placeholder: string
|
||||
required: boolean
|
||||
type: string
|
||||
variable: string
|
||||
}
|
||||
|
||||
type PromptTemplateItem = {
|
||||
id: string
|
||||
role: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type ParameterItem = {
|
||||
description: string
|
||||
name: string
|
||||
required: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
type NodePanelPresenceEventData = {
|
||||
nodeId: string
|
||||
action: 'open' | 'close'
|
||||
user: NodePanelPresenceUser
|
||||
clientId: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
type StartNodeData = {
|
||||
variables: WorkflowVariable[]
|
||||
}
|
||||
|
||||
type LLMNodeData = {
|
||||
context: {
|
||||
enabled: boolean
|
||||
variable_selector: string[]
|
||||
}
|
||||
model: {
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
completion_params: {
|
||||
temperature: number
|
||||
}
|
||||
}
|
||||
prompt_template: PromptTemplateItem[]
|
||||
vision: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type ParameterExtractorNodeData = {
|
||||
model: {
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
completion_params: {
|
||||
temperature: number
|
||||
}
|
||||
}
|
||||
parameters: ParameterItem[]
|
||||
query: unknown[]
|
||||
reasoning_mode: string
|
||||
vision: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type LLMNodeDataWithUnknownTemplate = Omit<LLMNodeData, 'prompt_template'> & {
|
||||
prompt_template: unknown
|
||||
}
|
||||
|
||||
type ManagerDoc = LoroDoc | { commit: () => void }
|
||||
|
||||
type CollaborationManagerInternals = {
|
||||
doc: ManagerDoc
|
||||
nodesMap: LoroMap
|
||||
edgesMap: LoroMap
|
||||
syncNodes: (oldNodes: Node[], newNodes: Node[]) => void
|
||||
syncEdges: (oldEdges: Edge[], newEdges: Edge[]) => void
|
||||
applyNodePanelPresenceUpdate: (update: NodePanelPresenceEventData) => void
|
||||
forceDisconnect: () => void
|
||||
activeConnections: Set<string>
|
||||
isUndoRedoInProgress: boolean
|
||||
}
|
||||
|
||||
const createVariable = (name: string, overrides: Partial<WorkflowVariable> = {}): WorkflowVariable => ({
|
||||
default: '',
|
||||
hint: '',
|
||||
label: name,
|
||||
max_length: 48,
|
||||
options: [],
|
||||
placeholder: '',
|
||||
required: true,
|
||||
type: 'text-input',
|
||||
variable: name,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const deepClone = <T>(value: T): T => JSON.parse(JSON.stringify(value))
|
||||
|
||||
const createNodeSnapshot = (variableNames: string[]): Node<StartNodeData> => ({
|
||||
id: NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 24 },
|
||||
positionAbsolute: { x: 0, y: 24 },
|
||||
height: 88,
|
||||
width: 242,
|
||||
selected: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
data: {
|
||||
selected: true,
|
||||
title: '开始',
|
||||
desc: '',
|
||||
type: BlockEnum.Start,
|
||||
variables: variableNames.map(name => createVariable(name)),
|
||||
},
|
||||
})
|
||||
|
||||
const LLM_NODE_ID = 'llm-node'
|
||||
const PARAM_NODE_ID = 'param-extractor-node'
|
||||
|
||||
const createLLMNodeSnapshot = (promptTemplates: PromptTemplateItem[]): Node<LLMNodeData> => ({
|
||||
id: LLM_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 200, y: 120 },
|
||||
positionAbsolute: { x: 200, y: 120 },
|
||||
height: 320,
|
||||
width: 460,
|
||||
selected: false,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
data: {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
desc: '',
|
||||
selected: false,
|
||||
context: {
|
||||
enabled: false,
|
||||
variable_selector: [],
|
||||
},
|
||||
model: {
|
||||
mode: 'chat',
|
||||
name: 'gemini-2.5-pro',
|
||||
provider: 'langgenius/gemini/google',
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
prompt_template: promptTemplates,
|
||||
},
|
||||
})
|
||||
|
||||
const createParameterExtractorNodeSnapshot = (parameters: ParameterItem[]): Node<ParameterExtractorNodeData> => ({
|
||||
id: PARAM_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 420, y: 220 },
|
||||
positionAbsolute: { x: 420, y: 220 },
|
||||
height: 260,
|
||||
width: 420,
|
||||
selected: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
data: {
|
||||
type: BlockEnum.ParameterExtractor,
|
||||
title: '参数提取器',
|
||||
desc: '',
|
||||
selected: true,
|
||||
model: {
|
||||
mode: 'chat',
|
||||
name: '',
|
||||
provider: '',
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
reasoning_mode: 'prompt',
|
||||
parameters,
|
||||
query: [],
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const getVariables = (node: Node): string[] => {
|
||||
const data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }>
|
||||
const variables = data.variables ?? []
|
||||
return variables.map(item => item.variable)
|
||||
}
|
||||
|
||||
const getVariableObject = (node: Node, name: string): WorkflowVariable | undefined => {
|
||||
const data = node.data as CommonNodeType<{ variables?: WorkflowVariable[] }>
|
||||
const variables = data.variables ?? []
|
||||
return variables.find(item => item.variable === name)
|
||||
}
|
||||
|
||||
const getPromptTemplates = (node: Node): PromptTemplateItem[] => {
|
||||
const data = node.data as CommonNodeType<{ prompt_template?: PromptTemplateItem[] }>
|
||||
return data.prompt_template ?? []
|
||||
}
|
||||
|
||||
const getParameters = (node: Node): ParameterItem[] => {
|
||||
const data = node.data as CommonNodeType<{ parameters?: ParameterItem[] }>
|
||||
return data.parameters ?? []
|
||||
}
|
||||
|
||||
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||
manager as unknown as CollaborationManagerInternals
|
||||
|
||||
const setupManager = (): { manager: CollaborationManager, internals: CollaborationManagerInternals } => {
|
||||
const manager = new CollaborationManager()
|
||||
const doc = new LoroDoc()
|
||||
const internals = getManagerInternals(manager)
|
||||
internals.doc = doc
|
||||
internals.nodesMap = doc.getMap('nodes')
|
||||
internals.edgesMap = doc.getMap('edges')
|
||||
return { manager, internals }
|
||||
}
|
||||
|
||||
describe('CollaborationManager syncNodes', () => {
|
||||
let manager: CollaborationManager
|
||||
let internals: CollaborationManagerInternals
|
||||
|
||||
beforeEach(() => {
|
||||
const setup = setupManager()
|
||||
manager = setup.manager
|
||||
internals = setup.internals
|
||||
|
||||
const initialNode = createNodeSnapshot(['a'])
|
||||
internals.syncNodes([], [deepClone(initialNode)])
|
||||
})
|
||||
|
||||
it('updates collaborators map when a single client adds a variable', () => {
|
||||
const base = [createNodeSnapshot(['a'])]
|
||||
const next = [createNodeSnapshot(['a', 'b'])]
|
||||
|
||||
internals.syncNodes(base, next)
|
||||
|
||||
const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
expect(stored).toBeDefined()
|
||||
expect(getVariables(stored!)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('applies the latest parallel additions derived from the same base snapshot', () => {
|
||||
const base = [createNodeSnapshot(['a'])]
|
||||
const userA = [createNodeSnapshot(['a', 'b'])]
|
||||
const userB = [createNodeSnapshot(['a', 'c'])]
|
||||
|
||||
internals.syncNodes(base, userA)
|
||||
|
||||
const afterUserA = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
expect(getVariables(afterUserA!)).toEqual(['a', 'b'])
|
||||
|
||||
internals.syncNodes(base, userB)
|
||||
|
||||
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
const finalVariables = getVariables(finalNode!)
|
||||
|
||||
expect(finalVariables).toEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('prefers the incoming mutation when the same variable is edited concurrently', () => {
|
||||
const base = [createNodeSnapshot(['a'])]
|
||||
const userA = [
|
||||
{
|
||||
...createNodeSnapshot(['a']),
|
||||
data: {
|
||||
...createNodeSnapshot(['a']).data,
|
||||
variables: [
|
||||
createVariable('a', { label: 'A from userA', hint: 'hintA' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
const userB = [
|
||||
{
|
||||
...createNodeSnapshot(['a']),
|
||||
data: {
|
||||
...createNodeSnapshot(['a']).data,
|
||||
variables: [
|
||||
createVariable('a', { label: 'A from userB', hint: 'hintB' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
internals.syncNodes(base, userA)
|
||||
internals.syncNodes(base, userB)
|
||||
|
||||
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
const finalVariable = getVariableObject(finalNode!, 'a')
|
||||
|
||||
expect(finalVariable?.label).toBe('A from userB')
|
||||
expect(finalVariable?.hint).toBe('hintB')
|
||||
})
|
||||
|
||||
it('reflects the last writer when concurrent removal and edits happen', () => {
|
||||
const base = [createNodeSnapshot(['a', 'b'])]
|
||||
internals.syncNodes([], [deepClone(base[0])])
|
||||
const userA = [
|
||||
{
|
||||
...createNodeSnapshot(['a']),
|
||||
data: {
|
||||
...createNodeSnapshot(['a']).data,
|
||||
variables: [
|
||||
createVariable('a', { label: 'A after deletion' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
const userB = [
|
||||
{
|
||||
...createNodeSnapshot(['a', 'b']),
|
||||
data: {
|
||||
...createNodeSnapshot(['a']).data,
|
||||
variables: [
|
||||
createVariable('a'),
|
||||
createVariable('b', { label: 'B edited but should vanish' }),
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
internals.syncNodes(base, userA)
|
||||
internals.syncNodes(base, userB)
|
||||
|
||||
const finalNode = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)
|
||||
const finalVariables = getVariables(finalNode!)
|
||||
expect(finalVariables).toEqual(['a', 'b'])
|
||||
expect(getVariableObject(finalNode!, 'b')).toBeDefined()
|
||||
})
|
||||
|
||||
it('synchronizes prompt_template list updates across collaborators', () => {
|
||||
const { manager: promptManager, internals: promptInternals } = setupManager()
|
||||
|
||||
const baseTemplate = [
|
||||
{
|
||||
id: 'abcfa5f9-3c44-4252-aeba-4b6eaf0acfc4',
|
||||
role: 'system',
|
||||
text: 'avc',
|
||||
},
|
||||
]
|
||||
|
||||
const baseNode = createLLMNodeSnapshot(baseTemplate)
|
||||
promptInternals.syncNodes([], [deepClone(baseNode)])
|
||||
|
||||
const updatedTemplates = [
|
||||
...baseTemplate,
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
text: 'hello world',
|
||||
},
|
||||
]
|
||||
|
||||
const updatedNode = createLLMNodeSnapshot(updatedTemplates)
|
||||
promptInternals.syncNodes([deepClone(baseNode)], [deepClone(updatedNode)])
|
||||
|
||||
const stored = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID)
|
||||
expect(stored).toBeDefined()
|
||||
|
||||
const storedTemplates = getPromptTemplates(stored!)
|
||||
expect(storedTemplates).toHaveLength(2)
|
||||
expect(storedTemplates[0]).toEqual(baseTemplate[0])
|
||||
expect(storedTemplates[1]).toEqual(updatedTemplates[1])
|
||||
|
||||
const editedTemplates = [
|
||||
{
|
||||
id: 'abcfa5f9-3c44-4252-aeba-4b6eaf0acfc4',
|
||||
role: 'system',
|
||||
text: 'updated system prompt',
|
||||
},
|
||||
]
|
||||
const editedNode = createLLMNodeSnapshot(editedTemplates)
|
||||
|
||||
promptInternals.syncNodes([deepClone(updatedNode)], [deepClone(editedNode)])
|
||||
|
||||
const final = (promptManager.getNodes() as Node[]).find(node => node.id === LLM_NODE_ID)
|
||||
const finalTemplates = getPromptTemplates(final!)
|
||||
expect(finalTemplates).toHaveLength(1)
|
||||
expect(finalTemplates[0].text).toBe('updated system prompt')
|
||||
})
|
||||
|
||||
it('keeps parameter list in sync when nodes add, edit, or remove parameters', () => {
|
||||
const { manager: parameterManager, internals: parameterInternals } = setupManager()
|
||||
|
||||
const baseParameters: ParameterItem[] = [
|
||||
{ description: 'bb', name: 'aa', required: false, type: 'string' },
|
||||
{ description: 'dd', name: 'cc', required: false, type: 'string' },
|
||||
]
|
||||
|
||||
const baseNode = createParameterExtractorNodeSnapshot(baseParameters)
|
||||
parameterInternals.syncNodes([], [deepClone(baseNode)])
|
||||
|
||||
const updatedParameters: ParameterItem[] = [
|
||||
...baseParameters,
|
||||
{ description: 'ff', name: 'ee', required: true, type: 'number' },
|
||||
]
|
||||
|
||||
const updatedNode = createParameterExtractorNodeSnapshot(updatedParameters)
|
||||
parameterInternals.syncNodes([deepClone(baseNode)], [deepClone(updatedNode)])
|
||||
|
||||
const stored = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID)
|
||||
expect(stored).toBeDefined()
|
||||
expect(getParameters(stored!)).toEqual(updatedParameters)
|
||||
|
||||
const editedParameters: ParameterItem[] = [
|
||||
{ description: 'bb edited', name: 'aa', required: true, type: 'string' },
|
||||
]
|
||||
const editedNode = createParameterExtractorNodeSnapshot(editedParameters)
|
||||
|
||||
parameterInternals.syncNodes([deepClone(updatedNode)], [deepClone(editedNode)])
|
||||
|
||||
const final = (parameterManager.getNodes() as Node[]).find(node => node.id === PARAM_NODE_ID)
|
||||
expect(getParameters(final!)).toEqual(editedParameters)
|
||||
})
|
||||
|
||||
it('handles nodes without data gracefully', () => {
|
||||
const emptyNode: Node = {
|
||||
id: 'empty-node',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: undefined as unknown as CommonNodeType<Record<string, never>>,
|
||||
}
|
||||
|
||||
internals.syncNodes([], [deepClone(emptyNode)])
|
||||
|
||||
const stored = (manager.getNodes() as Node[]).find(node => node.id === 'empty-node')
|
||||
expect(stored).toBeDefined()
|
||||
expect(stored?.data).toEqual({})
|
||||
})
|
||||
|
||||
it('preserves CRDT list instances when synchronizing parsed state back into the manager', () => {
|
||||
const { manager: promptManager, internals: promptInternals } = setupManager()
|
||||
|
||||
const base = createLLMNodeSnapshot([
|
||||
{ id: 'system', role: 'system', text: 'base' },
|
||||
])
|
||||
promptInternals.syncNodes([], [deepClone(base)])
|
||||
|
||||
const storedBefore = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
|
||||
expect(storedBefore).toBeDefined()
|
||||
const firstTemplate = storedBefore?.data.prompt_template?.[0]
|
||||
expect(firstTemplate?.text).toBe('base')
|
||||
|
||||
// simulate consumer mutating the plain JSON array and syncing back
|
||||
const baseNode = storedBefore!
|
||||
const mutatedNode = deepClone(baseNode)
|
||||
mutatedNode.data.prompt_template.push({
|
||||
id: 'user',
|
||||
role: 'user',
|
||||
text: 'mutated',
|
||||
})
|
||||
|
||||
promptInternals.syncNodes([baseNode], [mutatedNode])
|
||||
|
||||
const storedAfter = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData> | undefined
|
||||
const templatesAfter = storedAfter?.data.prompt_template
|
||||
expect(Array.isArray(templatesAfter)).toBe(true)
|
||||
expect(templatesAfter).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reuses CRDT list when syncing parameters repeatedly', () => {
|
||||
const { manager: parameterManager, internals: parameterInternals } = setupManager()
|
||||
|
||||
const initialParameters: ParameterItem[] = [
|
||||
{ description: 'desc', name: 'param', required: false, type: 'string' },
|
||||
]
|
||||
const node = createParameterExtractorNodeSnapshot(initialParameters)
|
||||
parameterInternals.syncNodes([], [deepClone(node)])
|
||||
|
||||
const stored = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as Node<ParameterExtractorNodeData>
|
||||
const mutatedNode = deepClone(stored)
|
||||
mutatedNode.data.parameters[0].description = 'updated'
|
||||
|
||||
parameterInternals.syncNodes([stored], [mutatedNode])
|
||||
|
||||
const storedAfter = parameterManager.getNodes().find(n => n.id === PARAM_NODE_ID) as
|
||||
| Node<ParameterExtractorNodeData>
|
||||
| undefined
|
||||
const params = storedAfter?.data.parameters ?? []
|
||||
expect(params).toHaveLength(1)
|
||||
expect(params[0].description).toBe('updated')
|
||||
})
|
||||
|
||||
it('filters out transient/private data keys while keeping allowlisted ones', () => {
|
||||
const nodeWithPrivate: Node<{ _foo: string, variables: WorkflowVariable[] }> = {
|
||||
id: 'private-node',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'private',
|
||||
desc: '',
|
||||
_foo: 'should disappear',
|
||||
_children: [{ nodeId: 'child-a', nodeType: BlockEnum.Start }],
|
||||
selected: true,
|
||||
variables: [],
|
||||
},
|
||||
}
|
||||
|
||||
internals.syncNodes([], [deepClone(nodeWithPrivate)])
|
||||
|
||||
const stored = (manager.getNodes() as Node[]).find(node => node.id === 'private-node')!
|
||||
const storedData = stored.data as CommonNodeType<{ _foo?: string }>
|
||||
expect(storedData._foo).toBeUndefined()
|
||||
expect(storedData._children).toEqual([{ nodeId: 'child-a', nodeType: BlockEnum.Start }])
|
||||
expect(storedData.selected).toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes list fields when they are omitted in the update snapshot', () => {
|
||||
const baseNode = createNodeSnapshot(['alpha'])
|
||||
internals.syncNodes([], [deepClone(baseNode)])
|
||||
|
||||
const withoutVariables: Node<StartNodeData> = {
|
||||
...deepClone(baseNode),
|
||||
data: {
|
||||
...deepClone(baseNode).data,
|
||||
},
|
||||
}
|
||||
delete (withoutVariables.data as CommonNodeType<{ variables?: WorkflowVariable[] }>).variables
|
||||
|
||||
internals.syncNodes([deepClone(baseNode)], [withoutVariables])
|
||||
|
||||
const stored = (manager.getNodes() as Node[]).find(node => node.id === NODE_ID)!
|
||||
const storedData = stored.data as CommonNodeType<{ variables?: WorkflowVariable[] }>
|
||||
expect(storedData.variables).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats non-array list inputs as empty lists during synchronization', () => {
|
||||
const { manager: promptManager, internals: promptInternals } = setupManager()
|
||||
|
||||
const nodeWithInvalidTemplate = createLLMNodeSnapshot([])
|
||||
promptInternals.syncNodes([], [deepClone(nodeWithInvalidTemplate)])
|
||||
|
||||
const mutated = deepClone(nodeWithInvalidTemplate) as Node<LLMNodeDataWithUnknownTemplate>
|
||||
mutated.data.prompt_template = 'not-an-array'
|
||||
|
||||
promptInternals.syncNodes([deepClone(nodeWithInvalidTemplate)], [mutated])
|
||||
|
||||
const stored = promptManager.getNodes().find(node => node.id === LLM_NODE_ID) as Node<LLMNodeData>
|
||||
expect(Array.isArray(stored.data.prompt_template)).toBe(true)
|
||||
expect(stored.data.prompt_template).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('updates edges map when edges are added, modified, and removed', () => {
|
||||
const { manager: edgeManager } = setupManager()
|
||||
|
||||
const edge: Edge = {
|
||||
id: 'edge-1',
|
||||
source: 'node-a',
|
||||
target: 'node-b',
|
||||
type: 'default',
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.LLM,
|
||||
_waitingRun: false,
|
||||
},
|
||||
}
|
||||
|
||||
edgeManager.setEdges([], [edge])
|
||||
expect(edgeManager.getEdges()).toHaveLength(1)
|
||||
const storedEdge = edgeManager.getEdges()[0]!
|
||||
expect(storedEdge.data).toBeDefined()
|
||||
expect(storedEdge.data!._waitingRun).toBe(false)
|
||||
|
||||
const updatedEdge: Edge = {
|
||||
...edge,
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.LLM,
|
||||
_waitingRun: true,
|
||||
},
|
||||
}
|
||||
edgeManager.setEdges([edge], [updatedEdge])
|
||||
expect(edgeManager.getEdges()).toHaveLength(1)
|
||||
const updatedStoredEdge = edgeManager.getEdges()[0]!
|
||||
expect(updatedStoredEdge.data).toBeDefined()
|
||||
expect(updatedStoredEdge.data!._waitingRun).toBe(true)
|
||||
|
||||
edgeManager.setEdges([updatedEdge], [])
|
||||
expect(edgeManager.getEdges()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CollaborationManager public API wrappers', () => {
|
||||
let manager: CollaborationManager
|
||||
let internals: CollaborationManagerInternals
|
||||
const baseNodes: Node[] = []
|
||||
const updatedNodes: Node[] = [
|
||||
{
|
||||
id: 'new-node',
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: 'New node',
|
||||
desc: '',
|
||||
},
|
||||
},
|
||||
]
|
||||
const baseEdges: Edge[] = []
|
||||
const updatedEdges: Edge[] = [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'source',
|
||||
target: 'target',
|
||||
type: 'default',
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.End,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new CollaborationManager()
|
||||
internals = getManagerInternals(manager)
|
||||
})
|
||||
|
||||
it('setNodes delegates to syncNodes and commits the CRDT document', () => {
|
||||
const commit = vi.fn()
|
||||
internals.doc = { commit }
|
||||
const syncSpy = vi.spyOn(internals, 'syncNodes').mockImplementation(() => undefined)
|
||||
|
||||
manager.setNodes(baseNodes, updatedNodes)
|
||||
|
||||
expect(syncSpy).toHaveBeenCalledWith(baseNodes, updatedNodes)
|
||||
expect(commit).toHaveBeenCalled()
|
||||
syncSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('setNodes skips syncing when undo/redo replay is running', () => {
|
||||
const commit = vi.fn()
|
||||
internals.doc = { commit }
|
||||
internals.isUndoRedoInProgress = true
|
||||
const syncSpy = vi.spyOn(internals, 'syncNodes').mockImplementation(() => undefined)
|
||||
|
||||
manager.setNodes(baseNodes, updatedNodes)
|
||||
|
||||
expect(syncSpy).not.toHaveBeenCalled()
|
||||
expect(commit).not.toHaveBeenCalled()
|
||||
syncSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('setEdges delegates to syncEdges and commits the CRDT document', () => {
|
||||
const commit = vi.fn()
|
||||
internals.doc = { commit }
|
||||
const syncSpy = vi.spyOn(internals, 'syncEdges').mockImplementation(() => undefined)
|
||||
|
||||
manager.setEdges(baseEdges, updatedEdges)
|
||||
|
||||
expect(syncSpy).toHaveBeenCalledWith(baseEdges, updatedEdges)
|
||||
expect(commit).toHaveBeenCalled()
|
||||
syncSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('disconnect tears down the collaboration state only when last connection closes', () => {
|
||||
const forceSpy = vi.spyOn(internals, 'forceDisconnect').mockImplementation(() => undefined)
|
||||
internals.activeConnections.add('conn-a')
|
||||
internals.activeConnections.add('conn-b')
|
||||
|
||||
manager.disconnect('conn-a')
|
||||
expect(forceSpy).not.toHaveBeenCalled()
|
||||
|
||||
manager.disconnect('conn-b')
|
||||
expect(forceSpy).toHaveBeenCalledTimes(1)
|
||||
forceSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('applyNodePanelPresenceUpdate keeps a client visible on a single node at a time', () => {
|
||||
const updates: NodePanelPresenceMap[] = []
|
||||
manager.onNodePanelPresenceUpdate((presence) => {
|
||||
updates.push(presence)
|
||||
})
|
||||
|
||||
const user: NodePanelPresenceUser = { userId: 'user-1', username: 'Dana' }
|
||||
|
||||
internals.applyNodePanelPresenceUpdate({
|
||||
nodeId: 'node-a',
|
||||
action: 'open',
|
||||
user,
|
||||
clientId: 'client-1',
|
||||
timestamp: 100,
|
||||
})
|
||||
|
||||
internals.applyNodePanelPresenceUpdate({
|
||||
nodeId: 'node-b',
|
||||
action: 'open',
|
||||
user,
|
||||
clientId: 'client-1',
|
||||
timestamp: 200,
|
||||
})
|
||||
|
||||
const finalSnapshot = updates[updates.length - 1]!
|
||||
expect(finalSnapshot).toEqual({
|
||||
'node-b': {
|
||||
'client-1': {
|
||||
userId: 'user-1',
|
||||
username: 'Dana',
|
||||
clientId: 'client-1',
|
||||
timestamp: 200,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('applyNodePanelPresenceUpdate clears node entries when last viewer closes the panel', () => {
|
||||
const updates: NodePanelPresenceMap[] = []
|
||||
manager.onNodePanelPresenceUpdate((presence) => {
|
||||
updates.push(presence)
|
||||
})
|
||||
|
||||
const user: NodePanelPresenceUser = { userId: 'user-2', username: 'Kai' }
|
||||
|
||||
internals.applyNodePanelPresenceUpdate({
|
||||
nodeId: 'node-a',
|
||||
action: 'open',
|
||||
user,
|
||||
clientId: 'client-9',
|
||||
timestamp: 300,
|
||||
})
|
||||
|
||||
internals.applyNodePanelPresenceUpdate({
|
||||
nodeId: 'node-a',
|
||||
action: 'close',
|
||||
user,
|
||||
clientId: 'client-9',
|
||||
timestamp: 301,
|
||||
})
|
||||
|
||||
expect(updates[updates.length - 1]).toEqual({})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,138 @@
|
||||
import type { LoroDoc } from 'loro-crdt'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import { CRDTProvider } from '../crdt-provider'
|
||||
|
||||
type FakeDocEvent = {
|
||||
by: string
|
||||
}
|
||||
|
||||
type FakeDoc = {
|
||||
export: ReturnType<typeof vi.fn>
|
||||
import: ReturnType<typeof vi.fn>
|
||||
subscribe: ReturnType<typeof vi.fn>
|
||||
trigger: (event: FakeDocEvent) => void
|
||||
}
|
||||
|
||||
const createFakeDoc = (): FakeDoc => {
|
||||
let handler: ((payload: FakeDocEvent) => void) | null = null
|
||||
|
||||
const exportFn = vi.fn(() => new Uint8Array([1, 2, 3]))
|
||||
const importFn = vi.fn()
|
||||
const subscribeFn = vi.fn((cb: (payload: FakeDocEvent) => void) => {
|
||||
handler = cb
|
||||
})
|
||||
|
||||
return {
|
||||
export: exportFn,
|
||||
import: importFn,
|
||||
subscribe: subscribeFn,
|
||||
trigger: (event: FakeDocEvent) => {
|
||||
handler?.(event)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type MockSocket = {
|
||||
trigger: (event: string, ...args: unknown[]) => void
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
off: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const createMockSocket = (): MockSocket => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => void>()
|
||||
|
||||
const socket: MockSocket = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, handler)
|
||||
}),
|
||||
off: vi.fn((event: string) => {
|
||||
handlers.delete(event)
|
||||
}),
|
||||
trigger: (event: string, ...args: unknown[]) => {
|
||||
const handler = handlers.get(event)
|
||||
if (handler)
|
||||
handler(...args)
|
||||
},
|
||||
}
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
describe('CRDTProvider', () => {
|
||||
it('emits graph_event when local changes happen', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
expect(provider).toBeInstanceOf(CRDTProvider)
|
||||
|
||||
doc.trigger({ by: 'local' })
|
||||
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'graph_event',
|
||||
expect.any(Uint8Array),
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(doc.export).toHaveBeenCalledWith({ mode: 'update' })
|
||||
})
|
||||
|
||||
it('ignores non-local events', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
|
||||
doc.trigger({ by: 'remote' })
|
||||
|
||||
expect(socket.emit).not.toHaveBeenCalled()
|
||||
provider.destroy()
|
||||
})
|
||||
|
||||
it('imports remote updates on graph_update', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
|
||||
const payload = new Uint8Array([9, 9, 9])
|
||||
socket.trigger('graph_update', payload)
|
||||
|
||||
expect(doc.import).toHaveBeenCalledWith(expect.any(Uint8Array))
|
||||
expect(Array.from(doc.import.mock.calls[0][0])).toEqual([9, 9, 9])
|
||||
provider.destroy()
|
||||
})
|
||||
|
||||
it('removes graph_update listener on destroy', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
provider.destroy()
|
||||
|
||||
expect(socket.off).toHaveBeenCalledWith('graph_update')
|
||||
})
|
||||
|
||||
it('logs an error when graph_update import fails but continues operating', () => {
|
||||
const doc = createFakeDoc()
|
||||
const socket = createMockSocket()
|
||||
doc.import.mockImplementation(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
const provider = new CRDTProvider(socket as unknown as Socket, doc as unknown as LoroDoc)
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
socket.trigger('graph_update', new Uint8Array([1]))
|
||||
expect(errorSpy).toHaveBeenCalledWith('Error importing graph update:', expect.any(Error))
|
||||
|
||||
doc.import.mockReset()
|
||||
socket.trigger('graph_update', new Uint8Array([2, 3]))
|
||||
expect(doc.import).toHaveBeenCalled()
|
||||
|
||||
provider.destroy()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,93 @@
|
||||
import { EventEmitter } from '../event-emitter'
|
||||
|
||||
describe('EventEmitter', () => {
|
||||
it('registers and invokes handlers via on/emit', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const handler = vi.fn()
|
||||
|
||||
emitter.on('test', handler)
|
||||
emitter.emit('test', { value: 42 })
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ value: 42 })
|
||||
})
|
||||
|
||||
it('removes specific handler with off', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const handlerA = vi.fn()
|
||||
const handlerB = vi.fn()
|
||||
|
||||
emitter.on('test', handlerA)
|
||||
emitter.on('test', handlerB)
|
||||
|
||||
emitter.off('test', handlerA)
|
||||
emitter.emit('test', 'payload')
|
||||
|
||||
expect(handlerA).not.toHaveBeenCalled()
|
||||
expect(handlerB).toHaveBeenCalledWith('payload')
|
||||
})
|
||||
|
||||
it('clears all listeners when off is called without handler', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const handlerA = vi.fn()
|
||||
const handlerB = vi.fn()
|
||||
|
||||
emitter.on('trigger', handlerA)
|
||||
emitter.on('trigger', handlerB)
|
||||
|
||||
emitter.off('trigger')
|
||||
emitter.emit('trigger', 'payload')
|
||||
|
||||
expect(handlerA).not.toHaveBeenCalled()
|
||||
expect(handlerB).not.toHaveBeenCalled()
|
||||
expect(emitter.getListenerCount('trigger')).toBe(0)
|
||||
})
|
||||
|
||||
it('removeAllListeners clears every registered event', () => {
|
||||
const emitter = new EventEmitter()
|
||||
emitter.on('one', vi.fn())
|
||||
emitter.on('two', vi.fn())
|
||||
|
||||
emitter.removeAllListeners()
|
||||
|
||||
expect(emitter.getListenerCount('one')).toBe(0)
|
||||
expect(emitter.getListenerCount('two')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns an unsubscribe function from on', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const handler = vi.fn()
|
||||
|
||||
const unsubscribe = emitter.on('detach', handler)
|
||||
unsubscribe()
|
||||
|
||||
emitter.emit('detach', 'value')
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues emitting when a handler throws', () => {
|
||||
const emitter = new EventEmitter()
|
||||
const errorHandler = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
const failingHandler = vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const succeedingHandler = vi.fn()
|
||||
|
||||
emitter.on('safe', failingHandler)
|
||||
emitter.on('safe', succeedingHandler)
|
||||
|
||||
emitter.emit('safe', 7)
|
||||
|
||||
expect(failingHandler).toHaveBeenCalledWith(7)
|
||||
expect(succeedingHandler).toHaveBeenCalledWith(7)
|
||||
expect(errorHandler).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error in event handler for safe:'),
|
||||
expect.any(Error),
|
||||
)
|
||||
|
||||
errorHandler.mockRestore()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,161 @@
|
||||
type MockSocket = {
|
||||
trigger: (event: string, ...args: unknown[]) => void
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
on: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
type IoOptions = {
|
||||
auth?: unknown
|
||||
path?: string
|
||||
transports?: string[]
|
||||
withCredentials?: boolean
|
||||
}
|
||||
|
||||
const ioMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: (...args: Parameters<typeof ioMock>) => ioMock(...args),
|
||||
}))
|
||||
|
||||
const createMockSocket = (id: string): MockSocket => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => void>()
|
||||
|
||||
const socket: MockSocket & { id: string } = {
|
||||
id,
|
||||
connected: true,
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(() => {
|
||||
socket.connected = false
|
||||
}),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, handler)
|
||||
}),
|
||||
trigger: (event: string, ...args: unknown[]) => {
|
||||
const handler = handlers.get(event)
|
||||
if (handler)
|
||||
handler(...args)
|
||||
},
|
||||
}
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
describe('WebSocketClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
ioMock.mockReset()
|
||||
})
|
||||
|
||||
it('connects with default url and registers base listeners', async () => {
|
||||
const mockSocket = createMockSocket('socket-fallback')
|
||||
ioMock.mockImplementation(() => mockSocket)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
const socket = client.connect('app-1')
|
||||
|
||||
expect(ioMock).toHaveBeenCalledWith(
|
||||
'ws://localhost:5001',
|
||||
expect.objectContaining({
|
||||
path: '/socket.io',
|
||||
transports: ['websocket'],
|
||||
withCredentials: true,
|
||||
}),
|
||||
)
|
||||
expect(socket).toBe(mockSocket)
|
||||
expect(mockSocket.on).toHaveBeenCalledWith('connect', expect.any(Function))
|
||||
expect(mockSocket.on).toHaveBeenCalledWith('disconnect', expect.any(Function))
|
||||
expect(mockSocket.on).toHaveBeenCalledWith('connect_error', expect.any(Function))
|
||||
})
|
||||
|
||||
it('reuses existing connected socket and avoids duplicate connections', async () => {
|
||||
const mockSocket = createMockSocket('socket-reuse')
|
||||
ioMock.mockImplementation(() => mockSocket)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
|
||||
const first = client.connect('app-reuse')
|
||||
const second = client.connect('app-reuse')
|
||||
|
||||
expect(ioMock).toHaveBeenCalledTimes(1)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('emits user_connect on connect without auth payload', async () => {
|
||||
const mockSocket = createMockSocket('socket-auth')
|
||||
ioMock.mockImplementation((url: string, options: IoOptions) => {
|
||||
expect(options.auth).toBeUndefined()
|
||||
return mockSocket
|
||||
})
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
client.connect('app-auth')
|
||||
|
||||
const connectHandler = mockSocket.on.mock.calls.find(call => call[0] === 'connect')?.[1] as () => void
|
||||
expect(connectHandler).toBeDefined()
|
||||
connectHandler()
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith(
|
||||
'user_connect',
|
||||
{ workflow_id: 'app-auth' },
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('disconnects a specific app and clears internal maps', async () => {
|
||||
const mockSocket = createMockSocket('socket-disconnect-one')
|
||||
ioMock.mockImplementation(() => mockSocket)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
client.connect('app-disconnect')
|
||||
|
||||
expect(client.isConnected('app-disconnect')).toBe(true)
|
||||
client.disconnect('app-disconnect')
|
||||
|
||||
expect(mockSocket.disconnect).toHaveBeenCalled()
|
||||
expect(client.getSocket('app-disconnect')).toBeNull()
|
||||
expect(client.isConnected('app-disconnect')).toBe(false)
|
||||
})
|
||||
|
||||
it('disconnects all apps when no id is provided', async () => {
|
||||
const socketA = createMockSocket('socket-a')
|
||||
const socketB = createMockSocket('socket-b')
|
||||
ioMock.mockImplementationOnce(() => socketA).mockImplementationOnce(() => socketB)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
client.connect('app-a')
|
||||
client.connect('app-b')
|
||||
|
||||
client.disconnect()
|
||||
|
||||
expect(socketA.disconnect).toHaveBeenCalled()
|
||||
expect(socketB.disconnect).toHaveBeenCalled()
|
||||
expect(client.getConnectedApps()).toEqual([])
|
||||
})
|
||||
|
||||
it('reports connected apps, sockets, and debug info correctly', async () => {
|
||||
const socketA = createMockSocket('socket-debug-a')
|
||||
const socketB = createMockSocket('socket-debug-b')
|
||||
socketB.connected = false
|
||||
ioMock.mockImplementationOnce(() => socketA).mockImplementationOnce(() => socketB)
|
||||
|
||||
const { WebSocketClient } = await import('../websocket-manager')
|
||||
const client = new WebSocketClient()
|
||||
client.connect('app-a')
|
||||
client.connect('app-b')
|
||||
|
||||
expect(client.getConnectedApps()).toEqual(['app-a'])
|
||||
|
||||
const debugInfo = client.getDebugInfo()
|
||||
expect(debugInfo).toMatchObject({
|
||||
'app-a': { connected: true, socketId: 'socket-debug-a' },
|
||||
'app-b': { connected: false, socketId: 'socket-debug-b' },
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,39 @@
|
||||
import type { LoroDoc } from 'loro-crdt'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import { emitWithAuthGuard } from './websocket-manager'
|
||||
|
||||
export class CRDTProvider {
|
||||
private doc: LoroDoc
|
||||
private socket: Socket
|
||||
private onUnauthorized?: () => void
|
||||
|
||||
constructor(socket: Socket, doc: LoroDoc, onUnauthorized?: () => void) {
|
||||
this.socket = socket
|
||||
this.doc = doc
|
||||
this.onUnauthorized = onUnauthorized
|
||||
this.setupEventListeners()
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.doc.subscribe((event: { by?: string }) => {
|
||||
if (event.by === 'local') {
|
||||
const update = this.doc.export({ mode: 'update' })
|
||||
emitWithAuthGuard(this.socket, 'graph_event', update, { onUnauthorized: this.onUnauthorized })
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('graph_update', (updateData: Uint8Array) => {
|
||||
try {
|
||||
const data = new Uint8Array(updateData)
|
||||
this.doc.import(data)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error importing graph update:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.socket.off('graph_update')
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
export type EventHandler<T = unknown> = (data: T) => void
|
||||
|
||||
export class EventEmitter {
|
||||
private events: Map<string, Set<EventHandler<unknown>>> = new Map()
|
||||
|
||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||
if (!this.events.has(event))
|
||||
this.events.set(event, new Set())
|
||||
|
||||
this.events.get(event)!.add(handler as EventHandler<unknown>)
|
||||
|
||||
return () => this.off(event, handler)
|
||||
}
|
||||
|
||||
off<T = unknown>(event: string, handler?: EventHandler<T>): void {
|
||||
if (!this.events.has(event))
|
||||
return
|
||||
|
||||
const handlers = this.events.get(event)!
|
||||
if (handler)
|
||||
handlers.delete(handler as EventHandler<unknown>)
|
||||
else
|
||||
handlers.clear()
|
||||
|
||||
if (handlers.size === 0)
|
||||
this.events.delete(event)
|
||||
}
|
||||
|
||||
emit<T = unknown>(event: string, data: T): void {
|
||||
if (!this.events.has(event))
|
||||
return
|
||||
|
||||
const handlers = this.events.get(event)!
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error in event handler for ${event}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removeAllListeners(): void {
|
||||
this.events.clear()
|
||||
}
|
||||
|
||||
getListenerCount(event: string): number {
|
||||
return this.events.get(event)?.size || 0
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import type { DebugInfo, WebSocketConfig } from '../types/websocket'
|
||||
import { io } from 'socket.io-client'
|
||||
import { SOCKET_URL } from '@/config'
|
||||
|
||||
type AckArgs = unknown[]
|
||||
|
||||
const isUnauthorizedAck = (...ackArgs: AckArgs): boolean => {
|
||||
const [first, second] = ackArgs
|
||||
|
||||
if (second === 401 || first === 401)
|
||||
return true
|
||||
|
||||
if (first && typeof first === 'object' && 'msg' in first) {
|
||||
const message = (first as { msg?: unknown }).msg
|
||||
return message === 'unauthorized'
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export type EmitAckOptions = {
|
||||
onAck?: (...ackArgs: AckArgs) => void
|
||||
onUnauthorized?: (...ackArgs: AckArgs) => void
|
||||
}
|
||||
|
||||
export const emitWithAuthGuard = (
|
||||
socket: Socket | null | undefined,
|
||||
event: string,
|
||||
payload: unknown,
|
||||
options?: EmitAckOptions,
|
||||
): void => {
|
||||
if (!socket)
|
||||
return
|
||||
|
||||
socket.emit(
|
||||
event,
|
||||
payload,
|
||||
(...ackArgs: AckArgs) => {
|
||||
options?.onAck?.(...ackArgs)
|
||||
if (isUnauthorizedAck(...ackArgs))
|
||||
options?.onUnauthorized?.(...ackArgs)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export class WebSocketClient {
|
||||
private connections: Map<string, Socket> = new Map()
|
||||
private connecting: Set<string> = new Set()
|
||||
private readonly url: string
|
||||
private readonly transports: WebSocketConfig['transports']
|
||||
private readonly withCredentials?: boolean
|
||||
|
||||
constructor(config: WebSocketConfig = {}) {
|
||||
this.url = SOCKET_URL
|
||||
this.transports = config.transports || ['websocket']
|
||||
this.withCredentials = config.withCredentials !== false
|
||||
}
|
||||
|
||||
connect(appId: string): Socket {
|
||||
const existingSocket = this.connections.get(appId)
|
||||
if (existingSocket?.connected)
|
||||
return existingSocket
|
||||
|
||||
if (this.connecting.has(appId)) {
|
||||
const pendingSocket = this.connections.get(appId)
|
||||
if (pendingSocket)
|
||||
return pendingSocket
|
||||
}
|
||||
|
||||
if (existingSocket && !existingSocket.connected) {
|
||||
existingSocket.disconnect()
|
||||
this.connections.delete(appId)
|
||||
}
|
||||
|
||||
this.connecting.add(appId)
|
||||
|
||||
const socketOptions: {
|
||||
path: string
|
||||
transports: WebSocketConfig['transports']
|
||||
withCredentials?: boolean
|
||||
} = {
|
||||
path: '/socket.io',
|
||||
transports: this.transports,
|
||||
withCredentials: this.withCredentials,
|
||||
}
|
||||
|
||||
const socket = io(this.url, socketOptions)
|
||||
|
||||
this.connections.set(appId, socket)
|
||||
this.setupBaseEventListeners(socket, appId)
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
disconnect(appId?: string): void {
|
||||
if (appId) {
|
||||
const socket = this.connections.get(appId)
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
this.connections.delete(appId)
|
||||
this.connecting.delete(appId)
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.connections.forEach(socket => socket.disconnect())
|
||||
this.connections.clear()
|
||||
this.connecting.clear()
|
||||
}
|
||||
}
|
||||
|
||||
getSocket(appId: string): Socket | null {
|
||||
return this.connections.get(appId) || null
|
||||
}
|
||||
|
||||
isConnected(appId: string): boolean {
|
||||
return this.connections.get(appId)?.connected || false
|
||||
}
|
||||
|
||||
getConnectedApps(): string[] {
|
||||
const connectedApps: string[] = []
|
||||
this.connections.forEach((socket, appId) => {
|
||||
if (socket.connected)
|
||||
connectedApps.push(appId)
|
||||
})
|
||||
return connectedApps
|
||||
}
|
||||
|
||||
getDebugInfo(): DebugInfo {
|
||||
const info: DebugInfo = {}
|
||||
this.connections.forEach((socket, appId) => {
|
||||
info[appId] = {
|
||||
connected: socket.connected,
|
||||
connecting: this.connecting.has(appId),
|
||||
socketId: socket.id,
|
||||
}
|
||||
})
|
||||
return info
|
||||
}
|
||||
|
||||
private setupBaseEventListeners(socket: Socket, appId: string): void {
|
||||
socket.on('connect', () => {
|
||||
this.connecting.delete(appId)
|
||||
emitWithAuthGuard(socket, 'user_connect', { workflow_id: appId })
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
this.connecting.delete(appId)
|
||||
})
|
||||
|
||||
socket.on('connect_error', () => {
|
||||
this.connecting.delete(appId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const webSocketClient = new WebSocketClient()
|
||||
@ -0,0 +1,144 @@
|
||||
import type { ReactFlowInstance } from 'reactflow'
|
||||
import type {
|
||||
CollaborationState,
|
||||
CursorPosition,
|
||||
NodePanelPresenceMap,
|
||||
OnlineUser,
|
||||
} from '../types/collaboration'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { collaborationManager } from '../core/collaboration-manager'
|
||||
import { CursorService } from '../services/cursor-service'
|
||||
|
||||
type CollaborationViewState = {
|
||||
isConnected: boolean
|
||||
onlineUsers: OnlineUser[]
|
||||
cursors: Record<string, CursorPosition>
|
||||
nodePanelPresence: NodePanelPresenceMap
|
||||
isLeader: boolean
|
||||
}
|
||||
|
||||
type ReactFlowStore = NonNullable<Parameters<typeof collaborationManager.connect>[1]>
|
||||
|
||||
const initialState: CollaborationViewState = {
|
||||
isConnected: false,
|
||||
onlineUsers: [],
|
||||
cursors: {},
|
||||
nodePanelPresence: {},
|
||||
isLeader: false,
|
||||
}
|
||||
|
||||
export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) {
|
||||
const [state, setState] = useState<CollaborationViewState>(initialState)
|
||||
|
||||
const cursorServiceRef = useRef<CursorService | null>(null)
|
||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled) {
|
||||
Promise.resolve().then(() => {
|
||||
setState(initialState)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let connectionId: string | null = null
|
||||
let isUnmounted = false
|
||||
|
||||
if (!cursorServiceRef.current)
|
||||
cursorServiceRef.current = new CursorService()
|
||||
|
||||
const initCollaboration = async () => {
|
||||
try {
|
||||
const id = await collaborationManager.connect(appId, reactFlowStore)
|
||||
if (isUnmounted) {
|
||||
collaborationManager.disconnect(id)
|
||||
return
|
||||
}
|
||||
connectionId = id
|
||||
setState(prev => ({ ...prev, isConnected: collaborationManager.isConnected() }))
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to initialize collaboration:', error)
|
||||
}
|
||||
}
|
||||
|
||||
initCollaboration()
|
||||
|
||||
const unsubscribeStateChange = collaborationManager.onStateChange((newState: Partial<CollaborationState>) => {
|
||||
if (newState.isConnected === undefined)
|
||||
return
|
||||
|
||||
setState(prev => ({ ...prev, isConnected: newState.isConnected ?? prev.isConnected }))
|
||||
})
|
||||
|
||||
const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: Record<string, CursorPosition>) => {
|
||||
setState(prev => ({ ...prev, cursors }))
|
||||
})
|
||||
|
||||
const unsubscribeUsers = collaborationManager.onOnlineUsersUpdate((users: OnlineUser[]) => {
|
||||
setState(prev => ({ ...prev, onlineUsers: users }))
|
||||
})
|
||||
|
||||
const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate((presence: NodePanelPresenceMap) => {
|
||||
setState(prev => ({ ...prev, nodePanelPresence: presence }))
|
||||
})
|
||||
|
||||
const unsubscribeLeaderChange = collaborationManager.onLeaderChange((isLeader: boolean) => {
|
||||
setState(prev => ({ ...prev, isLeader }))
|
||||
})
|
||||
|
||||
return () => {
|
||||
isUnmounted = true
|
||||
unsubscribeStateChange()
|
||||
unsubscribeCursors()
|
||||
unsubscribeUsers()
|
||||
unsubscribeNodePanelPresence()
|
||||
unsubscribeLeaderChange()
|
||||
cursorServiceRef.current?.stopTracking()
|
||||
if (connectionId)
|
||||
collaborationManager.disconnect(connectionId)
|
||||
}
|
||||
}, [appId, reactFlowStore, isCollaborationEnabled])
|
||||
|
||||
const prevIsConnected = useRef(false)
|
||||
useEffect(() => {
|
||||
if (prevIsConnected.current && !state.isConnected) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Network connection lost. Please check your network.',
|
||||
})
|
||||
}
|
||||
prevIsConnected.current = state.isConnected || false
|
||||
}, [state.isConnected])
|
||||
|
||||
const startCursorTracking = (containerRef: React.RefObject<HTMLElement>, reactFlowInstance?: ReactFlowInstance) => {
|
||||
if (!isCollaborationEnabled || !cursorServiceRef.current)
|
||||
return
|
||||
|
||||
if (cursorServiceRef.current) {
|
||||
cursorServiceRef.current.startTracking(containerRef, (position) => {
|
||||
collaborationManager.emitCursorMove(position)
|
||||
}, reactFlowInstance)
|
||||
}
|
||||
}
|
||||
|
||||
const stopCursorTracking = () => {
|
||||
cursorServiceRef.current?.stopTracking()
|
||||
}
|
||||
|
||||
const result = {
|
||||
isConnected: state.isConnected || false,
|
||||
onlineUsers: state.onlineUsers || [],
|
||||
cursors: state.cursors || {},
|
||||
nodePanelPresence: state.nodePanelPresence || {},
|
||||
isLeader: state.isLeader || false,
|
||||
leaderId: collaborationManager.getLeaderId(),
|
||||
isEnabled: isCollaborationEnabled,
|
||||
startCursorTracking,
|
||||
stopCursorTracking,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
5
web/app/components/workflow/collaboration/index.ts
Normal file
5
web/app/components/workflow/collaboration/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { collaborationManager } from './core/collaboration-manager'
|
||||
export { webSocketClient } from './core/websocket-manager'
|
||||
export { useCollaboration } from './hooks/use-collaboration'
|
||||
export { CursorService } from './services/cursor-service'
|
||||
export * from './types'
|
||||
@ -0,0 +1,90 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ReactFlowInstance } from 'reactflow'
|
||||
import type { CursorPosition } from '../types/collaboration'
|
||||
|
||||
const CURSOR_MIN_MOVE_DISTANCE = 10
|
||||
const CURSOR_THROTTLE_MS = 300
|
||||
|
||||
export class CursorService {
|
||||
private containerRef: RefObject<HTMLElement> | null = null
|
||||
private reactFlowInstance: ReactFlowInstance | null = null
|
||||
private isTracking = false
|
||||
private onCursorUpdate: ((cursors: Record<string, CursorPosition>) => void) | null = null
|
||||
private onEmitPosition: ((position: CursorPosition) => void) | null = null
|
||||
private lastEmitTime = 0
|
||||
private lastPosition: { x: number, y: number } | null = null
|
||||
|
||||
startTracking(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
onEmitPosition: (position: CursorPosition) => void,
|
||||
reactFlowInstance?: ReactFlowInstance,
|
||||
): void {
|
||||
if (this.isTracking)
|
||||
this.stopTracking()
|
||||
|
||||
this.containerRef = containerRef
|
||||
this.onEmitPosition = onEmitPosition
|
||||
this.reactFlowInstance = reactFlowInstance || null
|
||||
this.isTracking = true
|
||||
|
||||
if (containerRef.current)
|
||||
containerRef.current.addEventListener('mousemove', this.handleMouseMove)
|
||||
}
|
||||
|
||||
stopTracking(): void {
|
||||
if (this.containerRef?.current)
|
||||
this.containerRef.current.removeEventListener('mousemove', this.handleMouseMove)
|
||||
|
||||
this.containerRef = null
|
||||
this.reactFlowInstance = null
|
||||
this.onEmitPosition = null
|
||||
this.isTracking = false
|
||||
this.lastPosition = null
|
||||
}
|
||||
|
||||
setCursorUpdateHandler(handler: (cursors: Record<string, CursorPosition>) => void): void {
|
||||
this.onCursorUpdate = handler
|
||||
}
|
||||
|
||||
updateCursors(cursors: Record<string, CursorPosition>): void {
|
||||
if (this.onCursorUpdate)
|
||||
this.onCursorUpdate(cursors)
|
||||
}
|
||||
|
||||
private handleMouseMove = (event: MouseEvent): void => {
|
||||
if (!this.containerRef?.current || !this.onEmitPosition)
|
||||
return
|
||||
|
||||
const rect = this.containerRef.current.getBoundingClientRect()
|
||||
let x = event.clientX - rect.left
|
||||
let y = event.clientY - rect.top
|
||||
|
||||
// Transform coordinates to ReactFlow world coordinates if ReactFlow instance is available
|
||||
if (this.reactFlowInstance) {
|
||||
const viewport = this.reactFlowInstance.getViewport()
|
||||
// Convert screen coordinates to world coordinates
|
||||
// World coordinates = (screen coordinates - viewport translation) / zoom
|
||||
x = (x - viewport.x) / viewport.zoom
|
||||
y = (y - viewport.y) / viewport.zoom
|
||||
}
|
||||
|
||||
// Always emit cursor position (remove boundary check since world coordinates can be negative)
|
||||
const now = Date.now()
|
||||
const timeThrottled = now - this.lastEmitTime > CURSOR_THROTTLE_MS
|
||||
const minDistance = CURSOR_MIN_MOVE_DISTANCE / (this.reactFlowInstance?.getZoom() || 1)
|
||||
const distanceThrottled = !this.lastPosition
|
||||
|| (Math.abs(x - this.lastPosition.x) > minDistance)
|
||||
|| (Math.abs(y - this.lastPosition.y) > minDistance)
|
||||
|
||||
if (timeThrottled && distanceThrottled) {
|
||||
this.lastPosition = { x, y }
|
||||
this.lastEmitTime = now
|
||||
this.onEmitPosition({
|
||||
x,
|
||||
y,
|
||||
userId: '',
|
||||
timestamp: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
103
web/app/components/workflow/collaboration/types/collaboration.ts
Normal file
103
web/app/components/workflow/collaboration/types/collaboration.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import type { Viewport } from 'reactflow'
|
||||
import type { ConversationVariable, Edge, EnvironmentVariable, Node } from '../../types'
|
||||
import type { Features } from '@/app/components/base/features/types'
|
||||
|
||||
export type OnlineUser = {
|
||||
user_id: string
|
||||
username: string
|
||||
avatar: string
|
||||
sid: string
|
||||
}
|
||||
|
||||
export type WorkflowOnlineUsers = {
|
||||
workflow_id: string
|
||||
users: OnlineUser[]
|
||||
}
|
||||
|
||||
export type OnlineUserListResponse = {
|
||||
data: WorkflowOnlineUsers[]
|
||||
}
|
||||
|
||||
export type CursorPosition = {
|
||||
x: number
|
||||
y: number
|
||||
userId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type NodePanelPresenceUser = {
|
||||
userId: string
|
||||
username: string
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
export type NodePanelPresenceInfo = NodePanelPresenceUser & {
|
||||
clientId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type NodePanelPresenceMap = Record<string, Record<string, NodePanelPresenceInfo>>
|
||||
|
||||
export type CollaborationState = {
|
||||
appId: string
|
||||
isConnected: boolean
|
||||
onlineUsers: OnlineUser[]
|
||||
cursors: Record<string, CursorPosition>
|
||||
nodePanelPresence: NodePanelPresenceMap
|
||||
}
|
||||
|
||||
export type GraphSyncData = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
|
||||
export type CollaborationEventType
|
||||
= | 'mouse_move'
|
||||
| 'vars_and_features_update'
|
||||
| 'sync_request'
|
||||
| 'app_state_update'
|
||||
| 'app_meta_update'
|
||||
| 'mcp_server_update'
|
||||
| 'workflow_update'
|
||||
| 'comments_update'
|
||||
| 'node_panel_presence'
|
||||
| 'app_publish_update'
|
||||
| 'graph_resync_request'
|
||||
| 'workflow_restore_request'
|
||||
| 'workflow_restore_intent'
|
||||
| 'workflow_restore_complete'
|
||||
|
||||
export type CollaborationUpdate = {
|
||||
type: CollaborationEventType
|
||||
userId: string
|
||||
data: Record<string, unknown>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type RestoreRequestData = {
|
||||
versionId: string
|
||||
versionName?: string
|
||||
initiatorUserId: string
|
||||
initiatorName: string
|
||||
graphData: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport?: Viewport
|
||||
}
|
||||
features?: Features
|
||||
environmentVariables?: EnvironmentVariable[]
|
||||
conversationVariables?: ConversationVariable[]
|
||||
}
|
||||
|
||||
export type RestoreIntentData = {
|
||||
versionId: string
|
||||
versionName?: string
|
||||
initiatorUserId: string
|
||||
initiatorName: string
|
||||
}
|
||||
|
||||
export type RestoreCompleteData = {
|
||||
versionId: string
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
34
web/app/components/workflow/collaboration/types/events.ts
Normal file
34
web/app/components/workflow/collaboration/types/events.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export type CollaborationEvent<TData = unknown> = {
|
||||
type: string
|
||||
data: TData
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type GraphUpdateEvent = {
|
||||
type: 'graph_update'
|
||||
} & CollaborationEvent<Uint8Array>
|
||||
|
||||
export type CursorMoveEvent = {
|
||||
type: 'cursor_move'
|
||||
} & CollaborationEvent<{
|
||||
x: number
|
||||
y: number
|
||||
userId: string
|
||||
}>
|
||||
|
||||
export type UserConnectEvent = {
|
||||
type: 'user_connect'
|
||||
} & CollaborationEvent<{
|
||||
workflow_id: string
|
||||
}>
|
||||
|
||||
export type OnlineUsersEvent = {
|
||||
type: 'online_users'
|
||||
} & CollaborationEvent<{
|
||||
users: Array<{
|
||||
user_id: string
|
||||
username: string
|
||||
avatar: string
|
||||
sid: string
|
||||
}>
|
||||
}>
|
||||
3
web/app/components/workflow/collaboration/types/index.ts
Normal file
3
web/app/components/workflow/collaboration/types/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './collaboration'
|
||||
export * from './events'
|
||||
export * from './websocket'
|
||||
15
web/app/components/workflow/collaboration/types/websocket.ts
Normal file
15
web/app/components/workflow/collaboration/types/websocket.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export type WebSocketConfig = {
|
||||
token?: string
|
||||
transports?: string[]
|
||||
withCredentials?: boolean
|
||||
}
|
||||
|
||||
export type ConnectionInfo = {
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
socketId?: string
|
||||
}
|
||||
|
||||
export type DebugInfo = {
|
||||
[appId: string]: ConnectionInfo
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generate a consistent color for a user based on their ID
|
||||
* Used for cursor colors and avatar backgrounds
|
||||
*/
|
||||
export const getUserColor = (id: string): string => {
|
||||
const colors = ['#155AEF', '#0BA5EC', '#444CE7', '#7839EE', '#4CA30D', '#0E9384', '#DD2590', '#FF4405', '#D92D20', '#F79009', '#828DAD']
|
||||
const hash = id.split('').reduce((a, b) => {
|
||||
a = ((a << 5) - a) + b.charCodeAt(0)
|
||||
return a & a
|
||||
}, 0)
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
34
web/app/components/workflow/comment-manager.tsx
Normal file
34
web/app/components/workflow/comment-manager.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useEventListener } from 'ahooks'
|
||||
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
||||
import { useWorkflowStore } from './store'
|
||||
|
||||
const CommentManager = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleCreateComment, handleCommentCancel } = useWorkflowComment()
|
||||
|
||||
useEventListener('click', (e) => {
|
||||
const { controlMode, mousePosition, pendingComment } = workflowStore.getState()
|
||||
|
||||
if (controlMode === 'comment') {
|
||||
const target = e.target as HTMLElement
|
||||
const isInDropdown = target.closest('[data-mention-dropdown]')
|
||||
const isInCommentInput = target.closest('[data-comment-input]')
|
||||
const isOnCanvasPane = target.closest('.react-flow__pane')
|
||||
|
||||
// Only when clicking on the React Flow canvas pane (background),
|
||||
// and not inside comment input or its dropdown
|
||||
if (!isInDropdown && !isInCommentInput && isOnCanvasPane) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (pendingComment)
|
||||
handleCommentCancel()
|
||||
else
|
||||
handleCreateComment(mousePosition)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default CommentManager
|
||||
148
web/app/components/workflow/comment/comment-icon.spec.tsx
Normal file
148
web/app/components/workflow/comment/comment-icon.spec.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import type { WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CommentIcon } from './comment-icon'
|
||||
|
||||
type Position = { x: number, y: number }
|
||||
|
||||
let mockUserId = 'user-1'
|
||||
|
||||
const mockFlowToScreenPosition = vi.fn((position: Position) => position)
|
||||
const mockScreenToFlowPosition = vi.fn((position: Position) => position)
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
flowToScreenPosition: mockFlowToScreenPosition,
|
||||
screenToFlowPosition: mockScreenToFlowPosition,
|
||||
}),
|
||||
useViewport: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: {
|
||||
id: mockUserId,
|
||||
name: 'User',
|
||||
avatar_url: 'avatar',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/user-avatar-list', () => ({
|
||||
UserAvatarList: ({ users }: { users: Array<{ id: string }> }) => (
|
||||
<div data-testid="avatar-list">{users.map(user => user.id).join(',')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./comment-preview', () => ({
|
||||
default: ({ onClick }: { onClick?: () => void }) => (
|
||||
<button type="button" data-testid="comment-preview" onClick={onClick}>
|
||||
Preview
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
const createComment = (overrides: Partial<WorkflowCommentList> = {}): WorkflowCommentList => ({
|
||||
id: 'comment-1',
|
||||
position_x: 0,
|
||||
position_y: 0,
|
||||
content: 'Hello',
|
||||
created_by: 'user-1',
|
||||
created_by_account: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
},
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
resolved: false,
|
||||
mention_count: 0,
|
||||
reply_count: 0,
|
||||
participants: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('CommentIcon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUserId = 'user-1'
|
||||
})
|
||||
|
||||
it('toggles preview on hover when inactive', () => {
|
||||
const comment = createComment()
|
||||
const { container } = render(
|
||||
<CommentIcon comment={comment} onClick={vi.fn()} isActive={false} />,
|
||||
)
|
||||
const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement
|
||||
const hoverTarget = marker.firstElementChild as HTMLElement
|
||||
|
||||
fireEvent.mouseEnter(hoverTarget)
|
||||
expect(screen.getByTestId('comment-preview')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(hoverTarget)
|
||||
expect(screen.queryByTestId('comment-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onPositionUpdate after dragging by author', () => {
|
||||
const comment = createComment({ position_x: 0, position_y: 0 })
|
||||
const onClick = vi.fn()
|
||||
const onPositionUpdate = vi.fn()
|
||||
const { container } = render(
|
||||
<CommentIcon
|
||||
comment={comment}
|
||||
onClick={onClick}
|
||||
onPositionUpdate={onPositionUpdate}
|
||||
/>,
|
||||
)
|
||||
const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement
|
||||
|
||||
fireEvent.pointerDown(marker, {
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
})
|
||||
fireEvent.pointerMove(marker, {
|
||||
pointerId: 1,
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
})
|
||||
fireEvent.pointerUp(marker, {
|
||||
pointerId: 1,
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
})
|
||||
|
||||
expect(mockScreenToFlowPosition).toHaveBeenCalledWith({ x: 10, y: 10 })
|
||||
expect(onPositionUpdate).toHaveBeenCalledWith({ x: 10, y: 10 })
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClick for non-author clicks', () => {
|
||||
mockUserId = 'user-2'
|
||||
const comment = createComment()
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<CommentIcon comment={comment} onClick={onClick} isActive={false} />,
|
||||
)
|
||||
const marker = container.querySelector('[data-role="comment-marker"]') as HTMLElement
|
||||
|
||||
fireEvent.pointerDown(marker, {
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
clientX: 50,
|
||||
clientY: 60,
|
||||
})
|
||||
fireEvent.pointerUp(marker, {
|
||||
pointerId: 1,
|
||||
clientX: 50,
|
||||
clientY: 60,
|
||||
})
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
268
web/app/components/workflow/comment/comment-icon.tsx
Normal file
268
web/app/components/workflow/comment/comment-icon.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
'use client'
|
||||
|
||||
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import type { WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useReactFlow, useViewport } from 'reactflow'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import CommentPreview from './comment-preview'
|
||||
|
||||
type CommentIconProps = {
|
||||
comment: WorkflowCommentList
|
||||
onClick: () => void
|
||||
isActive?: boolean
|
||||
onPositionUpdate?: (position: { x: number, y: number }) => void
|
||||
}
|
||||
|
||||
export const CommentIcon: FC<CommentIconProps> = memo(({ comment, onClick, isActive = false, onPositionUpdate }) => {
|
||||
const { flowToScreenPosition, screenToFlowPosition } = useReactFlow()
|
||||
const viewport = useViewport()
|
||||
const { userProfile } = useAppContext()
|
||||
const isAuthor = comment.created_by_account?.id === userProfile?.id
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [dragPosition, setDragPosition] = useState<{ x: number, y: number } | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const dragStateRef = useRef<{
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
startX: number
|
||||
startY: number
|
||||
hasMoved: boolean
|
||||
} | null>(null)
|
||||
|
||||
const workflowContainerRect = typeof document !== 'undefined'
|
||||
? document.getElementById('workflow-container')?.getBoundingClientRect()
|
||||
: null
|
||||
const containerLeft = workflowContainerRect?.left ?? 0
|
||||
const containerTop = workflowContainerRect?.top ?? 0
|
||||
|
||||
const screenPosition = useMemo(() => {
|
||||
return flowToScreenPosition({
|
||||
x: comment.position_x,
|
||||
y: comment.position_y,
|
||||
})
|
||||
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
|
||||
|
||||
const effectiveScreenPosition = dragPosition ?? screenPosition
|
||||
const canvasPosition = useMemo(() => ({
|
||||
x: effectiveScreenPosition.x - containerLeft,
|
||||
y: effectiveScreenPosition.y - containerTop,
|
||||
}), [effectiveScreenPosition.x, effectiveScreenPosition.y, containerLeft, containerTop])
|
||||
const cursorClass = useMemo(() => {
|
||||
if (!isAuthor)
|
||||
return 'cursor-pointer'
|
||||
if (isActive)
|
||||
return isDragging ? 'cursor-grabbing' : ''
|
||||
return isDragging ? 'cursor-grabbing' : 'cursor-pointer'
|
||||
}, [isActive, isAuthor, isDragging])
|
||||
|
||||
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0)
|
||||
return
|
||||
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
|
||||
if (!isAuthor) {
|
||||
if (event.currentTarget.dataset.role !== 'comment-preview')
|
||||
setShowPreview(false)
|
||||
return
|
||||
}
|
||||
|
||||
dragStateRef.current = {
|
||||
offsetX: event.clientX - screenPosition.x,
|
||||
offsetY: event.clientY - screenPosition.y,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
hasMoved: false,
|
||||
}
|
||||
|
||||
setDragPosition(screenPosition)
|
||||
setIsDragging(false)
|
||||
|
||||
if (event.currentTarget.dataset.role !== 'comment-preview')
|
||||
setShowPreview(false)
|
||||
|
||||
if (event.currentTarget.setPointerCapture)
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}, [isAuthor, screenPosition])
|
||||
|
||||
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const dragState = dragStateRef.current
|
||||
if (!dragState)
|
||||
return
|
||||
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
|
||||
const nextX = event.clientX - dragState.offsetX
|
||||
const nextY = event.clientY - dragState.offsetY
|
||||
|
||||
if (!dragState.hasMoved) {
|
||||
const distance = Math.hypot(event.clientX - dragState.startX, event.clientY - dragState.startY)
|
||||
if (distance > 4) {
|
||||
dragState.hasMoved = true
|
||||
setIsDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
setDragPosition({ x: nextX, y: nextY })
|
||||
}, [])
|
||||
|
||||
const finishDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const dragState = dragStateRef.current
|
||||
if (!dragState)
|
||||
return false
|
||||
|
||||
if (event.currentTarget.hasPointerCapture?.(event.pointerId))
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
|
||||
dragStateRef.current = null
|
||||
setDragPosition(null)
|
||||
setIsDragging(false)
|
||||
return dragState.hasMoved
|
||||
}, [])
|
||||
|
||||
const handlePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
|
||||
const finalScreenPosition = dragPosition ?? screenPosition
|
||||
const didDrag = finishDrag(event)
|
||||
|
||||
setShowPreview(false)
|
||||
|
||||
if (didDrag) {
|
||||
if (onPositionUpdate) {
|
||||
const flowPosition = screenToFlowPosition({
|
||||
x: finalScreenPosition.x,
|
||||
y: finalScreenPosition.y,
|
||||
})
|
||||
onPositionUpdate(flowPosition)
|
||||
}
|
||||
}
|
||||
else if (!isActive) {
|
||||
onClick()
|
||||
}
|
||||
}, [dragPosition, finishDrag, isActive, onClick, onPositionUpdate, screenPosition, screenToFlowPosition])
|
||||
|
||||
const handlePointerCancel = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
finishDrag(event)
|
||||
}, [finishDrag])
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (isActive || isDragging)
|
||||
return
|
||||
setShowPreview(true)
|
||||
}, [isActive, isDragging])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setShowPreview(false)
|
||||
}, [])
|
||||
|
||||
const participants = useMemo(() => {
|
||||
const list = comment.participants ?? []
|
||||
const author = comment.created_by_account
|
||||
if (!author)
|
||||
return [...list]
|
||||
const rest = list.filter(user => user.id !== author.id)
|
||||
return [author, ...rest]
|
||||
}, [comment.created_by_account, comment.participants])
|
||||
|
||||
// Calculate dynamic width based on number of participants
|
||||
const participantCount = participants.length
|
||||
const maxVisible = Math.min(3, participantCount)
|
||||
const showCount = participantCount > 3
|
||||
const avatarSize = 24
|
||||
const avatarSpacing = 4 // -space-x-1 is about 4px overlap
|
||||
|
||||
// Width calculation: first avatar + (additional avatars * (size - spacing)) + padding
|
||||
const dynamicWidth = Math.max(40, // minimum width
|
||||
8 + avatarSize + Math.max(0, (showCount ? 2 : maxVisible - 1)) * (avatarSize - avatarSpacing) + 8)
|
||||
|
||||
const pointerEventHandlers = useMemo(() => ({
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
onPointerCancel: handlePointerCancel,
|
||||
}), [handlePointerCancel, handlePointerDown, handlePointerMove, handlePointerUp])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute z-10"
|
||||
style={{
|
||||
left: canvasPosition.x,
|
||||
top: canvasPosition.y,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
data-role="comment-marker"
|
||||
{...pointerEventHandlers}
|
||||
>
|
||||
<div
|
||||
className={cursorClass}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div
|
||||
className="relative h-10 rounded-br-full rounded-tl-full rounded-tr-full"
|
||||
style={{ width: dynamicWidth }}
|
||||
>
|
||||
<div className={`absolute inset-[6px] overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full border bg-components-panel-bg transition-shadow ${
|
||||
isActive
|
||||
? 'border-primary-500 ring-1 ring-primary-500'
|
||||
: 'border-components-panel-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-center px-1">
|
||||
<UserAvatarList
|
||||
users={participants}
|
||||
maxVisible={3}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview panel */}
|
||||
{showPreview && !isActive && (
|
||||
<div
|
||||
className="absolute z-20"
|
||||
style={{
|
||||
left: (effectiveScreenPosition.x - containerLeft) - dynamicWidth / 2,
|
||||
top: (effectiveScreenPosition.y - containerTop) + 20,
|
||||
transform: 'translateY(-100%)',
|
||||
}}
|
||||
data-role="comment-preview"
|
||||
{...pointerEventHandlers}
|
||||
onMouseEnter={() => setShowPreview(true)}
|
||||
onMouseLeave={() => setShowPreview(false)}
|
||||
>
|
||||
<CommentPreview
|
||||
comment={comment}
|
||||
onClick={() => {
|
||||
setShowPreview(false)
|
||||
onClick()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.comment.id === nextProps.comment.id
|
||||
&& prevProps.comment.position_x === nextProps.comment.position_x
|
||||
&& prevProps.comment.position_y === nextProps.comment.position_y
|
||||
&& prevProps.onClick === nextProps.onClick
|
||||
&& prevProps.isActive === nextProps.isActive
|
||||
&& prevProps.onPositionUpdate === nextProps.onPositionUpdate
|
||||
)
|
||||
})
|
||||
|
||||
CommentIcon.displayName = 'CommentIcon'
|
||||
106
web/app/components/workflow/comment/comment-input.spec.tsx
Normal file
106
web/app/components/workflow/comment/comment-input.spec.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import type { FC } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CommentInput } from './comment-input'
|
||||
|
||||
type MentionInputProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
||||
placeholder?: string
|
||||
autoFocus?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const stableT = (key: string, options?: { ns?: string }) => (
|
||||
options?.ns ? `${options.ns}.${key}` : key
|
||||
)
|
||||
|
||||
let mentionInputProps: MentionInputProps | null = null
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: stableT,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
avatar_url: 'avatar',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/avatar', () => ({
|
||||
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./mention-input', () => ({
|
||||
MentionInput: ((props: MentionInputProps) => {
|
||||
mentionInputProps = props
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mention-input"
|
||||
onClick={() => props.onSubmit('Hello', ['user-2'])}
|
||||
>
|
||||
MentionInput
|
||||
</button>
|
||||
)
|
||||
}) as FC<MentionInputProps>,
|
||||
}))
|
||||
|
||||
describe('CommentInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mentionInputProps = null
|
||||
})
|
||||
|
||||
it('passes translated placeholder to mention input', () => {
|
||||
render(
|
||||
<CommentInput
|
||||
position={{ x: 0, y: 0 }}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mentionInputProps?.placeholder).toBe('workflow.comments.placeholder.add')
|
||||
expect(mentionInputProps?.autoFocus).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onCancel when Escape is pressed', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<CommentInput
|
||||
position={{ x: 0, y: 0 }}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('forwards mention submit to onSubmit', () => {
|
||||
const onSubmit = vi.fn()
|
||||
|
||||
render(
|
||||
<CommentInput
|
||||
position={{ x: 0, y: 0 }}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('mention-input'))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('Hello', ['user-2'])
|
||||
})
|
||||
})
|
||||
175
web/app/components/workflow/comment/comment-input.tsx
Normal file
175
web/app/components/workflow/comment/comment-input.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import type { FC, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { MentionInput } from './mention-input'
|
||||
|
||||
type CommentInputProps = {
|
||||
position: { x: number, y: number }
|
||||
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
||||
onCancel: () => void
|
||||
onPositionChange?: (position: {
|
||||
pageX: number
|
||||
pageY: number
|
||||
elementX: number
|
||||
elementY: number
|
||||
}) => void
|
||||
}
|
||||
|
||||
export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, onCancel, onPositionChange }) => {
|
||||
const [content, setContent] = useState('')
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const dragStateRef = useRef<{
|
||||
pointerId: number | null
|
||||
startPointerX: number
|
||||
startPointerY: number
|
||||
startX: number
|
||||
startY: number
|
||||
active: boolean
|
||||
} & {
|
||||
endHandler?: (event: PointerEvent) => void
|
||||
}>({
|
||||
pointerId: null,
|
||||
startPointerX: 0,
|
||||
startPointerY: 0,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
active: false,
|
||||
endHandler: undefined,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleGlobalKeyDown, true)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleGlobalKeyDown, true)
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
const handleMentionSubmit = useCallback((content: string, mentionedUserIds: string[]) => {
|
||||
onSubmit(content, mentionedUserIds)
|
||||
setContent('')
|
||||
}, [onSubmit])
|
||||
|
||||
const handleDragPointerMove = useCallback((event: PointerEvent) => {
|
||||
const state = dragStateRef.current
|
||||
if (!state.active || (state.pointerId !== null && event.pointerId !== state.pointerId))
|
||||
return
|
||||
if (!onPositionChange)
|
||||
return
|
||||
event.preventDefault()
|
||||
const deltaX = event.clientX - state.startPointerX
|
||||
const deltaY = event.clientY - state.startPointerY
|
||||
onPositionChange({
|
||||
pageX: event.clientX,
|
||||
pageY: event.clientY,
|
||||
elementX: state.startX + deltaX,
|
||||
elementY: state.startY + deltaY,
|
||||
})
|
||||
}, [onPositionChange])
|
||||
|
||||
const stopDragging = useCallback((event?: PointerEvent) => {
|
||||
const state = dragStateRef.current
|
||||
if (!state.active)
|
||||
return
|
||||
if (event && state.pointerId !== null && event.pointerId !== state.pointerId)
|
||||
return
|
||||
state.active = false
|
||||
state.pointerId = null
|
||||
window.removeEventListener('pointermove', handleDragPointerMove)
|
||||
if (state.endHandler) {
|
||||
window.removeEventListener('pointerup', state.endHandler)
|
||||
window.removeEventListener('pointercancel', state.endHandler)
|
||||
state.endHandler = undefined
|
||||
}
|
||||
}, [handleDragPointerMove])
|
||||
|
||||
const handleDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0)
|
||||
return
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
if (!onPositionChange)
|
||||
return
|
||||
const endHandler = (pointerEvent: PointerEvent) => {
|
||||
stopDragging(pointerEvent)
|
||||
}
|
||||
dragStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startPointerX: event.clientX,
|
||||
startPointerY: event.clientY,
|
||||
startX: position.x,
|
||||
startY: position.y,
|
||||
active: true,
|
||||
endHandler,
|
||||
}
|
||||
window.addEventListener('pointermove', handleDragPointerMove, { passive: false })
|
||||
window.addEventListener('pointerup', endHandler)
|
||||
window.addEventListener('pointercancel', endHandler)
|
||||
}, [handleDragPointerMove, onPositionChange, position.x, position.y, stopDragging])
|
||||
|
||||
useEffect(() => () => {
|
||||
stopDragging()
|
||||
}, [stopDragging])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[60] w-96"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
}}
|
||||
data-comment-input
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="relative shrink-0 cursor-move"
|
||||
onPointerDown={handleDragPointerDown}
|
||||
>
|
||||
<div className="relative h-8 w-8 overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full bg-primary-500">
|
||||
<div className="absolute inset-[2px] overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full bg-components-panel-bg-blur">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 overflow-hidden rounded-full">
|
||||
<Avatar
|
||||
avatar={userProfile.avatar_url}
|
||||
name={userProfile.name}
|
||||
size={24}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[4px] shadow-md',
|
||||
)}
|
||||
>
|
||||
<div className="relative pl-[9px] pt-[4px]">
|
||||
<MentionInput
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
onSubmit={handleMentionSubmit}
|
||||
placeholder={t('comments.placeholder.add', { ns: 'workflow' })}
|
||||
autoFocus
|
||||
className="relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CommentInput.displayName = 'CommentInput'
|
||||
86
web/app/components/workflow/comment/comment-preview.spec.tsx
Normal file
86
web/app/components/workflow/comment/comment-preview.spec.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import type { WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CommentPreview from './comment-preview'
|
||||
|
||||
type UserProfile = WorkflowCommentList['created_by_account']
|
||||
|
||||
const mockSetHovering = vi.fn()
|
||||
let capturedUsers: UserProfile[] = []
|
||||
|
||||
vi.mock('@/app/components/base/user-avatar-list', () => ({
|
||||
UserAvatarList: ({ users }: { users: UserProfile[] }) => {
|
||||
capturedUsers = users
|
||||
return <div data-testid="avatar-list">{users.map(user => user.id).join(',')}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (value: number) => `time:${value}`,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: { setCommentPreviewHovering: (value: boolean) => void }) => unknown) =>
|
||||
selector({ setCommentPreviewHovering: mockSetHovering }),
|
||||
}))
|
||||
|
||||
const createComment = (overrides: Partial<WorkflowCommentList> = {}): WorkflowCommentList => {
|
||||
const author = { id: 'user-1', name: 'Alice', email: 'alice@example.com' }
|
||||
const participant = { id: 'user-2', name: 'Bob', email: 'bob@example.com' }
|
||||
|
||||
return {
|
||||
id: 'comment-1',
|
||||
position_x: 0,
|
||||
position_y: 0,
|
||||
content: 'Hello',
|
||||
created_by: author.id,
|
||||
created_by_account: author,
|
||||
created_at: 1,
|
||||
updated_at: 10,
|
||||
resolved: false,
|
||||
mention_count: 0,
|
||||
reply_count: 0,
|
||||
participants: [author, participant],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('CommentPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedUsers = []
|
||||
})
|
||||
|
||||
it('orders participants with author first and formats time', () => {
|
||||
const comment = createComment()
|
||||
|
||||
render(<CommentPreview comment={comment} />)
|
||||
|
||||
expect(capturedUsers.map(user => user.id)).toEqual(['user-1', 'user-2'])
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
expect(screen.getByText('time:10000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates hover state on enter and leave', () => {
|
||||
const comment = createComment()
|
||||
const { container } = render(<CommentPreview comment={comment} />)
|
||||
const root = container.firstElementChild as HTMLElement
|
||||
|
||||
fireEvent.mouseEnter(root)
|
||||
fireEvent.mouseLeave(root)
|
||||
|
||||
expect(mockSetHovering).toHaveBeenCalledWith(true)
|
||||
expect(mockSetHovering).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('clears hover state on unmount', () => {
|
||||
const comment = createComment()
|
||||
const { unmount } = render(<CommentPreview comment={comment} />)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockSetHovering).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
59
web/app/components/workflow/comment/comment-preview.tsx
Normal file
59
web/app/components/workflow/comment/comment-preview.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useStore } from '../store'
|
||||
|
||||
type CommentPreviewProps = {
|
||||
comment: WorkflowCommentList
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const CommentPreview: FC<CommentPreviewProps> = ({ comment, onClick }) => {
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const setCommentPreviewHovering = useStore(s => s.setCommentPreviewHovering)
|
||||
const participants = useMemo(() => {
|
||||
const list = comment.participants ?? []
|
||||
const author = comment.created_by_account
|
||||
if (!author)
|
||||
return [...list]
|
||||
const rest = list.filter(user => user.id !== author.id)
|
||||
return [author, ...rest]
|
||||
}, [comment.created_by_account, comment.participants])
|
||||
useEffect(() => () => {
|
||||
setCommentPreviewHovering(false)
|
||||
}, [setCommentPreviewHovering])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-80 cursor-pointer rounded-3xl rounded-bl-[3px] border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-[10px] transition-colors hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setCommentPreviewHovering(true)}
|
||||
onMouseLeave={() => setCommentPreviewHovering(false)}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<UserAvatarList
|
||||
users={participants}
|
||||
maxVisible={3}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-start">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="system-sm-medium truncate text-text-primary">{comment.created_by_account.name}</div>
|
||||
<div className="system-2xs-regular shrink-0 text-text-tertiary">
|
||||
{formatTimeFromNow(comment.updated_at * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="system-sm-regular break-words text-text-secondary">{comment.content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CommentPreview)
|
||||
45
web/app/components/workflow/comment/cursor.spec.tsx
Normal file
45
web/app/components/workflow/comment/cursor.spec.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ControlMode } from '../types'
|
||||
import { CommentCursor } from './cursor'
|
||||
|
||||
const mockState = {
|
||||
controlMode: ControlMode.Pointer,
|
||||
mousePosition: {
|
||||
elementX: 10,
|
||||
elementY: 20,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/public/other', () => ({
|
||||
Comment: (props: { className?: string }) => <svg data-testid="comment-icon" {...props} />,
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
|
||||
}))
|
||||
|
||||
describe('CommentCursor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders nothing when not in comment mode', () => {
|
||||
mockState.controlMode = ControlMode.Pointer
|
||||
|
||||
render(<CommentCursor />)
|
||||
|
||||
expect(screen.queryByTestId('comment-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders at current mouse position when in comment mode', () => {
|
||||
mockState.controlMode = ControlMode.Comment
|
||||
|
||||
render(<CommentCursor />)
|
||||
|
||||
const icon = screen.getByTestId('comment-icon')
|
||||
const container = icon.parentElement as HTMLElement
|
||||
|
||||
expect(container).toHaveStyle({ left: '10px', top: '20px' })
|
||||
})
|
||||
})
|
||||
28
web/app/components/workflow/comment/cursor.tsx
Normal file
28
web/app/components/workflow/comment/cursor.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { Comment } from '@/app/components/base/icons/src/public/other'
|
||||
import { useStore } from '../store'
|
||||
import { ControlMode } from '../types'
|
||||
|
||||
export const CommentCursor: FC = memo(() => {
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
|
||||
if (controlMode !== ControlMode.Comment)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute z-50 flex h-6 w-6 items-center justify-center"
|
||||
style={{
|
||||
left: mousePosition.elementX,
|
||||
top: mousePosition.elementY,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
<Comment className="text-text-primary" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CommentCursor.displayName = 'CommentCursor'
|
||||
5
web/app/components/workflow/comment/index.tsx
Normal file
5
web/app/components/workflow/comment/index.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
export { CommentIcon } from './comment-icon'
|
||||
export { CommentInput } from './comment-input'
|
||||
export { CommentCursor } from './cursor'
|
||||
export { MentionInput } from './mention-input'
|
||||
export { CommentThread } from './thread'
|
||||
661
web/app/components/workflow/comment/mention-input.tsx
Normal file
661
web/app/components/workflow/comment/mention-input.tsx
Normal file
@ -0,0 +1,661 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { UserProfile } from '@/service/workflow-comment'
|
||||
import { RiArrowUpLine, RiAtLine, RiLoader2Line } from '@remixicon/react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { EnterKey } from '@/app/components/base/icons/src/public/common'
|
||||
import { fetchMentionableUsers } from '@/service/workflow-comment'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useStore, useWorkflowStore } from '../store'
|
||||
|
||||
type MentionInputProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
||||
onCancel?: () => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
className?: string
|
||||
isEditing?: boolean
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
isEditing = false,
|
||||
autoFocus = false,
|
||||
}, forwardedRef) => {
|
||||
const params = useParams()
|
||||
const { t } = useTranslation()
|
||||
const appId = params.appId as string
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const highlightContentRef = useRef<HTMLDivElement>(null)
|
||||
const actionContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const actionRightRef = useRef<HTMLDivElement | null>(null)
|
||||
const baseTextareaHeightRef = useRef<number | null>(null)
|
||||
|
||||
// Expose textarea ref to parent component
|
||||
useImperativeHandle(forwardedRef, () => textareaRef.current!, [])
|
||||
|
||||
const workflowStore = useWorkflowStore()
|
||||
const mentionUsersFromStore = useStore(state => (
|
||||
appId ? state.mentionableUsersCache[appId] : undefined
|
||||
))
|
||||
const mentionUsers = mentionUsersFromStore ?? []
|
||||
|
||||
const [showMentionDropdown, setShowMentionDropdown] = useState(false)
|
||||
const [mentionQuery, setMentionQuery] = useState('')
|
||||
const [mentionPosition, setMentionPosition] = useState(0)
|
||||
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0)
|
||||
const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([])
|
||||
const resolvedPlaceholder = placeholder ?? t('comments.placeholder.add', { ns: 'workflow' })
|
||||
const BASE_PADDING = 4
|
||||
const [shouldReserveButtonGap, setShouldReserveButtonGap] = useState(isEditing)
|
||||
const [shouldReserveHorizontalSpace, setShouldReserveHorizontalSpace] = useState(() => !isEditing)
|
||||
const [paddingRight, setPaddingRight] = useState(() => BASE_PADDING + (isEditing ? 0 : 48))
|
||||
const [paddingBottom, setPaddingBottom] = useState(() => BASE_PADDING + (isEditing ? 32 : 0))
|
||||
|
||||
const mentionNameList = useMemo(() => {
|
||||
const names = mentionUsers
|
||||
.map(user => user.name?.trim())
|
||||
.filter((name): name is string => Boolean(name))
|
||||
|
||||
const uniqueNames = Array.from(new Set(names))
|
||||
uniqueNames.sort((a, b) => b.length - a.length)
|
||||
return uniqueNames
|
||||
}, [mentionUsers])
|
||||
|
||||
const highlightedValue = useMemo<ReactNode>(() => {
|
||||
if (!value)
|
||||
return ''
|
||||
|
||||
if (mentionNameList.length === 0)
|
||||
return value
|
||||
|
||||
const segments: ReactNode[] = []
|
||||
let cursor = 0
|
||||
let hasMention = false
|
||||
|
||||
while (cursor < value.length) {
|
||||
let nextMatchStart = -1
|
||||
let matchedName = ''
|
||||
|
||||
for (const name of mentionNameList) {
|
||||
const searchStart = value.indexOf(`@${name}`, cursor)
|
||||
if (searchStart === -1)
|
||||
continue
|
||||
|
||||
const previousChar = searchStart > 0 ? value[searchStart - 1] : ''
|
||||
if (searchStart > 0 && !/\s/.test(previousChar))
|
||||
continue
|
||||
|
||||
if (
|
||||
nextMatchStart === -1
|
||||
|| searchStart < nextMatchStart
|
||||
|| (searchStart === nextMatchStart && name.length > matchedName.length)
|
||||
) {
|
||||
nextMatchStart = searchStart
|
||||
matchedName = name
|
||||
}
|
||||
}
|
||||
|
||||
if (nextMatchStart === -1)
|
||||
break
|
||||
|
||||
if (nextMatchStart > cursor)
|
||||
segments.push(<span key={`text-${cursor}`}>{value.slice(cursor, nextMatchStart)}</span>)
|
||||
|
||||
const mentionEnd = nextMatchStart + matchedName.length + 1
|
||||
segments.push(
|
||||
<span key={`mention-${nextMatchStart}`} className="text-primary-600">
|
||||
{value.slice(nextMatchStart, mentionEnd)}
|
||||
</span>,
|
||||
)
|
||||
|
||||
hasMention = true
|
||||
cursor = mentionEnd
|
||||
}
|
||||
|
||||
if (!hasMention)
|
||||
return value
|
||||
|
||||
if (cursor < value.length)
|
||||
segments.push(<span key={`text-${cursor}`}>{value.slice(cursor)}</span>)
|
||||
|
||||
return segments
|
||||
}, [value, mentionNameList])
|
||||
|
||||
const loadMentionableUsers = useCallback(async () => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const state = workflowStore.getState()
|
||||
if (state.mentionableUsersCache[appId] !== undefined)
|
||||
return
|
||||
|
||||
if (state.mentionableUsersLoading[appId])
|
||||
return
|
||||
|
||||
state.setMentionableUsersLoading(appId, true)
|
||||
try {
|
||||
const users = await fetchMentionableUsers(appId)
|
||||
workflowStore.getState().setMentionableUsersCache(appId, users)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to load mentionable users:', error)
|
||||
}
|
||||
finally {
|
||||
workflowStore.getState().setMentionableUsersLoading(appId, false)
|
||||
}
|
||||
}, [appId, workflowStore])
|
||||
|
||||
useEffect(() => {
|
||||
loadMentionableUsers()
|
||||
}, [loadMentionableUsers])
|
||||
const syncHighlightScroll = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
const highlightContent = highlightContentRef.current
|
||||
if (!textarea || !highlightContent)
|
||||
return
|
||||
|
||||
const { scrollTop, scrollLeft } = textarea
|
||||
highlightContent.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)`
|
||||
}, [])
|
||||
|
||||
const evaluateContentLayout = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea)
|
||||
return
|
||||
|
||||
const extraBottom = Math.max(0, paddingBottom - BASE_PADDING)
|
||||
const effectiveClientHeight = textarea.clientHeight - extraBottom
|
||||
|
||||
if (baseTextareaHeightRef.current === null)
|
||||
baseTextareaHeightRef.current = effectiveClientHeight
|
||||
|
||||
const baseHeight = baseTextareaHeightRef.current ?? effectiveClientHeight
|
||||
const hasMultiline = effectiveClientHeight > baseHeight + 1
|
||||
const shouldReserveVertical = isEditing ? true : hasMultiline
|
||||
|
||||
setShouldReserveButtonGap(shouldReserveVertical)
|
||||
setShouldReserveHorizontalSpace(!hasMultiline)
|
||||
}, [isEditing, paddingBottom])
|
||||
|
||||
const updateLayoutPadding = useCallback(() => {
|
||||
const actionEl = actionContainerRef.current
|
||||
const rect = actionEl?.getBoundingClientRect()
|
||||
const rightRect = actionRightRef.current?.getBoundingClientRect()
|
||||
let actionWidth = 0
|
||||
if (rightRect)
|
||||
actionWidth = Math.ceil(rightRect.width)
|
||||
else if (rect)
|
||||
actionWidth = Math.ceil(rect.width)
|
||||
|
||||
const actionHeight = rect ? Math.ceil(rect.height) : 0
|
||||
const fallbackWidth = Math.max(0, paddingRight - BASE_PADDING)
|
||||
const fallbackHeight = Math.max(0, paddingBottom - BASE_PADDING)
|
||||
const effectiveWidth = actionWidth > 0 ? actionWidth : fallbackWidth
|
||||
const effectiveHeight = actionHeight > 0 ? actionHeight : fallbackHeight
|
||||
|
||||
const nextRight = BASE_PADDING + (shouldReserveHorizontalSpace ? effectiveWidth : 0)
|
||||
const nextBottom = BASE_PADDING + (shouldReserveButtonGap ? effectiveHeight : 0)
|
||||
|
||||
setPaddingRight(prev => (prev === nextRight ? prev : nextRight))
|
||||
setPaddingBottom(prev => (prev === nextBottom ? prev : nextBottom))
|
||||
}, [shouldReserveButtonGap, shouldReserveHorizontalSpace, paddingRight, paddingBottom])
|
||||
|
||||
const setActionContainerRef = useCallback((node: HTMLDivElement | null) => {
|
||||
actionContainerRef.current = node
|
||||
|
||||
if (!isEditing)
|
||||
actionRightRef.current = node
|
||||
else if (!node)
|
||||
actionRightRef.current = null
|
||||
|
||||
if (node && typeof window !== 'undefined')
|
||||
window.requestAnimationFrame(() => updateLayoutPadding())
|
||||
}, [isEditing, updateLayoutPadding])
|
||||
|
||||
const setActionRightRef = useCallback((node: HTMLDivElement | null) => {
|
||||
actionRightRef.current = node
|
||||
|
||||
if (node && typeof window !== 'undefined')
|
||||
window.requestAnimationFrame(() => updateLayoutPadding())
|
||||
}, [updateLayoutPadding])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncHighlightScroll()
|
||||
}, [value, syncHighlightScroll])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
Promise.resolve().then(() => {
|
||||
evaluateContentLayout()
|
||||
})
|
||||
}, [value, evaluateContentLayout])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
Promise.resolve().then(() => {
|
||||
updateLayoutPadding()
|
||||
})
|
||||
}, [updateLayoutPadding, isEditing, shouldReserveButtonGap])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
evaluateContentLayout()
|
||||
updateLayoutPadding()
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [evaluateContentLayout, updateLayoutPadding])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.resolve().then(() => {
|
||||
baseTextareaHeightRef.current = null
|
||||
evaluateContentLayout()
|
||||
setShouldReserveHorizontalSpace(!isEditing)
|
||||
})
|
||||
}, [isEditing, evaluateContentLayout])
|
||||
|
||||
const filteredMentionUsers = useMemo(() => {
|
||||
if (!mentionQuery)
|
||||
return mentionUsers
|
||||
return mentionUsers.filter(user =>
|
||||
user.name.toLowerCase().includes(mentionQuery.toLowerCase())
|
||||
|| user.email.toLowerCase().includes(mentionQuery.toLowerCase()),
|
||||
)
|
||||
}, [mentionUsers, mentionQuery])
|
||||
|
||||
const shouldDisableMentionButton = useMemo(() => {
|
||||
if (showMentionDropdown)
|
||||
return true
|
||||
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea)
|
||||
return false
|
||||
|
||||
const cursorPosition = textarea.selectionStart || 0
|
||||
const textBeforeCursor = value.slice(0, cursorPosition)
|
||||
return /@\w*$/.test(textBeforeCursor)
|
||||
}, [showMentionDropdown, value])
|
||||
|
||||
const dropdownPosition = useMemo(() => {
|
||||
if (!showMentionDropdown || !textareaRef.current)
|
||||
return { x: 0, y: 0, placement: 'bottom' as const }
|
||||
|
||||
const textareaRect = textareaRef.current.getBoundingClientRect()
|
||||
const dropdownHeight = 160 // max-h-40 = 10rem = 160px
|
||||
const viewportHeight = window.innerHeight
|
||||
const spaceBelow = viewportHeight - textareaRect.bottom
|
||||
const spaceAbove = textareaRect.top
|
||||
|
||||
const shouldPlaceAbove = spaceBelow < dropdownHeight && spaceAbove > spaceBelow
|
||||
|
||||
return {
|
||||
x: textareaRect.left,
|
||||
y: shouldPlaceAbove ? textareaRect.top - 4 : textareaRect.bottom + 4,
|
||||
placement: shouldPlaceAbove ? 'top' as const : 'bottom' as const,
|
||||
}
|
||||
}, [showMentionDropdown])
|
||||
|
||||
const handleContentChange = useCallback((newValue: string) => {
|
||||
onChange(newValue)
|
||||
|
||||
setTimeout(() => {
|
||||
const cursorPosition = textareaRef.current?.selectionStart || 0
|
||||
const textBeforeCursor = newValue.slice(0, cursorPosition)
|
||||
const mentionMatch = textBeforeCursor.match(/@(\w*)$/)
|
||||
|
||||
if (mentionMatch) {
|
||||
setMentionQuery(mentionMatch[1])
|
||||
setMentionPosition(cursorPosition - mentionMatch[0].length)
|
||||
setShowMentionDropdown(true)
|
||||
setSelectedMentionIndex(0)
|
||||
}
|
||||
else {
|
||||
setShowMentionDropdown(false)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.requestAnimationFrame(() => {
|
||||
evaluateContentLayout()
|
||||
syncHighlightScroll()
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
}, [onChange, evaluateContentLayout, syncHighlightScroll])
|
||||
|
||||
const handleMentionButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea)
|
||||
return
|
||||
|
||||
const cursorPosition = textarea.selectionStart || 0
|
||||
const textBeforeCursor = value.slice(0, cursorPosition)
|
||||
|
||||
if (showMentionDropdown)
|
||||
return
|
||||
|
||||
if (/@\w*$/.test(textBeforeCursor))
|
||||
return
|
||||
|
||||
const newContent = `${value.slice(0, cursorPosition)}@${value.slice(cursorPosition)}`
|
||||
|
||||
onChange(newContent)
|
||||
|
||||
setTimeout(() => {
|
||||
const newCursorPos = cursorPosition + 1
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
|
||||
setMentionQuery('')
|
||||
setMentionPosition(cursorPosition)
|
||||
setShowMentionDropdown(true)
|
||||
setSelectedMentionIndex(0)
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.requestAnimationFrame(() => {
|
||||
evaluateContentLayout()
|
||||
syncHighlightScroll()
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
}, [value, onChange, evaluateContentLayout, syncHighlightScroll, showMentionDropdown])
|
||||
|
||||
const insertMention = useCallback((user: UserProfile) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea)
|
||||
return
|
||||
|
||||
const beforeMention = value.slice(0, mentionPosition)
|
||||
const afterMention = value.slice(textarea.selectionStart || 0)
|
||||
|
||||
const needsSpaceBefore = mentionPosition > 0 && !/\s/.test(value[mentionPosition - 1])
|
||||
const prefix = needsSpaceBefore ? ' ' : ''
|
||||
const newContent = `${beforeMention}${prefix}@${user.name} ${afterMention}`
|
||||
|
||||
onChange(newContent)
|
||||
setShowMentionDropdown(false)
|
||||
|
||||
const newMentionedUserIds = [...mentionedUserIds, user.id]
|
||||
setMentionedUserIds(newMentionedUserIds)
|
||||
|
||||
setTimeout(() => {
|
||||
const extraSpace = needsSpaceBefore ? 1 : 0
|
||||
const newCursorPos = mentionPosition + extraSpace + user.name.length + 2 // (space) + @ + name + space
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
textarea.focus()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.requestAnimationFrame(() => {
|
||||
evaluateContentLayout()
|
||||
syncHighlightScroll()
|
||||
})
|
||||
}
|
||||
}, 0)
|
||||
}, [value, mentionPosition, onChange, mentionedUserIds, evaluateContentLayout, syncHighlightScroll])
|
||||
|
||||
const handleSubmit = useCallback(async (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
if (value.trim()) {
|
||||
try {
|
||||
await onSubmit(value.trim(), mentionedUserIds)
|
||||
setMentionedUserIds([])
|
||||
setShowMentionDropdown(false)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to submit', error)
|
||||
}
|
||||
}
|
||||
}, [value, mentionedUserIds, onSubmit])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
// Ignore key events during IME composition (e.g., Chinese, Japanese input)
|
||||
if (e.nativeEvent.isComposing)
|
||||
return
|
||||
|
||||
if (showMentionDropdown) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedMentionIndex(prev =>
|
||||
prev < filteredMentionUsers.length - 1 ? prev + 1 : 0,
|
||||
)
|
||||
}
|
||||
else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedMentionIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredMentionUsers.length - 1,
|
||||
)
|
||||
}
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (filteredMentionUsers[selectedMentionIndex])
|
||||
insertMention(filteredMentionUsers[selectedMentionIndex])
|
||||
|
||||
return
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setShowMentionDropdown(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !showMentionDropdown) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}, [showMentionDropdown, filteredMentionUsers, selectedMentionIndex, insertMention, handleSubmit])
|
||||
|
||||
const resetMentionState = useCallback(() => {
|
||||
setMentionedUserIds([])
|
||||
setShowMentionDropdown(false)
|
||||
setMentionQuery('')
|
||||
setMentionPosition(0)
|
||||
setSelectedMentionIndex(0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
Promise.resolve().then(() => {
|
||||
resetMentionState()
|
||||
})
|
||||
}
|
||||
}, [value, resetMentionState])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && textareaRef.current) {
|
||||
const textarea = textareaRef.current
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
const length = textarea.value.length
|
||||
textarea.setSelectionRange(length, length)
|
||||
}, 0)
|
||||
}
|
||||
}, [autoFocus])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('relative flex items-center', className)}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words p-1 leading-6',
|
||||
'body-lg-regular text-text-primary',
|
||||
)}
|
||||
style={{ paddingRight, paddingBottom }}
|
||||
>
|
||||
<div
|
||||
ref={highlightContentRef}
|
||||
className="min-h-full"
|
||||
style={{ willChange: 'transform' }}
|
||||
>
|
||||
{highlightedValue}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
'body-lg-regular relative z-10 w-full resize-none bg-transparent p-1 leading-6 text-transparent caret-primary-500 outline-none',
|
||||
'placeholder:text-text-tertiary',
|
||||
)}
|
||||
style={{ paddingRight, paddingBottom }}
|
||||
placeholder={resolvedPlaceholder}
|
||||
autoFocus={autoFocus}
|
||||
minRows={isEditing ? 4 : 1}
|
||||
maxRows={4}
|
||||
value={value}
|
||||
disabled={disabled || loading}
|
||||
onChange={e => handleContentChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={syncHighlightScroll}
|
||||
/>
|
||||
|
||||
{!isEditing && (
|
||||
<div
|
||||
ref={setActionContainerRef}
|
||||
className="absolute bottom-0 right-1 z-20 flex items-end gap-1"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'z-20 flex h-8 w-8 items-center justify-center rounded-lg transition-opacity',
|
||||
shouldDisableMentionButton
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick}
|
||||
>
|
||||
<RiAtLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<Button
|
||||
className="z-20 ml-2 w-8 px-0"
|
||||
variant="primary"
|
||||
disabled={!value.trim() || disabled || loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loading
|
||||
? <RiLoader2Line className="h-4 w-4 animate-spin text-components-button-primary-text" />
|
||||
: <RiArrowUpLine className="h-4 w-4 text-components-button-primary-text" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div
|
||||
ref={setActionContainerRef}
|
||||
className="absolute bottom-0 left-1 right-1 z-20 flex items-end justify-between"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'z-20 flex h-8 w-8 items-center justify-center rounded-lg transition-opacity',
|
||||
shouldDisableMentionButton
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: 'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick}
|
||||
>
|
||||
<RiAtLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div
|
||||
ref={setActionRightRef}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Button variant="secondary" size="small" onClick={onCancel} disabled={loading}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={loading || !value.trim()}
|
||||
onClick={() => handleSubmit()}
|
||||
className="gap-1"
|
||||
>
|
||||
{loading && <RiLoader2Line className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||
<span>{t('operation.save', { ns: 'common' })}</span>
|
||||
{!loading && (
|
||||
<EnterKey className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMentionDropdown && filteredMentionUsers.length > 0 && typeof document !== 'undefined' && createPortal(
|
||||
<div
|
||||
className="bg-components-panel-bg/95 fixed z-[9999] max-h-[248px] w-[280px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border shadow-lg backdrop-blur-[10px]"
|
||||
style={{
|
||||
left: dropdownPosition.x,
|
||||
[dropdownPosition.placement === 'top' ? 'bottom' : 'top']: dropdownPosition.placement === 'top'
|
||||
? window.innerHeight - dropdownPosition.y
|
||||
: dropdownPosition.y,
|
||||
}}
|
||||
data-mention-dropdown
|
||||
>
|
||||
{filteredMentionUsers.map((user, index) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md py-1 pl-2 pr-3 hover:bg-state-base-hover',
|
||||
index === selectedMentionIndex && 'bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => insertMention(user)}
|
||||
>
|
||||
<Avatar
|
||||
avatar={user.avatar_url || null}
|
||||
name={user.name}
|
||||
size={24}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-text-primary">
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-text-tertiary">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
MentionInputInner.displayName = 'MentionInputInner'
|
||||
|
||||
export const MentionInput = memo(MentionInputInner)
|
||||
634
web/app/components/workflow/comment/thread.tsx
Normal file
634
web/app/components/workflow/comment/thread.tsx
Normal file
@ -0,0 +1,634 @@
|
||||
'use client'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiMoreFill } from '@remixicon/react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useReactFlow, useViewport } from 'reactflow'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useStore } from '../store'
|
||||
import { MentionInput } from './mention-input'
|
||||
|
||||
type CommentThreadProps = {
|
||||
comment: WorkflowCommentDetail
|
||||
loading?: boolean
|
||||
replySubmitting?: boolean
|
||||
replyUpdating?: boolean
|
||||
onClose: () => void
|
||||
onDelete?: () => void
|
||||
onResolve?: () => void
|
||||
onPrev?: () => void
|
||||
onNext?: () => void
|
||||
canGoPrev?: boolean
|
||||
canGoNext?: boolean
|
||||
onReply?: (content: string, mentionedUserIds?: string[]) => Promise<void> | void
|
||||
onReplyEdit?: (replyId: string, content: string, mentionedUserIds?: string[]) => Promise<void> | void
|
||||
onReplyDelete?: (replyId: string) => void
|
||||
onReplyDeleteDirect?: (replyId: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
const ThreadMessage: FC<{
|
||||
authorId: string
|
||||
authorName: string
|
||||
avatarUrl?: string | null
|
||||
createdAt: number
|
||||
content: string
|
||||
mentionableNames: string[]
|
||||
className?: string
|
||||
}> = ({ authorId, authorName, avatarUrl, createdAt, content, mentionableNames, className }) => {
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { userProfile } = useAppContext()
|
||||
const currentUserId = userProfile?.id
|
||||
const isCurrentUser = authorId === currentUserId
|
||||
const userColor = isCurrentUser ? undefined : getUserColor(authorId)
|
||||
|
||||
const highlightedContent = useMemo<ReactNode>(() => {
|
||||
if (!content)
|
||||
return ''
|
||||
|
||||
// Extract valid user names from mentionableNames, sorted by length (longest first)
|
||||
const normalizedNames = Array.from(new Set(mentionableNames
|
||||
.map(name => name.trim())
|
||||
.filter(Boolean)))
|
||||
normalizedNames.sort((a, b) => b.length - a.length)
|
||||
|
||||
if (normalizedNames.length === 0)
|
||||
return content
|
||||
|
||||
const segments: ReactNode[] = []
|
||||
let hasMention = false
|
||||
let cursor = 0
|
||||
|
||||
while (cursor < content.length) {
|
||||
let nextMatchStart = -1
|
||||
let matchedName = ''
|
||||
|
||||
for (const name of normalizedNames) {
|
||||
const searchStart = content.indexOf(`@${name}`, cursor)
|
||||
if (searchStart === -1)
|
||||
continue
|
||||
|
||||
const previousChar = searchStart > 0 ? content[searchStart - 1] : ''
|
||||
if (searchStart > 0 && !/\s/.test(previousChar))
|
||||
continue
|
||||
|
||||
if (
|
||||
nextMatchStart === -1
|
||||
|| searchStart < nextMatchStart
|
||||
|| (searchStart === nextMatchStart && name.length > matchedName.length)
|
||||
) {
|
||||
nextMatchStart = searchStart
|
||||
matchedName = name
|
||||
}
|
||||
}
|
||||
|
||||
if (nextMatchStart === -1)
|
||||
break
|
||||
|
||||
if (nextMatchStart > cursor)
|
||||
segments.push(<span key={`text-${cursor}`}>{content.slice(cursor, nextMatchStart)}</span>)
|
||||
|
||||
const mentionEnd = nextMatchStart + matchedName.length + 1
|
||||
segments.push(
|
||||
<span key={`mention-${nextMatchStart}`} className="text-primary-600">
|
||||
{content.slice(nextMatchStart, mentionEnd)}
|
||||
</span>,
|
||||
)
|
||||
hasMention = true
|
||||
cursor = mentionEnd
|
||||
}
|
||||
|
||||
if (!hasMention)
|
||||
return content
|
||||
|
||||
if (cursor < content.length)
|
||||
segments.push(<span key={`text-${cursor}`}>{content.slice(cursor)}</span>)
|
||||
|
||||
return segments
|
||||
}, [content, mentionableNames])
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-3 pt-1', className)}>
|
||||
<div className="shrink-0">
|
||||
<Avatar
|
||||
name={authorName}
|
||||
avatar={avatarUrl || null}
|
||||
size={24}
|
||||
className={cn('h-8 w-8 rounded-full')}
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pb-4 text-text-primary last:pb-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="system-sm-medium text-text-primary">{authorName}</span>
|
||||
<span className="system-2xs-regular text-text-tertiary">{formatTimeFromNow(createdAt * 1000)}</span>
|
||||
</div>
|
||||
<div className="system-sm-regular mt-1 whitespace-pre-wrap break-words text-text-secondary">
|
||||
{highlightedContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CommentThread: FC<CommentThreadProps> = memo(({
|
||||
comment,
|
||||
loading = false,
|
||||
replySubmitting = false,
|
||||
replyUpdating = false,
|
||||
onClose,
|
||||
onDelete,
|
||||
onResolve,
|
||||
onPrev,
|
||||
onNext,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
onReply,
|
||||
onReplyEdit,
|
||||
onReplyDelete,
|
||||
onReplyDeleteDirect,
|
||||
}) => {
|
||||
const params = useParams()
|
||||
const appId = params.appId as string
|
||||
const { flowToScreenPosition } = useReactFlow()
|
||||
const viewport = useViewport()
|
||||
const { userProfile } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
const [replyContent, setReplyContent] = useState('')
|
||||
const [activeReplyMenuId, setActiveReplyMenuId] = useState<string | null>(null)
|
||||
const [editingReply, setEditingReply] = useState<{ id: string, content: string }>({ id: '', content: '' })
|
||||
const [deletingReplyId, setDeletingReplyId] = useState<string | null>(null)
|
||||
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false)
|
||||
|
||||
// Focus management refs
|
||||
const replyInputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const threadRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Get mentionable users from store
|
||||
const mentionUsersFromStore = useStore(state => (
|
||||
appId ? state.mentionableUsersCache[appId] : undefined
|
||||
))
|
||||
const mentionUsers = mentionUsersFromStore ?? []
|
||||
const setCommentPreviewHovering = useStore(state => state.setCommentPreviewHovering)
|
||||
|
||||
// Extract all mentionable names for highlighting
|
||||
const mentionableNames = useMemo(() => {
|
||||
const names = mentionUsers
|
||||
.map(user => user.name?.trim())
|
||||
.filter((name): name is string => Boolean(name))
|
||||
return Array.from(new Set(names))
|
||||
}, [mentionUsers])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.resolve().then(() => {
|
||||
setReplyContent('')
|
||||
})
|
||||
}, [comment.id])
|
||||
|
||||
useEffect(() => () => {
|
||||
setCommentPreviewHovering(false)
|
||||
}, [setCommentPreviewHovering])
|
||||
|
||||
// P0: Auto-focus reply input when thread opens or comment changes
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (replyInputRef.current && !editingReply.id && onReply)
|
||||
replyInputRef.current.focus()
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [comment.id, editingReply.id, onReply])
|
||||
|
||||
// P2: Handle Esc key to close thread
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't intercept if actively editing a reply
|
||||
if (editingReply.id)
|
||||
return
|
||||
|
||||
// Don't intercept if mention dropdown is open (let MentionInput handle it)
|
||||
if (document.querySelector('[data-mention-dropdown]'))
|
||||
return
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [onClose, editingReply.id])
|
||||
|
||||
const handleReplySubmit = useCallback(async (content: string, mentionedUserIds: string[]) => {
|
||||
if (!onReply || replySubmitting)
|
||||
return
|
||||
|
||||
setReplyContent('')
|
||||
|
||||
try {
|
||||
await onReply(content, mentionedUserIds)
|
||||
|
||||
// P0: Restore focus to reply input after successful submission
|
||||
setTimeout(() => {
|
||||
replyInputRef.current?.focus()
|
||||
}, 0)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to send reply', error)
|
||||
setReplyContent(content)
|
||||
}
|
||||
}, [onReply, replySubmitting])
|
||||
|
||||
const screenPosition = useMemo(() => {
|
||||
return flowToScreenPosition({
|
||||
x: comment.position_x,
|
||||
y: comment.position_y,
|
||||
})
|
||||
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
|
||||
const workflowContainerRect = typeof document !== 'undefined'
|
||||
? document.getElementById('workflow-container')?.getBoundingClientRect()
|
||||
: null
|
||||
const containerLeft = workflowContainerRect?.left ?? 0
|
||||
const containerTop = workflowContainerRect?.top ?? 0
|
||||
const canvasPosition = useMemo(() => ({
|
||||
x: screenPosition.x - containerLeft,
|
||||
y: screenPosition.y - containerTop,
|
||||
}), [screenPosition.x, screenPosition.y, containerLeft, containerTop])
|
||||
|
||||
const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => {
|
||||
setEditingReply({ id: reply.id, content: reply.content })
|
||||
setActiveReplyMenuId(null)
|
||||
}, [])
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingReply({ id: '', content: '' })
|
||||
|
||||
// P1: Restore focus to reply input after canceling edit
|
||||
setTimeout(() => {
|
||||
replyInputRef.current?.focus()
|
||||
}, 0)
|
||||
}, [])
|
||||
|
||||
const handleEditSubmit = useCallback(async (content: string, mentionedUserIds: string[]) => {
|
||||
if (!onReplyEdit || !editingReply)
|
||||
return
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed)
|
||||
return
|
||||
|
||||
setIsSubmittingEdit(true)
|
||||
try {
|
||||
await onReplyEdit(editingReply.id, trimmed, mentionedUserIds)
|
||||
setEditingReply({ id: '', content: '' })
|
||||
|
||||
// P1: Restore focus to reply input after saving edit
|
||||
setTimeout(() => {
|
||||
replyInputRef.current?.focus()
|
||||
}, 0)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to edit reply', error)
|
||||
}
|
||||
finally {
|
||||
setIsSubmittingEdit(false)
|
||||
}
|
||||
}, [editingReply, onReplyEdit])
|
||||
|
||||
const replies = comment.replies || []
|
||||
const messageListRef = useRef<HTMLDivElement>(null)
|
||||
const previousReplyCountRef = useRef<number | undefined>(undefined)
|
||||
const previousCommentIdRef = useRef<string | undefined>(undefined)
|
||||
|
||||
// Close dropdown when scrolling
|
||||
useEffect(() => {
|
||||
const container = messageListRef.current
|
||||
if (!container || !activeReplyMenuId)
|
||||
return
|
||||
|
||||
const handleScroll = () => {
|
||||
setActiveReplyMenuId(null)
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [activeReplyMenuId])
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
const container = messageListRef.current
|
||||
if (!container)
|
||||
return
|
||||
|
||||
const isFirstRender = previousCommentIdRef.current === undefined
|
||||
const isNewComment = comment.id !== previousCommentIdRef.current
|
||||
const hasNewReply = previousReplyCountRef.current !== undefined
|
||||
&& replies.length > previousReplyCountRef.current
|
||||
|
||||
// Scroll on first render, new comment, or new reply
|
||||
if (isFirstRender || isNewComment || hasNewReply) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
previousCommentIdRef.current = comment.id
|
||||
previousReplyCountRef.current = replies.length
|
||||
}, [comment.id, replies.length])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-50 w-[360px] max-w-[360px]"
|
||||
style={{
|
||||
left: canvasPosition.x + 40,
|
||||
top: canvasPosition.y,
|
||||
transform: 'translateY(-20%)',
|
||||
}}
|
||||
onMouseEnter={() => setCommentPreviewHovering(true)}
|
||||
onMouseLeave={() => setCommentPreviewHovering(false)}
|
||||
>
|
||||
<div
|
||||
ref={threadRef}
|
||||
className="relative flex h-[360px] flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="comment-thread-title"
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-t-2xl border-b border-components-panel-border bg-components-panel-bg-blur px-4 py-3">
|
||||
<div
|
||||
id="comment-thread-title"
|
||||
className="font-semibold uppercase text-text-primary"
|
||||
>
|
||||
{t('comments.panelTitle', { ns: 'workflow' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip
|
||||
popupContent={t('comments.aria.deleteComment', { ns: 'workflow' })}
|
||||
position="top"
|
||||
popupClassName="!px-2 !py-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
|
||||
onClick={onDelete}
|
||||
aria-label={t('comments.aria.deleteComment', { ns: 'workflow' })}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
popupContent={t('comments.aria.resolveComment', { ns: 'workflow' })}
|
||||
position="top"
|
||||
popupClassName="!px-2 !py-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={comment.resolved || loading}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
|
||||
onClick={onResolve}
|
||||
aria-label={t('comments.aria.resolveComment', { ns: 'workflow' })}
|
||||
>
|
||||
{comment.resolved ? <RiCheckboxCircleFill className="h-4 w-4" /> : <RiCheckboxCircleLine className="h-4 w-4" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Divider type="vertical" className="h-3.5" />
|
||||
<Tooltip
|
||||
popupContent={t('comments.aria.previousComment', { ns: 'workflow' })}
|
||||
position="top"
|
||||
popupClassName="!px-2 !py-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGoPrev || loading}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
|
||||
onClick={onPrev}
|
||||
aria-label={t('comments.aria.previousComment', { ns: 'workflow' })}
|
||||
>
|
||||
<RiArrowUpSLine className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
popupContent={t('comments.aria.nextComment', { ns: 'workflow' })}
|
||||
position="top"
|
||||
popupClassName="!px-2 !py-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGoNext || loading}
|
||||
className={cn('flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled')}
|
||||
onClick={onNext}
|
||||
aria-label={t('comments.aria.nextComment', { ns: 'workflow' })}
|
||||
>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t('comments.aria.closeComment', { ns: 'workflow' })}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={messageListRef}
|
||||
className="relative mt-2 flex-1 overflow-y-auto px-4 pb-4"
|
||||
>
|
||||
<div className="-mx-4 rounded-lg px-4 py-2 transition-colors hover:bg-components-panel-on-panel-item-bg-hover">
|
||||
<ThreadMessage
|
||||
authorId={comment.created_by_account?.id || ''}
|
||||
authorName={comment.created_by_account?.name || t('comments.fallback.user', { ns: 'workflow' })}
|
||||
avatarUrl={comment.created_by_account?.avatar_url || null}
|
||||
createdAt={comment.created_at}
|
||||
content={comment.content}
|
||||
mentionableNames={mentionableNames}
|
||||
/>
|
||||
</div>
|
||||
{replies.length > 0 && (
|
||||
<div className="mt-2 space-y-3 pt-3">
|
||||
{replies.map((reply) => {
|
||||
const isReplyEditing = editingReply?.id === reply.id
|
||||
const isOwnReply = reply.created_by_account?.id === userProfile?.id
|
||||
return (
|
||||
<div
|
||||
key={reply.id}
|
||||
className="group relative -mx-4 rounded-lg px-4 py-2 transition-colors hover:bg-components-panel-on-panel-item-bg-hover"
|
||||
>
|
||||
{isOwnReply && !isReplyEditing && (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-end"
|
||||
open={activeReplyMenuId === reply.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeletingReplyId(null)
|
||||
setActiveReplyMenuId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-1 top-1 gap-1',
|
||||
activeReplyMenuId === reply.id ? 'flex' : 'hidden group-hover:flex',
|
||||
)}
|
||||
data-reply-menu
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeletingReplyId(null)
|
||||
setActiveReplyMenuId(prev => prev === reply.id ? null : reply.id)
|
||||
}}
|
||||
aria-label={t('comments.aria.replyActions', { ns: 'workflow' })}
|
||||
>
|
||||
<RiMoreFill className="h-4 w-4" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
</div>
|
||||
<PortalToFollowElemContent
|
||||
className="z-[100] w-36 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[10px]"
|
||||
data-reply-menu
|
||||
>
|
||||
{/* Menu buttons - hidden when showing delete confirm */}
|
||||
<div className={cn(deletingReplyId === reply.id ? 'hidden' : 'block')}>
|
||||
<button
|
||||
className="flex w-full items-center justify-start rounded-t-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleStartEdit(reply)
|
||||
}}
|
||||
>
|
||||
{t('comments.actions.editReply', { ns: 'workflow' })}
|
||||
</button>
|
||||
<button
|
||||
className="text-negative flex w-full items-center justify-start rounded-b-xl px-3 py-2 text-left text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (onReplyDeleteDirect) {
|
||||
setDeletingReplyId(reply.id)
|
||||
}
|
||||
else {
|
||||
setActiveReplyMenuId(null)
|
||||
onReplyDelete?.(reply.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('comments.actions.deleteReply', { ns: 'workflow' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation - shown when deletingReplyId matches */}
|
||||
<div className={cn(deletingReplyId === reply.id ? 'block' : 'hidden')}>
|
||||
<InlineDeleteConfirm
|
||||
title={t('comments.actions.deleteReply', { ns: 'workflow' })}
|
||||
onConfirm={() => {
|
||||
setDeletingReplyId(null)
|
||||
setActiveReplyMenuId(null)
|
||||
onReplyDeleteDirect?.(reply.id)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setDeletingReplyId(null)
|
||||
}}
|
||||
className="m-0 w-full border-0 shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)}
|
||||
{isReplyEditing
|
||||
? (
|
||||
<div className="flex gap-3 pt-1">
|
||||
<div className="shrink-0">
|
||||
<Avatar
|
||||
name={reply.created_by_account?.name || t('comments.fallback.user', { ns: 'workflow' })}
|
||||
avatar={reply.created_by_account?.avatar_url || null}
|
||||
size={24}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-1 shadow-md backdrop-blur-[10px]">
|
||||
<MentionInput
|
||||
value={editingReply?.content ?? ''}
|
||||
onChange={newContent => setEditingReply(prev => prev ? { ...prev, content: newContent } : prev)}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={handleCancelEdit}
|
||||
placeholder={t('comments.placeholder.editReply', { ns: 'workflow' })}
|
||||
disabled={loading}
|
||||
loading={replyUpdating || isSubmittingEdit}
|
||||
isEditing={true}
|
||||
className="system-sm-regular"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ThreadMessage
|
||||
authorId={reply.created_by_account?.id || ''}
|
||||
authorName={reply.created_by_account?.name || t('comments.fallback.user', { ns: 'workflow' })}
|
||||
avatarUrl={reply.created_by_account?.avatar_url || null}
|
||||
createdAt={reply.created_at}
|
||||
content={reply.content}
|
||||
mentionableNames={mentionableNames}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="bg-components-panel-bg/70 absolute inset-0 z-30 flex items-center justify-center text-sm text-text-tertiary">
|
||||
{t('comments.loading', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
{onReply && (
|
||||
<div className="border-t border-components-panel-border px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar
|
||||
avatar={userProfile?.avatar_url || null}
|
||||
name={userProfile?.name || t('you', { ns: 'common' })}
|
||||
size={24}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-[2px] shadow-sm">
|
||||
<MentionInput
|
||||
ref={replyInputRef}
|
||||
value={replyContent}
|
||||
onChange={setReplyContent}
|
||||
onSubmit={handleReplySubmit}
|
||||
placeholder={t('comments.placeholder.reply', { ns: 'workflow' })}
|
||||
disabled={loading}
|
||||
loading={replySubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CommentThread.displayName = 'CommentThread'
|
||||
@ -1,16 +1,19 @@
|
||||
import type { StartNodeType } from './nodes/start/types'
|
||||
import type { CommonNodeType, InputVar, Node } from './types'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { WorkflowDraftFeaturesPayload } from '@/service/workflow'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { updateFeatures } from '@/service/workflow'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from './hooks'
|
||||
import useConfig from './nodes/start/use-config'
|
||||
import { useStore } from './store'
|
||||
@ -18,11 +21,11 @@ import { InputVarType } from './types'
|
||||
|
||||
const Features = () => {
|
||||
const setShowFeaturesPanel = useStore(s => s.setShowFeaturesPanel)
|
||||
const appId = useStore(s => s.appId)
|
||||
const isChatMode = useIsChatMode()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const featuresStore = useFeaturesStore()
|
||||
const nodes = useNodes<CommonNodeType>()
|
||||
|
||||
const startNode = nodes.find(node => node.data.type === 'start')
|
||||
const { id, data } = startNode as Node<StartNodeType>
|
||||
const { handleAddVariable } = useConfig(id, data)
|
||||
@ -40,10 +43,44 @@ const Features = () => {
|
||||
handleAddVariable(startNodeVariable)
|
||||
}
|
||||
|
||||
const handleFeaturesChange = useCallback(() => {
|
||||
handleSyncWorkflowDraft()
|
||||
const handleFeaturesChange = useCallback(async () => {
|
||||
if (!appId || !featuresStore)
|
||||
return
|
||||
|
||||
try {
|
||||
const currentFeatures = featuresStore.getState().features
|
||||
|
||||
// Transform features to match the expected server format (same as doSyncWorkflowDraft)
|
||||
const transformedFeatures: WorkflowDraftFeaturesPayload = {
|
||||
opening_statement: currentFeatures.opening?.enabled ? (currentFeatures.opening?.opening_statement || '') : '',
|
||||
suggested_questions: currentFeatures.opening?.enabled ? (currentFeatures.opening?.suggested_questions || []) : [],
|
||||
suggested_questions_after_answer: currentFeatures.suggested,
|
||||
text_to_speech: currentFeatures.text2speech,
|
||||
speech_to_text: currentFeatures.speech2text,
|
||||
retriever_resource: currentFeatures.citation,
|
||||
sensitive_word_avoidance: currentFeatures.moderation,
|
||||
file_upload: currentFeatures.file,
|
||||
}
|
||||
|
||||
await updateFeatures({
|
||||
appId,
|
||||
features: transformedFeatures,
|
||||
})
|
||||
|
||||
// Emit update event to other connected clients
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'vars_and_features_update',
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to update features:', error)
|
||||
}
|
||||
|
||||
setShowFeaturesPanel(true)
|
||||
}, [handleSyncWorkflowDraft, setShowFeaturesPanel])
|
||||
}, [appId, featuresStore, setShowFeaturesPanel])
|
||||
|
||||
return (
|
||||
<NewFeaturePanel
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
import EditingTitle from './editing-title'
|
||||
import EnvButton from './env-button'
|
||||
import GlobalVariableButton from './global-variable-button'
|
||||
import OnlineUsers from './online-users'
|
||||
import RunAndHistory from './run-and-history'
|
||||
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
|
||||
import VersionHistoryButton from './version-history-button'
|
||||
@ -73,6 +74,8 @@ const HeaderInNormal = ({
|
||||
<ScrollToSelectedNodeButton />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<OnlineUsers />
|
||||
{components?.left}
|
||||
<Divider type="vertical" className="mx-auto h-3.5" />
|
||||
<RunAndHistory {...runAndHistoryProps} />
|
||||
<div className="shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]">
|
||||
|
||||
@ -4,12 +4,14 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useInvalidAllLastRun } from '@/service/use-workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Toast from '../../base/toast'
|
||||
import {
|
||||
useNodesSyncDraft,
|
||||
useLeaderRestore,
|
||||
useWorkflowRun,
|
||||
} from '../hooks'
|
||||
import { useHooksStore } from '../hooks-store'
|
||||
@ -31,6 +33,8 @@ const HeaderInRestoring = ({
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const userProfile = useAppContextSelector(s => s.userProfile)
|
||||
const featuresStore = useFeaturesStore()
|
||||
const configsMap = useHooksStore(s => s.configsMap)
|
||||
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
|
||||
const {
|
||||
@ -42,7 +46,7 @@ const HeaderInRestoring = ({
|
||||
const {
|
||||
handleLoadBackupDraft,
|
||||
} = useWorkflowRun()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { requestRestore } = useLeaderRestore()
|
||||
|
||||
const handleCancelRestore = useCallback(() => {
|
||||
handleLoadBackupDraft()
|
||||
@ -51,10 +55,32 @@ const HeaderInRestoring = ({
|
||||
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (!currentVersion)
|
||||
return
|
||||
|
||||
setShowWorkflowVersionHistoryPanel(false)
|
||||
workflowStore.setState({ isRestoring: false })
|
||||
workflowStore.setState({ backupDraft: undefined })
|
||||
handleSyncWorkflowDraft(true, false, {
|
||||
|
||||
const { graph } = currentVersion
|
||||
const features = featuresStore?.getState().features
|
||||
const environmentVariables = currentVersion.environment_variables || []
|
||||
const conversationVariables = currentVersion.conversation_variables || []
|
||||
|
||||
requestRestore({
|
||||
versionId: currentVersion.id,
|
||||
versionName: currentVersion.marked_name,
|
||||
initiatorUserId: userProfile.id,
|
||||
initiatorName: userProfile.name,
|
||||
graphData: {
|
||||
nodes: graph.nodes,
|
||||
edges: graph.edges,
|
||||
viewport: graph.viewport,
|
||||
},
|
||||
features,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
@ -73,7 +99,7 @@ const HeaderInRestoring = ({
|
||||
})
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
|
||||
}, [currentVersion, featuresStore, setShowWorkflowVersionHistoryPanel, workflowStore, requestRestore, userProfile, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
241
web/app/components/workflow/header/online-users.tsx
Normal file
241
web/app/components/workflow/header/online-users.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
import type { OnlineUser } from '../collaboration/types'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { getAvatar } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useCollaboration } from '../collaboration/hooks/use-collaboration'
|
||||
import { getUserColor } from '../collaboration/utils/user-color'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const useAvatarUrls = (users: OnlineUser[]) => {
|
||||
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAvatars = async () => {
|
||||
const newAvatarUrls: Record<string, string> = {}
|
||||
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
if (user.avatar) {
|
||||
try {
|
||||
const response = await getAvatar({ avatar: user.avatar })
|
||||
newAvatarUrls[user.sid] = response.avatar_url
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch avatar:', error)
|
||||
newAvatarUrls[user.sid] = user.avatar
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
setAvatarUrls(newAvatarUrls)
|
||||
}
|
||||
|
||||
if (users.length > 0)
|
||||
fetchAvatars()
|
||||
}, [users])
|
||||
|
||||
return avatarUrls
|
||||
}
|
||||
|
||||
const OnlineUsers = () => {
|
||||
const appId = useStore(s => s.appId)
|
||||
const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string)
|
||||
const { userProfile } = useAppContext()
|
||||
const reactFlow = useReactFlow()
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const avatarUrls = useAvatarUrls(onlineUsers || [])
|
||||
|
||||
const currentUserId = userProfile?.id
|
||||
|
||||
const renderDisplayName = (
|
||||
user: OnlineUser,
|
||||
baseClassName: string,
|
||||
suffixClassName: string,
|
||||
) => {
|
||||
const baseName = user.username || 'User'
|
||||
const isCurrentUser = user.user_id === currentUserId
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1', baseClassName)}>
|
||||
<span>{baseName}</span>
|
||||
{isCurrentUser && (
|
||||
<span className={suffixClassName}>
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Function to jump to user's cursor position
|
||||
const jumpToUserCursor = (userId: string) => {
|
||||
const cursor = cursors[userId]
|
||||
if (!cursor)
|
||||
return
|
||||
|
||||
// Convert world coordinates to center the view on the cursor
|
||||
reactFlow.setCenter(cursor.x, cursor.y, { zoom: 1, duration: 800 })
|
||||
}
|
||||
|
||||
if (!isCollaborationEnabled || !onlineUsers || onlineUsers.length === 0)
|
||||
return null
|
||||
|
||||
// Display logic:
|
||||
// 1-3 users: show all avatars
|
||||
// 4+ users: show 2 avatars + count + arrow
|
||||
const shouldShowCount = onlineUsers.length >= 4
|
||||
const maxVisible = shouldShowCount ? 2 : 3
|
||||
const visibleUsers = onlineUsers.slice(0, maxVisible)
|
||||
const remainingCount = onlineUsers.length - maxVisible
|
||||
|
||||
const getAvatarUrl = (user: OnlineUser) => {
|
||||
return avatarUrls[user.sid] || user.avatar
|
||||
}
|
||||
|
||||
const hasCounter = remainingCount > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-full border-[0.5px] border-components-panel-border',
|
||||
'bg-components-panel-bg py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]',
|
||||
hasCounter ? 'min-w-[87px] gap-px pl-1 pr-1.5' : 'gap-1 px-1.5',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-6 items-center">
|
||||
<div className="flex items-center">
|
||||
{visibleUsers.map((user, index) => {
|
||||
const isCurrentUser = user.user_id === currentUserId
|
||||
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${user.sid}-${index}`}
|
||||
popupContent={renderDisplayName(
|
||||
user,
|
||||
'system-xs-medium text-text-secondary',
|
||||
'text-text-quaternary',
|
||||
)}
|
||||
position="bottom"
|
||||
triggerMethod="hover"
|
||||
needsDelay={false}
|
||||
asChild
|
||||
popupClassName="flex h-[28px] w-[85px] items-center justify-center gap-1 rounded-md border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-3 py-[6px] shadow-lg shadow-shadow-shadow-5 backdrop-blur-[10px]"
|
||||
noDecoration
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex size-6 items-center justify-center',
|
||||
index > 0 && '-ml-1.5',
|
||||
!isCurrentUser && 'cursor-pointer transition-transform hover:scale-110',
|
||||
)}
|
||||
style={{ zIndex: visibleUsers.length - index }}
|
||||
onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)}
|
||||
>
|
||||
<Avatar
|
||||
name={user.username || 'User'}
|
||||
avatar={getAvatarUrl(user)}
|
||||
size={24}
|
||||
className="ring-1 ring-components-panel-bg"
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<PortalToFollowElem
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
crossAxis: -48,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setDropdownOpen(prev => !prev)}
|
||||
asChild
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 cursor-pointer select-none items-center justify-center rounded-full bg-components-icon-bg-midnight-solid text-[10px] font-semibold uppercase leading-[12px] text-white ring-1 ring-components-panel-bg',
|
||||
visibleUsers.length > 0 && '-ml-1',
|
||||
)}
|
||||
>
|
||||
+
|
||||
{remainingCount}
|
||||
</div>
|
||||
<ChevronDownIcon className="h-3 w-3 cursor-pointer text-gray-500" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent
|
||||
className="z-[9999]"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1.5',
|
||||
'flex flex-col',
|
||||
'max-h-[200px] w-[240px] overflow-y-auto',
|
||||
'rounded-xl border-[0.5px] border-components-panel-border',
|
||||
'bg-components-panel-bg-blur p-1',
|
||||
'shadow-lg shadow-shadow-shadow-5',
|
||||
'backdrop-blur-[10px]',
|
||||
)}
|
||||
>
|
||||
{onlineUsers.map((user) => {
|
||||
const isCurrentUser = user.user_id === currentUserId
|
||||
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
|
||||
return (
|
||||
<div
|
||||
key={user.sid}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5',
|
||||
!isCurrentUser && 'cursor-pointer hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isCurrentUser) {
|
||||
jumpToUserCursor(user.user_id)
|
||||
setDropdownOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
name={user.username || 'User'}
|
||||
avatar={getAvatarUrl(user)}
|
||||
size={24}
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
{renderDisplayName(
|
||||
user,
|
||||
'system-xs-medium text-text-secondary',
|
||||
'text-text-tertiary',
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnlineUsers
|
||||
@ -5,28 +5,42 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Divider from '../../base/divider'
|
||||
import TipPopup from '../operator/tip-popup'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
|
||||
export type UndoRedoProps = { handleUndo: () => void, handleRedo: () => void }
|
||||
const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
const { t } = useTranslation()
|
||||
const { store } = useWorkflowHistoryStore()
|
||||
const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true })
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.temporal.subscribe((state) => {
|
||||
// Update button states based on Loro's UndoManager
|
||||
const updateButtonStates = () => {
|
||||
setButtonsDisabled({
|
||||
undo: state.pastStates.length === 0,
|
||||
redo: state.futureStates.length === 0,
|
||||
undo: !collaborationManager.canUndo(),
|
||||
redo: !collaborationManager.canRedo(),
|
||||
})
|
||||
}
|
||||
|
||||
// Initial state
|
||||
Promise.resolve().then(() => {
|
||||
updateButtonStates()
|
||||
})
|
||||
|
||||
// Listen for undo/redo state changes
|
||||
const unsubscribe = collaborationManager.onUndoRedoStateChange((state) => {
|
||||
setButtonsDisabled({
|
||||
undo: !state.canUndo,
|
||||
redo: !state.canRedo,
|
||||
})
|
||||
})
|
||||
|
||||
return () => unsubscribe()
|
||||
}, [store])
|
||||
}, [])
|
||||
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { FileUpload } from '../../base/features/types'
|
||||
import type { TriggerType } from '@/app/components/workflow/header/test-run-menu'
|
||||
import type {
|
||||
BlockEnum,
|
||||
Node,
|
||||
@ -9,7 +10,7 @@ import type {
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
import type { FlowType } from '@/types/common'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import type { FetchWorkflowDraftResponse, VarInInspect } from '@/types/workflow'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useContext } from 'react'
|
||||
import {
|
||||
@ -19,6 +20,13 @@ import { createStore } from 'zustand/vanilla'
|
||||
import { InteractionMode } from '@/app/components/workflow'
|
||||
import { HooksStoreContext } from './provider'
|
||||
|
||||
export type WorkflowRunOptions = {
|
||||
mode?: TriggerType
|
||||
scheduleNodeId?: string
|
||||
webhookNodeId?: string
|
||||
pluginNodeId?: string
|
||||
allNodeIds?: string[]
|
||||
}
|
||||
export type AvailableNodesMetaData = {
|
||||
nodes: NodeDefaultBase[]
|
||||
nodesMap: Record<BlockEnum, NodeDefaultBase>
|
||||
@ -39,9 +47,9 @@ export type CommonHooksFnMap = {
|
||||
handleRefreshWorkflowDraft: () => void
|
||||
handleBackupDraft: () => void
|
||||
handleLoadBackupDraft: () => void
|
||||
handleRestoreFromPublishedWorkflow: (...args: any[]) => void
|
||||
handleRun: (params: any, callback?: IOtherOptions, options?: any) => void
|
||||
handleStopRun: (...args: any[]) => void
|
||||
handleRestoreFromPublishedWorkflow: (publishedWorkflow: FetchWorkflowDraftResponse) => void
|
||||
handleRun: (params: unknown, callback?: IOtherOptions, options?: WorkflowRunOptions) => void | Promise<void>
|
||||
handleStopRun: (taskId: string) => void
|
||||
handleStartWorkflowRun: () => void
|
||||
handleWorkflowStartRunInWorkflow: () => void
|
||||
handleWorkflowStartRunInChatflow: () => void
|
||||
@ -58,7 +66,7 @@ export type CommonHooksFnMap = {
|
||||
hasNodeInspectVars: (nodeId: string) => boolean
|
||||
hasSetInspectVar: (nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => boolean
|
||||
fetchInspectVarValue: (selector: ValueSelector, schemaTypeDefinitions: SchemaTypeDefinition[]) => Promise<void>
|
||||
editInspectVarValue: (nodeId: string, varId: string, value: any) => Promise<void>
|
||||
editInspectVarValue: (nodeId: string, varId: string, value: unknown) => Promise<void>
|
||||
renameInspectVarName: (nodeId: string, oldName: string, newName: string) => Promise<void>
|
||||
appendNodeInspectVars: (nodeId: string, payload: VarInInspect[], allNodes: Node[]) => void
|
||||
deleteInspectVar: (nodeId: string, varId: string) => Promise<void>
|
||||
@ -72,7 +80,7 @@ export type CommonHooksFnMap = {
|
||||
configsMap?: {
|
||||
flowId: string
|
||||
flowType: FlowType
|
||||
fileSettings: FileUpload
|
||||
fileSettings?: FileUpload
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,9 +95,9 @@ export const createHooksStore = ({
|
||||
handleRefreshWorkflowDraft = noop,
|
||||
handleBackupDraft = noop,
|
||||
handleLoadBackupDraft = noop,
|
||||
handleRestoreFromPublishedWorkflow = noop,
|
||||
handleRestoreFromPublishedWorkflow = (_publishedWorkflow: FetchWorkflowDraftResponse) => noop(),
|
||||
handleRun = noop,
|
||||
handleStopRun = noop,
|
||||
handleStopRun = (_taskId: string) => noop(),
|
||||
handleStartWorkflowRun = noop,
|
||||
handleWorkflowStartRunInWorkflow = noop,
|
||||
handleWorkflowStartRunInChatflow = noop,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
export * from './use-auto-generate-webhook-url'
|
||||
export * from './use-auto-generate-webhook-url'
|
||||
export * from './use-available-blocks'
|
||||
export * from './use-checklist'
|
||||
export * from './use-DSL'
|
||||
export * from './use-edges-interactions'
|
||||
export * from './use-inspect-vars-crud'
|
||||
export * from './use-leader-restore'
|
||||
export * from './use-node-data-update'
|
||||
export * from './use-nodes-interactions'
|
||||
export * from './use-nodes-layout'
|
||||
@ -12,10 +14,12 @@ export * from './use-nodes-sync-draft'
|
||||
export * from './use-panel-interactions'
|
||||
export * from './use-selection-interactions'
|
||||
export * from './use-serial-async-callback'
|
||||
export * from './use-serial-async-callback'
|
||||
export * from './use-set-workflow-vars-with-value'
|
||||
export * from './use-shortcuts'
|
||||
export * from './use-tool-icon'
|
||||
export * from './use-workflow'
|
||||
export * from './use-workflow-comment'
|
||||
export * from './use-workflow-history'
|
||||
export * from './use-workflow-interactions'
|
||||
export * from './use-workflow-mode'
|
||||
|
||||
@ -363,7 +363,10 @@ export const useChecklistBeforePublish = () => {
|
||||
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
|
||||
}
|
||||
const checkData = getCheckData(node.data, datasets)
|
||||
const { errorMessage } = nodesExtraData![node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid)
|
||||
const nodeMetaData = nodesExtraData?.[node.data.type as BlockEnum]
|
||||
if (!nodeMetaData)
|
||||
continue
|
||||
const { errorMessage } = nodeMetaData.checkValid(checkData, t, moreDataForCheckValid)
|
||||
|
||||
if (errorMessage) {
|
||||
notify({ type: 'error', message: `[${node.data.title}] ${errorMessage}` })
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import type { Edge, Node } from '../types'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
|
||||
const sanitizeNodeForBroadcast = (node: Node): Node => {
|
||||
if (!node.data)
|
||||
return node
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(node.data, 'selected'))
|
||||
return node
|
||||
|
||||
const sanitizedData = { ...node.data }
|
||||
delete (sanitizedData as Record<string, unknown>).selected
|
||||
|
||||
return {
|
||||
...node,
|
||||
data: sanitizedData,
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeEdgeForBroadcast = (edge: Edge): Edge => {
|
||||
if (!edge.data)
|
||||
return edge
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(edge.data, '_connectedNodeIsSelected'))
|
||||
return edge
|
||||
|
||||
const sanitizedData = { ...edge.data }
|
||||
delete (sanitizedData as Record<string, unknown>)._connectedNodeIsSelected
|
||||
|
||||
return {
|
||||
...edge,
|
||||
data: sanitizedData,
|
||||
}
|
||||
}
|
||||
|
||||
export const useCollaborativeWorkflow = () => {
|
||||
const store = useStoreApi()
|
||||
const { setNodes: collabSetNodes, setEdges: collabSetEdges } = collaborationManager
|
||||
|
||||
const setNodes = useCallback((newNodes: Node[], shouldBroadcast: boolean = true) => {
|
||||
const { getNodes, setNodes: reactFlowSetNodes } = store.getState()
|
||||
if (shouldBroadcast) {
|
||||
const oldNodes = getNodes()
|
||||
collabSetNodes(
|
||||
oldNodes.map(sanitizeNodeForBroadcast),
|
||||
newNodes.map(sanitizeNodeForBroadcast),
|
||||
)
|
||||
}
|
||||
reactFlowSetNodes(newNodes)
|
||||
}, [store, collabSetNodes])
|
||||
|
||||
const setEdges = useCallback((newEdges: Edge[], shouldBroadcast: boolean = true) => {
|
||||
const { edges, setEdges: reactFlowSetEdges } = store.getState()
|
||||
if (shouldBroadcast) {
|
||||
collabSetEdges(
|
||||
edges.map(sanitizeEdgeForBroadcast),
|
||||
newEdges.map(sanitizeEdgeForBroadcast),
|
||||
)
|
||||
}
|
||||
|
||||
reactFlowSetEdges(newEdges)
|
||||
}, [store, collabSetEdges])
|
||||
|
||||
const collaborativeStore = useCallback(() => {
|
||||
const state = store.getState()
|
||||
return {
|
||||
|
||||
nodes: state.getNodes(),
|
||||
edges: state.edges,
|
||||
|
||||
setNodes,
|
||||
setEdges,
|
||||
|
||||
}
|
||||
}, [store, setNodes, setEdges])
|
||||
|
||||
return {
|
||||
getState: collaborativeStore,
|
||||
setNodes,
|
||||
setEdges,
|
||||
}
|
||||
}
|
||||
@ -7,69 +7,60 @@ import type {
|
||||
} from '../types'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { BlockEnum } from '../types'
|
||||
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
|
||||
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
import { useWorkflowHistory, WorkflowHistoryEvent } from './use-workflow-history'
|
||||
|
||||
export const useEdgesInteractions = () => {
|
||||
const store = useStoreApi()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
|
||||
const handleEdgeEnter = useCallback<EdgeMouseHandler>((_, edge) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const { edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const currentEdge = draft.find(e => e.id === edge.id)!
|
||||
|
||||
currentEdge.data._hovering = true
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
setEdges(newEdges, false)
|
||||
}, [collaborativeWorkflow, getNodesReadOnly])
|
||||
|
||||
const handleEdgeLeave = useCallback<EdgeMouseHandler>((_, edge) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const { edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const currentEdge = draft.find(e => e.id === edge.id)!
|
||||
|
||||
currentEdge.data._hovering = false
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
setEdges(newEdges, false)
|
||||
}, [collaborativeWorkflow, getNodesReadOnly])
|
||||
|
||||
const handleEdgeDeleteByDeleteBranch = useCallback((nodeId: string, branchId: string) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
} = collaborativeWorkflow.getState()
|
||||
const edgeWillBeDeleted = edges.filter(edge => edge.source === nodeId && edge.sourceHandle === branchId)
|
||||
|
||||
if (!edgeWillBeDeleted.length)
|
||||
return
|
||||
|
||||
const nodes = getNodes()
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
edgeWillBeDeleted.map(edge => ({ type: 'remove', edge })),
|
||||
nodes,
|
||||
@ -91,24 +82,23 @@ export const useEdgesInteractions = () => {
|
||||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
}, [getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
const handleEdgeDelete = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
} = collaborativeWorkflow.getState()
|
||||
const currentEdgeIndex = edges.findIndex(edge => edge.selected)
|
||||
|
||||
if (currentEdgeIndex < 0)
|
||||
return
|
||||
const currentEdge = edges[currentEdgeIndex]
|
||||
const nodes = getNodes()
|
||||
|
||||
// collect edges to delete (including corresponding real edges for temp edges)
|
||||
const edgesToDelete: Set<string> = new Set([currentEdge.id])
|
||||
@ -179,7 +169,7 @@ export const useEdgesInteractions = () => {
|
||||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
}, [getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
const handleEdgesChange = useCallback<OnEdgesChange>((changes) => {
|
||||
if (getNodesReadOnly())
|
||||
@ -188,7 +178,7 @@ export const useEdgesInteractions = () => {
|
||||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
} = collaborativeWorkflow.getState()
|
||||
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
changes.forEach((change) => {
|
||||
@ -197,7 +187,7 @@ export const useEdgesInteractions = () => {
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
}, [collaborativeWorkflow, getNodesReadOnly])
|
||||
|
||||
return {
|
||||
handleEdgeEnter,
|
||||
|
||||
168
web/app/components/workflow/hooks/use-leader-restore.ts
Normal file
168
web/app/components/workflow/hooks/use-leader-restore.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import type { RestoreCompleteData, RestoreIntentData, RestoreRequestData } from '../collaboration/types/collaboration'
|
||||
import type { SyncCallback } from './use-nodes-sync-draft'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
|
||||
type RestoreCallbacks = SyncCallback
|
||||
|
||||
export const usePerformRestore = () => {
|
||||
const { doSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const featuresStore = useFeaturesStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const reactflow = useReactFlow()
|
||||
|
||||
return useCallback((data: RestoreRequestData, callbacks?: RestoreCallbacks) => {
|
||||
collaborationManager.emitRestoreIntent({
|
||||
versionId: data.versionId,
|
||||
versionName: data.versionName,
|
||||
initiatorUserId: data.initiatorUserId,
|
||||
initiatorName: data.initiatorName,
|
||||
})
|
||||
|
||||
if (data.features && featuresStore) {
|
||||
const { setFeatures } = featuresStore.getState()
|
||||
setFeatures(data.features)
|
||||
}
|
||||
|
||||
if (data.environmentVariables) {
|
||||
workflowStore.getState().setEnvironmentVariables(data.environmentVariables)
|
||||
}
|
||||
|
||||
if (data.conversationVariables) {
|
||||
workflowStore.getState().setConversationVariables(data.conversationVariables)
|
||||
}
|
||||
|
||||
const { nodes, edges, viewport } = data.graphData
|
||||
const currentNodes = collaborationManager.getNodes()
|
||||
const currentEdges = collaborationManager.getEdges()
|
||||
|
||||
collaborationManager.setNodes(currentNodes, nodes)
|
||||
collaborationManager.setEdges(currentEdges, edges)
|
||||
collaborationManager.refreshGraphSynchronously()
|
||||
|
||||
if (viewport)
|
||||
reactflow.setViewport(viewport)
|
||||
|
||||
doSyncWorkflowDraft(false, {
|
||||
onSuccess: () => {
|
||||
collaborationManager.emitRestoreComplete({
|
||||
versionId: data.versionId,
|
||||
success: true,
|
||||
})
|
||||
|
||||
if (appDetail)
|
||||
collaborationManager.emitWorkflowUpdate(appDetail.id)
|
||||
|
||||
callbacks?.onSuccess?.()
|
||||
},
|
||||
onError: () => {
|
||||
collaborationManager.emitRestoreComplete({
|
||||
versionId: data.versionId,
|
||||
success: false,
|
||||
error: 'Failed to sync restore to server',
|
||||
})
|
||||
callbacks?.onError?.()
|
||||
},
|
||||
onSettled: () => {
|
||||
callbacks?.onSettled?.()
|
||||
},
|
||||
})
|
||||
}, [appDetail, doSyncWorkflowDraft, featuresStore, reactflow, workflowStore])
|
||||
}
|
||||
|
||||
export const useLeaderRestoreListener = () => {
|
||||
const { t } = useTranslation()
|
||||
const performRestore = usePerformRestore()
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = collaborationManager.onRestoreRequest((data: RestoreRequestData) => {
|
||||
Toast.notify({
|
||||
type: 'info',
|
||||
message: t('versionHistory.action.restoreInProgress', {
|
||||
ns: 'workflow',
|
||||
userName: data.initiatorName,
|
||||
versionName: data.versionName || data.versionId,
|
||||
}),
|
||||
duration: 3000,
|
||||
})
|
||||
performRestore(data)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [performRestore, t])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = collaborationManager.onRestoreIntent((data: RestoreIntentData) => {
|
||||
Toast.notify({
|
||||
type: 'info',
|
||||
message: t('versionHistory.action.restoreInProgress', {
|
||||
ns: 'workflow',
|
||||
userName: data.initiatorName,
|
||||
versionName: data.versionName || data.versionId,
|
||||
}),
|
||||
duration: 3000,
|
||||
})
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [t])
|
||||
}
|
||||
|
||||
export const useLeaderRestore = () => {
|
||||
const performRestore = usePerformRestore()
|
||||
const pendingCallbacksRef = useRef<{
|
||||
versionId: string
|
||||
callbacks: RestoreCallbacks | null
|
||||
} | null>(null)
|
||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
|
||||
const requestRestore = useCallback((data: RestoreRequestData, callbacks?: RestoreCallbacks) => {
|
||||
if (!isCollaborationEnabled || !collaborationManager.isConnected() || collaborationManager.getIsLeader()) {
|
||||
performRestore(data, callbacks)
|
||||
return
|
||||
}
|
||||
|
||||
pendingCallbacksRef.current = {
|
||||
versionId: data.versionId,
|
||||
callbacks: callbacks || null,
|
||||
}
|
||||
collaborationManager.emitRestoreRequest(data)
|
||||
}, [isCollaborationEnabled, performRestore])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = collaborationManager.onRestoreComplete((data: RestoreCompleteData) => {
|
||||
const pending = pendingCallbacksRef.current
|
||||
if (!pending || pending.versionId !== data.versionId)
|
||||
return
|
||||
|
||||
const callbacks = pending.callbacks
|
||||
if (!callbacks) {
|
||||
pendingCallbacksRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (data.success)
|
||||
callbacks.onSuccess?.()
|
||||
else
|
||||
callbacks.onError?.()
|
||||
|
||||
callbacks.onSettled?.()
|
||||
pendingCallbacksRef.current = null
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
return {
|
||||
requestRestore,
|
||||
}
|
||||
}
|
||||
@ -1,33 +1,30 @@
|
||||
import type { SyncCallback } from './use-nodes-sync-draft'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
|
||||
type NodeDataUpdatePayload = {
|
||||
id: string
|
||||
data: Record<string, any>
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const useNodeDataUpdate = () => {
|
||||
const store = useStoreApi()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
|
||||
const handleNodeDataUpdate = useCallback(({ id, data }: NodeDataUpdatePayload) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const newNodes = produce(getNodes(), (draft) => {
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
const currentNode = draft.find(node => node.id === id)!
|
||||
|
||||
if (currentNode)
|
||||
currentNode.data = { ...currentNode.data, ...data }
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleNodeDataUpdateWithSyncDraft = useCallback((
|
||||
payload: NodeDataUpdatePayload,
|
||||
|
||||
@ -21,8 +21,8 @@ import {
|
||||
getConnectedEdges,
|
||||
getOutgoers,
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
import {
|
||||
CUSTOM_EDGE,
|
||||
ITERATION_CHILDREN_Z_INDEX,
|
||||
@ -40,7 +40,8 @@ import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
|
||||
import { useNodeLoopInteractions } from '../nodes/loop/use-interactions'
|
||||
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { BlockEnum, isTriggerNode } from '../types'
|
||||
import { BlockEnum, ControlMode, isTriggerNode } from '../types'
|
||||
|
||||
import {
|
||||
generateNewNode,
|
||||
genNewNodeTitleFromOld,
|
||||
@ -51,6 +52,7 @@ import {
|
||||
} from '../utils'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url'
|
||||
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
|
||||
import { useHelpline } from './use-helpline'
|
||||
import useInspectVarsCrud from './use-inspect-vars-crud'
|
||||
import { checkMakeGroupAvailability } from './use-make-group'
|
||||
@ -222,7 +224,7 @@ function createGroupInboundEdges(params: {
|
||||
|
||||
export const useNodesInteractions = () => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const reactflow = useReactFlow()
|
||||
const { store: workflowHistoryStore } = useWorkflowHistoryStore()
|
||||
@ -241,7 +243,7 @@ export const useNodesInteractions = () => {
|
||||
})
|
||||
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
|
||||
|
||||
const { saveStateToHistory, undo, redo } = useWorkflowHistory()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
|
||||
|
||||
const handleNodeDragStart = useCallback<NodeDragHandler>(
|
||||
@ -284,10 +286,9 @@ export const useNodesInteractions = () => {
|
||||
if (node.type === CUSTOM_LOOP_START_NODE)
|
||||
return
|
||||
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
e.stopPropagation()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
|
||||
const { restrictPosition } = handleNodeIterationChildDrag(node)
|
||||
const { restrictPosition: restrictLoopPosition }
|
||||
@ -350,13 +351,7 @@ export const useNodesInteractions = () => {
|
||||
})
|
||||
setNodes(newNodes)
|
||||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
handleNodeIterationChildDrag,
|
||||
handleNodeLoopChildDrag,
|
||||
handleSetHelpline,
|
||||
],
|
||||
[getNodesReadOnly, collaborativeWorkflow, handleNodeIterationChildDrag, handleNodeLoopChildDrag, handleSetHelpline],
|
||||
)
|
||||
|
||||
const handleNodeDragStop = useCallback<NodeDragHandler>(
|
||||
@ -408,11 +403,11 @@ export const useNodesInteractions = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { connectingNodePayload, setEnteringNodePayload }
|
||||
= workflowStore.getState()
|
||||
|
||||
const { nodes, edges, setNodes, setEdges } = collaborativeWorkflow.getState()
|
||||
const {
|
||||
connectingNodePayload,
|
||||
setEnteringNodePayload,
|
||||
} = workflowStore.getState()
|
||||
if (connectingNodePayload) {
|
||||
if (connectingNodePayload.nodeId === node.id)
|
||||
return
|
||||
@ -451,7 +446,7 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
setNodes(newNodes, false)
|
||||
}
|
||||
}
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
@ -463,9 +458,9 @@ export const useNodesInteractions = () => {
|
||||
currentEdge.data._connectedNodeIsHovering = true
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
setEdges(newEdges, false)
|
||||
},
|
||||
[store, workflowStore, getNodesReadOnly],
|
||||
[collaborativeWorkflow, workflowStore, getNodesReadOnly],
|
||||
)
|
||||
|
||||
const handleNodeLeave = useCallback<NodeMouseHandler>(
|
||||
@ -489,21 +484,21 @@ export const useNodesInteractions = () => {
|
||||
|
||||
const { setEnteringNodePayload } = workflowStore.getState()
|
||||
setEnteringNodePayload(undefined)
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const newNodes = produce(getNodes(), (draft) => {
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
node.data._isEntering = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
setNodes(newNodes, false)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
edge.data._connectedNodeIsHovering = false
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
setEdges(newEdges, false)
|
||||
},
|
||||
[store, workflowStore, getNodesReadOnly],
|
||||
[collaborativeWorkflow, workflowStore, getNodesReadOnly],
|
||||
)
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
@ -514,9 +509,7 @@ export const useNodesInteractions = () => {
|
||||
) => {
|
||||
if (initShowLastRunTab)
|
||||
workflowStore.setState({ initShowLastRunTab: true })
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const selectedNode = nodes.find(node => node.data.selected)
|
||||
|
||||
if (!cancelSelection && selectedNode?.id === nodeId)
|
||||
@ -529,7 +522,7 @@ export const useNodesInteractions = () => {
|
||||
else node.data.selected = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
setNodes(newNodes, false)
|
||||
|
||||
const connectedEdges = getConnectedEdges(
|
||||
[{ id: nodeId } as Node],
|
||||
@ -551,15 +544,16 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
setEdges(newEdges, false)
|
||||
},
|
||||
[store, handleSyncWorkflowDraft],
|
||||
[collaborativeWorkflow],
|
||||
)
|
||||
|
||||
const handleNodeClick = useCallback<NodeMouseHandler>(
|
||||
(_, node) => {
|
||||
const { controlMode } = workflowStore.getState()
|
||||
if (controlMode === ControlMode.Comment)
|
||||
return
|
||||
if (node.type === CUSTOM_ITERATION_START_NODE)
|
||||
return
|
||||
if (node.type === CUSTOM_LOOP_START_NODE)
|
||||
@ -570,7 +564,7 @@ export const useNodesInteractions = () => {
|
||||
return
|
||||
handleNodeSelect(node.id)
|
||||
},
|
||||
[handleNodeSelect],
|
||||
[handleNodeSelect, workflowStore],
|
||||
)
|
||||
|
||||
const handleNodeConnect = useCallback<OnConnect>(
|
||||
@ -580,8 +574,7 @@ export const useNodesInteractions = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, edges, setNodes, setEdges } = collaborativeWorkflow.getState()
|
||||
const targetNode = nodes.find(node => node.id === target!)
|
||||
const sourceNode = nodes.find(node => node.id === source!)
|
||||
|
||||
@ -802,7 +795,7 @@ export const useNodesInteractions = () => {
|
||||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
workflowStore,
|
||||
handleSyncWorkflowDraft,
|
||||
saveStateToHistory,
|
||||
@ -816,8 +809,8 @@ export const useNodesInteractions = () => {
|
||||
|
||||
if (nodeId && handleType) {
|
||||
const { setConnectingNodePayload } = workflowStore.getState()
|
||||
const { getNodes } = store.getState()
|
||||
const node = getNodes().find(n => n.id === nodeId)!
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const node = nodes.find(n => n.id === nodeId)!
|
||||
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return
|
||||
@ -838,7 +831,7 @@ export const useNodesInteractions = () => {
|
||||
})
|
||||
}
|
||||
},
|
||||
[store, workflowStore, getNodesReadOnly],
|
||||
[collaborativeWorkflow, workflowStore, getNodesReadOnly],
|
||||
)
|
||||
|
||||
const handleNodeConnectEnd = useCallback<OnConnectEnd>(
|
||||
@ -856,8 +849,7 @@ export const useNodesInteractions = () => {
|
||||
const { setShowAssignVariablePopup, hoveringAssignVariableGroupId }
|
||||
= workflowStore.getState()
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const fromHandleType = connectingNodePayload.handleType
|
||||
const fromHandleId = connectingNodePayload.handleId
|
||||
const fromNode = nodes.find(
|
||||
@ -915,7 +907,7 @@ export const useNodesInteractions = () => {
|
||||
setConnectingNodePayload(undefined)
|
||||
setEnteringNodePayload(undefined)
|
||||
},
|
||||
[store, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow],
|
||||
[collaborativeWorkflow, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow],
|
||||
)
|
||||
|
||||
const { deleteNodeInspectorVars } = useInspectVarsCrud()
|
||||
@ -925,9 +917,7 @@ export const useNodesInteractions = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const currentNodeIndex = nodes.findIndex(node => node.id === nodeId)
|
||||
const currentNode = nodes[currentNodeIndex]
|
||||
|
||||
@ -1085,7 +1075,7 @@ export const useNodesInteractions = () => {
|
||||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
handleSyncWorkflowDraft,
|
||||
saveStateToHistory,
|
||||
workflowStore,
|
||||
@ -1108,12 +1098,14 @@ export const useNodesInteractions = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const nodesWithSameType = nodes.filter(
|
||||
node => node.data.type === nodeType,
|
||||
)
|
||||
const { defaultValue } = nodesMetaDataMap![nodeType]
|
||||
const nodeMetaData = nodesMetaDataMap?.[nodeType]
|
||||
if (!nodeMetaData)
|
||||
return
|
||||
const { defaultValue } = nodeMetaData
|
||||
const { newNode, newIterationStartNode, newLoopStartNode }
|
||||
= generateNewNode({
|
||||
type: getNodeCustomTypeByNodeDataType(nodeType),
|
||||
@ -1827,7 +1819,7 @@ export const useNodesInteractions = () => {
|
||||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
handleSyncWorkflowDraft,
|
||||
saveStateToHistory,
|
||||
workflowStore,
|
||||
@ -1846,14 +1838,16 @@ export const useNodesInteractions = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(node => node.id === currentNodeId)!
|
||||
const connectedEdges = getConnectedEdges([currentNode], edges)
|
||||
const nodesWithSameType = nodes.filter(
|
||||
node => node.data.type === nodeType,
|
||||
)
|
||||
const { defaultValue } = nodesMetaDataMap![nodeType]
|
||||
const nodeMetaData = nodesMetaDataMap?.[nodeType]
|
||||
if (!nodeMetaData)
|
||||
return
|
||||
const { defaultValue } = nodeMetaData
|
||||
const {
|
||||
newNode: newCurrentNode,
|
||||
newIterationStartNode,
|
||||
@ -1934,7 +1928,7 @@ export const useNodesInteractions = () => {
|
||||
},
|
||||
[
|
||||
getNodesReadOnly,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
handleSyncWorkflowDraft,
|
||||
saveStateToHistory,
|
||||
nodesMetaDataMap,
|
||||
@ -1943,16 +1937,14 @@ export const useNodesInteractions = () => {
|
||||
)
|
||||
|
||||
const handleNodesCancelSelected = useCallback(() => {
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
node.data.selected = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleNodeContextMenu = useCallback(
|
||||
(e: MouseEvent, node: Node) => {
|
||||
@ -1992,9 +1984,7 @@ export const useNodesInteractions = () => {
|
||||
|
||||
const { setClipboardElements } = workflowStore.getState()
|
||||
|
||||
const { getNodes } = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
|
||||
if (nodeId) {
|
||||
// If nodeId is provided, copy that specific node
|
||||
@ -2018,7 +2008,9 @@ export const useNodesInteractions = () => {
|
||||
return false
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return true
|
||||
const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum]
|
||||
const metaData = nodesMetaDataMap?.[node.data.type as BlockEnum]?.metaData
|
||||
if (!metaData)
|
||||
return false
|
||||
if (metaData.isSingleton)
|
||||
return false
|
||||
return !node.data.isInIteration && !node.data.isInLoop
|
||||
@ -2034,7 +2026,9 @@ export const useNodesInteractions = () => {
|
||||
return false
|
||||
if (node.type === CUSTOM_NOTE_NODE)
|
||||
return true
|
||||
const { metaData } = nodesMetaDataMap![node.data.type as BlockEnum]
|
||||
const metaData = nodesMetaDataMap?.[node.data.type as BlockEnum]?.metaData
|
||||
if (!metaData)
|
||||
return false
|
||||
return !metaData.isSingleton
|
||||
})
|
||||
|
||||
@ -2042,7 +2036,7 @@ export const useNodesInteractions = () => {
|
||||
setClipboardElements([selectedNode])
|
||||
}
|
||||
},
|
||||
[getNodesReadOnly, store, workflowStore],
|
||||
[getNodesReadOnly, collaborativeWorkflow, workflowStore],
|
||||
)
|
||||
|
||||
const handleNodesPaste = useCallback(() => {
|
||||
@ -2051,11 +2045,10 @@ export const useNodesInteractions = () => {
|
||||
|
||||
const { clipboardElements, mousePosition } = workflowStore.getState()
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
|
||||
const nodesToPaste: Node[] = []
|
||||
const edgesToPaste: Edge[] = []
|
||||
const nodes = getNodes()
|
||||
|
||||
if (clipboardElements.length) {
|
||||
const { x, y } = getTopLeftNodePosition(clipboardElements)
|
||||
@ -2070,12 +2063,15 @@ export const useNodesInteractions = () => {
|
||||
const parentChildrenToAppend: { parentId: string, childId: string, childType: BlockEnum }[] = []
|
||||
clipboardElements.forEach((nodeToPaste, index) => {
|
||||
const nodeType = nodeToPaste.data.type
|
||||
const nodeDefaultValue = nodeToPaste.type !== CUSTOM_NOTE_NODE
|
||||
? nodesMetaDataMap?.[nodeType]?.defaultValue
|
||||
: undefined
|
||||
|
||||
const { newNode, newIterationStartNode, newLoopStartNode }
|
||||
= generateNewNode({
|
||||
type: nodeToPaste.type,
|
||||
data: {
|
||||
...(nodeToPaste.type !== CUSTOM_NOTE_NODE && nodesMetaDataMap![nodeType].defaultValue),
|
||||
...(nodeDefaultValue || {}),
|
||||
...nodeToPaste.data,
|
||||
selected: false,
|
||||
_isBundled: false,
|
||||
@ -2217,7 +2213,7 @@ export const useNodesInteractions = () => {
|
||||
}, [
|
||||
getNodesReadOnly,
|
||||
workflowStore,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
reactflow,
|
||||
saveStateToHistory,
|
||||
handleSyncWorkflowDraft,
|
||||
@ -2241,9 +2237,8 @@ export const useNodesInteractions = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { getNodes, edges } = store.getState()
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const bundledNodes = nodes.filter(
|
||||
node => node.data._isBundled,
|
||||
)
|
||||
@ -2264,17 +2259,16 @@ export const useNodesInteractions = () => {
|
||||
|
||||
if (selectedNode)
|
||||
handleNodeDelete(selectedNode.id)
|
||||
}, [store, getNodesReadOnly, handleNodeDelete])
|
||||
}, [collaborativeWorkflow, getNodesReadOnly, handleNodeDelete])
|
||||
|
||||
const handleNodeResize = useCallback(
|
||||
(nodeId: string, params: ResizeParamsWithDirection) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const { x, y, width, height } = params
|
||||
|
||||
const nodes = getNodes()
|
||||
const currentNode = nodes.find(n => n.id === nodeId)!
|
||||
const childrenNodes = nodes.filter(n =>
|
||||
currentNode.data._children?.find((c: any) => c.nodeId === n.id),
|
||||
@ -2335,7 +2329,7 @@ export const useNodesInteractions = () => {
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeResize, { nodeId })
|
||||
},
|
||||
[getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory],
|
||||
[getNodesReadOnly, collaborativeWorkflow, handleSyncWorkflowDraft, saveStateToHistory],
|
||||
)
|
||||
|
||||
const handleNodeDisconnect = useCallback(
|
||||
@ -2343,8 +2337,7 @@ export const useNodesInteractions = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(node => node.id === nodeId)!
|
||||
const connectedEdges = getConnectedEdges([currentNode], edges)
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
@ -2375,25 +2368,24 @@ export const useNodesInteractions = () => {
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
|
||||
},
|
||||
[store, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory],
|
||||
[collaborativeWorkflow, getNodesReadOnly, handleSyncWorkflowDraft, saveStateToHistory],
|
||||
)
|
||||
|
||||
const handleHistoryBack = useCallback(() => {
|
||||
if (getNodesReadOnly() || getWorkflowReadOnly())
|
||||
return
|
||||
|
||||
const { setEdges, setNodes } = store.getState()
|
||||
undo()
|
||||
|
||||
// Use collaborative undo from Loro
|
||||
collaborationManager.undo()
|
||||
const { edges, nodes } = workflowHistoryStore.getState()
|
||||
if (edges.length === 0 && nodes.length === 0)
|
||||
return
|
||||
const { setNodes, setEdges } = collaborativeWorkflow.getState()
|
||||
|
||||
setEdges(edges)
|
||||
setNodes(nodes)
|
||||
}, [
|
||||
store,
|
||||
undo,
|
||||
collaborativeWorkflow,
|
||||
workflowHistoryStore,
|
||||
getNodesReadOnly,
|
||||
getWorkflowReadOnly,
|
||||
@ -2403,18 +2395,17 @@ export const useNodesInteractions = () => {
|
||||
if (getNodesReadOnly() || getWorkflowReadOnly())
|
||||
return
|
||||
|
||||
const { setEdges, setNodes } = store.getState()
|
||||
redo()
|
||||
|
||||
// Use collaborative redo from Loro
|
||||
collaborationManager.redo()
|
||||
const { edges, nodes } = workflowHistoryStore.getState()
|
||||
if (edges.length === 0 && nodes.length === 0)
|
||||
return
|
||||
const { setNodes, setEdges } = collaborativeWorkflow.getState()
|
||||
|
||||
setEdges(edges)
|
||||
setNodes(nodes)
|
||||
}, [
|
||||
redo,
|
||||
store,
|
||||
collaborativeWorkflow,
|
||||
workflowHistoryStore,
|
||||
getNodesReadOnly,
|
||||
getWorkflowReadOnly,
|
||||
@ -2425,8 +2416,7 @@ export const useNodesInteractions = () => {
|
||||
const dimOtherNodes = useCallback(() => {
|
||||
if (isDimming)
|
||||
return
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
|
||||
const selectedNode = nodes.find(n => n.data.selected)
|
||||
if (!selectedNode)
|
||||
@ -2525,12 +2515,11 @@ export const useNodesInteractions = () => {
|
||||
draft.push(...tempEdges)
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [isDimming, store])
|
||||
}, [isDimming, collaborativeWorkflow])
|
||||
|
||||
/** Restore all nodes to full opacity */
|
||||
const undimAllNodes = useCallback(() => {
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
setIsDimming(false)
|
||||
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
@ -2550,18 +2539,16 @@ export const useNodesInteractions = () => {
|
||||
},
|
||||
)
|
||||
setEdges(newEdges)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
// Check if there are any nodes selected via box selection
|
||||
const hasBundledNodes = useCallback(() => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
return nodes.some(node => node.data._isBundled)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const getCanMakeGroup = useCallback(() => {
|
||||
const { getNodes, edges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
const bundledNodes = nodes.filter(node => node.data._isBundled)
|
||||
|
||||
if (bundledNodes.length <= 1)
|
||||
@ -2578,11 +2565,10 @@ export const useNodesInteractions = () => {
|
||||
|
||||
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode)
|
||||
return canMakeGroup
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleMakeGroup = useCallback(() => {
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const bundledNodes = nodes.filter(node => node.data._isBundled)
|
||||
|
||||
if (bundledNodes.length <= 1)
|
||||
@ -2771,35 +2757,32 @@ export const useNodesInteractions = () => {
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, {
|
||||
nodeId: groupNode.id,
|
||||
})
|
||||
}, [handleSyncWorkflowDraft, saveStateToHistory, store, t, workflowStore])
|
||||
}, [handleSyncWorkflowDraft, saveStateToHistory, collaborativeWorkflow, t, workflowStore])
|
||||
|
||||
// check if the current selection can be ungrouped (single selected Group node)
|
||||
const getCanUngroup = useCallback(() => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const selectedNodes = nodes.filter(node => node.selected)
|
||||
|
||||
if (selectedNodes.length !== 1)
|
||||
return false
|
||||
|
||||
return selectedNodes[0].data.type === BlockEnum.Group
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
// get the selected group node id for ungroup operation
|
||||
const getSelectedGroupId = useCallback(() => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const selectedNodes = nodes.filter(node => node.selected)
|
||||
|
||||
if (selectedNodes.length === 1 && selectedNodes[0].data.type === BlockEnum.Group)
|
||||
return selectedNodes[0].id
|
||||
|
||||
return undefined
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleUngroup = useCallback((groupId: string) => {
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const groupNode = nodes.find(n => n.id === groupId)
|
||||
|
||||
if (!groupNode || groupNode.data.type !== BlockEnum.Group)
|
||||
@ -2846,7 +2829,7 @@ export const useNodesInteractions = () => {
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeDelete, {
|
||||
nodeId: groupId,
|
||||
})
|
||||
}, [handleSyncWorkflowDraft, saveStateToHistory, store])
|
||||
}, [handleSyncWorkflowDraft, saveStateToHistory, collaborativeWorkflow])
|
||||
|
||||
return {
|
||||
handleNodeDragStart,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import { collaborationManager } from '../collaboration/core/collaboration-manager'
|
||||
import { useStore } from '../store'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
|
||||
@ -18,11 +19,21 @@ export const useNodesSyncDraft = () => {
|
||||
const handleSyncWorkflowDraft = useCallback((
|
||||
sync?: boolean,
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: SyncCallback,
|
||||
callback?: {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
},
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
if (collaborationManager.isConnected() && !collaborationManager.getIsLeader()) {
|
||||
if (sync)
|
||||
collaborationManager.emitSyncRequest()
|
||||
return
|
||||
}
|
||||
|
||||
if (sync)
|
||||
doSyncWorkflowDraft(notRefreshWhenSyncError, callback)
|
||||
else
|
||||
|
||||
@ -41,6 +41,8 @@ export const useShortcuts = (enabled = true): void => {
|
||||
const {
|
||||
handleModeHand,
|
||||
handleModePointer,
|
||||
handleModeComment,
|
||||
isCommentModeAvailable,
|
||||
} = useWorkflowMoveMode()
|
||||
const { handleLayout } = useWorkflowOrganize()
|
||||
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
|
||||
@ -182,6 +184,16 @@ export const useShortcuts = (enabled = true): void => {
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('c', (e) => {
|
||||
if (shouldHandleShortcut(e) && isCommentModeAvailable) {
|
||||
e.preventDefault()
|
||||
handleModeComment()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.o`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
|
||||
535
web/app/components/workflow/hooks/use-workflow-comment.ts
Normal file
535
web/app/components/workflow/hooks/use-workflow-comment.ts
Normal file
@ -0,0 +1,535 @@
|
||||
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment'
|
||||
import { useStore } from '../store'
|
||||
import { ControlMode } from '../types'
|
||||
|
||||
const EMPTY_USERS: UserProfile[] = []
|
||||
type CommentDetailResponse = WorkflowCommentDetail | { data: WorkflowCommentDetail }
|
||||
|
||||
const getCommentDetail = (response: CommentDetailResponse): WorkflowCommentDetail => {
|
||||
if ('data' in response)
|
||||
return response.data
|
||||
return response
|
||||
}
|
||||
|
||||
export const useWorkflowComment = () => {
|
||||
const params = useParams()
|
||||
const appId = params.appId as string
|
||||
const reactflow = useReactFlow()
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const pendingComment = useStore(s => s.pendingComment)
|
||||
const setPendingComment = useStore(s => s.setPendingComment)
|
||||
const setActiveCommentId = useStore(s => s.setActiveCommentId)
|
||||
const activeCommentId = useStore(s => s.activeCommentId)
|
||||
const comments = useStore(s => s.comments)
|
||||
const setComments = useStore(s => s.setComments)
|
||||
const loading = useStore(s => s.commentsLoading)
|
||||
const setCommentsLoading = useStore(s => s.setCommentsLoading)
|
||||
const activeComment = useStore(s => s.activeCommentDetail)
|
||||
const setActiveComment = useStore(s => s.setActiveCommentDetail)
|
||||
const activeCommentLoading = useStore(s => s.activeCommentDetailLoading)
|
||||
const setActiveCommentLoading = useStore(s => s.setActiveCommentDetailLoading)
|
||||
const replySubmitting = useStore(s => s.replySubmitting)
|
||||
const setReplySubmitting = useStore(s => s.setReplySubmitting)
|
||||
const replyUpdating = useStore(s => s.replyUpdating)
|
||||
const setReplyUpdating = useStore(s => s.setReplyUpdating)
|
||||
const commentDetailCache = useStore(s => s.commentDetailCache)
|
||||
const setCommentDetailCache = useStore(s => s.setCommentDetailCache)
|
||||
const rightPanelWidth = useStore(s => s.rightPanelWidth)
|
||||
const nodePanelWidth = useStore(s => s.nodePanelWidth)
|
||||
const mentionableUsers = useStore(state => (
|
||||
appId ? state.mentionableUsersCache[appId] ?? EMPTY_USERS : EMPTY_USERS
|
||||
))
|
||||
const { userProfile } = useAppContext()
|
||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
const commentDetailCacheRef = useRef<Record<string, WorkflowCommentDetail>>(commentDetailCache)
|
||||
const activeCommentIdRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
activeCommentIdRef.current = activeCommentId ?? null
|
||||
}, [activeCommentId])
|
||||
|
||||
useEffect(() => {
|
||||
commentDetailCacheRef.current = commentDetailCache
|
||||
}, [commentDetailCache])
|
||||
|
||||
const refreshActiveComment = useCallback(async (commentId: string) => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const detailResponse = await fetchWorkflowComment(appId, commentId) as CommentDetailResponse
|
||||
const detail = getCommentDetail(detailResponse)
|
||||
|
||||
commentDetailCacheRef.current = {
|
||||
...commentDetailCacheRef.current,
|
||||
[commentId]: detail,
|
||||
}
|
||||
setCommentDetailCache(commentDetailCacheRef.current)
|
||||
setActiveComment(detail)
|
||||
}, [appId, setActiveComment, setCommentDetailCache])
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
if (!appId || !isCollaborationEnabled)
|
||||
return
|
||||
|
||||
setCommentsLoading(true)
|
||||
try {
|
||||
const commentsData = await fetchWorkflowComments(appId)
|
||||
setComments(commentsData)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch comments:', error)
|
||||
}
|
||||
finally {
|
||||
setCommentsLoading(false)
|
||||
}
|
||||
}, [appId, isCollaborationEnabled, setComments, setCommentsLoading])
|
||||
|
||||
// Setup collaboration
|
||||
useEffect(() => {
|
||||
if (!appId || !isCollaborationEnabled)
|
||||
return
|
||||
|
||||
const unsubscribe = collaborationManager.onCommentsUpdate(() => {
|
||||
loadComments()
|
||||
if (activeCommentIdRef.current)
|
||||
refreshActiveComment(activeCommentIdRef.current)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [appId, isCollaborationEnabled, loadComments, refreshActiveComment])
|
||||
|
||||
useEffect(() => {
|
||||
loadComments()
|
||||
}, [loadComments])
|
||||
|
||||
const handleCommentSubmit = useCallback(async (content: string, mentionedUserIds: string[] = []) => {
|
||||
if (!pendingComment)
|
||||
return
|
||||
|
||||
if (!appId) {
|
||||
console.error('AppId is missing')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert screen position to flow position when submitting
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const flowPosition = screenToFlowPosition({
|
||||
x: pendingComment.pageX,
|
||||
y: pendingComment.pageY,
|
||||
})
|
||||
|
||||
const newComment = await createWorkflowComment(appId, {
|
||||
position_x: flowPosition.x,
|
||||
position_y: flowPosition.y,
|
||||
content,
|
||||
mentioned_user_ids: mentionedUserIds,
|
||||
})
|
||||
|
||||
const createdAt = Number(newComment.created_at)
|
||||
const createdAtSeconds = Number.isNaN(createdAt)
|
||||
? Math.floor(Date.parse(newComment.created_at) / 1000)
|
||||
: createdAt
|
||||
const createdByAccount = {
|
||||
id: userProfile?.id ?? '',
|
||||
name: userProfile?.name ?? '',
|
||||
email: userProfile?.email ?? '',
|
||||
avatar_url: userProfile?.avatar_url || userProfile?.avatar || undefined,
|
||||
}
|
||||
const mentionedUsers = mentionedUserIds
|
||||
.map(mentionedId => mentionableUsers.find(user => user.id === mentionedId))
|
||||
.filter((user): user is NonNullable<typeof user> => Boolean(user))
|
||||
const uniqueParticipantsMap = new Map<string, typeof createdByAccount>()
|
||||
if (createdByAccount.id)
|
||||
uniqueParticipantsMap.set(createdByAccount.id, createdByAccount)
|
||||
for (const user of mentionedUsers) {
|
||||
if (!uniqueParticipantsMap.has(user.id)) {
|
||||
uniqueParticipantsMap.set(user.id, {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatar_url: user.avatar_url,
|
||||
})
|
||||
}
|
||||
}
|
||||
const participants = Array.from(uniqueParticipantsMap.values())
|
||||
|
||||
const composedComment: WorkflowCommentList = {
|
||||
id: newComment.id,
|
||||
position_x: flowPosition.x,
|
||||
position_y: flowPosition.y,
|
||||
content,
|
||||
created_by: createdByAccount.id,
|
||||
created_by_account: createdByAccount,
|
||||
created_at: createdAtSeconds,
|
||||
updated_at: createdAtSeconds,
|
||||
resolved: false,
|
||||
mention_count: mentionedUserIds.length,
|
||||
reply_count: 0,
|
||||
participants,
|
||||
}
|
||||
|
||||
const composedDetail: WorkflowCommentDetail = {
|
||||
id: newComment.id,
|
||||
position_x: flowPosition.x,
|
||||
position_y: flowPosition.y,
|
||||
content,
|
||||
created_by: createdByAccount.id,
|
||||
created_by_account: createdByAccount,
|
||||
created_at: createdAtSeconds,
|
||||
updated_at: createdAtSeconds,
|
||||
resolved: false,
|
||||
replies: [],
|
||||
mentions: mentionedUserIds.map(mentionedId => ({
|
||||
mentioned_user_id: mentionedId,
|
||||
mentioned_user_account: mentionableUsers.find(user => user.id === mentionedId) ?? null,
|
||||
reply_id: null,
|
||||
})),
|
||||
}
|
||||
|
||||
setComments([...comments, composedComment])
|
||||
commentDetailCacheRef.current = {
|
||||
...commentDetailCacheRef.current,
|
||||
[newComment.id]: composedDetail,
|
||||
}
|
||||
setCommentDetailCache(commentDetailCacheRef.current)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
setPendingComment(null)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to create comment:', error)
|
||||
setPendingComment(null)
|
||||
}
|
||||
}, [appId, pendingComment, setPendingComment, reactflow, comments, setComments, userProfile, setCommentDetailCache, mentionableUsers])
|
||||
|
||||
const handleCommentCancel = useCallback(() => {
|
||||
setPendingComment(null)
|
||||
}, [setPendingComment])
|
||||
|
||||
useEffect(() => {
|
||||
if (controlMode !== ControlMode.Comment)
|
||||
setPendingComment(null)
|
||||
}, [controlMode, setPendingComment])
|
||||
|
||||
const handleCommentIconClick = useCallback(async (comment: WorkflowCommentList) => {
|
||||
setPendingComment(null)
|
||||
|
||||
activeCommentIdRef.current = comment.id
|
||||
setActiveCommentId(comment.id)
|
||||
|
||||
const cachedDetail = commentDetailCacheRef.current[comment.id]
|
||||
setActiveComment(cachedDetail || comment)
|
||||
|
||||
const hasSelectedNode = reactflow.getNodes().some(node => node.data?.selected)
|
||||
const commentPanelWidth = controlMode === ControlMode.Comment ? 420 : 0
|
||||
const fallbackPanelWidth = (hasSelectedNode ? nodePanelWidth : 0) + commentPanelWidth
|
||||
const effectivePanelWidth = Math.max(rightPanelWidth ?? 0, fallbackPanelWidth)
|
||||
|
||||
const baseHorizontalOffsetPx = 220
|
||||
const panelCompensationPx = effectivePanelWidth / 2
|
||||
const desiredHorizontalOffsetPx = baseHorizontalOffsetPx + panelCompensationPx
|
||||
const maxOffset = Math.max(0, (window.innerWidth / 2) - 60)
|
||||
const horizontalOffsetPx = Math.min(desiredHorizontalOffsetPx, maxOffset)
|
||||
|
||||
reactflow.setCenter(
|
||||
comment.position_x + horizontalOffsetPx,
|
||||
comment.position_y,
|
||||
{ zoom: 1, duration: 600 },
|
||||
)
|
||||
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
setActiveCommentLoading(!cachedDetail)
|
||||
|
||||
try {
|
||||
const detailResponse = await fetchWorkflowComment(appId, comment.id) as CommentDetailResponse
|
||||
const detail = getCommentDetail(detailResponse)
|
||||
|
||||
commentDetailCacheRef.current = {
|
||||
...commentDetailCacheRef.current,
|
||||
[comment.id]: detail,
|
||||
}
|
||||
setCommentDetailCache(commentDetailCacheRef.current)
|
||||
|
||||
if (activeCommentIdRef.current === comment.id)
|
||||
setActiveComment(detail)
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Failed to load workflow comment detail', e)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [
|
||||
appId,
|
||||
controlMode,
|
||||
nodePanelWidth,
|
||||
reactflow,
|
||||
rightPanelWidth,
|
||||
setActiveComment,
|
||||
setActiveCommentId,
|
||||
setActiveCommentLoading,
|
||||
setCommentDetailCache,
|
||||
setPendingComment,
|
||||
])
|
||||
|
||||
const handleCommentResolve = useCallback(async (commentId: string) => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
setActiveCommentLoading(true)
|
||||
try {
|
||||
await resolveWorkflowComment(appId, commentId)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await refreshActiveComment(commentId)
|
||||
await loadComments()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to resolve comment:', error)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
||||
|
||||
const handleCommentDelete = useCallback(async (commentId: string) => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
setActiveCommentLoading(true)
|
||||
try {
|
||||
await deleteWorkflowComment(appId, commentId)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
const updatedCache = { ...commentDetailCacheRef.current }
|
||||
delete updatedCache[commentId]
|
||||
commentDetailCacheRef.current = updatedCache
|
||||
setCommentDetailCache(updatedCache)
|
||||
|
||||
const currentComments = comments.filter(c => c.id !== commentId)
|
||||
const commentIndex = comments.findIndex(c => c.id === commentId)
|
||||
const fallbackTarget = commentIndex >= 0 ? comments[commentIndex + 1] ?? comments[commentIndex - 1] : undefined
|
||||
|
||||
await loadComments()
|
||||
|
||||
if (fallbackTarget) {
|
||||
handleCommentIconClick(fallbackTarget)
|
||||
}
|
||||
else if (currentComments.length > 0) {
|
||||
const nextComment = currentComments[0]
|
||||
handleCommentIconClick(nextComment)
|
||||
}
|
||||
else {
|
||||
setActiveComment(null)
|
||||
setActiveCommentId(null)
|
||||
activeCommentIdRef.current = null
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to delete comment:', error)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, comments, handleCommentIconClick, loadComments, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache])
|
||||
|
||||
const handleCommentPositionUpdate = useCallback(async (commentId: string, position: { x: number, y: number }) => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const targetComment = comments.find(c => c.id === commentId)
|
||||
if (!targetComment)
|
||||
return
|
||||
|
||||
const nextPosition = {
|
||||
position_x: position.x,
|
||||
position_y: position.y,
|
||||
}
|
||||
|
||||
const previousComments = comments
|
||||
const updatedComments = comments.map(c =>
|
||||
c.id === commentId
|
||||
? { ...c, ...nextPosition }
|
||||
: c,
|
||||
)
|
||||
setComments(updatedComments)
|
||||
|
||||
const cachedDetail = commentDetailCacheRef.current[commentId]
|
||||
const updatedDetail = cachedDetail ? { ...cachedDetail, ...nextPosition } : null
|
||||
if (updatedDetail) {
|
||||
commentDetailCacheRef.current = {
|
||||
...commentDetailCacheRef.current,
|
||||
[commentId]: updatedDetail,
|
||||
}
|
||||
setCommentDetailCache(commentDetailCacheRef.current)
|
||||
|
||||
if (activeCommentIdRef.current === commentId)
|
||||
setActiveComment(updatedDetail)
|
||||
}
|
||||
else if (activeComment?.id === commentId) {
|
||||
setActiveComment({ ...activeComment, ...nextPosition })
|
||||
}
|
||||
|
||||
try {
|
||||
await updateWorkflowComment(appId, commentId, {
|
||||
content: targetComment.content,
|
||||
position_x: nextPosition.position_x,
|
||||
position_y: nextPosition.position_y,
|
||||
})
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to update comment position:', error)
|
||||
setComments(previousComments)
|
||||
|
||||
if (cachedDetail) {
|
||||
commentDetailCacheRef.current = {
|
||||
...commentDetailCacheRef.current,
|
||||
[commentId]: cachedDetail,
|
||||
}
|
||||
setCommentDetailCache(commentDetailCacheRef.current)
|
||||
|
||||
if (activeCommentIdRef.current === commentId)
|
||||
setActiveComment(cachedDetail)
|
||||
}
|
||||
else if (activeComment?.id === commentId) {
|
||||
setActiveComment(activeComment)
|
||||
}
|
||||
}
|
||||
}, [activeComment, appId, comments, setComments, setCommentDetailCache, setActiveComment])
|
||||
|
||||
const handleCommentReply = useCallback(async (commentId: string, content: string, mentionedUserIds: string[] = []) => {
|
||||
if (!appId)
|
||||
return
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed)
|
||||
return
|
||||
|
||||
setReplySubmitting(true)
|
||||
try {
|
||||
await createWorkflowCommentReply(appId, commentId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await refreshActiveComment(commentId)
|
||||
await loadComments()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to create reply:', error)
|
||||
}
|
||||
finally {
|
||||
setReplySubmitting(false)
|
||||
}
|
||||
}, [appId, loadComments, refreshActiveComment, setReplySubmitting])
|
||||
|
||||
const handleCommentReplyUpdate = useCallback(async (commentId: string, replyId: string, content: string, mentionedUserIds: string[] = []) => {
|
||||
if (!appId)
|
||||
return
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed)
|
||||
return
|
||||
|
||||
setReplyUpdating(true)
|
||||
try {
|
||||
await updateWorkflowCommentReply(appId, commentId, replyId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await refreshActiveComment(commentId)
|
||||
await loadComments()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to update reply:', error)
|
||||
}
|
||||
finally {
|
||||
setReplyUpdating(false)
|
||||
}
|
||||
}, [appId, loadComments, refreshActiveComment, setReplyUpdating])
|
||||
|
||||
const handleCommentReplyDelete = useCallback(async (commentId: string, replyId: string) => {
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
setActiveCommentLoading(true)
|
||||
try {
|
||||
await deleteWorkflowCommentReply(appId, commentId, replyId)
|
||||
|
||||
collaborationManager.emitCommentsUpdate(appId)
|
||||
|
||||
await refreshActiveComment(commentId)
|
||||
await loadComments()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to delete reply:', error)
|
||||
}
|
||||
finally {
|
||||
setActiveCommentLoading(false)
|
||||
}
|
||||
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
|
||||
|
||||
const handleCommentNavigate = useCallback((direction: 'prev' | 'next') => {
|
||||
const currentId = activeCommentIdRef.current
|
||||
if (!currentId)
|
||||
return
|
||||
const idx = comments.findIndex(c => c.id === currentId)
|
||||
if (idx === -1)
|
||||
return
|
||||
const target = direction === 'prev' ? comments[idx - 1] : comments[idx + 1]
|
||||
if (target)
|
||||
handleCommentIconClick(target)
|
||||
}, [comments, handleCommentIconClick])
|
||||
|
||||
const handleActiveCommentClose = useCallback(() => {
|
||||
setActiveComment(null)
|
||||
setActiveCommentLoading(false)
|
||||
setActiveCommentId(null)
|
||||
activeCommentIdRef.current = null
|
||||
}, [setActiveComment, setActiveCommentId, setActiveCommentLoading])
|
||||
|
||||
const handleCreateComment = useCallback((mousePosition: {
|
||||
pageX: number
|
||||
pageY: number
|
||||
elementX: number
|
||||
elementY: number
|
||||
}) => {
|
||||
if (controlMode === ControlMode.Comment)
|
||||
setPendingComment(mousePosition)
|
||||
}, [controlMode, setPendingComment])
|
||||
|
||||
return {
|
||||
comments,
|
||||
loading,
|
||||
pendingComment,
|
||||
activeComment,
|
||||
activeCommentLoading,
|
||||
replySubmitting,
|
||||
replyUpdating,
|
||||
handleCommentSubmit,
|
||||
handleCommentCancel,
|
||||
handleCommentIconClick,
|
||||
handleActiveCommentClose,
|
||||
handleCommentResolve,
|
||||
handleCommentDelete,
|
||||
handleCommentNavigate,
|
||||
handleCommentReply,
|
||||
handleCommentReplyUpdate,
|
||||
handleCommentReplyDelete,
|
||||
handleCommentPositionUpdate,
|
||||
refreshActiveComment,
|
||||
handleCreateComment,
|
||||
loadComments,
|
||||
}
|
||||
}
|
||||
@ -4,8 +4,11 @@ import { produce } from 'immer'
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useReactFlow, useStoreApi } from 'reactflow'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
NODE_LAYOUT_HORIZONTAL_PADDING,
|
||||
@ -55,6 +58,9 @@ export const useWorkflowMoveMode = () => {
|
||||
getNodesReadOnly,
|
||||
} = useNodesReadOnly()
|
||||
const { handleSelectionCancel } = useSelectionInteractions()
|
||||
const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const isCommentModeAvailable = isCollaborationEnabled && (appDetail?.mode === 'workflow' || appDetail?.mode === 'advanced-chat')
|
||||
|
||||
const handleModePointer = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
@ -71,31 +77,40 @@ export const useWorkflowMoveMode = () => {
|
||||
handleSelectionCancel()
|
||||
}, [getNodesReadOnly, setControlMode, handleSelectionCancel])
|
||||
|
||||
const handleModeComment = useCallback(() => {
|
||||
if (getNodesReadOnly() || !isCommentModeAvailable)
|
||||
return
|
||||
|
||||
setControlMode(ControlMode.Comment)
|
||||
handleSelectionCancel()
|
||||
}, [getNodesReadOnly, setControlMode, handleSelectionCancel, isCommentModeAvailable])
|
||||
|
||||
return {
|
||||
handleModePointer,
|
||||
handleModeHand,
|
||||
handleModeComment,
|
||||
isCommentModeAvailable,
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowOrganize = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useStoreApi()
|
||||
const reactflow = useReactFlow()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
|
||||
const handleLayout = useCallback(async () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
workflowStore.setState({ nodeAnimation: true })
|
||||
const {
|
||||
getNodes,
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
} = collaborativeWorkflow.getState()
|
||||
const { setViewport } = reactflow
|
||||
const nodes = getNodes()
|
||||
|
||||
const loopAndIterationNodes = nodes.filter(
|
||||
node => (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration)
|
||||
@ -233,7 +248,7 @@ export const useWorkflowOrganize = () => {
|
||||
setTimeout(() => {
|
||||
handleSyncWorkflowDraft()
|
||||
})
|
||||
}, [getNodesReadOnly, store, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
}, [getNodesReadOnly, collaborativeWorkflow, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
return {
|
||||
handleLayout,
|
||||
|
||||
@ -16,9 +16,9 @@ import {
|
||||
import {
|
||||
getIncomers,
|
||||
getOutgoers,
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -38,6 +38,7 @@ import {
|
||||
getWorkflowEntryNode,
|
||||
isWorkflowEntryNode,
|
||||
} from '../utils/workflow-entry'
|
||||
|
||||
import { useAvailableBlocks } from './use-available-blocks'
|
||||
|
||||
export const useIsChatMode = () => {
|
||||
@ -47,26 +48,18 @@ export const useIsChatMode = () => {
|
||||
}
|
||||
|
||||
export const useWorkflow = () => {
|
||||
const store = useStoreApi()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
const { getAvailableBlocks } = useAvailableBlocks()
|
||||
const { nodesMap } = useNodesMetaData()
|
||||
|
||||
const getNodeById = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(node => node.id === nodeId)
|
||||
return currentNode
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const getTreeLeafNodes = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
// let startNode = getWorkflowEntryNode(nodes)
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(node => node.id === nodeId)
|
||||
|
||||
let startNodes = nodes.filter(node => nodesMap?.[node.data.type as BlockEnum]?.metaData.isStart) || []
|
||||
@ -109,14 +102,11 @@ export const useWorkflow = () => {
|
||||
return uniqBy(list, 'id').filter((item: Node) => {
|
||||
return SUPPORT_OUTPUT_VARS_NODE.includes(item.data.type)
|
||||
})
|
||||
}, [store, nodesMap])
|
||||
}, [collaborativeWorkflow, nodesMap])
|
||||
|
||||
const getBeforeNodesInSameBranch = useCallback((nodeId: string, newNodes?: Node[], newEdges?: Edge[]) => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
const nodes = newNodes || getNodes()
|
||||
const { nodes: oldNodes, edges } = collaborativeWorkflow.getState()
|
||||
const nodes = newNodes || oldNodes
|
||||
const currentNode = nodes.find(node => node.id === nodeId)
|
||||
|
||||
const list: Node[] = []
|
||||
@ -159,14 +149,11 @@ export const useWorkflow = () => {
|
||||
}
|
||||
|
||||
return []
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const getBeforeNodesInSameBranchIncludeParent = useCallback((nodeId: string, newNodes?: Node[], newEdges?: Edge[]) => {
|
||||
const nodes = getBeforeNodesInSameBranch(nodeId, newNodes, newEdges)
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
const allNodes = getNodes()
|
||||
const { nodes: allNodes } = collaborativeWorkflow.getState()
|
||||
const node = allNodes.find(n => n.id === nodeId)
|
||||
const parentNodeId = node?.parentId
|
||||
const parentNode = allNodes.find(n => n.id === parentNodeId)
|
||||
@ -174,14 +161,10 @@ export const useWorkflow = () => {
|
||||
nodes.push(parentNode)
|
||||
|
||||
return nodes
|
||||
}, [getBeforeNodesInSameBranch, store])
|
||||
}, [getBeforeNodesInSameBranch, collaborativeWorkflow])
|
||||
|
||||
const getAfterNodesInSameBranch = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(node => node.id === nodeId)!
|
||||
|
||||
if (!currentNode)
|
||||
@ -205,40 +188,29 @@ export const useWorkflow = () => {
|
||||
})
|
||||
|
||||
return uniqBy(list, 'id')
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const getBeforeNodeById = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
const node = nodes.find(node => node.id === nodeId)!
|
||||
|
||||
return getIncomers(node, nodes, edges)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const getIterationNodeChildren = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
|
||||
return nodes.filter(node => node.parentId === nodeId)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const getLoopNodeChildren = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
|
||||
return nodes.filter(node => node.parentId === nodeId)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const isFromStartNode = useCallback((nodeId: string) => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(node => node.id === nodeId)
|
||||
|
||||
if (!currentNode)
|
||||
@ -261,11 +233,10 @@ export const useWorkflow = () => {
|
||||
}
|
||||
|
||||
return checkPreviousNodes(currentNode)
|
||||
}, [store, getBeforeNodeById])
|
||||
}, [collaborativeWorkflow, getBeforeNodeById])
|
||||
|
||||
const handleOutVarRenameChange = useCallback((nodeId: string, oldValeSelector: ValueSelector, newVarSelector: ValueSelector) => {
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
const allNodes = getNodes()
|
||||
const { nodes: allNodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const affectedNodes = findUsedVarNodes(oldValeSelector, allNodes)
|
||||
if (affectedNodes.length > 0) {
|
||||
const newNodes = allNodes.map((node) => {
|
||||
@ -276,7 +247,7 @@ export const useWorkflow = () => {
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const isVarUsedInNodes = useCallback((varSelector: ValueSelector) => {
|
||||
const nodeId = varSelector[0]
|
||||
@ -287,11 +258,11 @@ export const useWorkflow = () => {
|
||||
|
||||
const removeUsedVarInNodes = useCallback((varSelector: ValueSelector) => {
|
||||
const nodeId = varSelector[0]
|
||||
const { getNodes, setNodes } = store.getState()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
const afterNodes = getAfterNodesInSameBranch(nodeId)
|
||||
const effectNodes = findUsedVarNodes(varSelector, afterNodes)
|
||||
if (effectNodes.length > 0) {
|
||||
const newNodes = getNodes().map((node) => {
|
||||
const newNodes = nodes.map((node) => {
|
||||
if (effectNodes.find(n => n.id === node.id))
|
||||
return updateNodeVars(node, varSelector, [])
|
||||
|
||||
@ -299,7 +270,7 @@ export const useWorkflow = () => {
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}
|
||||
}, [getAfterNodesInSameBranch, store])
|
||||
}, [getAfterNodesInSameBranch, collaborativeWorkflow])
|
||||
|
||||
const isNodeVarsUsedInNodes = useCallback((node: Node, isChatMode: boolean) => {
|
||||
const outputVars = getNodeOutputVars(node, isChatMode)
|
||||
@ -310,11 +281,7 @@ export const useWorkflow = () => {
|
||||
}, [isVarUsedInNodes])
|
||||
|
||||
const getRootNodesById = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(node => node.id === nodeId)
|
||||
|
||||
const rootNodes: Node[] = []
|
||||
@ -354,7 +321,7 @@ export const useWorkflow = () => {
|
||||
return uniqBy(rootNodes, 'id')
|
||||
|
||||
return []
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const getStartNodes = useCallback((nodes: Node[], currentNode?: Node) => {
|
||||
const { id, parentId } = currentNode || {}
|
||||
@ -380,11 +347,7 @@ export const useWorkflow = () => {
|
||||
}, [nodesMap, getRootNodesById])
|
||||
|
||||
const isValidConnection = useCallback(({ source, sourceHandle, target }: Connection) => {
|
||||
const {
|
||||
edges,
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
const sourceNode: Node = nodes.find(node => node.id === source)!
|
||||
const targetNode: Node = nodes.find(node => node.id === target)!
|
||||
|
||||
@ -447,14 +410,13 @@ export const useWorkflow = () => {
|
||||
}
|
||||
|
||||
return !hasCycle(targetNode)
|
||||
}, [store, getAvailableBlocks])
|
||||
}, [collaborativeWorkflow, getAvailableBlocks])
|
||||
|
||||
const getNode = useCallback((nodeId?: string) => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
|
||||
return nodes.find(node => node.id === nodeId) || getWorkflowEntryNode(nodes)
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
return {
|
||||
getNodeById,
|
||||
@ -510,13 +472,10 @@ export const useNodesReadOnly = () => {
|
||||
}
|
||||
|
||||
export const useIsNodeInIteration = (iterationId: string) => {
|
||||
const store = useStoreApi()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
|
||||
const isNodeInIteration = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const node = nodes.find(node => node.id === nodeId)
|
||||
|
||||
if (!node)
|
||||
@ -526,20 +485,17 @@ export const useIsNodeInIteration = (iterationId: string) => {
|
||||
return true
|
||||
|
||||
return false
|
||||
}, [iterationId, store])
|
||||
}, [iterationId, collaborativeWorkflow])
|
||||
return {
|
||||
isNodeInIteration,
|
||||
}
|
||||
}
|
||||
|
||||
export const useIsNodeInLoop = (loopId: string) => {
|
||||
const store = useStoreApi()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
|
||||
const isNodeInLoop = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const node = nodes.find(node => node.id === nodeId)
|
||||
|
||||
if (!node)
|
||||
@ -550,7 +506,7 @@ export const useIsNodeInLoop = (loopId: string) => {
|
||||
return true
|
||||
|
||||
return false
|
||||
}, [loopId, store])
|
||||
}, [loopId, collaborativeWorkflow])
|
||||
return {
|
||||
isNodeInLoop,
|
||||
}
|
||||
|
||||
@ -5,9 +5,13 @@ import type {
|
||||
NodeMouseHandler,
|
||||
Viewport,
|
||||
} from 'reactflow'
|
||||
import type { CursorPosition, OnlineUser } from './collaboration/types'
|
||||
import type { Shape as HooksStoreShape } from './hooks-store'
|
||||
import type { WorkflowSliceShape } from './store/workflow/workflow-slice'
|
||||
import type {
|
||||
ConversationVariable,
|
||||
Edge,
|
||||
EnvironmentVariable,
|
||||
Node,
|
||||
} from './types'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
@ -18,6 +22,7 @@ import { isEqual } from 'es-toolkit/predicate'
|
||||
import { setAutoFreeze } from 'immer'
|
||||
import dynamic from 'next/dynamic'
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@ -25,6 +30,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
ReactFlowProvider,
|
||||
@ -47,6 +53,10 @@ import {
|
||||
import { fetchAllInspectVars } from '@/service/workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import CandidateNode from './candidate-node'
|
||||
import { collaborationManager } from './collaboration'
|
||||
import UserCursors from './collaboration/components/user-cursors'
|
||||
import { CommentCursor, CommentIcon, CommentInput, CommentThread } from './comment'
|
||||
import CommentManager from './comment-manager'
|
||||
import {
|
||||
CUSTOM_EDGE,
|
||||
CUSTOM_NODE,
|
||||
@ -67,6 +77,7 @@ import DatasetsDetailProvider from './datasets-detail-store/provider'
|
||||
import HelpLine from './help-line'
|
||||
import {
|
||||
useEdgesInteractions,
|
||||
useLeaderRestoreListener,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
@ -79,6 +90,7 @@ import {
|
||||
useWorkflowRefreshDraft,
|
||||
} from './hooks'
|
||||
import { HooksStoreContextProvider, useHooksStore } from './hooks-store'
|
||||
import { useWorkflowComment } from './hooks/use-workflow-comment'
|
||||
import { useWorkflowSearch } from './hooks/use-workflow-search'
|
||||
import NodeContextmenu from './node-contextmenu'
|
||||
import CustomNode from './nodes'
|
||||
@ -139,15 +151,28 @@ export enum InteractionMode {
|
||||
Subgraph = 'subgraph',
|
||||
}
|
||||
|
||||
type WorkflowDataUpdatePayload = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport?: Viewport
|
||||
hash?: string
|
||||
features?: unknown
|
||||
conversation_variables?: ConversationVariable[]
|
||||
environment_variables?: EnvironmentVariable[]
|
||||
}
|
||||
|
||||
export type WorkflowProps = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
viewport?: Viewport
|
||||
children?: React.ReactNode
|
||||
onWorkflowDataUpdate?: (v: any) => void
|
||||
onWorkflowDataUpdate?: (v: WorkflowDataUpdatePayload) => void
|
||||
allowSelectionWhenReadOnly?: boolean
|
||||
canvasReadOnly?: boolean
|
||||
interactionMode?: InteractionMode
|
||||
cursors?: Record<string, CursorPosition>
|
||||
myUserId?: string | null
|
||||
onlineUsers?: OnlineUser[]
|
||||
}
|
||||
export const Workflow: FC<WorkflowProps> = memo(({
|
||||
nodes: originalNodes,
|
||||
@ -158,10 +183,15 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
allowSelectionWhenReadOnly = false,
|
||||
canvasReadOnly = false,
|
||||
interactionMode = 'default',
|
||||
cursors,
|
||||
myUserId,
|
||||
onlineUsers,
|
||||
}) => {
|
||||
const workflowContainerRef = useRef<HTMLDivElement>(null)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const reactflow = useReactFlow()
|
||||
const store = useStoreApi()
|
||||
const [isMouseOverCanvas, setIsMouseOverCanvas] = useState(false)
|
||||
const [nodes, setNodes] = useNodesState(originalNodes)
|
||||
const [edges, setEdges] = useEdgesState(originalEdges)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
@ -217,6 +247,18 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
useEffect(() => {
|
||||
setNodesOnlyChangeWithData(currentNodes as Node[])
|
||||
}, [currentNodes, setNodesOnlyChangeWithData])
|
||||
useEffect(() => {
|
||||
return collaborationManager.onGraphImport(({ nodes: importedNodes, edges: importedEdges }) => {
|
||||
if (!isEqual(nodes, importedNodes)) {
|
||||
setNodes(importedNodes)
|
||||
store.getState().setNodes(importedNodes)
|
||||
}
|
||||
if (!isEqual(edges, importedEdges)) {
|
||||
setEdges(importedEdges)
|
||||
store.getState().setEdges(importedEdges)
|
||||
}
|
||||
})
|
||||
}, [edges, nodes, setEdges, setNodes, store])
|
||||
const {
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
@ -224,8 +266,47 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
const { workflowReadOnly } = useWorkflowReadOnly()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const {
|
||||
comments,
|
||||
pendingComment,
|
||||
activeComment,
|
||||
activeCommentLoading,
|
||||
replySubmitting,
|
||||
replyUpdating,
|
||||
handleCommentSubmit,
|
||||
handleCommentCancel,
|
||||
handleCommentIconClick,
|
||||
handleActiveCommentClose,
|
||||
handleCommentResolve,
|
||||
handleCommentDelete,
|
||||
handleCommentReply,
|
||||
handleCommentReplyUpdate,
|
||||
handleCommentReplyDelete,
|
||||
handleCommentPositionUpdate,
|
||||
} = useWorkflowComment()
|
||||
const showUserComments = useStore(s => s.showUserComments)
|
||||
const showUserCursors = useStore(s => s.showUserCursors)
|
||||
const showResolvedComments = useStore(s => s.showResolvedComments)
|
||||
const isCommentPreviewHovering = useStore(s => s.isCommentPreviewHovering)
|
||||
const setPendingCommentState = useStore(s => s.setPendingComment)
|
||||
const isCommentInputActive = Boolean(pendingComment)
|
||||
const { t } = useTranslation()
|
||||
const visibleComments = useMemo(() => {
|
||||
if (showResolvedComments)
|
||||
return comments
|
||||
return comments.filter(comment => !comment.resolved)
|
||||
}, [comments, showResolvedComments])
|
||||
const handleVisibleCommentNavigate = useCallback((direction: 'prev' | 'next') => {
|
||||
if (!activeComment)
|
||||
return
|
||||
const idx = visibleComments.findIndex(comment => comment.id === activeComment.id)
|
||||
if (idx === -1)
|
||||
return
|
||||
const target = direction === 'prev' ? visibleComments[idx - 1] : visibleComments[idx + 1]
|
||||
if (target)
|
||||
handleCommentIconClick(target)
|
||||
}, [activeComment, handleCommentIconClick, visibleComments])
|
||||
|
||||
const store = useStoreApi()
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === WORKFLOW_DATA_UPDATE) {
|
||||
if (interactionMode === InteractionMode.Subgraph)
|
||||
@ -260,6 +341,10 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
}
|
||||
}, [handleSyncWorkflowDraft])
|
||||
|
||||
const handlePendingCommentPositionChange = useCallback((position: NonNullable<WorkflowSliceShape['pendingComment']>) => {
|
||||
setPendingCommentState(position)
|
||||
}, [setPendingCommentState])
|
||||
|
||||
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
|
||||
const handleSyncWorkflowDraftWhenPageClose = useCallback(() => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
@ -283,6 +368,33 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
syncWorkflowDraftWhenPageClose()
|
||||
}, [syncWorkflowDraftWhenPageClose])
|
||||
|
||||
// Optimized comment deletion using showConfirm
|
||||
const handleCommentDeleteClick = useCallback((commentId: string) => {
|
||||
if (!showConfirm) {
|
||||
setShowConfirm({
|
||||
title: t('comments.confirm.deleteThreadTitle', { ns: 'workflow' }),
|
||||
desc: t('comments.confirm.deleteThreadDesc', { ns: 'workflow' }),
|
||||
onConfirm: async () => {
|
||||
await handleCommentDelete(commentId)
|
||||
setShowConfirm(undefined)
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [showConfirm, setShowConfirm, handleCommentDelete, t])
|
||||
|
||||
const handleCommentReplyDeleteClick = useCallback((commentId: string, replyId: string) => {
|
||||
if (!showConfirm) {
|
||||
setShowConfirm({
|
||||
title: t('comments.confirm.deleteReplyTitle', { ns: 'workflow' }),
|
||||
desc: t('comments.confirm.deleteReplyDesc', { ns: 'workflow' }),
|
||||
onConfirm: async () => {
|
||||
await handleCommentReplyDelete(commentId, replyId)
|
||||
setShowConfirm(undefined)
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [showConfirm, setShowConfirm, handleCommentReplyDelete, t])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('visibilitychange', handleSyncWorkflowDraftWhenPageClose)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
@ -315,9 +427,43 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
elementY: e.clientY - containerClientRect.top,
|
||||
},
|
||||
})
|
||||
const target = e.target as HTMLElement
|
||||
const onPane = !!target?.closest('.react-flow__pane')
|
||||
setIsMouseOverCanvas(onPane)
|
||||
}
|
||||
})
|
||||
|
||||
// Prevent browser zoom interactions from hijacking gestures meant for the workflow canvas
|
||||
useEffect(() => {
|
||||
const preventBrowserZoom = (event: WheelEvent) => {
|
||||
if (!isCommentPreviewHovering && !isCommentInputActive)
|
||||
return
|
||||
|
||||
if (event.ctrlKey || event.metaKey)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const preventGestureZoom = (event: Event) => {
|
||||
if (!isCommentPreviewHovering && !isCommentInputActive)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
window.addEventListener('wheel', preventBrowserZoom, { passive: false })
|
||||
const gestureEvents: Array<'gesturestart' | 'gesturechange' | 'gestureend'> = ['gesturestart', 'gesturechange', 'gestureend']
|
||||
gestureEvents.forEach((eventName) => {
|
||||
window.addEventListener(eventName, preventGestureZoom, { passive: false })
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('wheel', preventBrowserZoom)
|
||||
gestureEvents.forEach((eventName) => {
|
||||
window.removeEventListener(eventName, preventGestureZoom)
|
||||
})
|
||||
}
|
||||
}, [isCommentPreviewHovering, isCommentInputActive])
|
||||
|
||||
const {
|
||||
handleNodeDragStart,
|
||||
handleNodeDrag,
|
||||
@ -361,6 +507,8 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
// Initialize workflow node search functionality
|
||||
useWorkflowSearch()
|
||||
|
||||
useLeaderRestoreListener()
|
||||
|
||||
// Set up scroll to node event listener using the utility function
|
||||
useEffect(() => {
|
||||
return setupScrollToNodeListener(nodes, reactflow)
|
||||
@ -431,7 +579,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
<div
|
||||
id="workflow-container"
|
||||
className={cn(
|
||||
'relative h-full w-full min-w-[960px]',
|
||||
'relative h-full w-full min-w-[960px] overflow-hidden',
|
||||
workflowReadOnly && 'workflow-panel-animation',
|
||||
nodeAnimation && 'workflow-node-animation',
|
||||
)}
|
||||
@ -439,8 +587,9 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
>
|
||||
<SyncingDataModal />
|
||||
{!isSubGraph && <CandidateNode />}
|
||||
<CommentManager />
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 top-0 z-10 flex w-12 items-center justify-center p-1 pl-2"
|
||||
className="pointer-events-none absolute left-0 top-0 z-[60] flex w-12 items-center justify-center p-1 pl-2"
|
||||
style={{ height: controlHeight }}
|
||||
>
|
||||
{!isSubGraph && <Control />}
|
||||
@ -450,23 +599,84 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
{!isSubGraph && <NodeContextmenu />}
|
||||
{!isSubGraph && <SelectionContextmenu />}
|
||||
{!isSubGraph && <HelpLine />}
|
||||
{
|
||||
!!showConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
onCancel={() => setShowConfirm(undefined)}
|
||||
onConfirm={showConfirm.onConfirm}
|
||||
title={showConfirm.title}
|
||||
content={showConfirm.desc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{!!showConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
onCancel={() => setShowConfirm(undefined)}
|
||||
onConfirm={showConfirm.onConfirm}
|
||||
title={showConfirm.title}
|
||||
content={showConfirm.desc}
|
||||
/>
|
||||
)}
|
||||
{controlMode === ControlMode.Comment && isMouseOverCanvas && (
|
||||
<CommentCursor />
|
||||
)}
|
||||
{pendingComment && (
|
||||
<CommentInput
|
||||
position={{
|
||||
x: pendingComment.elementX,
|
||||
y: pendingComment.elementY,
|
||||
}}
|
||||
onSubmit={handleCommentSubmit}
|
||||
onCancel={handleCommentCancel}
|
||||
onPositionChange={handlePendingCommentPositionChange}
|
||||
/>
|
||||
)}
|
||||
{visibleComments.map((comment, index) => {
|
||||
const isActive = activeComment?.id === comment.id
|
||||
|
||||
if (isActive && activeComment) {
|
||||
const canGoPrev = index > 0
|
||||
const canGoNext = index < visibleComments.length - 1
|
||||
return (
|
||||
<Fragment key={comment.id}>
|
||||
<CommentIcon
|
||||
key={`${comment.id}-icon`}
|
||||
comment={comment}
|
||||
onClick={() => handleCommentIconClick(comment)}
|
||||
isActive={true}
|
||||
onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)}
|
||||
/>
|
||||
<CommentThread
|
||||
key={`${comment.id}-thread`}
|
||||
comment={activeComment}
|
||||
loading={activeCommentLoading}
|
||||
replySubmitting={replySubmitting}
|
||||
replyUpdating={replyUpdating}
|
||||
onClose={handleActiveCommentClose}
|
||||
onResolve={() => handleCommentResolve(comment.id)}
|
||||
onDelete={() => handleCommentDeleteClick(comment.id)}
|
||||
onPrev={canGoPrev ? () => handleVisibleCommentNavigate('prev') : undefined}
|
||||
onNext={canGoNext ? () => handleVisibleCommentNavigate('next') : undefined}
|
||||
onReply={(content, ids) => handleCommentReply(comment.id, content, ids ?? [])}
|
||||
onReplyEdit={(replyId, content, ids) => handleCommentReplyUpdate(comment.id, replyId, content, ids ?? [])}
|
||||
onReplyDelete={replyId => handleCommentReplyDeleteClick(comment.id, replyId)}
|
||||
onReplyDeleteDirect={replyId => handleCommentReplyDelete(comment.id, replyId)}
|
||||
canGoPrev={canGoPrev}
|
||||
canGoNext={canGoNext}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (showUserComments || controlMode === ControlMode.Comment)
|
||||
? (
|
||||
<CommentIcon
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
onClick={() => handleCommentIconClick(comment)}
|
||||
onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)}
|
||||
/>
|
||||
)
|
||||
: null
|
||||
})}
|
||||
{children}
|
||||
<ReactFlow
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
className={controlMode === ControlMode.Comment ? 'comment-mode-flow' : ''}
|
||||
onNodeDragStart={handleNodeDragStart}
|
||||
onNodeDrag={handleNodeDrag}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
@ -491,7 +701,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
defaultViewport={viewport}
|
||||
multiSelectionKeyCode={null}
|
||||
deleteKeyCode={null}
|
||||
nodesDraggable={!(nodesReadOnly || canvasReadOnly || isSubGraph)}
|
||||
nodesDraggable={!(nodesReadOnly || canvasReadOnly || isSubGraph) && controlMode !== ControlMode.Comment}
|
||||
nodesConnectable={!(nodesReadOnly || canvasReadOnly || isSubGraph)}
|
||||
nodesFocusable={allowSelectionWhenReadOnly ? true : !nodesReadOnly}
|
||||
edgesFocusable={isSubGraph ? false : (allowSelectionWhenReadOnly ? true : !nodesReadOnly)}
|
||||
@ -512,6 +722,13 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
className="bg-workflow-canvas-workflow-bg"
|
||||
color="var(--color-workflow-canvas-workflow-dot-color)"
|
||||
/>
|
||||
{showUserCursors && cursors && (
|
||||
<UserCursors
|
||||
cursors={cursors}
|
||||
myUserId={myUserId || null}
|
||||
onlineUsers={onlineUsers || []}
|
||||
/>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
@ -519,14 +736,25 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
|
||||
type WorkflowWithInnerContextProps = WorkflowProps & {
|
||||
hooksStore?: Partial<HooksStoreShape>
|
||||
cursors?: Record<string, CursorPosition>
|
||||
myUserId?: string | null
|
||||
onlineUsers?: OnlineUser[]
|
||||
}
|
||||
export const WorkflowWithInnerContext = memo(({
|
||||
hooksStore,
|
||||
cursors,
|
||||
myUserId,
|
||||
onlineUsers,
|
||||
...restProps
|
||||
}: WorkflowWithInnerContextProps) => {
|
||||
return (
|
||||
<HooksStoreContextProvider {...hooksStore}>
|
||||
<Workflow {...restProps} />
|
||||
<Workflow
|
||||
{...restProps}
|
||||
cursors={cursors}
|
||||
myUserId={myUserId}
|
||||
onlineUsers={onlineUsers}
|
||||
/>
|
||||
</HooksStoreContextProvider>
|
||||
)
|
||||
})
|
||||
|
||||
@ -30,8 +30,15 @@ export const useDefaultValue = (
|
||||
const index = default_value.findIndex(form => form.key === key)
|
||||
|
||||
if (index > -1) {
|
||||
const newDefaultValue = [...default_value]
|
||||
newDefaultValue[index].value = value
|
||||
const newDefaultValue = default_value.map((form) => {
|
||||
if (form.key !== key)
|
||||
return form
|
||||
// clone the entry so we do not mutate the original reference (which would block CRDT diffs)
|
||||
return {
|
||||
...form,
|
||||
value,
|
||||
}
|
||||
})
|
||||
handleNodeDataUpdateWithSyncDraft({
|
||||
id,
|
||||
data: {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -28,10 +29,29 @@ export const TitleInput = memo(({
|
||||
onBlur(localValue)
|
||||
}
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
;(e.target as HTMLInputElement).blur()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Sync local state with incoming collaborative updates so remote title edits appear immediately.
|
||||
useEffect(() => {
|
||||
Promise.resolve().then(() => {
|
||||
setLocalValue(value)
|
||||
})
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<input
|
||||
value={localValue}
|
||||
onChange={e => setLocalValue(e.target.value)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`
|
||||
system-xl-semibold mr-2 h-7 min-w-0 grow appearance-none rounded-md border border-transparent bg-transparent px-1 text-text-primary
|
||||
outline-none focus:shadow-xs
|
||||
|
||||
@ -22,6 +22,7 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import {
|
||||
@ -34,6 +35,8 @@ import {
|
||||
import { usePluginStore } from '@/app/components/plugins/plugin-detail-panel/store'
|
||||
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useNodeDataUpdate,
|
||||
@ -59,6 +62,7 @@ import {
|
||||
hasRetryNode,
|
||||
isSupportCustomRunForm,
|
||||
} from '@/app/components/workflow/utils'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useAllBuiltInTools } from '@/service/use-tools'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
@ -109,11 +113,51 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const appId = useStore(s => s.appId)
|
||||
const { userProfile } = useAppContext()
|
||||
const { isConnected, nodePanelPresence } = useCollaboration(appId as string)
|
||||
const { showMessageLogModal } = useAppStore(useShallow(state => ({
|
||||
showMessageLogModal: state.showMessageLogModal,
|
||||
})))
|
||||
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
|
||||
|
||||
const currentUserPresence = useMemo(() => {
|
||||
const userId = userProfile?.id || ''
|
||||
const username = userProfile?.name || userProfile?.email || 'User'
|
||||
const avatar = userProfile?.avatar_url || userProfile?.avatar || null
|
||||
|
||||
return {
|
||||
userId,
|
||||
username,
|
||||
avatar,
|
||||
}
|
||||
}, [userProfile?.avatar, userProfile?.avatar_url, userProfile?.email, userProfile?.id, userProfile?.name])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !currentUserPresence.userId)
|
||||
return
|
||||
|
||||
collaborationManager.emitNodePanelPresence(id, true, currentUserPresence)
|
||||
|
||||
return () => {
|
||||
collaborationManager.emitNodePanelPresence(id, false, currentUserPresence)
|
||||
}
|
||||
}, [id, isConnected, currentUserPresence])
|
||||
|
||||
const viewingUsers = useMemo(() => {
|
||||
const presence = nodePanelPresence?.[id]
|
||||
if (!presence)
|
||||
return []
|
||||
|
||||
return Object.values(presence)
|
||||
.filter(viewer => viewer.userId && viewer.userId !== currentUserPresence.userId)
|
||||
.map(viewer => ({
|
||||
id: viewer.userId,
|
||||
name: viewer.username,
|
||||
avatar_url: viewer.avatar || null,
|
||||
}))
|
||||
}, [currentUserPresence.userId, id, nodePanelPresence])
|
||||
|
||||
const showSingleRunPanel = useStore(s => s.showSingleRunPanel)
|
||||
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
|
||||
const nodePanelWidth = useStore(s => s.nodePanelWidth)
|
||||
@ -490,6 +534,15 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
value={data.title || ''}
|
||||
onBlur={handleTitleBlur}
|
||||
/>
|
||||
{viewingUsers.length > 0 && (
|
||||
<div className="ml-3 shrink-0">
|
||||
<UserAvatarList
|
||||
users={viewingUsers}
|
||||
maxVisible={3}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center text-text-tertiary">
|
||||
{
|
||||
isSupportSingleRun && !nodesReadOnly && (
|
||||
|
||||
@ -19,19 +19,24 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
|
||||
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions'
|
||||
import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions'
|
||||
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
BlockEnum,
|
||||
ControlMode,
|
||||
isTriggerNode,
|
||||
NodeRunningStatus,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AddVariablePopupWithPosition from './components/add-variable-popup-with-position'
|
||||
import EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-container'
|
||||
@ -72,6 +77,36 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
|
||||
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
|
||||
const toolIcon = useToolIcon(data)
|
||||
const { userProfile } = useAppContext()
|
||||
const appId = useStore(s => s.appId)
|
||||
const { nodePanelPresence } = useCollaboration(appId as string)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
|
||||
const currentUserPresence = useMemo(() => {
|
||||
const userId = userProfile?.id || ''
|
||||
const username = userProfile?.name || userProfile?.email || 'User'
|
||||
const avatar = userProfile?.avatar_url || userProfile?.avatar || null
|
||||
|
||||
return {
|
||||
userId,
|
||||
username,
|
||||
avatar,
|
||||
}
|
||||
}, [userProfile?.avatar, userProfile?.avatar_url, userProfile?.email, userProfile?.id, userProfile?.name])
|
||||
|
||||
const viewingUsers = useMemo(() => {
|
||||
const presence = nodePanelPresence?.[id]
|
||||
if (!presence)
|
||||
return []
|
||||
|
||||
return Object.values(presence)
|
||||
.filter(viewer => viewer.userId && viewer.userId !== currentUserPresence.userId)
|
||||
.map(viewer => ({
|
||||
id: viewer.userId,
|
||||
name: viewer.username,
|
||||
avatar_url: viewer.avatar || null,
|
||||
}))
|
||||
}, [currentUserPresence.userId, id, nodePanelPresence])
|
||||
|
||||
useEffect(() => {
|
||||
if (nodeRef.current && data.selected && data.isInIteration) {
|
||||
@ -223,6 +258,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
className={cn(
|
||||
'group relative pb-1 shadow-xs',
|
||||
'rounded-[15px] border border-transparent',
|
||||
(controlMode === ControlMode.Comment) && 'hover:cursor-none',
|
||||
(data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
|
||||
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
|
||||
!data._runningStatus && 'hover:shadow-lg',
|
||||
@ -320,6 +356,15 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{viewingUsers.length > 0 && (
|
||||
<div className="ml-3 shrink-0">
|
||||
<UserAvatarList
|
||||
users={viewingUsers}
|
||||
maxVisible={3}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
!!(data._iterationLength && data._iterationIndex && data._runningStatus === NodeRunningStatus.Running) && (
|
||||
|
||||
@ -22,9 +22,10 @@ export const useReplaceDataSourceNode = (id: string) => {
|
||||
|
||||
if (emptyNodeIndex < 0)
|
||||
return
|
||||
const {
|
||||
defaultValue,
|
||||
} = nodesMetaDataMap![type]
|
||||
const nodeMetaData = nodesMetaDataMap?.[type]
|
||||
if (!nodeMetaData)
|
||||
return
|
||||
const { defaultValue } = nodeMetaData
|
||||
const emptyNode = nodes[emptyNodeIndex]
|
||||
const { newNode } = generateNewNode({
|
||||
data: {
|
||||
|
||||
@ -15,30 +15,50 @@ const strToKeyValueList = (value: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeList = (items: KeyValue[]) => {
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
id: item.id || uniqueId(UNIQUE_ID_PREFIX),
|
||||
}))
|
||||
}
|
||||
|
||||
const stringifyList = (items: KeyValue[], noFilter?: boolean) => {
|
||||
const source = noFilter ? items : items.filter(item => item.key && item.value)
|
||||
return source.map(item => `${item.key}:${item.value}`).join('\n')
|
||||
}
|
||||
|
||||
const useKeyValueList = (value: string, onChange: (value: string) => void, noFilter?: boolean) => {
|
||||
const [list, doSetList] = useState<KeyValue[]>(() => value ? strToKeyValueList(value) : [])
|
||||
const setList = (l: KeyValue[]) => {
|
||||
doSetList(l.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
id: item.id || uniqueId(UNIQUE_ID_PREFIX),
|
||||
}
|
||||
}))
|
||||
}
|
||||
useEffect(() => {
|
||||
const setList = useCallback((nextList: KeyValue[]) => {
|
||||
const normalized = normalizeList(nextList)
|
||||
doSetList(normalized)
|
||||
if (noFilter)
|
||||
return
|
||||
const newValue = list.filter(item => item.key && item.value).map(item => `${item.key}:${item.value}`).join('\n')
|
||||
|
||||
const newValue = stringifyList(normalized, noFilter)
|
||||
if (newValue !== value)
|
||||
onChange(newValue)
|
||||
}, [list, noFilter])
|
||||
}, [noFilter, onChange, value])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.resolve().then(() => {
|
||||
doSetList((prev) => {
|
||||
const targetItems = value ? strToKeyValueList(value) : []
|
||||
const currentValue = stringifyList(prev, noFilter)
|
||||
const targetValue = stringifyList(targetItems, noFilter)
|
||||
if (currentValue === targetValue)
|
||||
return prev
|
||||
return normalizeList(targetItems)
|
||||
})
|
||||
})
|
||||
}, [value, noFilter])
|
||||
const addItem = useCallback(() => {
|
||||
setList([...list, {
|
||||
id: uniqueId(UNIQUE_ID_PREFIX),
|
||||
key: '',
|
||||
value: '',
|
||||
}])
|
||||
}, [list])
|
||||
}, [list, setList])
|
||||
|
||||
const [isKeyValueEdit, {
|
||||
toggle: toggleIsKeyValueEdit,
|
||||
|
||||
@ -61,7 +61,7 @@ const Node: FC<NodeProps<IterationNodeType>> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
data._children!.length === 1 && (
|
||||
data._children?.length === 1 && (
|
||||
<AddBlock
|
||||
iterationNodeId={id}
|
||||
iterationNodeData={data}
|
||||
|
||||
@ -6,8 +6,8 @@ import type {
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useNodesMetaData } from '@/app/components/workflow/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
|
||||
import {
|
||||
ITERATION_PADDING,
|
||||
} from '../../constants'
|
||||
@ -19,18 +19,16 @@ import { CUSTOM_ITERATION_START_NODE } from '../iteration-start/constants'
|
||||
|
||||
export const useNodeIterationInteractions = () => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
const { nodesMap: nodesMetaDataMap } = useNodesMetaData()
|
||||
const collaborativeWorkflow = useCollaborativeWorkflow()
|
||||
|
||||
const handleNodeIterationRerender = useCallback((nodeId: string) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const currentNode = nodes.find(n => n.id === nodeId)!
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
|
||||
if (!childrenNodes.length)
|
||||
return
|
||||
let rightNode: Node
|
||||
let bottomNode: Node
|
||||
|
||||
@ -72,11 +70,10 @@ export const useNodeIterationInteractions = () => {
|
||||
|
||||
setNodes(newNodes)
|
||||
}
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleNodeIterationChildDrag = useCallback((node: Node) => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
|
||||
const restrictPosition: { x?: number, y?: number } = { x: undefined, y: undefined }
|
||||
|
||||
@ -98,21 +95,19 @@ export const useNodeIterationInteractions = () => {
|
||||
return {
|
||||
restrictPosition,
|
||||
}
|
||||
}, [store])
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleNodeIterationChildSizeChange = useCallback((nodeId: string) => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const currentNode = nodes.find(n => n.id === nodeId)!
|
||||
const parentId = currentNode.parentId
|
||||
|
||||
if (parentId)
|
||||
handleNodeIterationRerender(parentId)
|
||||
}, [store, handleNodeIterationRerender])
|
||||
}, [collaborativeWorkflow, handleNodeIterationRerender])
|
||||
|
||||
const handleNodeIterationChildrenCopy = useCallback((nodeId: string, newNodeId: string, idMapping: Record<string, string>) => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
|
||||
const newIdMapping = { ...idMapping }
|
||||
const childNodeTypeCount: ChildNodeTypeCount = {}
|
||||
@ -120,6 +115,7 @@ export const useNodeIterationInteractions = () => {
|
||||
const copyChildren = childrenNodes.map((child, index) => {
|
||||
const childNodeType = child.data.type as BlockEnum
|
||||
const nodesWithSameType = nodes.filter(node => node.data.type === childNodeType)
|
||||
const defaultValue = nodesMetaDataMap?.[childNodeType]?.defaultValue ?? {}
|
||||
|
||||
if (!childNodeTypeCount[childNodeType])
|
||||
childNodeTypeCount[childNodeType] = nodesWithSameType.length + 1
|
||||
@ -129,7 +125,7 @@ export const useNodeIterationInteractions = () => {
|
||||
const { newNode } = generateNewNode({
|
||||
type: getNodeCustomTypeByNodeDataType(childNodeType),
|
||||
data: {
|
||||
...nodesMetaDataMap![childNodeType].defaultValue,
|
||||
...defaultValue,
|
||||
...child.data,
|
||||
selected: false,
|
||||
_isBundled: false,
|
||||
@ -154,7 +150,7 @@ export const useNodeIterationInteractions = () => {
|
||||
copyChildren,
|
||||
newIdMapping,
|
||||
}
|
||||
}, [store, t])
|
||||
}, [collaborativeWorkflow, t])
|
||||
|
||||
return {
|
||||
handleNodeIterationRerender,
|
||||
|
||||
@ -223,21 +223,39 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
|
||||
|
||||
const [selectedDatasetsLoaded, setSelectedDatasetsLoaded] = useState(false)
|
||||
// datasets
|
||||
// Fetch details whenever dataset IDs change so create/delete stays consistent across collaborators.
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const inputs = inputRef.current
|
||||
const datasetIds = inputs.dataset_ids
|
||||
if (datasetIds?.length > 0) {
|
||||
const { data: dataSetsWithDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: datasetIds } as any })
|
||||
setSelectedDatasets(dataSetsWithDetail)
|
||||
let aborted = false
|
||||
const datasetIds = inputs.dataset_ids
|
||||
const loadDatasets = async () => {
|
||||
if (!datasetIds || datasetIds.length === 0) {
|
||||
if (!aborted) {
|
||||
setSelectedDatasets([])
|
||||
setSelectedDatasetsLoaded(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft.dataset_ids = datasetIds
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setSelectedDatasetsLoaded(true)
|
||||
})()
|
||||
}, [])
|
||||
setSelectedDatasetsLoaded(false)
|
||||
try {
|
||||
const { data: dataSetsWithDetail } = await fetchDatasets({
|
||||
url: '/datasets',
|
||||
params: { page: 1, ids: datasetIds } as any,
|
||||
})
|
||||
if (aborted)
|
||||
return
|
||||
setSelectedDatasets(dataSetsWithDetail)
|
||||
updateDatasetsDetail(dataSetsWithDetail)
|
||||
}
|
||||
finally {
|
||||
if (!aborted)
|
||||
setSelectedDatasetsLoaded(true)
|
||||
}
|
||||
}
|
||||
loadDatasets()
|
||||
return () => {
|
||||
aborted = true
|
||||
}
|
||||
}, [inputs.dataset_ids, updateDatasetsDetail])
|
||||
|
||||
useEffect(() => {
|
||||
const inputs = inputRef.current
|
||||
|
||||
@ -68,7 +68,7 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({
|
||||
>
|
||||
<RiArrowDownDoubleLine className='h-3 w-3 text-text-tertiary' />
|
||||
<span className='system-xs-regular text-text-tertiary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.showAdvancedOptions')}
|
||||
{t('nodes.llm.jsonSchema.showAdvancedOptions', { ns: 'workflow' })}
|
||||
</span>
|
||||
</button>
|
||||
)} */}
|
||||
|
||||
@ -41,6 +41,9 @@ const FormItem = ({
|
||||
}: FormItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { value_type, var_type, value } = item
|
||||
const normalizedVarValue = useMemo(() => {
|
||||
return Array.isArray(value) ? value : []
|
||||
}, [value])
|
||||
|
||||
const handleInputChange = useCallback((e: any) => {
|
||||
onChange(e.target.value)
|
||||
@ -79,7 +82,7 @@ const FormItem = ({
|
||||
readonly={false}
|
||||
nodeId={nodeId}
|
||||
isShowNodeName
|
||||
value={value}
|
||||
value={normalizedVarValue}
|
||||
onChange={handleChange}
|
||||
filterVar={filterVar}
|
||||
placeholder={t('nodes.assigner.setParameter', { ns: 'workflow' }) as string}
|
||||
|
||||
@ -47,7 +47,7 @@ const Node: FC<NodeProps<LoopNodeType>> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
data._children!.length === 1 && (
|
||||
data._children?.length === 1 && (
|
||||
<AddBlock
|
||||
loopNodeId={id}
|
||||
loopNodeData={data}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user