Merge branch 'p284' into deploy/dev

This commit is contained in:
hjlarry
2025-09-22 18:03:09 +08:00
88 changed files with 8365 additions and 2587 deletions

View File

@ -18,6 +18,7 @@ import RunAndHistory from './run-and-history'
import EditingTitle from './editing-title'
import EnvButton from './env-button'
import VersionHistoryButton from './version-history-button'
import OnlineUsers from './online-users'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
export type HeaderInNormalProps = {
@ -64,7 +65,9 @@ const HeaderInNormal = ({
<EditingTitle />
</div>
<div className='flex items-center gap-2'>
<OnlineUsers />
{components?.left}
<Divider type='vertical' className='mx-auto h-3.5' />
<EnvButton disabled={nodesReadOnly} />
<Divider type='vertical' className='mx-auto h-3.5' />
<RunAndHistory {...runAndHistoryProps} />

View File

@ -21,6 +21,7 @@ import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useHooksStore } from '../hooks-store'
import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
export type HeaderInRestoringProps = {
onRestoreSettled?: () => void
@ -60,6 +61,9 @@ const HeaderInRestoring = ({
type: 'success',
message: t('workflow.versionHistory.action.restoreSuccess'),
})
// Notify other collaboration clients about the workflow restore
if (appDetail)
collaborationManager.emitWorkflowUpdate(appDetail.id)
},
onError: () => {
Toast.notify({
@ -70,10 +74,10 @@ const HeaderInRestoring = ({
onSettled: () => {
onRestoreSettled?.()
},
})
}, true) // Enable forceUpload for restore operation
deleteAllInspectVars()
invalidAllLastRun()
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled, appDetail])
return (
<>

View File

@ -0,0 +1,195 @@
'use client'
import { useEffect, useState } from 'react'
import { useReactFlow } from 'reactflow'
import Avatar from '@/app/components/base/avatar'
import { useCollaboration } from '../collaboration/hooks/use-collaboration'
import { useStore } from '../store'
import cn from '@/utils/classnames'
import { ChevronDown } from '@/app/components/base/icons/src/vender/solid/arrows'
import { getUserColor } from '../collaboration/utils/user-color'
import Tooltip from '@/app/components/base/tooltip'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useAppContext } from '@/context/app-context'
import { getAvatar } from '@/service/common'
const useAvatarUrls = (users: any[]) => {
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 } = useCollaboration(appId as string)
const { userProfile } = useAppContext()
const reactFlow = useReactFlow()
const [dropdownOpen, setDropdownOpen] = useState(false)
const avatarUrls = useAvatarUrls(onlineUsers || [])
const currentUserId = userProfile?.id
// 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 (!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: any) => {
return avatarUrls[user.sid] || user.avatar
}
return (
<div className="flex items-center rounded-full bg-white px-1 py-1">
<div className="flex items-center">
<div className="flex items-center -space-x-2">
{visibleUsers.map((user, index) => {
const isCurrentUser = user.user_id === currentUserId
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
const displayName = isCurrentUser
? `${user.username || 'User'} (You)`
: (user.username || 'User')
return (
<Tooltip
key={`${user.sid}-${index}`}
popupContent={displayName}
position="bottom"
triggerMethod="hover"
needsDelay={false}
asChild
>
<div
className={cn(
'relative',
!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={28}
className="ring-2 ring-white"
backgroundColor={userColor}
/>
</div>
</Tooltip>
)
})}
{remainingCount > 0 && (
<PortalToFollowElem
open={dropdownOpen}
onOpenChange={setDropdownOpen}
placement="bottom-start"
>
<PortalToFollowElemTrigger
onMouseEnter={() => setDropdownOpen(true)}
onMouseLeave={() => setDropdownOpen(false)}
asChild
>
<div className="flex items-center">
<div
className={cn(
'flex h-7 w-7 items-center justify-center',
'cursor-pointer rounded-full bg-gray-300',
'text-xs font-medium text-gray-700',
'ring-2 ring-white',
)}
>
+{remainingCount}
</div>
<ChevronDown className="ml-1 h-3 w-3 text-gray-500" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
onMouseEnter={() => setDropdownOpen(true)}
onMouseLeave={() => setDropdownOpen(false)}
className="z-[9999]"
>
<div className="mt-2 min-w-[200px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg">
{onlineUsers.map((user) => {
const isCurrentUser = user.user_id === currentUserId
const userColor = isCurrentUser ? undefined : getUserColor(user.user_id)
const displayName = isCurrentUser
? `${user.username || 'User'} (You)`
: (user.username || 'User')
return (
<div
key={user.sid}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2',
!isCurrentUser && 'cursor-pointer hover:bg-components-panel-on-panel-item-bg-hover',
)}
onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)}
>
<div className="relative">
<Avatar
name={user.username || 'User'}
avatar={getAvatarUrl(user)}
size={24}
backgroundColor={userColor}
/>
</div>
<span className="text-sm text-text-secondary">
{displayName}
</span>
</div>
)
})}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)}
</div>
</div>
</div>
)
}
export default OnlineUsers

View File

@ -6,27 +6,39 @@ import {
RiArrowGoForwardFill,
} from '@remixicon/react'
import TipPopup from '../operator/tip-popup'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import Divider from '../../base/divider'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import classNames from '@/utils/classnames'
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
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()