mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
Merge branch 'feat/collaboration2' into feat/support-agent-sandbox
This commit is contained in:
@ -18,6 +18,7 @@ import {
|
||||
import EditingTitle from './editing-title'
|
||||
import EnvButton from './env-button'
|
||||
import GlobalVariableButton from './global-variable-button'
|
||||
import OnlineUsers from './online-users'
|
||||
import RunAndHistory from './run-and-history'
|
||||
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
|
||||
import VersionHistoryButton from './version-history-button'
|
||||
@ -73,6 +74,8 @@ const HeaderInNormal = ({
|
||||
<ScrollToSelectedNodeButton />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<OnlineUsers />
|
||||
{components?.left}
|
||||
<Divider type="vertical" className="mx-auto h-3.5" />
|
||||
<RunAndHistory {...runAndHistoryProps} />
|
||||
<div className="shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]">
|
||||
|
||||
@ -4,12 +4,14 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useInvalidAllLastRun } from '@/service/use-workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Toast from '../../base/toast'
|
||||
import {
|
||||
useNodesSyncDraft,
|
||||
useLeaderRestore,
|
||||
useWorkflowRun,
|
||||
} from '../hooks'
|
||||
import { useHooksStore } from '../hooks-store'
|
||||
@ -31,6 +33,8 @@ const HeaderInRestoring = ({
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const userProfile = useAppContextSelector(s => s.userProfile)
|
||||
const featuresStore = useFeaturesStore()
|
||||
const configsMap = useHooksStore(s => s.configsMap)
|
||||
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
|
||||
const {
|
||||
@ -42,7 +46,7 @@ const HeaderInRestoring = ({
|
||||
const {
|
||||
handleLoadBackupDraft,
|
||||
} = useWorkflowRun()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { requestRestore } = useLeaderRestore()
|
||||
|
||||
const handleCancelRestore = useCallback(() => {
|
||||
handleLoadBackupDraft()
|
||||
@ -51,10 +55,32 @@ const HeaderInRestoring = ({
|
||||
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (!currentVersion)
|
||||
return
|
||||
|
||||
setShowWorkflowVersionHistoryPanel(false)
|
||||
workflowStore.setState({ isRestoring: false })
|
||||
workflowStore.setState({ backupDraft: undefined })
|
||||
handleSyncWorkflowDraft(true, false, {
|
||||
|
||||
const { graph } = currentVersion
|
||||
const features = featuresStore?.getState().features
|
||||
const environmentVariables = currentVersion.environment_variables || []
|
||||
const conversationVariables = currentVersion.conversation_variables || []
|
||||
|
||||
requestRestore({
|
||||
versionId: currentVersion.id,
|
||||
versionName: currentVersion.marked_name,
|
||||
initiatorUserId: userProfile.id,
|
||||
initiatorName: userProfile.name,
|
||||
graphData: {
|
||||
nodes: graph.nodes,
|
||||
edges: graph.edges,
|
||||
viewport: graph.viewport,
|
||||
},
|
||||
features,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
@ -73,7 +99,7 @@ const HeaderInRestoring = ({
|
||||
})
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
|
||||
}, [currentVersion, featuresStore, setShowWorkflowVersionHistoryPanel, workflowStore, requestRestore, userProfile, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
241
web/app/components/workflow/header/online-users.tsx
Normal file
241
web/app/components/workflow/header/online-users.tsx
Normal file
@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
import type { OnlineUser } from '../collaboration/types'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { getAvatar } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useCollaboration } from '../collaboration/hooks/use-collaboration'
|
||||
import { getUserColor } from '../collaboration/utils/user-color'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const useAvatarUrls = (users: OnlineUser[]) => {
|
||||
const [avatarUrls, setAvatarUrls] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAvatars = async () => {
|
||||
const newAvatarUrls: Record<string, string> = {}
|
||||
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
if (user.avatar) {
|
||||
try {
|
||||
const response = await getAvatar({ avatar: user.avatar })
|
||||
newAvatarUrls[user.sid] = response.avatar_url
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch avatar:', error)
|
||||
newAvatarUrls[user.sid] = user.avatar
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
setAvatarUrls(newAvatarUrls)
|
||||
}
|
||||
|
||||
if (users.length > 0)
|
||||
fetchAvatars()
|
||||
}, [users])
|
||||
|
||||
return avatarUrls
|
||||
}
|
||||
|
||||
const OnlineUsers = () => {
|
||||
const appId = useStore(s => s.appId)
|
||||
const { onlineUsers, cursors, isEnabled: isCollaborationEnabled } = useCollaboration(appId as string)
|
||||
const { userProfile } = useAppContext()
|
||||
const reactFlow = useReactFlow()
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const avatarUrls = useAvatarUrls(onlineUsers || [])
|
||||
|
||||
const currentUserId = userProfile?.id
|
||||
|
||||
const renderDisplayName = (
|
||||
user: OnlineUser,
|
||||
baseClassName: string,
|
||||
suffixClassName: string,
|
||||
) => {
|
||||
const baseName = user.username || 'User'
|
||||
const isCurrentUser = user.user_id === currentUserId
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1', baseClassName)}>
|
||||
<span>{baseName}</span>
|
||||
{isCurrentUser && (
|
||||
<span className={suffixClassName}>
|
||||
(You)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Function to jump to user's cursor position
|
||||
const jumpToUserCursor = (userId: string) => {
|
||||
const cursor = cursors[userId]
|
||||
if (!cursor)
|
||||
return
|
||||
|
||||
// Convert world coordinates to center the view on the cursor
|
||||
reactFlow.setCenter(cursor.x, cursor.y, { zoom: 1, duration: 800 })
|
||||
}
|
||||
|
||||
if (!isCollaborationEnabled || !onlineUsers || onlineUsers.length === 0)
|
||||
return null
|
||||
|
||||
// Display logic:
|
||||
// 1-3 users: show all avatars
|
||||
// 4+ users: show 2 avatars + count + arrow
|
||||
const shouldShowCount = onlineUsers.length >= 4
|
||||
const maxVisible = shouldShowCount ? 2 : 3
|
||||
const visibleUsers = onlineUsers.slice(0, maxVisible)
|
||||
const remainingCount = onlineUsers.length - maxVisible
|
||||
|
||||
const getAvatarUrl = (user: OnlineUser) => {
|
||||
return avatarUrls[user.sid] || user.avatar
|
||||
}
|
||||
|
||||
const hasCounter = remainingCount > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-full border-[0.5px] border-components-panel-border',
|
||||
'bg-components-panel-bg py-1 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]',
|
||||
hasCounter ? 'min-w-[87px] gap-px pl-1 pr-1.5' : 'gap-1 px-1.5',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-6 items-center">
|
||||
<div className="flex items-center">
|
||||
{visibleUsers.map((user, index) => {
|
||||
const isCurrentUser = user.user_id === currentUserId
|
||||
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${user.sid}-${index}`}
|
||||
popupContent={renderDisplayName(
|
||||
user,
|
||||
'system-xs-medium text-text-secondary',
|
||||
'text-text-quaternary',
|
||||
)}
|
||||
position="bottom"
|
||||
triggerMethod="hover"
|
||||
needsDelay={false}
|
||||
asChild
|
||||
popupClassName="flex h-[28px] w-[85px] items-center justify-center gap-1 rounded-md border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-3 py-[6px] shadow-lg shadow-shadow-shadow-5 backdrop-blur-[10px]"
|
||||
noDecoration
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex size-6 items-center justify-center',
|
||||
index > 0 && '-ml-1.5',
|
||||
!isCurrentUser && 'cursor-pointer transition-transform hover:scale-110',
|
||||
)}
|
||||
style={{ zIndex: visibleUsers.length - index }}
|
||||
onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)}
|
||||
>
|
||||
<Avatar
|
||||
name={user.username || 'User'}
|
||||
avatar={getAvatarUrl(user)}
|
||||
size={24}
|
||||
className="ring-1 ring-components-panel-bg"
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{remainingCount > 0 && (
|
||||
<PortalToFollowElem
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
crossAxis: -48,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setDropdownOpen(prev => !prev)}
|
||||
asChild
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 w-6 cursor-pointer select-none items-center justify-center rounded-full bg-components-icon-bg-midnight-solid text-[10px] font-semibold uppercase leading-[12px] text-white ring-1 ring-components-panel-bg',
|
||||
visibleUsers.length > 0 && '-ml-1',
|
||||
)}
|
||||
>
|
||||
+
|
||||
{remainingCount}
|
||||
</div>
|
||||
<ChevronDownIcon className="h-3 w-3 cursor-pointer text-gray-500" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent
|
||||
className="z-[9999]"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1.5',
|
||||
'flex flex-col',
|
||||
'max-h-[200px] w-[240px] overflow-y-auto',
|
||||
'rounded-xl border-[0.5px] border-components-panel-border',
|
||||
'bg-components-panel-bg-blur p-1',
|
||||
'shadow-lg shadow-shadow-shadow-5',
|
||||
'backdrop-blur-[10px]',
|
||||
)}
|
||||
>
|
||||
{onlineUsers.map((user) => {
|
||||
const isCurrentUser = user.user_id === currentUserId
|
||||
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
|
||||
return (
|
||||
<div
|
||||
key={user.sid}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5',
|
||||
!isCurrentUser && 'cursor-pointer hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isCurrentUser) {
|
||||
jumpToUserCursor(user.user_id)
|
||||
setDropdownOpen(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
name={user.username || 'User'}
|
||||
avatar={getAvatarUrl(user)}
|
||||
size={24}
|
||||
backgroundColor={userColor}
|
||||
/>
|
||||
</div>
|
||||
{renderDisplayName(
|
||||
user,
|
||||
'system-xs-medium text-text-secondary',
|
||||
'text-text-tertiary',
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnlineUsers
|
||||
@ -5,28 +5,42 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Divider from '../../base/divider'
|
||||
import TipPopup from '../operator/tip-popup'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
|
||||
export type UndoRedoProps = { handleUndo: () => void, handleRedo: () => void }
|
||||
const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
const { t } = useTranslation()
|
||||
const { store } = useWorkflowHistoryStore()
|
||||
const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true })
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.temporal.subscribe((state) => {
|
||||
// Update button states based on Loro's UndoManager
|
||||
const updateButtonStates = () => {
|
||||
setButtonsDisabled({
|
||||
undo: state.pastStates.length === 0,
|
||||
redo: state.futureStates.length === 0,
|
||||
undo: !collaborationManager.canUndo(),
|
||||
redo: !collaborationManager.canRedo(),
|
||||
})
|
||||
}
|
||||
|
||||
// Initial state
|
||||
Promise.resolve().then(() => {
|
||||
updateButtonStates()
|
||||
})
|
||||
|
||||
// Listen for undo/redo state changes
|
||||
const unsubscribe = collaborationManager.onUndoRedoStateChange((state) => {
|
||||
setButtonsDisabled({
|
||||
undo: !state.canUndo,
|
||||
redo: !state.canRedo,
|
||||
})
|
||||
})
|
||||
|
||||
return () => unsubscribe()
|
||||
}, [store])
|
||||
}, [])
|
||||
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user