Merge branch 'feat/collaboration2' into feat/support-agent-sandbox

This commit is contained in:
hjlarry
2026-01-25 00:00:03 +08:00
221 changed files with 13878 additions and 1226 deletions

View File

@ -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

View File

@ -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

View File

@ -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({

View File

@ -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)

View File

@ -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 (

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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] ?? []} />
))
}

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"
}

View 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

View File

@ -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'

View 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"
}

View 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

View File

@ -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'

View File

@ -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} />

View 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'

View File

@ -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">

View File

@ -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'>

View File

@ -24,7 +24,7 @@ export const useAvailableNodesMetaData = () => {
},
knowledgeBaseDefault,
dataSourceEmptyDefault,
], [])
] as AvailableNodesMetaData['nodes'], [])
const helpLinkUri = useMemo(() => docLink(
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration',

View File

@ -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}`,

View File

@ -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" />

View File

@ -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

View File

@ -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>

View File

@ -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!)

View File

@ -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>
)
}

View File

@ -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 />}
</>
)
}

View File

@ -40,7 +40,7 @@ export const useAvailableNodesMetaData = () => {
TriggerPluginDefault,
]
),
], [isChatMode, startNodeMetaData])
] as AvailableNodesMetaData['nodes'], [isChatMode, startNodeMetaData])
const availableNodesMetaData = useMemo<NodeDefaultBase[]>(() => {
const toNodeDefaultBase = (

View File

@ -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}`,

View File

@ -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((

View File

@ -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])

View File

@ -35,7 +35,7 @@ const NodeSelectorWrapper = (props: NodeSelectorProps) => {
return true
})
}, [availableNodesMetaData?.nodes])
}, [availableNodesMetaData?.nodes]) as NodeSelectorProps['blocks']
return (
<NodeSelector

View File

@ -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({

View File

@ -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

View File

@ -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)
})
})

View File

@ -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({})
})
})

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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

View File

@ -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')
}
}

View File

@ -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
}
}

View File

@ -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()

View File

@ -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
}

View 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'

View File

@ -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,
})
}
}
}

View 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
}

View 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
}>
}>

View File

@ -0,0 +1,3 @@
export * from './collaboration'
export * from './events'
export * from './websocket'

View 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
}

View File

@ -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]
}

View 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

View 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)
})
})

View 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'

View 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'])
})
})

View 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'

View 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)
})
})

View 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)

View 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' })
})
})

View 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'

View 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'

View 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)

View 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'

View File

@ -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

View File

@ -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]">

View File

@ -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 (
<>

View 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

View File

@ -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()

View File

@ -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,

View File

@ -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'

View File

@ -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}` })

View File

@ -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,
}
}

View File

@ -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,

View 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,
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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()

View 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,
}
}

View File

@ -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,

View File

@ -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,
}

View File

@ -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>
)
})

View File

@ -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: {

View File

@ -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

View File

@ -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 && (

View File

@ -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) && (

View File

@ -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: {

View File

@ -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,

View File

@ -61,7 +61,7 @@ const Node: FC<NodeProps<IterationNodeType>> = ({
)
}
{
data._children!.length === 1 && (
data._children?.length === 1 && (
<AddBlock
iterationNodeId={id}
iterationNodeData={data}

View File

@ -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,

View File

@ -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

View File

@ -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>
)} */}

View File

@ -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}

View File

@ -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